- Rename all German identifiers, types, mode ids, file names, CSS classes and data-attributes to English throughout tippen/src; only user-facing text (lesson titles, word lists, labels, spoken praise) stays German. - Add a word/nonsense-word list to the "Übung: die Grundstellung" home-row lesson in the curriculum. - Remove the unused HandHint component and speech.ts, and carry forward the in-progress App.tsx/component/generator/progress edits from other sessions. - Refresh the regenerated music-library cache index. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
125 lines
4.9 KiB
TypeScript
125 lines
4.9 KiB
TypeScript
/** The typing engine: one keystroke in, a new state and a list of events out.
|
|
*
|
|
* Kept pure, the way ../../../web/src/lib/keyboard.ts keeps the player's key map pure -
|
|
* so every rule below is testable without a DOM, and so the six game modes can all
|
|
* drive the same logic while differing only in how they *draw* the target.
|
|
*
|
|
* Three rules here are deliberate choices for a six-year-old rather than the obvious
|
|
* implementation, and each one is load-bearing:
|
|
*
|
|
* 1. A wrong key does not advance and does not insert. There is no backspace to
|
|
* manage and no corrupted line to read back; the right key still has to be found.
|
|
* 2. A wrong key counts once per position. Hammering the same wrong key five times in
|
|
* a moment of panic is one mistake, not five, so one bad second cannot wreck a run.
|
|
* 3. The clock starts on the first keystroke, not when the screen opens. Staring at
|
|
* the screen, getting distracted, or being called away mid-thought is free. */
|
|
|
|
import { grade, type RunResult } from "./grading";
|
|
|
|
export interface Stroke {
|
|
/** What was actually pressed, lowercased for letters. */
|
|
key: string;
|
|
/** What was wanted at that position. */
|
|
expected: string;
|
|
correct: boolean;
|
|
/** ms timestamp, from the same clock `press` is called with. */
|
|
at: number;
|
|
}
|
|
|
|
export interface RunState {
|
|
/** The full line being typed. */
|
|
target: string;
|
|
/** How far in we are - always an index into `target`, never past its length. */
|
|
index: number;
|
|
strokes: Stroke[];
|
|
/** Positions where at least one wrong key has already been counted. Rule 2. */
|
|
missed: ReadonlySet<number>;
|
|
/** Consecutive correct keys, for the streak sound and the bubble chain. */
|
|
streak: number;
|
|
startedAt: number | null;
|
|
finishedAt: number | null;
|
|
}
|
|
|
|
export type RunEvent =
|
|
| { type: "correct"; key: string; index: number; streak: number }
|
|
| { type: "wrong"; key: string; expected: string; index: number; firstAt: boolean }
|
|
| { type: "finished"; result: RunResult };
|
|
|
|
export function startRun(target: string): RunState {
|
|
return {
|
|
target,
|
|
index: 0,
|
|
strokes: [],
|
|
missed: new Set(),
|
|
streak: 0,
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
};
|
|
}
|
|
|
|
/** Keys that are never typing input: pressing Shift to reach a capital must not count
|
|
* as a stroke of its own, and neither must a stray Alt or a browser shortcut's Meta. */
|
|
const MODIFIERS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]);
|
|
|
|
/** Is this a key the engine should look at at all? Anything longer than one code point
|
|
* is a named key ("Enter", "ArrowLeft", "F1") and belongs to the app, not the run.
|
|
* Backspace is swallowed on purpose: rule 1 means there is nothing to delete. */
|
|
export function isTypingKey(key: string): boolean {
|
|
if (MODIFIERS.has(key)) return false;
|
|
return [...key].length === 1;
|
|
}
|
|
|
|
export function isFinished(state: RunState): boolean {
|
|
return state.finishedAt !== null;
|
|
}
|
|
|
|
export function currentChar(state: RunState): string | null {
|
|
return state.target[state.index] ?? null;
|
|
}
|
|
|
|
/** Apply one keystroke. Returns the state unchanged (and no events) for anything that
|
|
* is not typing input, or once the run is over, so the caller can stay dumb. */
|
|
export function press(state: RunState, key: string, now: number): [RunState, RunEvent[]] {
|
|
if (isFinished(state) || !isTypingKey(key)) return [state, []];
|
|
|
|
const expected = state.target[state.index];
|
|
if (expected === undefined) return [state, []];
|
|
|
|
// The layout is what decides case, not the run: typing "A" where "a" is wanted is
|
|
// correct. Capitals are their own lesson (world 4), and that lesson's target text
|
|
// carries the capital, so this comparison still teaches Shift where it matters.
|
|
const correct = key.toLowerCase() === expected.toLowerCase();
|
|
const startedAt = state.startedAt ?? now;
|
|
const stroke: Stroke = { key, expected, correct, at: now };
|
|
const strokes = [...state.strokes, stroke];
|
|
|
|
if (!correct) {
|
|
const firstAt = !state.missed.has(state.index);
|
|
const missed = firstAt ? new Set(state.missed).add(state.index) : state.missed;
|
|
const next: RunState = { ...state, strokes, missed, streak: 0, startedAt };
|
|
return [next, [{ type: "wrong", key, expected, index: state.index, firstAt }]];
|
|
}
|
|
|
|
const index = state.index + 1;
|
|
const streak = state.streak + 1;
|
|
const done = index >= state.target.length;
|
|
const next: RunState = {
|
|
...state,
|
|
index,
|
|
strokes,
|
|
streak,
|
|
startedAt,
|
|
finishedAt: done ? now : null,
|
|
};
|
|
const events: RunEvent[] = [{ type: "correct", key, index: state.index, streak }];
|
|
if (done) events.push({ type: "finished", result: grade(next) });
|
|
return [next, events];
|
|
}
|
|
|
|
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
|
* was typed, so a half-finished bubbles round still earns its pearls. */
|
|
export function abandonRun(state: RunState, now: number): RunState {
|
|
if (isFinished(state) || state.startedAt === null) return state;
|
|
return { ...state, finishedAt: now };
|
|
}
|