First implementation of 10 finger typing

This commit is contained in:
2026-09-11 21:55:45 +02:00
parent 7e5fd5ab75
commit f97de193d8
63 changed files with 8275 additions and 0 deletions

333
tippen/src/App.tsx Normal file
View File

@@ -0,0 +1,333 @@
/** 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 { BlasenRun } from "./components/modes/BlasenRun";
import { FuetternRun } from "./components/modes/FuetternRun";
import { PerlenRun } from "./components/modes/PerlenRun";
import { QuallenRun } from "./components/modes/QuallenRun";
import { RennenRun } from "./components/modes/RennenRun";
import { TauchgangRun } from "./components/modes/TauchgangRun";
import type { KreaturId } 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 { besteTier, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress";
import type { Progress } from "./lib/progress";
import { say, stop as stopSpeech } from "./lib/speech";
import { blasenAnzahlFuer } from "./lib/theme";
type Screen = "aquarium" | "karte" | "lauf";
/** What a mode is handed to draw. Tagged rather than optional-fielded so the render
* below narrows on `art` 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 Lauf =
| { art: "buchstaben"; letters: readonly string[] }
| { art: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
/** Modes that drill one key at a time rather than a line. */
const BUCHSTABEN_MODI: readonly ModeId[] = ["blasen", "quallen"];
interface Ergebnis {
result: RunResult;
unlockedTitel: string | null;
neuesTier: KreaturId | null;
bestseit: boolean;
}
export function App() {
const [progress, setProgress] = useState<Progress>(loadProgress);
const [screen, setScreen] = useState<Screen>("aquarium");
const [lessonId, setLessonId] = useState<string | null>(null);
const [modus, setModus] = useState<ModeId>("tauchgang");
const [ergebnis, setErgebnis] = useState<Ergebnis | 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 [runde, setRunde] = 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 weiter = useMemo(() => {
const offen = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked);
return offen.find((l) => (progress.lessons[l.id]?.bestSterne ?? 0) < 2) ?? offen.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 lauf = useMemo((): Lauf | null => {
if (!lesson) return null;
const seed = lesson.nummer * 1000 + runde * 7 + (modus === "blasen" ? 3 : 0);
const rng = mulberry32(seed);
const focusKey = focusKeyFor(progress, lesson.activeKeys);
const spaceActive = lesson.activeKeys.includes(" ");
if (BUCHSTABEN_MODI.includes(modus)) {
return {
art: "buchstaben",
letters: letterStream(lesson.activeKeys, rng, blasenAnzahlFuer(lesson.welt), focusKey, lesson.neueKeys),
};
}
const chunks = lineFor(lesson, rng, {
chunks: lesson.chunks,
chunkSize: lesson.chunkSize,
focusKey,
});
return { art: "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, modus, runde]);
const starte = useCallback(
(ziel: Lesson) => {
setLessonId(ziel.id);
setModus(ziel.modi.includes(modus) ? modus : "tauchgang");
setErgebnis(null);
setRunde((r) => r + 1);
setScreen("lauf");
say(ziel.titel, progress.settings.speech);
},
[modus, progress.settings.speech],
);
const onFinished = useCallback(
(result: RunResult) => {
if (!lessonId) return;
const outcome = recordRun(progress, lessonId, result);
setProgress(outcome.progress);
setErgebnis({
result,
unlockedTitel: outcome.unlockedLessonId
? (lessonById(outcome.unlockedLessonId)?.titel ?? null)
: null,
neuesTier: outcome.neuesTier,
bestseit: outcome.bestseit,
});
if (progress.settings.sound && (outcome.unlockedLessonId || outcome.neuesTier)) {
playFanfare();
}
},
[lessonId, progress],
);
const nochmal = useCallback(() => {
setErgebnis(null);
setRunde((r) => r + 1);
}, []);
const weiterNachErgebnis = useCallback(() => {
const folgend = lessonId ? nextLesson(lessonId) : null;
setErgebnis(null);
if (folgend && progress.lessons[folgend.id]?.unlocked) starte(folgend);
else setScreen("karte");
}, [lessonId, progress, starte]);
const zurueck = useCallback(() => {
stopSpeech();
if (ergebnis) return setErgebnis(null);
if (screen === "lauf") return setScreen("karte");
if (screen === "karte") return setScreen("aquarium");
}, [ergebnis, screen]);
// --- navigation keys -----------------------------------------------------
const latest = useRef({ screen, ergebnis, selected, zurueck, nochmal, weiter, starte });
latest.current = { screen, ergebnis, selected, zurueck, nochmal, weiter, starte };
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.zurueck();
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.ergebnis) {
if (event.key === "Enter") {
event.preventDefault();
current.nochmal();
}
return;
}
// Everything below is navigation, and must not fire while typing.
if (current.screen === "lauf") return;
if (event.key === "Enter") {
event.preventDefault();
if (current.screen === "aquarium") {
if (current.weiter) current.starte(current.weiter);
} else {
const lesson = LESSONS[current.selected];
if (lesson) current.starte(lesson);
}
return;
}
if (current.screen !== "karte") return;
const schritt =
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (schritt !== 0) {
event.preventDefault();
playPop(340);
setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + schritt)));
}
};
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 === "lauf" && !ergebnis) return;
const key = event.key.toLowerCase();
if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } }));
if (key === "s") setProgress((p) => ({ ...p, settings: { ...p.settings, speech: !p.settings.speech } }));
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, ergebnis]);
// --- render --------------------------------------------------------------
const folgend = 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 gemeinsam = {
activeKeys: lesson?.activeKeys ?? [],
progress,
paused: ergebnis !== null,
onFinished,
};
const buchstabenProps = (letters: readonly string[]) => ({ letters, ...gemeinsam });
const textProps = (l: Extract<Lauf, { art: "text" }>) => ({
chunks: l.chunks,
text: l.text,
spaceActive: l.spaceActive,
...gemeinsam,
});
return (
<Stage tiere={progress.aquarium} gedaempft={screen === "lauf"}>
<AppHeader
title={screen === "lauf" && lesson ? lesson.titel : "Delfin Tippen"}
compact={screen === "lauf"}
status={
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
<span>🦪 {progress.perlen}</span>
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
</div>
}
/>
{screen === "aquarium" && (
<Aquarium
progress={progress}
weiter={weiter}
onWeiter={() => weiter && starte(weiter)}
onKarte={() => setScreen("karte")}
/>
)}
{screen === "karte" && (
<LessonMap progress={progress} selected={selected} onPick={starte} />
)}
{screen === "lauf" && lesson && lauf && (
<>
{lauf.art === "buchstaben" ? (
modus === "quallen" ? (
<QuallenRun {...buchstabenProps(lauf.letters)} />
) : (
<BlasenRun {...buchstabenProps(lauf.letters)} />
)
) : modus === "fuettern" ? (
<FuetternRun {...textProps(lauf)} />
) : modus === "perlen" ? (
<PerlenRun {...textProps(lauf)} />
) : modus === "rennen" ? (
<RennenRun {...textProps(lauf)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
) : (
<TauchgangRun {...textProps(lauf)} />
)}
<div style={{ padding: "0 32px 18px", flex: "none" }}>
<ModePicker
modi={lesson.modi}
aktiv={modus}
onPick={(gewaehlt) => {
setModus(gewaehlt);
setRunde((r) => r + 1);
}}
/>
</div>
</>
)}
{ergebnis && (
<ResultSheet
result={ergebnis.result}
unlockedTitel={ergebnis.unlockedTitel}
neuesTier={ergebnis.neuesTier}
bestseit={ergebnis.bestseit}
bestEver={besteTier(progress)}
onNochmal={nochmal}
onWeiter={weiterNachErgebnis}
weiterLabel={
folgend && progress.lessons[folgend.id]?.unlocked ? `${folgend.titel}` : "Zur Karte"
}
/>
)}
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</Stage>
);
}