/** Mounts the pure engine against real keystrokes. * * This is the thin adapter the plan calls for, and the same shape the music player uses * in ../../web/src/App.tsx: everything the listener reads lives in a ref, so the * listener is installed exactly once and a re-render never reattaches it. That matters * more here than there - a listener that is torn down and rebuilt between keystrokes * drops keys, and dropping a six-year-old's keystroke looks to her like the game is * broken. * * Every mode drives this. They differ in what they draw, not in what typing means. */ import { useCallback, useEffect, useRef, useState } from "react"; import { isTypingKey, press, startRun } from "../lib/tippen/engine"; import type { RunEvent, RunState } from "../lib/tippen/engine"; import type { RunResult } from "../lib/tippen/grading"; import { playWrong, playDone, playCorrect } from "../lib/tippen/pop"; interface Options { /** The text to type. Changing it restarts the run. */ target: string; sound: boolean; /** Called once, when the last character lands. */ onFinished: (result: RunResult) => void; /** Extra per-event hook for a mode that needs it (popping a bubble, say). */ onEvent?: (event: RunEvent) => void; /** Paused runs ignore keystrokes - used while the result sheet is up. */ paused?: boolean; } export interface RunHandle { state: RunState; /** True while the cursor is sitting on a key that was just missed. */ wrong: boolean; restart: () => void; } export function useRun({ target, sound, onFinished, onEvent, paused = false }: Options): RunHandle { const [state, setState] = useState(() => startRun(target)); const [wrong, setWrong] = useState(false); // Everything the listener needs, kept current without reinstalling it. const latest = useRef({ state, sound, onFinished, onEvent, paused }); latest.current = { state, sound, onFinished, onEvent, paused }; const restart = useCallback(() => { setState(startRun(target)); setWrong(false); }, [target]); // A new target is a new run - the modes swap the line rather than remounting. useEffect(() => restart(), [restart]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const current = latest.current; if (current.paused) return; // Let the app keep its own keys: Escape leaves, F1 helps, and a browser shortcut // with a modifier held is the browser's business, not the run's. if (event.ctrlKey || event.metaKey || event.altKey) return; if (!isTypingKey(event.key)) return; // Space scrolls the page and Tab leaves it; both are typing input here. event.preventDefault(); const [next, events] = press(current.state, event.key, performance.now()); if (events.length === 0) return; setState(next); for (const runEvent of events) { if (runEvent.type === "correct") { setWrong(false); if (current.sound) playCorrect(runEvent.streak); } else if (runEvent.type === "wrong") { setWrong(true); if (current.sound) playWrong(); } else { if (current.sound) playDone(); current.onFinished(runEvent.result); } current.onEvent?.(runEvent); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); return { state, wrong, restart }; }