feat: add sound effects, improved stroke recognition, instant locale switching with scramble text animation
Release Build / build-docker (push) Successful in 1m9s
Release Build / build-android-and-release (push) Successful in 7m14s

- 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:
2026-04-18 22:19:36 +02:00
parent 979f8fc5e1
commit 049eaa1807
18 changed files with 907 additions and 166 deletions
+168 -18
View File
@@ -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) {