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>
This commit is contained in:
90
web/src/hooks/useTippenRun.ts
Normal file
90
web/src/hooks/useTippenRun.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/** 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user