Files
musicmouse/web/src/hooks/useTippenRun.ts
Martin Bauer 7f5e2733c2 Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player /
smarthome / typing), replacing the old single room-toggle corner button with a
vertical icon tab rail. Curriculum and progress now come from the backend
(musicmouse/tippen/*) instead of a build-time YAML import and localStorage.

Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question
mark for locked albums/tracks with a hint on what unlocks them, and
ResultSheet gets a new unlock-animation block alongside the existing
lesson-unlock and aquarium-creature celebrations.

CSS from the two apps is merged carefully: identical rules (bubble/card/
key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is,
while rules that bake in each app's own hue are kept separate under a
`tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so
neither app's look bleeds into the other's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:25:42 +02:00

91 lines
3.4 KiB
TypeScript

/** 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<RunState>(() => 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 };
}