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:
2026-09-12 21:25:42 +02:00
parent f7a5d24d8d
commit 7f5e2733c2
84 changed files with 1577 additions and 4642 deletions

View File

@@ -0,0 +1,372 @@
/** The typing game, as a tab of the music player rather than its own app.
*
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
* keydown listener for navigation, four screens) - with two changes: curriculum and
* progress are now fetched from the backend instead of a build-time YAML import and
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
* `onExit` is the new base case for "back" - Escape/the map's own navigation peel one
* layer at a time, same as before, but the aquarium screen is no longer the floor: one
* more Escape leaves the tab entirely, back to the music player.
*
* The rule that still matters most: **while a run is going, every key belongs to the
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
* run's own listener lives in hooks/useTippenRun.ts. The music player's own global
* keydown listener (lib/keyboard.ts) is kept out of this entirely: it bails out
* immediately whenever the typing tab is active, so the two never fight over a key. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
import { useTippenProgress } from "../hooks/useTippenProgress";
import { Aquarium } from "./tippen/Aquarium";
import { AppHeader } from "./tippen/AppHeader";
import { HelpOverlay } from "./tippen/HelpOverlay";
import { LessonMap } from "./tippen/LessonMap";
import { ResultSheet } from "./tippen/ResultSheet";
import { Stage } from "./tippen/Stage";
import { BubblesRun } from "./tippen/modes/BubblesRun";
import { DiveRun } from "./tippen/modes/DiveRun";
import { FeedRun } from "./tippen/modes/FeedRun";
import { JellyfishRun } from "./tippen/modes/JellyfishRun";
import { RaceRun } from "./tippen/modes/RaceRun";
import type { CreatureId } from "../lib/tippen/aquarium";
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
import type { RunResult } from "../lib/tippen/grading";
import { playFanfare, playPop } from "../lib/tippen/pop";
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
import type { UnlockedReward } from "../lib/tippen/progress";
import { bubbleCountFor } from "../lib/tippen/theme";
type Screen = "aquarium" | "map" | "run";
/** What a mode is handed to draw - see the original App.tsx for why there are only two
* shapes for five modes. */
type RunTarget =
| { kind: "letters"; letters: readonly string[] }
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
interface Outcome {
result: RunResult;
unlockedTitle: string | null;
newCreature: CreatureId | null;
isNewBest: boolean;
unlockedReward: UnlockedReward | null;
}
interface Props {
onExit: () => void;
}
export function TippenApp({ onExit }: Props) {
const { curriculum, loading: curriculumLoading } = useTippenCurriculum();
const configured = curriculum !== null;
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
const [screen, setScreen] = useState<Screen>("aquarium");
const [lessonId, setLessonId] = useState<string | null>(null);
const [mode, setMode] = useState<ModeId>("dive");
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [selected, setSelected] = useState(0);
/** Bumped to generate a fresh line - a new seed for the same lesson. */
const [round, setRound] = useState(0);
const lesson = lessonId !== null && curriculum ? lessonById(curriculum, lessonId) : null;
/** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
const nextUp = useMemo(() => {
if (!curriculum || !progress) return null;
const unlocked = curriculum.lessons.filter((l) => progress.lessons[l.id]?.unlocked);
return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null;
}, [curriculum, progress]);
/** The line for this run. Reproducible from the lesson, the mode and the round
* counter, so a re-render never reshuffles the text mid-run. */
const run = useMemo((): RunTarget | null => {
if (!lesson || !progress) return null;
const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0);
const rng = mulberry32(seed);
const focusKey = focusKeyFor(progress, lesson.activeKeys);
// Always a real space, even before the space-bar lesson formally teaches the thumb:
// a gap she can see but not type is confusing, not gentle. See generator.ts.
const spaceActive = true;
if (LETTER_ONLY_MODES.includes(mode)) {
const share = lesson.emphasis ? SHARE_FOR_EMPHASIS[lesson.emphasis] : undefined;
return {
kind: "letters",
letters: letterStream(
lesson.activeKeys,
rng,
bubbleCountFor(lesson.world),
focusKey,
lesson.spotlightKeys,
share,
),
};
}
const chunks = lineFor(lesson, rng, { chunks: lesson.chunks, chunkSize: lesson.chunkSize, focusKey });
return { kind: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
// `progress` is deliberately not a dependency - see the original App.tsx.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lesson, mode, round]);
const start = useCallback((lesson: Lesson) => {
setLessonId(lesson.id);
setMode(lesson.primaryMode);
setOutcome(null);
setRound((r) => r + 1);
setScreen("run");
}, []);
const onFinished = useCallback(
(result: RunResult) => {
if (!lessonId || !progress) return;
void recordRun(lessonId, result).then((recorded) => {
setOutcome({
result,
unlockedTitle: recorded.unlockedLessonTitle,
newCreature: recorded.newCreature,
isNewBest: recorded.isNewBest,
unlockedReward: recorded.unlockedReward,
});
if (
progress.settings.sound &&
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward)
) {
playFanfare();
}
});
},
[lessonId, progress, recordRun],
);
const retry = useCallback(() => {
setOutcome(null);
setRound((r) => r + 1);
}, []);
/** A bonus replay in a mode this lesson didn't gate progress on - never touches the
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
* the best score, not change what is unlocked. */
const playBonus = useCallback((bonusMode: ModeId) => {
setMode(bonusMode);
setOutcome(null);
setRound((r) => r + 1);
}, []);
const continueAfterResult = useCallback(() => {
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
setOutcome(null);
if (next && progress?.lessons[next.id]?.unlocked) start(next);
else setScreen("map");
}, [lessonId, curriculum, progress, start]);
const goBack = useCallback(() => {
if (outcome) return setOutcome(null);
if (screen === "run") return setScreen("map");
if (screen === "map") return setScreen("aquarium");
if (screen === "aquarium") return onExit();
}, [outcome, screen, onExit]);
// --- navigation keys -----------------------------------------------------
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum });
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const current = latest.current;
if (event.key === "F1") {
event.preventDefault();
setShowHelp((open) => !open);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setShowHelp(false);
current.goBack();
return;
}
// Enter repeats a finished run; the result sheet's own button has focus, so this
// is only a fallback for when focus has been lost.
if (current.outcome) {
if (event.key === "Enter") {
event.preventDefault();
current.retry();
}
return;
}
// Everything below is navigation, and must not fire while typing.
if (current.screen === "run") return;
if (!current.curriculum) return;
if (event.key === "Enter") {
event.preventDefault();
if (current.screen === "aquarium") {
if (current.nextUp) current.start(current.nextUp);
} else {
const lesson = current.curriculum.lessons[current.selected];
if (lesson) current.start(lesson);
}
return;
}
if (current.screen !== "map") return;
// The map is a vertical path, so "next" is down rather than to the right.
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
if (step !== 0) {
event.preventDefault();
playPop(340);
setSelected((index) => Math.min(current.curriculum!.lessons.length - 1, Math.max(0, index + step)));
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Settings keys live outside a run, where they cannot collide with the alphabet.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (screen === "run" && !outcome) return;
if (!progress) return;
const key = event.key.toLowerCase();
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
if (key === "h") {
void saveSettings({
...progress.settings,
keyboardHint: progress.settings.keyboardHint === "off" ? "auto" : "off",
});
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [screen, outcome, progress, saveSettings]);
// --- render --------------------------------------------------------------
if (curriculumLoading || (configured && progressLoading) || !progress) {
return (
<Stage creatures={[]} dimmed={false}>
<AppHeader />
<LoadingScreen text="Einen Moment …" />
</Stage>
);
}
if (!curriculum) {
return (
<Stage creatures={[]} dimmed={false}>
<AppHeader />
<LoadingScreen text="Das Tippen-Spiel ist noch nicht eingerichtet." />
</Stage>
);
}
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
// Every mode takes the same bundle; only the drawing differs.
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
const letterProps = (letters: readonly string[]) => ({ letters, ...shared });
const textProps = (r: Extract<RunTarget, { kind: "text" }>) => ({
chunks: r.chunks,
text: r.text,
spaceActive: r.spaceActive,
...shared,
});
return (
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
<AppHeader
title={screen === "run" && lesson ? lesson.title : "Delfin Tippen"}
compact={screen === "run"}
status={
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
<span>🦪 {progress.pearls}</span>
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
</div>
}
/>
{screen === "aquarium" && (
<Aquarium
worlds={curriculum.worlds}
progress={progress}
nextLesson={nextUp}
onContinue={() => nextUp && start(nextUp)}
onOpenMap={() => setScreen("map")}
/>
)}
{screen === "map" && (
<LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
)}
{screen === "run" && lesson && run && (
<>
{run.kind === "letters" ? (
mode === "jellyfish" ? (
<JellyfishRun {...letterProps(run.letters)} />
) : (
<BubblesRun {...letterProps(run.letters)} />
)
) : mode === "feed" ? (
<FeedRun {...textProps(run)} />
) : mode === "race" ? (
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
) : (
<DiveRun {...textProps(run)} />
)}
</>
)}
{outcome && lesson && (
<ResultSheet
result={outcome.result}
unlockedTitle={outcome.unlockedTitle}
newCreature={outcome.newCreature}
unlockedReward={outcome.unlockedReward}
isNewBest={outcome.isNewBest}
bestEver={overallBestAnimal(progress)}
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
onPlayBonus={playBonus}
onRetry={retry}
onContinue={continueAfterResult}
continueLabel={next && progress.lessons[next.id]?.unlocked ? `${next.title}` : "Zur Karte"}
/>
)}
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</Stage>
);
}
function LoadingScreen({ text }: { text: string }) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--paper)",
fontSize: 18,
fontWeight: 800,
textAlign: "center",
padding: "0 32px",
}}
>
{text}
</div>
);
}