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:
34
web/src/hooks/useTippenCurriculum.ts
Normal file
34
web/src/hooks/useTippenCurriculum.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** The typing game's lesson plan, fetched once - see `lib/tippen/curriculum.ts` for the
|
||||
* derived Lesson/World shape everything else in the typing game expects. `null` after
|
||||
* loading means the backend has no `general.tippen` section configured, same as
|
||||
* `useHomeAssistant`'s `config` for the room page. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Curriculum } from "../lib/tippen/curriculum";
|
||||
import { fromApi } from "../lib/tippen/curriculum";
|
||||
|
||||
export interface TippenCurriculumState {
|
||||
curriculum: Curriculum | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useTippenCurriculum(): TippenCurriculumState {
|
||||
const [curriculum, setCurriculum] = useState<Curriculum | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void api.tippenCurriculum().then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setCurriculum(loaded ? fromApi(loaded) : null);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { curriculum, loading };
|
||||
}
|
||||
57
web/src/hooks/useTippenProgress.ts
Normal file
57
web/src/hooks/useTippenProgress.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** The typing game's progress: fetched once and updated from each run-recording
|
||||
* response, since the backend is the only place it lives now - there is no local save.
|
||||
* `enabled` mirrors `useHomeAssistant`'s `config` argument: pass whether the curriculum
|
||||
* resolved non-null, so this hook stays a no-op until there is something to fetch. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { RunResult } from "../lib/tippen/grading";
|
||||
import type { Progress, RunOutcome, Settings } from "../lib/tippen/progress";
|
||||
import { progressFromApi, runOutcomeFromApi, toRunInput } from "../lib/tippen/progress";
|
||||
|
||||
export interface TippenProgressState {
|
||||
progress: Progress | null;
|
||||
loading: boolean;
|
||||
recordRun: (lessonId: string, result: RunResult) => Promise<RunOutcome>;
|
||||
saveSettings: (settings: Settings) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTippenProgress(enabled: boolean): TippenProgressState {
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
void api.tippenProgress().then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setProgress(progressFromApi(loaded));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const recordRun = useCallback(async (lessonId: string, result: RunResult) => {
|
||||
const response = await api.recordTippenRun(toRunInput(lessonId, result));
|
||||
const outcome = runOutcomeFromApi(response);
|
||||
setProgress(outcome.progress);
|
||||
return outcome;
|
||||
}, []);
|
||||
|
||||
const saveSettings = useCallback(async (settings: Settings) => {
|
||||
const saved = await api.saveTippenSettings({
|
||||
sound: settings.sound,
|
||||
keyboard_hint: settings.keyboardHint,
|
||||
});
|
||||
setProgress((previous) =>
|
||||
previous
|
||||
? { ...previous, settings: { sound: saved.sound, keyboardHint: saved.keyboard_hint } }
|
||||
: previous,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { progress, loading, recordRun, saveSettings };
|
||||
}
|
||||
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