add new lesson mode and started code refraction
This commit is contained in:
411
client/src/utils/KanjiController.js
Normal file
411
client/src/utils/KanjiController.js
Normal file
@@ -0,0 +1,411 @@
|
||||
export const KANJI_CONSTANTS = {
|
||||
BASE_SIZE: 109,
|
||||
SVG_NS: 'http://www.w3.org/2000/svg',
|
||||
API_URL: 'https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji',
|
||||
|
||||
STROKE_WIDTH_BASE: 6,
|
||||
DASH_ARRAY_GRID: [5, 5],
|
||||
DASH_ARRAY_HINT: [10, 15],
|
||||
|
||||
ANIMATION_DURATION: 300,
|
||||
SAMPLE_POINTS: 60,
|
||||
|
||||
VALIDATION: {
|
||||
SAMPLES: 10,
|
||||
},
|
||||
|
||||
COLORS: {
|
||||
USER: { r: 255, g: 118, b: 117 },
|
||||
FINAL: { r: 0, g: 206, b: 201 },
|
||||
HINT: '#3f3f46',
|
||||
GRID: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
};
|
||||
|
||||
export class KanjiController {
|
||||
constructor(options = {}) {
|
||||
this.size = options.size || 300;
|
||||
this.accuracy = options.accuracy || 10;
|
||||
this.onComplete = options.onComplete || (() => {});
|
||||
this.onMistake = options.onMistake || (() => {});
|
||||
|
||||
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
|
||||
this.paths = [];
|
||||
this.currentStrokeIdx = 0;
|
||||
this.mistakes = 0;
|
||||
this.userPath = [];
|
||||
this.isDrawing = false;
|
||||
this.isAnimating = false;
|
||||
|
||||
this.ctx = { bg: null, hint: null, draw: null };
|
||||
}
|
||||
|
||||
static createPathElement(d) {
|
||||
const path = document.createElementNS(KANJI_CONSTANTS.SVG_NS, 'path');
|
||||
path.setAttribute('d', d);
|
||||
return path;
|
||||
}
|
||||
|
||||
static setContextDefaults(ctx) {
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
|
||||
}
|
||||
|
||||
static resamplePoints(points, count) {
|
||||
if (!points || points.length === 0) return [];
|
||||
|
||||
let totalLen = 0;
|
||||
const dists = [0];
|
||||
points.slice(1).forEach((p, i) => {
|
||||
const prev = points[i];
|
||||
const d = Math.hypot(p.x - prev.x, p.y - prev.y);
|
||||
totalLen += d;
|
||||
dists.push(totalLen);
|
||||
});
|
||||
|
||||
const step = totalLen / (count - 1);
|
||||
|
||||
return Array.from({ length: count }).map((_, i) => {
|
||||
const targetDist = i * step;
|
||||
let idx = dists.findIndex((d) => d >= targetDist);
|
||||
if (idx === -1) idx = dists.length - 1;
|
||||
if (idx > 0) idx -= 1;
|
||||
|
||||
if (idx >= points.length - 1) {
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
const dStart = dists[idx];
|
||||
const dEnd = dists[idx + 1];
|
||||
const segmentLen = dEnd - dStart;
|
||||
const t = segmentLen === 0 ? 0 : (targetDist - dStart) / segmentLen;
|
||||
|
||||
const p1 = points[idx];
|
||||
const p2 = points[idx + 1];
|
||||
|
||||
return {
|
||||
x: p1.x + (p2.x - p1.x) * t,
|
||||
y: p1.y + (p2.y - p1.y) * t,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
mount(canvasRefs) {
|
||||
this.ctx.bg = canvasRefs.bg.getContext('2d');
|
||||
this.ctx.hint = canvasRefs.hint.getContext('2d');
|
||||
this.ctx.draw = canvasRefs.draw.getContext('2d');
|
||||
this.resize(this.size);
|
||||
}
|
||||
|
||||
setAccuracy(val) {
|
||||
this.accuracy = val;
|
||||
}
|
||||
|
||||
resize(newSize) {
|
||||
this.size = newSize;
|
||||
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
|
||||
|
||||
Object.values(this.ctx).forEach((ctx) => {
|
||||
if (ctx && ctx.canvas) {
|
||||
ctx.canvas.width = this.size;
|
||||
ctx.canvas.height = this.size;
|
||||
KanjiController.setContextDefaults(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
this.drawGrid();
|
||||
if (this.paths.length) {
|
||||
this.redrawAllPerfectStrokes();
|
||||
}
|
||||
}
|
||||
|
||||
async loadChar(char, autoHint = false) {
|
||||
this.reset();
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${KANJI_CONSTANTS.API_URL}/${hex}.svg`);
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, 'image/svg+xml');
|
||||
this.paths = Array.from(doc.getElementsByTagName('path')).map((p) => p.getAttribute('d'));
|
||||
|
||||
this.drawGrid();
|
||||
if (autoHint) this.showHint();
|
||||
} catch (e) {
|
||||
console.error('Failed to load Kanji:', e);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.currentStrokeIdx = 0;
|
||||
this.mistakes = 0;
|
||||
this.isAnimating = false;
|
||||
this.userPath = [];
|
||||
|
||||
this.clearCanvas(this.ctx.draw);
|
||||
this.clearCanvas(this.ctx.hint);
|
||||
this.drawGrid();
|
||||
this.resetDrawStyle();
|
||||
}
|
||||
|
||||
startStroke(point) {
|
||||
if (this.currentStrokeIdx >= this.paths.length || this.isAnimating) return;
|
||||
|
||||
this.isDrawing = true;
|
||||
this.userPath = [point];
|
||||
|
||||
this.ctx.draw.beginPath();
|
||||
this.ctx.draw.moveTo(point.x, point.y);
|
||||
this.resetDrawStyle();
|
||||
}
|
||||
|
||||
moveStroke(point) {
|
||||
if (!this.isDrawing) return;
|
||||
this.userPath.push(point);
|
||||
this.ctx.draw.lineTo(point.x, point.y);
|
||||
this.ctx.draw.stroke();
|
||||
}
|
||||
|
||||
endStroke() {
|
||||
if (!this.isDrawing) return;
|
||||
this.isDrawing = false;
|
||||
this.validateStroke();
|
||||
}
|
||||
|
||||
drawGrid() {
|
||||
if (!this.ctx.bg) return;
|
||||
const ctx = this.ctx.bg;
|
||||
this.clearCanvas(ctx);
|
||||
|
||||
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.GRID;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_GRID);
|
||||
|
||||
const s = this.size;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s / 2, 0); ctx.lineTo(s / 2, s);
|
||||
ctx.moveTo(0, s / 2); ctx.lineTo(s, s / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
showHint() {
|
||||
this.clearCanvas(this.ctx.hint);
|
||||
if (this.currentStrokeIdx >= this.paths.length) return;
|
||||
|
||||
const d = this.paths[this.currentStrokeIdx];
|
||||
const pathEl = KanjiController.createPathElement(d);
|
||||
const len = pathEl.getTotalLength();
|
||||
|
||||
const ctx = this.ctx.hint;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.HINT;
|
||||
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_HINT);
|
||||
|
||||
const step = 5;
|
||||
const count = Math.floor(len / step) + 1;
|
||||
|
||||
Array.from({ length: count }).forEach((_, i) => {
|
||||
const dist = Math.min(i * step, len);
|
||||
const pt = pathEl.getPointAtLength(dist);
|
||||
if (i === 0) ctx.moveTo(pt.x * this.scale, pt.y * this.scale);
|
||||
else ctx.lineTo(pt.x * this.scale, pt.y * this.scale);
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
redrawAllPerfectStrokes(includeCurrent = false) {
|
||||
const ctx = this.ctx.draw;
|
||||
this.clearCanvas(ctx);
|
||||
ctx.save();
|
||||
ctx.scale(this.scale, this.scale);
|
||||
|
||||
const { r, g, b } = KANJI_CONSTANTS.COLORS.FINAL;
|
||||
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE / this.scale;
|
||||
ctx.setLineDash([]);
|
||||
|
||||
const limit = includeCurrent ? this.currentStrokeIdx + 1 : this.currentStrokeIdx;
|
||||
this.paths.slice(0, limit).forEach((d) => {
|
||||
ctx.stroke(new Path2D(d));
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if (!this.isAnimating) this.resetDrawStyle();
|
||||
}
|
||||
|
||||
clearCanvas(ctx) {
|
||||
if (ctx) ctx.clearRect(0, 0, this.size, this.size);
|
||||
}
|
||||
|
||||
resetDrawStyle() {
|
||||
const { r, g, b } = KANJI_CONSTANTS.COLORS.USER;
|
||||
if (this.ctx.draw) {
|
||||
this.ctx.draw.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
this.ctx.draw.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
|
||||
this.ctx.draw.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
validateStroke() {
|
||||
const targetD = this.paths[this.currentStrokeIdx];
|
||||
const userNormalized = this.userPath.map((p) => ({
|
||||
x: p.x / this.scale,
|
||||
y: p.y / this.scale,
|
||||
}));
|
||||
|
||||
if (this.checkMatch(userNormalized, targetD)) {
|
||||
this.animateMorph(this.userPath, targetD, () => {
|
||||
this.currentStrokeIdx += 1;
|
||||
this.mistakes = 0;
|
||||
this.redrawAllPerfectStrokes();
|
||||
|
||||
if (this.currentStrokeIdx >= this.paths.length) {
|
||||
this.onComplete();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.mistakes += 1;
|
||||
this.animateErrorFade(this.userPath, () => {
|
||||
this.redrawAllPerfectStrokes();
|
||||
const needsHint = this.mistakes >= 3;
|
||||
if (needsHint) this.showHint();
|
||||
this.onMistake(needsHint);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkMatch(userPts, targetD) {
|
||||
if (userPts.length < 3) return false;
|
||||
|
||||
const pathEl = KanjiController.createPathElement(targetD);
|
||||
const len = pathEl.getTotalLength();
|
||||
const { SAMPLES } = KANJI_CONSTANTS.VALIDATION;
|
||||
|
||||
const avgDistThreshold = this.accuracy * 0.8;
|
||||
const startEndThreshold = this.accuracy * 2.5;
|
||||
|
||||
const targetStart = pathEl.getPointAtLength(0);
|
||||
const targetEnd = pathEl.getPointAtLength(len);
|
||||
|
||||
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||
|
||||
if (dist(userPts[0], targetStart) > startEndThreshold) return false;
|
||||
if (dist(userPts[userPts.length - 1], targetEnd) > startEndThreshold) return false;
|
||||
|
||||
let totalError = 0;
|
||||
const sampleCount = SAMPLES + 1;
|
||||
|
||||
totalError = Array.from({ length: sampleCount }).reduce((acc, _, i) => {
|
||||
const targetPt = pathEl.getPointAtLength((i / SAMPLES) * len);
|
||||
const minD = userPts.reduce((min, up) => Math.min(min, dist(targetPt, up)), Infinity);
|
||||
return acc + minD;
|
||||
}, 0);
|
||||
|
||||
return (totalError / sampleCount) < avgDistThreshold;
|
||||
}
|
||||
|
||||
animateErrorFade(userPath, onComplete) {
|
||||
this.isAnimating = true;
|
||||
const startTime = performance.now();
|
||||
const DURATION = 300;
|
||||
|
||||
const tick = (now) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / DURATION, 1);
|
||||
const opacity = 1 - progress;
|
||||
|
||||
this.redrawAllPerfectStrokes();
|
||||
|
||||
if (opacity > 0) {
|
||||
const ctx = this.ctx.draw;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
|
||||
const { r, g, b } = KANJI_CONSTANTS.COLORS.USER;
|
||||
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
if (userPath.length > 0) {
|
||||
ctx.moveTo(userPath[0].x, userPath[0].y);
|
||||
userPath.slice(1).forEach((p) => ctx.lineTo(p.x, p.y));
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
this.isAnimating = false;
|
||||
this.resetDrawStyle();
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
animateMorph(userPoints, targetD, onComplete) {
|
||||
this.isAnimating = true;
|
||||
const targetPoints = this.getSvgPoints(targetD, KANJI_CONSTANTS.SAMPLE_POINTS);
|
||||
const startPoints = KanjiController.resamplePoints(userPoints, KANJI_CONSTANTS.SAMPLE_POINTS);
|
||||
|
||||
const startTime = performance.now();
|
||||
const { USER, FINAL } = KANJI_CONSTANTS.COLORS;
|
||||
|
||||
const tick = (now) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / KANJI_CONSTANTS.ANIMATION_DURATION, 1);
|
||||
const ease = 1 - (1 - progress) ** 3;
|
||||
|
||||
this.redrawAllPerfectStrokes(false);
|
||||
|
||||
const r = USER.r + (FINAL.r - USER.r) * ease;
|
||||
const g = USER.g + (FINAL.g - USER.g) * ease;
|
||||
const b = USER.b + (FINAL.b - USER.b) * ease;
|
||||
|
||||
const ctx = this.ctx.draw;
|
||||
ctx.strokeStyle = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
|
||||
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
|
||||
ctx.beginPath();
|
||||
|
||||
Array.from({ length: KANJI_CONSTANTS.SAMPLE_POINTS }).forEach((_, i) => {
|
||||
const sx = startPoints[i].x;
|
||||
const sy = startPoints[i].y;
|
||||
const ex = targetPoints[i].x;
|
||||
const ey = targetPoints[i].y;
|
||||
|
||||
const cx = sx + (ex - sx) * ease;
|
||||
const cy = sy + (ey - sy) * ease;
|
||||
|
||||
if (i === 0) ctx.moveTo(cx, cy);
|
||||
else ctx.lineTo(cx, cy);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
this.isAnimating = false;
|
||||
this.resetDrawStyle();
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
getSvgPoints(d, count) {
|
||||
const path = KanjiController.createPathElement(d);
|
||||
const len = path.getTotalLength();
|
||||
|
||||
return Array.from({ length: count }).map((_, i) => {
|
||||
const pt = path.getPointAtLength((i / (count - 1)) * len);
|
||||
return { x: pt.x * this.scale, y: pt.y * this.scale };
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user