/** The whole app: one state object, one keydown listener for navigation, four screens. * * The same shape as ../../web/src/App.tsx - state lives here, components are layout, and * the keyboard is handled in one place rather than scattered through the tree. * * One rule is worth stating because it is easy to break later: **while a run is going, * every key belongs to the run**. Only Escape and F1 are intercepted here. Otherwise a * lesson that happens to teach `m` would mute the game every time she typed it, and the * mute would look to her like the game breaking. The run's own listener lives in * hooks/useRun.ts and does the typing; this one only handles the screens around it. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Aquarium } from "./components/Aquarium"; import { AppHeader } from "./components/AppHeader"; import { HelpOverlay } from "./components/HelpOverlay"; import { LessonMap } from "./components/LessonMap"; import { ModePicker } from "./components/ModePicker"; import { ResultSheet } from "./components/ResultSheet"; import { Stage } from "./components/Stage"; import { BubblesRun } from "./components/modes/BubblesRun"; import { FeedRun } from "./components/modes/FeedRun"; import { PearlsRun } from "./components/modes/PearlsRun"; import { JellyfishRun } from "./components/modes/JellyfishRun"; import { RaceRun } from "./components/modes/RaceRun"; import { DiveRun } from "./components/modes/DiveRun"; import type { CreatureId } from "./lib/aquarium"; import { LESSONS, lessonById, nextLesson } from "./lib/curriculum"; import type { Lesson, ModeId } from "./lib/curriculum"; import { lineFor, lineText, letterStream, mulberry32 } from "./lib/generator"; import type { RunResult } from "./lib/grading"; import { playFanfare, playPop } from "./lib/pop"; import { overallBestAnimal, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress"; import type { Progress } from "./lib/progress"; import { bubbleCountFor } from "./lib/theme"; type Screen = "aquarium" | "map" | "run"; /** What a mode is handed to draw. Tagged rather than optional-fielded so the render * below narrows on `kind` instead of guessing from which keys are present. * * Only two shapes, for six modes: the arcade modes want a stream of single letters, and * everything else wants a line of chunks. That is the whole reason the modes are cheap * to add - they are presentations of one of two targets, all driven by the same engine. */ type RunTarget = | { kind: "letters"; letters: readonly string[] } | { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean }; /** Modes that drill one key at a time rather than a line. */ const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"]; interface Outcome { result: RunResult; unlockedTitle: string | null; newCreature: CreatureId | null; isNewBest: boolean; } export function App() { const [progress, setProgress] = useState(loadProgress); const [screen, setScreen] = useState("aquarium"); const [lessonId, setLessonId] = useState(null); const [mode, setMode] = useState("dive"); const [outcome, setOutcome] = 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); useEffect(() => saveProgress(progress), [progress]); const lesson = lessonId === null ? null : lessonById(lessonId); /** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */ const nextUp = useMemo(() => { const unlocked = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked); return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null; }, [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) 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)) { return { kind: "letters", letters: letterStream(lesson.activeKeys, rng, bubbleCountFor(lesson.world), focusKey, lesson.newKeys), }; } 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: the focus key is read once when the // line is built, and re-reading it after every keystroke would rebuild the line // underneath her fingers. // eslint-disable-next-line react-hooks/exhaustive-deps }, [lesson, mode, round]); const start = useCallback( (lesson: Lesson) => { setLessonId(lesson.id); setMode(lesson.modes.includes(mode) ? mode : "dive"); setOutcome(null); setRound((r) => r + 1); setScreen("run"); }, [mode], ); const onFinished = useCallback( (result: RunResult) => { if (!lessonId) return; const recorded = recordRun(progress, lessonId, result); setProgress(recorded.progress); setOutcome({ result, unlockedTitle: recorded.unlockedLessonId ? (lessonById(recorded.unlockedLessonId)?.title ?? null) : null, newCreature: recorded.newCreature, isNewBest: recorded.isNewBest, }); if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) { playFanfare(); } }, [lessonId, progress], ); const retry = useCallback(() => { setOutcome(null); setRound((r) => r + 1); }, []); const continueAfterResult = useCallback(() => { const next = lessonId ? nextLesson(lessonId) : null; setOutcome(null); if (next && progress.lessons[next.id]?.unlocked) start(next); else setScreen("map"); }, [lessonId, progress, start]); const goBack = useCallback(() => { if (outcome) return setOutcome(null); if (screen === "run") return setScreen("map"); if (screen === "map") return setScreen("aquarium"); }, [outcome, screen]); // --- navigation keys ----------------------------------------------------- const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start }); latest.current = { screen, outcome, selected, goBack, retry, nextUp, start }; 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 (event.key === "Enter") { event.preventDefault(); if (current.screen === "aquarium") { if (current.nextUp) current.start(current.nextUp); } else { const lesson = LESSONS[current.selected]; if (lesson) current.start(lesson); } return; } if (current.screen !== "map") return; const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; if (step !== 0) { event.preventDefault(); playPop(340); setSelected((index) => Math.min(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; const key = event.key.toLowerCase(); if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } })); if (key === "h") { setProgress((p) => ({ ...p, settings: { ...p.settings, keyboardHint: p.settings.keyboardHint === "off" ? "auto" : "off", }, })); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [screen, outcome]); // --- render -------------------------------------------------------------- const next = lessonId ? nextLesson(lessonId) : null; // Every mode takes the same bundle; only the drawing differs. Built here so adding a // seventh mode is one line in the switch below rather than eight repeated props. 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 ( 🦪 {progress.pearls} {!progress.settings.sound && 🔇} } /> {screen === "aquarium" && ( nextUp && start(nextUp)} onOpenMap={() => setScreen("map")} /> )} {screen === "map" && ( )} {screen === "run" && lesson && run && ( <> {run.kind === "letters" ? ( mode === "jellyfish" ? ( ) : ( ) ) : mode === "feed" ? ( ) : mode === "pearls" ? ( ) : mode === "race" ? ( ) : ( )}
{ setMode(chosen); setRound((r) => r + 1); }} />
)} {outcome && ( )} {showHelp && setShowHelp(false)} />}
); }