First implementation of 10 finger typing
This commit is contained in:
124
tippen/src/lib/engine.ts
Normal file
124
tippen/src/lib/engine.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/** 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 (Welt 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 Blasenplatzen round still earns its Perlen. */
|
||||
export function abandonRun(state: RunState, now: number): RunState {
|
||||
if (isFinished(state) || state.startedAt === null) return state;
|
||||
return { ...state, finishedAt: now };
|
||||
}
|
||||
Reference in New Issue
Block a user