/** 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, 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". The lesson map is the floor screen - the * swimming aquarium creatures already show behind it, so there is no separate landing * screen in front of it - and one Escape from the map 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 { AppHeader } from "./tippen/AppHeader"; import { HelpOverlay } from "./tippen/HelpOverlay"; import { LessonMap } from "./tippen/LessonMap"; import { ResultSheet } from "./tippen/ResultSheet"; import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay"; import { Stage } from "./tippen/Stage"; import { BubblesRun } from "./tippen/modes/BubblesRun"; import { DiveRun } from "./tippen/modes/DiveRun"; import { FeedRun } from "./tippen/modes/FeedRun"; import { RaceRun } from "./tippen/modes/RaceRun"; import type { CreatureId } from "../lib/tippen/aquarium"; import { creatureById } 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 { animalById } from "../lib/tippen/grading"; 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 = "map" | "run"; /** What a mode is handed to draw - see the original App.tsx for why there are only two * shapes for four modes. */ type RunTarget = | { kind: "letters"; letters: readonly string[] } | { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean }; const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles"]; 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("map"); const [lessonId, setLessonId] = useState(null); const [mode, setMode] = useState("dive"); const [outcome, setOutcome] = useState(null); /** The unlock celebration, in front of the result sheet - see RewardUnlockOverlay. * Separate from `outcome.unlockedReward` because it is dismissed on its own, leaving * the result sheet (and its recap of the same reward) behind it. */ const [celebration, setCelebration] = useState(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) => { setCelebration(null); 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, }); // The unlock celebration brings its own sounds (and is the bigger moment), so // the generic fanfare would only step on its opening. One or the other. if (recorded.unlockedReward) { setCelebration(recorded.unlockedReward); } else if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) { playFanfare(); } }); }, [lessonId, progress, recordRun], ); const retry = useCallback(() => { setCelebration(null); 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) => { setCelebration(null); setMode(bonusMode); setOutcome(null); setRound((r) => r + 1); }, []); const continueAfterResult = useCallback(() => { const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null; setCelebration(null); setOutcome(null); if (next && progress?.lessons[next.id]?.unlocked) start(next); else setScreen("map"); }, [lessonId, curriculum, progress, start]); const goBack = useCallback(() => { if (celebration) return setCelebration(null); if (outcome) return setOutcome(null); if (screen === "run") return setScreen("map"); if (screen === "map") return onExit(); }, [celebration, outcome, screen, onExit]); // --- navigation keys ----------------------------------------------------- const latest = useRef({ screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum }); latest.current = { screen, outcome, celebration, 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 dismisses the unlock celebration. It must be handled before the result // sheet's own Enter below, or finishing a reward run would restart it instantly // from behind the overlay. if (current.celebration) { if (event.key === "Enter") { event.preventDefault(); setCelebration(null); } 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(); 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 (celebration) 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, celebration, progress, saveSettings]); // --- render -------------------------------------------------------------- if (curriculumLoading || (configured && progressLoading) || !progress) { return ( ); } if (!curriculum) { return ( ); } const next = lessonId ? nextLesson(curriculum, lessonId) : null; const bestAnimal = overallBestAnimal(progress); // 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) => ({ chunks: r.chunks, text: r.text, spaceActive: r.spaceActive, ...shared, }); return ( {screen === "map" && ( <>
{curriculum.worlds.map((world) => { const creature = creatureById(world.reward); const owned = progress.aquarium.includes(creature.id); return ( ); })}
🔥 {progress.streak.days} {bestAnimal && ( {animalById(bestAnimal).emoji} {animalById(bestAnimal).name} )} )} {!progress.settings.sound && 🔇} } /> {screen === "map" && ( nextUp && start(nextUp)} /> )} {screen === "run" && lesson && run && ( <> {run.kind === "letters" ? ( ) : mode === "feed" ? ( ) : mode === "race" ? ( ) : ( )} )} {outcome && lesson && ( )} {celebration && ( setCelebration(null)} /> )} {showHelp && setShowHelp(false)} />}
); } function LoadingScreen({ text }: { text: string }) { return (
{text}
); }