import { SoundManager } from './SoundManager.js'; import { evaluateStroke } from './StrokeEvaluator.js'; import { createConfig } from './StrokeConfig.js'; 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, 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.onComplete = options.onComplete || (() => {}); this.onMistake = options.onMistake || (() => {}); // Stroke evaluation config (from StrokeConfig presets) this.config = options.config || createConfig('medium'); this.lastEvaluation = null; 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 }; this.hintAnimationFrame = 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); } setConfig(config) { this.config = config; } getLastEvaluation() { return this.lastEvaluation; } 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(); } } setAutoHint(val) { this.autoHint = val; if (!val) { this.clearCanvas(this.ctx.hint); } } async loadChar(char, autoHint = false) { this.autoHint = autoHint; this.paths = []; 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() { if (this.hintAnimationFrame) { cancelAnimationFrame(this.hintAnimationFrame); this.hintAnimationFrame = null; } 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(); if (this.autoHint && this.paths.length > 0) { this.showHint(); } } 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() { if (this.hintAnimationFrame) { cancelAnimationFrame(this.hintAnimationFrame); this.hintAnimationFrame = null; } 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.strokeStyle = KANJI_CONSTANTS.COLORS.HINT; ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_HINT); const startTime = performance.now(); const DURATION = 600; const animate = (now) => { const elapsed = now - startTime; const progress = Math.min(elapsed / DURATION, 1); this.clearCanvas(ctx); ctx.beginPath(); const drawLen = len * progress; const step = 5; let first = true; for (let dist = 0; dist <= drawLen; dist += step) { const pt = pathEl.getPointAtLength(dist); if (first) { ctx.moveTo(pt.x * this.scale, pt.y * this.scale); first = false; } else { ctx.lineTo(pt.x * this.scale, pt.y * this.scale); } } if (drawLen > 0) { const pt = pathEl.getPointAtLength(drawLen); if (first) ctx.moveTo(pt.x * this.scale, pt.y * this.scale); else ctx.lineTo(pt.x * this.scale, pt.y * this.scale); } ctx.stroke(); if (progress < 1) { this.hintAnimationFrame = requestAnimationFrame(animate); } else { this.hintAnimationFrame = null; } }; this.hintAnimationFrame = requestAnimationFrame(animate); } 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 pathEl = KanjiController.createPathElement(targetD); const result = evaluateStroke( this.userPath, pathEl, this.scale, this.config, { isFirstStroke: this.currentStrokeIdx === 0 }, ); this.lastEvaluation = result; if (result.pass) { if (this.hintAnimationFrame) { cancelAnimationFrame(this.hintAnimationFrame); this.hintAnimationFrame = null; } this.animateMorph(this.userPath, targetD, () => { this.currentStrokeIdx += 1; this.mistakes = 0; this.redrawAllPerfectStrokes(); this.clearCanvas(this.ctx.hint); if (this.currentStrokeIdx >= this.paths.length) { SoundManager.playDrawingComplete(); this.onComplete(); } else { if (this.autoHint) { this.showHint(); } } }); } else { SoundManager.playStrokeError(); this.mistakes += 1; this.animateErrorFade(this.userPath, () => { this.redrawAllPerfectStrokes(); const needsHint = this.mistakes >= 3; if (needsHint) this.showHint(); this.onMistake(needsHint); }); } } // Old checkMatch, normalizeAngle, classifyOrientation, measureCurvature // have been replaced by StrokeEvaluator.evaluateStroke(). 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 }; }); } }