feat: add sound effects, improved stroke recognition, instant locale switching with scramble text animation
- Add SoundManager with Web Audio API synthesized sounds (stroke error, drawing complete, session complete) - Add sound effects toggle in settings (persisted via localStorage) - Improve stroke recognition: curvature similarity, max point deviation, stroke length ratio checks - Loosen thresholds for long strokes and first-stroke positioning - Implement instant locale switching (no save required) - Add ScrambleText component with character-by-character morphing animation - Apply ScrambleText globally across all views, widgets, and dialogs - Add i18n keys for sound effects settings (EN/DE/JA)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { SoundManager } from './SoundManager.js';
|
||||
|
||||
export const KANJI_CONSTANTS = {
|
||||
BASE_SIZE: 109,
|
||||
SVG_NS: 'http://www.w3.org/2000/svg',
|
||||
@@ -11,7 +13,7 @@ export const KANJI_CONSTANTS = {
|
||||
SAMPLE_POINTS: 60,
|
||||
|
||||
VALIDATION: {
|
||||
SAMPLES: 10,
|
||||
SAMPLES: 20,
|
||||
},
|
||||
|
||||
COLORS: {
|
||||
@@ -323,12 +325,16 @@ export class KanjiController {
|
||||
this.clearCanvas(this.ctx.hint);
|
||||
|
||||
if (this.currentStrokeIdx >= this.paths.length) {
|
||||
SoundManager.playDrawingComplete();
|
||||
this.onComplete();
|
||||
} else if (this.autoHint) {
|
||||
this.showHint();
|
||||
} else {
|
||||
if (this.autoHint) {
|
||||
this.showHint();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
SoundManager.playStrokeError();
|
||||
this.mistakes += 1;
|
||||
this.animateErrorFade(this.userPath, () => {
|
||||
this.redrawAllPerfectStrokes();
|
||||
@@ -346,27 +352,171 @@ export class KanjiController {
|
||||
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;
|
||||
// ── 1. Start / end point check ──────────────────
|
||||
const targetStart = pathEl.getPointAtLength(0);
|
||||
const targetEnd = pathEl.getPointAtLength(len);
|
||||
const userStart = userPts[0];
|
||||
const userEnd = userPts[userPts.length - 1];
|
||||
|
||||
const isFirstStroke = this.currentStrokeIdx === 0;
|
||||
const startThreshold = this.accuracy * (isFirstStroke ? 3.5 : 2.5);
|
||||
// End tolerance scales with stroke length — long strokes get more forgiveness
|
||||
const lengthBonus = Math.min(len / 30, 1.5);
|
||||
const endThreshold = this.accuracy * (2.5 + lengthBonus);
|
||||
|
||||
if (dist(userStart, targetStart) > startThreshold) return false;
|
||||
if (dist(userEnd, targetEnd) > endThreshold) return false;
|
||||
|
||||
// ── 2. Overall direction check ──────────────────
|
||||
const userAngle = Math.atan2(userEnd.y - userStart.y, userEnd.x - userStart.x);
|
||||
const targetAngle = Math.atan2(targetEnd.y - targetStart.y, targetEnd.x - targetStart.x);
|
||||
const angleDiff = KanjiController.normalizeAngle(userAngle - targetAngle);
|
||||
|
||||
// Reject if direction is off by more than 90 degrees
|
||||
if (angleDiff > Math.PI / 2) return false;
|
||||
|
||||
// ── 3. Orientation classification guard ─────────
|
||||
const userOrientation = KanjiController.classifyOrientation(userStart, userEnd);
|
||||
const targetOrientation = KanjiController.classifyOrientation(targetStart, targetEnd);
|
||||
|
||||
// Only block if both strokes are clearly H or V (not diagonal or dot)
|
||||
if (userOrientation !== 'other' && targetOrientation !== 'other'
|
||||
&& userOrientation !== targetOrientation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 4. Sequential sample comparison ─────────────
|
||||
const userResampled = KanjiController.resamplePoints(userPts, SAMPLES + 1);
|
||||
let totalError = 0;
|
||||
const sampleCount = SAMPLES + 1;
|
||||
let maxError = 0;
|
||||
|
||||
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);
|
||||
for (let i = 0; i <= SAMPLES; i++) {
|
||||
const t = i / SAMPLES;
|
||||
const targetPt = pathEl.getPointAtLength(t * len);
|
||||
const userPt = userResampled[i];
|
||||
const d = dist(targetPt, userPt);
|
||||
totalError += d;
|
||||
if (d > maxError) maxError = d;
|
||||
}
|
||||
|
||||
return (totalError / sampleCount) < avgDistThreshold;
|
||||
const avgError = totalError / (SAMPLES + 1);
|
||||
const avgDistThreshold = this.accuracy * 1.0;
|
||||
const maxDistThreshold = this.accuracy * 3.5;
|
||||
|
||||
if (avgError > avgDistThreshold) return false;
|
||||
// No single point should be wildly off — catches "right average, wrong shape"
|
||||
if (maxError > maxDistThreshold) return false;
|
||||
|
||||
// ── 4b. Stroke length ratio ─────────────────────
|
||||
let userLen = 0;
|
||||
for (let i = 1; i < userPts.length; i++) {
|
||||
userLen += dist(userPts[i - 1], userPts[i]);
|
||||
}
|
||||
const lengthRatio = userLen / len;
|
||||
// User stroke should be between 30% and 300% of target length
|
||||
// Long straight strokes especially benefit from the upper bound
|
||||
if (lengthRatio < 0.3 || lengthRatio > 3.0) return false;
|
||||
|
||||
// ── 4c. Curvature similarity ────────────────────
|
||||
const targetCurvature = KanjiController.measureCurvature(
|
||||
userResampled.map((_, i) => {
|
||||
const t = i / SAMPLES;
|
||||
const pt = pathEl.getPointAtLength(t * len);
|
||||
return { x: pt.x, y: pt.y };
|
||||
}),
|
||||
);
|
||||
const userCurvature = KanjiController.measureCurvature(userPts);
|
||||
|
||||
// If target is clearly curved but user drew straight (or vice versa)
|
||||
const curvatureDiff = Math.abs(targetCurvature - userCurvature);
|
||||
// Scale threshold: strict = 0.2, loose = 0.5
|
||||
const curvatureThreshold = 0.15 + (this.accuracy / 20) * 0.35;
|
||||
if (curvatureDiff > curvatureThreshold) return false;
|
||||
|
||||
// ── 5. Endpoint direction check (hook detection) ─
|
||||
const tailFraction = 0.2;
|
||||
const targetTailStart = pathEl.getPointAtLength(len * (1 - tailFraction));
|
||||
const targetTailAngle = Math.atan2(
|
||||
targetEnd.y - targetTailStart.y,
|
||||
targetEnd.x - targetTailStart.x,
|
||||
);
|
||||
|
||||
// Check if target has a significant direction change at the end (hook)
|
||||
const mainTargetAngle = Math.atan2(
|
||||
targetEnd.y - targetStart.y,
|
||||
targetEnd.x - targetStart.x,
|
||||
);
|
||||
const targetHookAngle = KanjiController.normalizeAngle(targetTailAngle - mainTargetAngle);
|
||||
const hasHook = targetHookAngle > Math.PI / 6; // > 30 degrees = hook
|
||||
|
||||
if (hasHook) {
|
||||
// User must also have a direction change in the last portion
|
||||
const tailIdx = Math.max(0, Math.floor(userPts.length * (1 - tailFraction)));
|
||||
const userTailStart = userPts[tailIdx];
|
||||
const userTailAngle = Math.atan2(
|
||||
userEnd.y - userTailStart.y,
|
||||
userEnd.x - userTailStart.x,
|
||||
);
|
||||
const mainUserAngle = Math.atan2(
|
||||
userEnd.y - userStart.y,
|
||||
userEnd.x - userStart.x,
|
||||
);
|
||||
const userHookAngle = KanjiController.normalizeAngle(userTailAngle - mainUserAngle);
|
||||
|
||||
// User's hook should be at least half as pronounced as the target's
|
||||
// but use a generous threshold scaled by accuracy
|
||||
const hookThreshold = Math.max(Math.PI / 12, targetHookAngle * 0.4);
|
||||
if (userHookAngle < hookThreshold) {
|
||||
// Only reject on strict accuracy (< 12), forgive on loose settings
|
||||
if (this.accuracy < 12) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Static helpers for stroke validation ────────────
|
||||
|
||||
static normalizeAngle(angle) {
|
||||
let a = Math.abs(angle);
|
||||
if (a > Math.PI) a = 2 * Math.PI - a;
|
||||
return a;
|
||||
}
|
||||
|
||||
static classifyOrientation(start, end) {
|
||||
const dx = Math.abs(end.x - start.x);
|
||||
const dy = Math.abs(end.y - start.y);
|
||||
const totalDist = Math.hypot(dx, dy);
|
||||
|
||||
// Very short strokes (dots) — don't classify
|
||||
if (totalDist < 5) return 'other';
|
||||
|
||||
const ratio = dx / (dy || 0.001);
|
||||
if (ratio > 2.5) return 'horizontal';
|
||||
if (ratio < 0.4) return 'vertical';
|
||||
return 'other'; // diagonal or ambiguous
|
||||
}
|
||||
|
||||
static measureCurvature(points) {
|
||||
if (points.length < 3) return 0;
|
||||
|
||||
// Curvature = ratio of actual path length to straight-line distance
|
||||
// A perfectly straight line = 0, a semicircle ≈ 0.57
|
||||
let pathLen = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
pathLen += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
||||
}
|
||||
|
||||
const straightDist = Math.hypot(
|
||||
points[points.length - 1].x - points[0].x,
|
||||
points[points.length - 1].y - points[0].y,
|
||||
);
|
||||
|
||||
if (straightDist < 1) return 0; // dot-like stroke
|
||||
// Returns 0 for straight, >0 for curved (ratio - 1)
|
||||
return Math.max(0, (pathLen / straightDist) - 1);
|
||||
}
|
||||
|
||||
animateErrorFade(userPath, onComplete) {
|
||||
|
||||
Reference in New Issue
Block a user