Anglicize tippen's codebase and finish pending UI/curriculum cleanup
- Rename all German identifiers, types, mode ids, file names, CSS classes and data-attributes to English throughout tippen/src; only user-facing text (lesson titles, word lists, labels, spoken praise) stays German. - Add a word/nonsense-word list to the "Übung: die Grundstellung" home-row lesson in the curriculum. - Remove the unused HandHint component and speech.ts, and carry forward the in-progress App.tsx/component/generator/progress edits from other sessions. - Refresh the regenerated music-library cache index. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,79 +18,80 @@ 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 { 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 { besteTier, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress";
|
||||
import { overallBestAnimal, 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";
|
||||
import { bubbleCountFor } from "./lib/theme";
|
||||
|
||||
type Screen = "aquarium" | "karte" | "lauf";
|
||||
type Screen = "aquarium" | "map" | "run";
|
||||
|
||||
/** 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.
|
||||
* 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 Lauf =
|
||||
| { art: "buchstaben"; letters: readonly string[] }
|
||||
| { art: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
||||
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 BUCHSTABEN_MODI: readonly ModeId[] = ["blasen", "quallen"];
|
||||
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
|
||||
|
||||
interface Ergebnis {
|
||||
interface Outcome {
|
||||
result: RunResult;
|
||||
unlockedTitel: string | null;
|
||||
neuesTier: KreaturId | null;
|
||||
bestseit: boolean;
|
||||
unlockedTitle: string | null;
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: 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 [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 [runde, setRunde] = useState(0);
|
||||
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 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;
|
||||
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 lauf = useMemo((): Lauf | null => {
|
||||
const run = useMemo((): RunTarget | null => {
|
||||
if (!lesson) return null;
|
||||
const seed = lesson.nummer * 1000 + runde * 7 + (modus === "blasen" ? 3 : 0);
|
||||
const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0);
|
||||
const rng = mulberry32(seed);
|
||||
const focusKey = focusKeyFor(progress, lesson.activeKeys);
|
||||
const spaceActive = lesson.activeKeys.includes(" ");
|
||||
// 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 (BUCHSTABEN_MODI.includes(modus)) {
|
||||
if (LETTER_ONLY_MODES.includes(mode)) {
|
||||
return {
|
||||
art: "buchstaben",
|
||||
letters: letterStream(lesson.activeKeys, rng, blasenAnzahlFuer(lesson.welt), focusKey, lesson.neueKeys),
|
||||
kind: "letters",
|
||||
letters: letterStream(lesson.activeKeys, rng, bubbleCountFor(lesson.world), focusKey, lesson.newKeys),
|
||||
};
|
||||
}
|
||||
const chunks = lineFor(lesson, rng, {
|
||||
@@ -98,68 +99,66 @@ export function App() {
|
||||
chunkSize: lesson.chunkSize,
|
||||
focusKey,
|
||||
});
|
||||
return { art: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
|
||||
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, modus, runde]);
|
||||
}, [lesson, mode, round]);
|
||||
|
||||
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);
|
||||
const start = useCallback(
|
||||
(lesson: Lesson) => {
|
||||
setLessonId(lesson.id);
|
||||
setMode(lesson.modes.includes(mode) ? mode : "dive");
|
||||
setOutcome(null);
|
||||
setRound((r) => r + 1);
|
||||
setScreen("run");
|
||||
},
|
||||
[modus, progress.settings.speech],
|
||||
[mode],
|
||||
);
|
||||
|
||||
const onFinished = useCallback(
|
||||
(result: RunResult) => {
|
||||
if (!lessonId) return;
|
||||
const outcome = recordRun(progress, lessonId, result);
|
||||
setProgress(outcome.progress);
|
||||
setErgebnis({
|
||||
const recorded = recordRun(progress, lessonId, result);
|
||||
setProgress(recorded.progress);
|
||||
setOutcome({
|
||||
result,
|
||||
unlockedTitel: outcome.unlockedLessonId
|
||||
? (lessonById(outcome.unlockedLessonId)?.titel ?? null)
|
||||
unlockedTitle: recorded.unlockedLessonId
|
||||
? (lessonById(recorded.unlockedLessonId)?.title ?? null)
|
||||
: null,
|
||||
neuesTier: outcome.neuesTier,
|
||||
bestseit: outcome.bestseit,
|
||||
newCreature: recorded.newCreature,
|
||||
isNewBest: recorded.isNewBest,
|
||||
});
|
||||
if (progress.settings.sound && (outcome.unlockedLessonId || outcome.neuesTier)) {
|
||||
if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
|
||||
playFanfare();
|
||||
}
|
||||
},
|
||||
[lessonId, progress],
|
||||
);
|
||||
|
||||
const nochmal = useCallback(() => {
|
||||
setErgebnis(null);
|
||||
setRunde((r) => r + 1);
|
||||
const retry = useCallback(() => {
|
||||
setOutcome(null);
|
||||
setRound((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 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 zurueck = useCallback(() => {
|
||||
stopSpeech();
|
||||
if (ergebnis) return setErgebnis(null);
|
||||
if (screen === "lauf") return setScreen("karte");
|
||||
if (screen === "karte") return setScreen("aquarium");
|
||||
}, [ergebnis, screen]);
|
||||
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, ergebnis, selected, zurueck, nochmal, weiter, starte });
|
||||
latest.current = { screen, ergebnis, selected, zurueck, nochmal, weiter, starte };
|
||||
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start });
|
||||
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start };
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -173,41 +172,41 @@ export function App() {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setShowHelp(false);
|
||||
current.zurueck();
|
||||
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.ergebnis) {
|
||||
if (current.outcome) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
current.nochmal();
|
||||
current.retry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything below is navigation, and must not fire while typing.
|
||||
if (current.screen === "lauf") return;
|
||||
if (current.screen === "run") return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (current.screen === "aquarium") {
|
||||
if (current.weiter) current.starte(current.weiter);
|
||||
if (current.nextUp) current.start(current.nextUp);
|
||||
} else {
|
||||
const lesson = LESSONS[current.selected];
|
||||
if (lesson) current.starte(lesson);
|
||||
if (lesson) current.start(lesson);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.screen !== "karte") return;
|
||||
const schritt =
|
||||
if (current.screen !== "map") return;
|
||||
const step =
|
||||
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
||||
if (schritt !== 0) {
|
||||
if (step !== 0) {
|
||||
event.preventDefault();
|
||||
playPop(340);
|
||||
setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + schritt)));
|
||||
setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + step)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,10 +217,9 @@ export function App() {
|
||||
// Settings keys live outside a run, where they cannot collide with the alphabet.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (screen === "lauf" && !ergebnis) return;
|
||||
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 === "s") setProgress((p) => ({ ...p, settings: { ...p.settings, speech: !p.settings.speech } }));
|
||||
if (key === "h") {
|
||||
setProgress((p) => ({
|
||||
...p,
|
||||
@@ -234,36 +232,36 @@ export function App() {
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [screen, ergebnis]);
|
||||
}, [screen, outcome]);
|
||||
|
||||
// --- render --------------------------------------------------------------
|
||||
|
||||
const folgend = lessonId ? nextLesson(lessonId) : null;
|
||||
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 gemeinsam = {
|
||||
const shared = {
|
||||
activeKeys: lesson?.activeKeys ?? [],
|
||||
progress,
|
||||
paused: ergebnis !== null,
|
||||
paused: outcome !== 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,
|
||||
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 tiere={progress.aquarium} gedaempft={screen === "lauf"}>
|
||||
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
|
||||
<AppHeader
|
||||
title={screen === "lauf" && lesson ? lesson.titel : "Delfin Tippen"}
|
||||
compact={screen === "lauf"}
|
||||
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.perlen}</span>
|
||||
<span>🦪 {progress.pearls}</span>
|
||||
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
||||
</div>
|
||||
}
|
||||
@@ -272,57 +270,57 @@ export function App() {
|
||||
{screen === "aquarium" && (
|
||||
<Aquarium
|
||||
progress={progress}
|
||||
weiter={weiter}
|
||||
onWeiter={() => weiter && starte(weiter)}
|
||||
onKarte={() => setScreen("karte")}
|
||||
nextLesson={nextUp}
|
||||
onContinue={() => nextUp && start(nextUp)}
|
||||
onOpenMap={() => setScreen("map")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === "karte" && (
|
||||
<LessonMap progress={progress} selected={selected} onPick={starte} />
|
||||
{screen === "map" && (
|
||||
<LessonMap progress={progress} selected={selected} onPick={start} />
|
||||
)}
|
||||
|
||||
{screen === "lauf" && lesson && lauf && (
|
||||
{screen === "run" && lesson && run && (
|
||||
<>
|
||||
{lauf.art === "buchstaben" ? (
|
||||
modus === "quallen" ? (
|
||||
<QuallenRun {...buchstabenProps(lauf.letters)} />
|
||||
{run.kind === "letters" ? (
|
||||
mode === "jellyfish" ? (
|
||||
<JellyfishRun {...letterProps(run.letters)} />
|
||||
) : (
|
||||
<BlasenRun {...buchstabenProps(lauf.letters)} />
|
||||
<BubblesRun {...letterProps(run.letters)} />
|
||||
)
|
||||
) : modus === "fuettern" ? (
|
||||
<FuetternRun {...textProps(lauf)} />
|
||||
) : modus === "perlen" ? (
|
||||
<PerlenRun {...textProps(lauf)} />
|
||||
) : modus === "rennen" ? (
|
||||
<RennenRun {...textProps(lauf)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
|
||||
) : mode === "feed" ? (
|
||||
<FeedRun {...textProps(run)} />
|
||||
) : mode === "pearls" ? (
|
||||
<PearlsRun {...textProps(run)} />
|
||||
) : mode === "race" ? (
|
||||
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
|
||||
) : (
|
||||
<TauchgangRun {...textProps(lauf)} />
|
||||
<DiveRun {...textProps(run)} />
|
||||
)}
|
||||
<div style={{ padding: "0 32px 18px", flex: "none" }}>
|
||||
<ModePicker
|
||||
modi={lesson.modi}
|
||||
aktiv={modus}
|
||||
onPick={(gewaehlt) => {
|
||||
setModus(gewaehlt);
|
||||
setRunde((r) => r + 1);
|
||||
modes={lesson.modes}
|
||||
active={mode}
|
||||
onPick={(chosen) => {
|
||||
setMode(chosen);
|
||||
setRound((r) => r + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ergebnis && (
|
||||
{outcome && (
|
||||
<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"
|
||||
result={outcome.result}
|
||||
unlockedTitle={outcome.unlockedTitle}
|
||||
newCreature={outcome.newCreature}
|
||||
isNewBest={outcome.isNewBest}
|
||||
bestEver={overallBestAnimal(progress)}
|
||||
onRetry={retry}
|
||||
onContinue={continueAfterResult}
|
||||
continueLabel={
|
||||
next && progress.lessons[next.id]?.unlocked ? `${next.title} ▶` : "Zur Karte"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user