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>
91 lines
3.4 KiB
TypeScript
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 };
|
|
}
|