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:
2026-09-12 00:07:18 +02:00
parent f97de193d8
commit 498243af46
38 changed files with 1425 additions and 1599 deletions

File diff suppressed because one or more lines are too long

View File

@@ -18,79 +18,80 @@ import { LessonMap } from "./components/LessonMap";
import { ModePicker } from "./components/ModePicker"; import { ModePicker } from "./components/ModePicker";
import { ResultSheet } from "./components/ResultSheet"; import { ResultSheet } from "./components/ResultSheet";
import { Stage } from "./components/Stage"; import { Stage } from "./components/Stage";
import { BlasenRun } from "./components/modes/BlasenRun"; import { BubblesRun } from "./components/modes/BubblesRun";
import { FuetternRun } from "./components/modes/FuetternRun"; import { FeedRun } from "./components/modes/FeedRun";
import { PerlenRun } from "./components/modes/PerlenRun"; import { PearlsRun } from "./components/modes/PearlsRun";
import { QuallenRun } from "./components/modes/QuallenRun"; import { JellyfishRun } from "./components/modes/JellyfishRun";
import { RennenRun } from "./components/modes/RennenRun"; import { RaceRun } from "./components/modes/RaceRun";
import { TauchgangRun } from "./components/modes/TauchgangRun"; import { DiveRun } from "./components/modes/DiveRun";
import type { KreaturId } from "./lib/aquarium"; import type { CreatureId } from "./lib/aquarium";
import { LESSONS, lessonById, nextLesson } from "./lib/curriculum"; import { LESSONS, lessonById, nextLesson } from "./lib/curriculum";
import type { Lesson, ModeId } from "./lib/curriculum"; import type { Lesson, ModeId } from "./lib/curriculum";
import { lineFor, lineText, letterStream, mulberry32 } from "./lib/generator"; import { lineFor, lineText, letterStream, mulberry32 } from "./lib/generator";
import type { RunResult } from "./lib/grading"; import type { RunResult } from "./lib/grading";
import { playFanfare, playPop } from "./lib/pop"; 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 type { Progress } from "./lib/progress";
import { say, stop as stopSpeech } from "./lib/speech"; import { bubbleCountFor } from "./lib/theme";
import { blasenAnzahlFuer } 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 /** 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 * 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 * 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. */ * to add - they are presentations of one of two targets, all driven by the same engine. */
type Lauf = type RunTarget =
| { art: "buchstaben"; letters: readonly string[] } | { kind: "letters"; letters: readonly string[] }
| { art: "text"; chunks: readonly string[]; text: string; spaceActive: boolean }; | { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
/** Modes that drill one key at a time rather than a line. */ /** 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; result: RunResult;
unlockedTitel: string | null; unlockedTitle: string | null;
neuesTier: KreaturId | null; newCreature: CreatureId | null;
bestseit: boolean; isNewBest: boolean;
} }
export function App() { export function App() {
const [progress, setProgress] = useState<Progress>(loadProgress); const [progress, setProgress] = useState<Progress>(loadProgress);
const [screen, setScreen] = useState<Screen>("aquarium"); const [screen, setScreen] = useState<Screen>("aquarium");
const [lessonId, setLessonId] = useState<string | null>(null); const [lessonId, setLessonId] = useState<string | null>(null);
const [modus, setModus] = useState<ModeId>("tauchgang"); const [mode, setMode] = useState<ModeId>("dive");
const [ergebnis, setErgebnis] = useState<Ergebnis | null>(null); const [outcome, setOutcome] = useState<Outcome | null>(null);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [selected, setSelected] = useState(0); const [selected, setSelected] = useState(0);
/** Bumped to generate a fresh line - a new seed for the same lesson. */ /** 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]); useEffect(() => saveProgress(progress), [progress]);
const lesson = lessonId === null ? null : lessonById(lessonId); const lesson = lessonId === null ? null : lessonById(lessonId);
/** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */ /** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
const weiter = useMemo(() => { const nextUp = useMemo(() => {
const offen = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked); const unlocked = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked);
return offen.find((l) => (progress.lessons[l.id]?.bestSterne ?? 0) < 2) ?? offen.at(-1) ?? null; return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null;
}, [progress]); }, [progress]);
/** The line for this run. Reproducible from the lesson, the mode and the round /** 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. */ * 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; 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 rng = mulberry32(seed);
const focusKey = focusKeyFor(progress, lesson.activeKeys); 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 { return {
art: "buchstaben", kind: "letters",
letters: letterStream(lesson.activeKeys, rng, blasenAnzahlFuer(lesson.welt), focusKey, lesson.neueKeys), letters: letterStream(lesson.activeKeys, rng, bubbleCountFor(lesson.world), focusKey, lesson.newKeys),
}; };
} }
const chunks = lineFor(lesson, rng, { const chunks = lineFor(lesson, rng, {
@@ -98,68 +99,66 @@ export function App() {
chunkSize: lesson.chunkSize, chunkSize: lesson.chunkSize,
focusKey, 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 // `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 // line is built, and re-reading it after every keystroke would rebuild the line
// underneath her fingers. // underneath her fingers.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lesson, modus, runde]); }, [lesson, mode, round]);
const starte = useCallback( const start = useCallback(
(ziel: Lesson) => { (lesson: Lesson) => {
setLessonId(ziel.id); setLessonId(lesson.id);
setModus(ziel.modi.includes(modus) ? modus : "tauchgang"); setMode(lesson.modes.includes(mode) ? mode : "dive");
setErgebnis(null); setOutcome(null);
setRunde((r) => r + 1); setRound((r) => r + 1);
setScreen("lauf"); setScreen("run");
say(ziel.titel, progress.settings.speech);
}, },
[modus, progress.settings.speech], [mode],
); );
const onFinished = useCallback( const onFinished = useCallback(
(result: RunResult) => { (result: RunResult) => {
if (!lessonId) return; if (!lessonId) return;
const outcome = recordRun(progress, lessonId, result); const recorded = recordRun(progress, lessonId, result);
setProgress(outcome.progress); setProgress(recorded.progress);
setErgebnis({ setOutcome({
result, result,
unlockedTitel: outcome.unlockedLessonId unlockedTitle: recorded.unlockedLessonId
? (lessonById(outcome.unlockedLessonId)?.titel ?? null) ? (lessonById(recorded.unlockedLessonId)?.title ?? null)
: null, : null,
neuesTier: outcome.neuesTier, newCreature: recorded.newCreature,
bestseit: outcome.bestseit, isNewBest: recorded.isNewBest,
}); });
if (progress.settings.sound && (outcome.unlockedLessonId || outcome.neuesTier)) { if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
playFanfare(); playFanfare();
} }
}, },
[lessonId, progress], [lessonId, progress],
); );
const nochmal = useCallback(() => { const retry = useCallback(() => {
setErgebnis(null); setOutcome(null);
setRunde((r) => r + 1); setRound((r) => r + 1);
}, []); }, []);
const weiterNachErgebnis = useCallback(() => { const continueAfterResult = useCallback(() => {
const folgend = lessonId ? nextLesson(lessonId) : null; const next = lessonId ? nextLesson(lessonId) : null;
setErgebnis(null); setOutcome(null);
if (folgend && progress.lessons[folgend.id]?.unlocked) starte(folgend); if (next && progress.lessons[next.id]?.unlocked) start(next);
else setScreen("karte"); else setScreen("map");
}, [lessonId, progress, starte]); }, [lessonId, progress, start]);
const zurueck = useCallback(() => { const goBack = useCallback(() => {
stopSpeech(); if (outcome) return setOutcome(null);
if (ergebnis) return setErgebnis(null); if (screen === "run") return setScreen("map");
if (screen === "lauf") return setScreen("karte"); if (screen === "map") return setScreen("aquarium");
if (screen === "karte") return setScreen("aquarium"); }, [outcome, screen]);
}, [ergebnis, screen]);
// --- navigation keys ----------------------------------------------------- // --- navigation keys -----------------------------------------------------
const latest = useRef({ screen, ergebnis, selected, zurueck, nochmal, weiter, starte }); const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start });
latest.current = { screen, ergebnis, selected, zurueck, nochmal, weiter, starte }; latest.current = { screen, outcome, selected, goBack, retry, nextUp, start };
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -173,41 +172,41 @@ export function App() {
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
setShowHelp(false); setShowHelp(false);
current.zurueck(); current.goBack();
return; return;
} }
// Enter repeats a finished run; the result sheet's own button has focus, so this // 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. // is only a fallback for when focus has been lost.
if (current.ergebnis) { if (current.outcome) {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
current.nochmal(); current.retry();
} }
return; return;
} }
// Everything below is navigation, and must not fire while typing. // Everything below is navigation, and must not fire while typing.
if (current.screen === "lauf") return; if (current.screen === "run") return;
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
if (current.screen === "aquarium") { if (current.screen === "aquarium") {
if (current.weiter) current.starte(current.weiter); if (current.nextUp) current.start(current.nextUp);
} else { } else {
const lesson = LESSONS[current.selected]; const lesson = LESSONS[current.selected];
if (lesson) current.starte(lesson); if (lesson) current.start(lesson);
} }
return; return;
} }
if (current.screen !== "karte") return; if (current.screen !== "map") return;
const schritt = const step =
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (schritt !== 0) { if (step !== 0) {
event.preventDefault(); event.preventDefault();
playPop(340); 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. // Settings keys live outside a run, where they cannot collide with the alphabet.
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (screen === "lauf" && !ergebnis) return; if (screen === "run" && !outcome) return;
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } })); 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") { if (key === "h") {
setProgress((p) => ({ setProgress((p) => ({
...p, ...p,
@@ -234,36 +232,36 @@ export function App() {
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [screen, ergebnis]); }, [screen, outcome]);
// --- render -------------------------------------------------------------- // --- 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 // 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. // seventh mode is one line in the switch below rather than eight repeated props.
const gemeinsam = { const shared = {
activeKeys: lesson?.activeKeys ?? [], activeKeys: lesson?.activeKeys ?? [],
progress, progress,
paused: ergebnis !== null, paused: outcome !== null,
onFinished, onFinished,
}; };
const buchstabenProps = (letters: readonly string[]) => ({ letters, ...gemeinsam }); const letterProps = (letters: readonly string[]) => ({ letters, ...shared });
const textProps = (l: Extract<Lauf, { art: "text" }>) => ({ const textProps = (r: Extract<RunTarget, { kind: "text" }>) => ({
chunks: l.chunks, chunks: r.chunks,
text: l.text, text: r.text,
spaceActive: l.spaceActive, spaceActive: r.spaceActive,
...gemeinsam, ...shared,
}); });
return ( return (
<Stage tiere={progress.aquarium} gedaempft={screen === "lauf"}> <Stage creatures={progress.aquarium} dimmed={screen === "run"}>
<AppHeader <AppHeader
title={screen === "lauf" && lesson ? lesson.titel : "Delfin Tippen"} title={screen === "run" && lesson ? lesson.title : "Delfin Tippen"}
compact={screen === "lauf"} compact={screen === "run"}
status={ status={
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}> <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>} {!progress.settings.sound && <span title="Ton aus">🔇</span>}
</div> </div>
} }
@@ -272,57 +270,57 @@ export function App() {
{screen === "aquarium" && ( {screen === "aquarium" && (
<Aquarium <Aquarium
progress={progress} progress={progress}
weiter={weiter} nextLesson={nextUp}
onWeiter={() => weiter && starte(weiter)} onContinue={() => nextUp && start(nextUp)}
onKarte={() => setScreen("karte")} onOpenMap={() => setScreen("map")}
/> />
)} )}
{screen === "karte" && ( {screen === "map" && (
<LessonMap progress={progress} selected={selected} onPick={starte} /> <LessonMap progress={progress} selected={selected} onPick={start} />
)} )}
{screen === "lauf" && lesson && lauf && ( {screen === "run" && lesson && run && (
<> <>
{lauf.art === "buchstaben" ? ( {run.kind === "letters" ? (
modus === "quallen" ? ( mode === "jellyfish" ? (
<QuallenRun {...buchstabenProps(lauf.letters)} /> <JellyfishRun {...letterProps(run.letters)} />
) : ( ) : (
<BlasenRun {...buchstabenProps(lauf.letters)} /> <BubblesRun {...letterProps(run.letters)} />
) )
) : modus === "fuettern" ? ( ) : mode === "feed" ? (
<FuetternRun {...textProps(lauf)} /> <FeedRun {...textProps(run)} />
) : modus === "perlen" ? ( ) : mode === "pearls" ? (
<PerlenRun {...textProps(lauf)} /> <PearlsRun {...textProps(run)} />
) : modus === "rennen" ? ( ) : mode === "race" ? (
<RennenRun {...textProps(lauf)} ghost={progress.lessons[lesson.id]?.ghost ?? null} /> <RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
) : ( ) : (
<TauchgangRun {...textProps(lauf)} /> <DiveRun {...textProps(run)} />
)} )}
<div style={{ padding: "0 32px 18px", flex: "none" }}> <div style={{ padding: "0 32px 18px", flex: "none" }}>
<ModePicker <ModePicker
modi={lesson.modi} modes={lesson.modes}
aktiv={modus} active={mode}
onPick={(gewaehlt) => { onPick={(chosen) => {
setModus(gewaehlt); setMode(chosen);
setRunde((r) => r + 1); setRound((r) => r + 1);
}} }}
/> />
</div> </div>
</> </>
)} )}
{ergebnis && ( {outcome && (
<ResultSheet <ResultSheet
result={ergebnis.result} result={outcome.result}
unlockedTitel={ergebnis.unlockedTitel} unlockedTitle={outcome.unlockedTitle}
neuesTier={ergebnis.neuesTier} newCreature={outcome.newCreature}
bestseit={ergebnis.bestseit} isNewBest={outcome.isNewBest}
bestEver={besteTier(progress)} bestEver={overallBestAnimal(progress)}
onNochmal={nochmal} onRetry={retry}
onWeiter={weiterNachErgebnis} onContinue={continueAfterResult}
weiterLabel={ continueLabel={
folgend && progress.lessons[folgend.id]?.unlocked ? `${folgend.titel}` : "Zur Karte" next && progress.lessons[next.id]?.unlocked ? `${next.title}` : "Zur Karte"
} }
/> />
)} )}

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
interface Props { interface Props {
title?: string; title?: string;
/** Shown on the right - Perlen, streak, a back hint. */ /** Shown on the right - pearls, streak, a back hint. */
status?: ReactNode; status?: ReactNode;
/** Smaller header while a lesson is running, so the target line gets the room. */ /** Smaller header while a lesson is running, so the target line gets the room. */
compact?: boolean; compact?: boolean;

View File

@@ -1,31 +1,31 @@
/** The home screen: what she has collected, before she is asked to do anything. /** The home screen: what she has collected, before she is asked to do anything.
* *
* Deliberately the first thing on opening the app. The reward for finishing a Welt is a * Deliberately the first thing on opening the app. The reward for finishing a world is a
* pet that moves in for good - it swims behind every screen from then on (see * pet that moves in for good - it swims behind every screen from then on (see
* AquariumTiere.tsx) - and a reward you can see before you start is worth more than one * AquariumCreatures.tsx) - and a reward you can see before you start is worth more than
* you are told about afterwards. * one you are told about afterwards.
* *
* The panel shows every pet there is to earn: the ones at home in colour, the rest as * The panel shows every pet there is to earn: the ones at home in colour, the rest as
* pale outlines. The same idea as the tier ladder - the next thing has to be visible to * pale outlines. The same idea as the animal ladder - the next thing has to be visible to
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */ * be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
import { kreaturById } from "../lib/aquarium"; import { creatureById } from "../lib/aquarium";
import type { Lesson } from "../lib/curriculum"; import type { Lesson } from "../lib/curriculum";
import { WELTEN } from "../lib/curriculum"; import { WORLDS } from "../lib/curriculum";
import { tierById } from "../lib/grading"; import { animalById } from "../lib/grading";
import { besteTier } from "../lib/progress"; import { overallBestAnimal } from "../lib/progress";
import type { Progress } from "../lib/progress"; import type { Progress } from "../lib/progress";
interface Props { interface Props {
progress: Progress; progress: Progress;
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */ /** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
weiter: Lesson | null; nextLesson: Lesson | null;
onWeiter: () => void; onContinue: () => void;
onKarte: () => void; onOpenMap: () => void;
} }
export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) { export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props) {
const bestesTier = besteTier(progress); const bestAnimal = overallBestAnimal(progress);
return ( return (
<div <div
@@ -60,23 +60,23 @@ export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
alignItems: "center", alignItems: "center",
}} }}
> >
{WELTEN.map((welt) => { {WORLDS.map((world) => {
const kreatur = kreaturById(welt.belohnung); const creature = creatureById(world.reward);
const daheim = progress.aquarium.includes(kreatur.id); const owned = progress.aquarium.includes(creature.id);
return ( return (
<img <img
key={kreatur.id} key={creature.id}
src={kreatur.bild} src={creature.image}
alt={daheim ? kreatur.name : `Noch nicht da - Welt ${welt.nummer}`} alt={owned ? creature.name : `Noch nicht da - Welt ${world.number}`}
title={daheim ? kreatur.name : `Welt ${welt.nummer}`} title={owned ? creature.name : `Welt ${world.number}`}
style={{ style={{
width: 62, width: 62,
height: 62, height: 62,
objectFit: "contain", objectFit: "contain",
// Not yet earned: a pale outline of the shape, no colours given away. // Not yet earned: a pale outline of the shape, no colours given away.
filter: daheim ? "none" : "brightness(0) invert(1)", filter: owned ? "none" : "brightness(0) invert(1)",
opacity: daheim ? 1 : 0.2, opacity: owned ? 1 : 0.2,
animation: daheim ? `dolphinBob ${3 + welt.nummer * 0.4}s ease-in-out infinite` : undefined, animation: owned ? `dolphinBob ${3 + world.number * 0.4}s ease-in-out infinite` : undefined,
}} }}
/> />
); );
@@ -90,19 +90,19 @@ export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
)} )}
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}> <div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
<Stat label="Perlen" value={`🦪 ${progress.perlen}`} /> <Stat label="Perlen" value={`🦪 ${progress.pearls}`} />
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} /> <Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
<Stat <Stat
label="Schnellstes Tier" label="Schnellstes Tier"
value={bestesTier ? `${tierById(bestesTier).emoji} ${tierById(bestesTier).name}` : "—"} value={bestAnimal ? `${animalById(bestAnimal).emoji} ${animalById(bestAnimal).name}` : "—"}
/> />
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 12 }}> <div style={{ display: "flex", gap: 12 }}>
{weiter && ( {nextLesson && (
<button <button
onClick={onWeiter} onClick={onContinue}
style={{ style={{
border: "none", border: "none",
borderRadius: 999, borderRadius: 999,
@@ -115,10 +115,10 @@ export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
boxShadow: "0 8px 24px var(--shadow)", boxShadow: "0 8px 24px var(--shadow)",
}} }}
> >
{weiter.titel} {nextLesson.title}
</button> </button>
)} )}
<button className="pill" onClick={onKarte} style={{ fontSize: 16, padding: "15px 26px" }}> <button className="pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
🗺 Alle Lektionen 🗺 Alle Lektionen
</button> </button>
</div> </div>

View File

@@ -14,73 +14,73 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { kreaturById, neuerSchwimmer, pose, schwimme } from "../lib/aquarium"; import { creatureById, createSwimmer, pose, stepSwimmer } from "../lib/aquarium";
import type { KreaturId, Schwimmer } from "../lib/aquarium"; import type { CreatureId, Swimmer } from "../lib/aquarium";
interface Props { interface Props {
tiere: readonly KreaturId[]; creatures: readonly CreatureId[];
/** 0..1, eased - the pets fade back while she types. */ /** 0..1, eased - the pets fade back while she types. */
deckkraft: number; opacity: number;
} }
/** A tab in the background resumes with a gap of minutes. Swimming that in one step would /** A tab in the background resumes with a gap of minutes. Swimming that in one step would
* teleport every pet, so a frame never counts for more than this. */ * teleport every pet, so a frame never counts for more than this. */
const MAX_SCHRITT_S = 0.1; const MAX_STEP_S = 0.1;
export function AquariumTiere({ tiere, deckkraft }: Props) { export function AquariumCreatures({ creatures, opacity }: Props) {
const becken = useRef<HTMLDivElement>(null); const tank = useRef<HTMLDivElement>(null);
const bilder = useRef(new Map<KreaturId, HTMLImageElement>()); const images = useRef(new Map<CreatureId, HTMLImageElement>());
const schwimmer = useRef(new Map<KreaturId, Schwimmer>()); const swimmers = useRef(new Map<CreatureId, Swimmer>());
const aktuell = useRef(tiere); const current = useRef(creatures);
aktuell.current = tiere; current.current = creatures;
/** The pets present at first render - everyone after them is a newcomer. */ /** The pets present at first render - everyone after them is a newcomer. */
const daheim = useRef<ReadonlySet<KreaturId>>(new Set(tiere)); const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
useEffect(() => { useEffect(() => {
// Reduced motion: every pet is still placed and shown, it just holds still. // Reduced motion: every pet is still placed and shown, it just holds still.
const ruhig = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let frame = 0; let frame = 0;
let zuletzt = performance.now(); let lastTime = performance.now();
const schritt = (jetzt: number) => { const step = (now: number) => {
const dt = Math.min(MAX_SCHRITT_S, Math.max(0, (jetzt - zuletzt) / 1000)); const dt = Math.min(MAX_STEP_S, Math.max(0, (now - lastTime) / 1000));
zuletzt = jetzt; lastTime = now;
const el = becken.current; const el = tank.current;
if (el) { if (el) {
const groesse = { breite: el.clientWidth, hoehe: el.clientHeight }; const size = { width: el.clientWidth, height: el.clientHeight };
for (const id of aktuell.current) { for (const id of current.current) {
const bild = bilder.current.get(id); const image = images.current.get(id);
if (!bild) continue; if (!image) continue;
const kreatur = kreaturById(id); const creature = creatureById(id);
const hoehe = kreatur.groesse * groesse.hoehe; const height = creature.size * size.height;
const rand = hoehe / 2; const margin = height / 2;
const tempo = kreatur.tempo * groesse.breite; const speed = creature.speed * size.width;
const vorher = schwimmer.current.get(id); const previous = swimmers.current.get(id);
const s = vorher const s = previous
? ruhig ? reducedMotion
? vorher ? previous
: schwimme(vorher, dt, groesse, rand, tempo, Math.random) : stepSwimmer(previous, dt, size, margin, speed, Math.random)
: neuerSchwimmer(groesse, rand, Math.random, !ruhig && !daheim.current.has(id)); : createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id));
schwimmer.current.set(id, s); swimmers.current.set(id, s);
const p = pose(s, kreatur, tempo); const p = pose(s, creature, speed);
bild.style.height = `${hoehe}px`; image.style.height = `${height}px`;
bild.style.transform = image.style.transform =
`translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` + `translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
`rotate(${p.drehung}deg) scaleX(${p.spiegel})`; `rotate(${p.rotation}deg) scaleX(${p.mirror})`;
} }
} }
frame = requestAnimationFrame(schritt); frame = requestAnimationFrame(step);
}; };
frame = requestAnimationFrame(schritt); frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, []); }, []);
return ( return (
<div <div
ref={becken} ref={tank}
aria-hidden="true" aria-hidden="true"
style={{ style={{
position: "absolute", position: "absolute",
@@ -88,18 +88,18 @@ export function AquariumTiere({ tiere, deckkraft }: Props) {
overflow: "hidden", overflow: "hidden",
pointerEvents: "none", pointerEvents: "none",
zIndex: -1, zIndex: -1,
opacity: deckkraft, opacity,
transition: "opacity 700ms ease", transition: "opacity 700ms ease",
}} }}
> >
{tiere.map((id) => ( {creatures.map((id) => (
<img <img
key={id} key={id}
ref={(bild) => { ref={(image) => {
if (bild) bilder.current.set(id, bild); if (image) images.current.set(id, image);
else bilder.current.delete(id); else images.current.delete(id);
}} }}
src={kreaturById(id).bild} src={creatureById(id).image}
alt="" alt=""
draggable={false} draggable={false}
style={{ style={{

View File

@@ -1,54 +0,0 @@
/** Which finger types the key that is wanted right now, said in words.
*
* The keyboard shows *where*; this shows *which finger*, because at six the failure mode
* is not finding the key, it is finding it with the wrong finger and building the habit
* that the whole exercise exists to prevent.
*
* When the target is a capital it also names the Shift hand - the opposite one, which is
* the single rule that separates touch typing from a pinky cramp. */
import { fingerOf, needsShift, shiftHandFor } from "../lib/fingers";
interface Props {
nextKey: string | null;
}
export function HandHint({ nextKey }: Props) {
if (!nextKey) return null;
const finger = fingerOf(nextKey);
if (!finger) return null;
const shiftHand = needsShift(nextKey) ? shiftHandFor(nextKey) : null;
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--paper)",
fontWeight: 800,
fontSize: 17,
opacity: 0.92,
minHeight: 30,
}}
>
<span
style={{
width: 16,
height: 16,
borderRadius: 999,
flex: "none",
background: `oklch(72% 0.15 ${finger.hue})`,
boxShadow: `0 0 12px oklch(72% 0.15 ${finger.hue} / .7)`,
}}
/>
<span>{finger.label}</span>
{shiftHand && (
<span style={{ opacity: 0.85 }}>+ Umschalttaste {shiftHand}</span>
)}
</div>
);
}

View File

@@ -5,13 +5,12 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
const TASTEN: readonly [string, string][] = [ const KEYS: readonly [string, string][] = [
["⏎", "Lektion starten · Runde wiederholen"], ["⏎", "Lektion starten · Runde wiederholen"],
["Esc", "Eine Ebene zurück"], ["Esc", "Eine Ebene zurück"],
["← →", "Lektion auswählen"], ["← →", "Lektion auswählen"],
["H", "Tastatur-Hilfe ein- und ausblenden"], ["H", "Tastatur-Hilfe ein- und ausblenden"],
["M", "Ton an und aus"], ["M", "Ton an und aus"],
["S", "Sprache an und aus"],
["F1", "Diese Übersicht"], ["F1", "Diese Übersicht"],
]; ];
@@ -23,10 +22,10 @@ export function HelpOverlay({ onClose }: Props) {
Zaubertasten Zaubertasten
</div> </div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{TASTEN.map(([taste, was]) => ( {KEYS.map(([key, description]) => (
<div key={taste} style={{ display: "flex", alignItems: "center", gap: 14 }}> <div key={key} style={{ display: "flex", alignItems: "center", gap: 14 }}>
<span className="key-cap">{taste}</span> <span className="key-cap">{key}</span>
<span style={{ fontWeight: 700, color: "var(--ink)" }}>{was}</span> <span style={{ fontWeight: 700, color: "var(--ink)" }}>{description}</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -26,7 +26,7 @@ interface Props {
} }
/** Rows are offset the way a real keyboard staggers them. */ /** Rows are offset the way a real keyboard staggers them. */
const ROW_INDENT: Record<string, number> = { oben: 0, grund: 14, unten: 30 }; const ROW_INDENT: Record<string, number> = { top: 0, home: 14, bottom: 30 };
export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Props) { export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Props) {
if (mode === "off") return null; if (mode === "off") return null;
@@ -44,16 +44,16 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
<div className="kb-row" key={row} style={{ marginLeft: ROW_INDENT[row] ?? 0 }}> <div className="kb-row" key={row} style={{ marginLeft: ROW_INDENT[row] ?? 0 }}>
{keysInRow(row).map((key) => { {keysInRow(row).map((key) => {
const finger = fingerOf(key); const finger = fingerOf(key);
const istAktiv = active.has(key); const isActive = active.has(key);
// A key she has mastered fades out; one she is still learning stays bright. // A key she has mastered fades out; one she is still learning stays bright.
// Only ever applied to active keys - dimming an unlearned key would hide the // Only ever applied to active keys - dimming an unlearned key would hide the
// very thing she needs to find. // very thing she needs to find.
const gelernt = mode === "auto" && istAktiv ? mastery(progress, key) : 0; const masteryLevel = mode === "auto" && isActive ? mastery(progress, key) : 0;
return ( return (
<div <div
key={key} key={key}
className="kb-key" className="kb-key"
data-active={istAktiv} data-active={isActive}
data-next={key === next} data-next={key === next}
data-finger={finger?.id} data-finger={finger?.id}
data-home={key === "f" || key === "j"} data-home={key === "f" || key === "j"}
@@ -61,7 +61,7 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
["--finger-hue" as string]: finger?.hue ?? 0, ["--finger-hue" as string]: finger?.hue ?? 0,
// Never below 0.25: the keyboard stops helping, it does not vanish // Never below 0.25: the keyboard stops helping, it does not vanish
// mid-lesson and leave her staring at a blank strip. // mid-lesson and leave her staring at a blank strip.
opacity: istAktiv ? Math.max(0.25, 1 - gelernt * 0.75) : undefined, opacity: isActive ? Math.max(0.25, 1 - masteryLevel * 0.75) : undefined,
}} }}
> >
{key.toUpperCase()} {key.toUpperCase()}
@@ -73,18 +73,18 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
<div className="kb-row" style={{ marginTop: 2, alignItems: "center", gap: 10 }}> <div className="kb-row" style={{ marginTop: 2, alignItems: "center", gap: 10 }}>
{/* Both Shifts are drawn, and the one to use lights up - the opposite hand from {/* Both Shifts are drawn, and the one to use lights up - the opposite hand from
the letter, which is the rule Welt 4 exists to teach. */} the letter, which is the rule world 4 exists to teach. */}
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "links"}> <div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "left"}>
</div> </div>
<div <div
className="kb-key kb-space" className="kb-key kb-space"
data-active={spaceActive} data-active={spaceActive}
data-next={next === " "} data-next={next === " "}
data-finger="daumen" data-finger="thumb"
style={{ ["--finger-hue" as string]: 220 }} style={{ ["--finger-hue" as string]: 220 }}
/> />
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "rechts"}> <div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "right"}>
</div> </div>
</div> </div>

View File

@@ -1,13 +1,13 @@
/** The Karte: four Welten, each a row of lesson cards. /** The map: four worlds, each a row of lesson cards.
* *
* Locked lessons are dimmed rather than hidden - seeing that there is a Welt called * Locked lessons are dimmed rather than hidden - seeing that there is a world called
* "Große Buchstaben" waiting is half the reason to finish the one that is open. Each * "Große Buchstaben" waiting is half the reason to finish the one that is open. Each
* card carries its own best animal and star count, so the map doubles as the trophy * card carries its own best animal and star count, so the map doubles as the trophy
* cabinet. */ * cabinet. */
import { LESSONS, WELTEN } from "../lib/curriculum"; import { LESSONS, WORLDS } from "../lib/curriculum";
import type { Lesson } from "../lib/curriculum"; import type { Lesson } from "../lib/curriculum";
import { tierById } from "../lib/grading"; import { animalById } from "../lib/grading";
import type { Progress } from "../lib/progress"; import type { Progress } from "../lib/progress";
interface Props { interface Props {
@@ -24,24 +24,24 @@ export function LessonMap({ progress, selected, onPick }: Props) {
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 32px", minHeight: 0 }} style={{ flex: 1, overflowY: "auto", padding: "6px 32px 32px", minHeight: 0 }}
> >
<div style={{ maxWidth: 1180, margin: "0 auto", display: "flex", flexDirection: "column", gap: 22 }}> <div style={{ maxWidth: 1180, margin: "0 auto", display: "flex", flexDirection: "column", gap: 22 }}>
{WELTEN.map((welt) => ( {WORLDS.map((world) => (
<div key={welt.nummer} className="glass-panel" style={{ padding: "16px 20px 20px" }}> <div key={world.number} className="glass-panel" style={{ padding: "16px 20px 20px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 22 }}>{welt.emoji}</span> <span style={{ fontSize: 22 }}>{world.emoji}</span>
<span style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}> <span style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>
Welt {welt.nummer} {welt.titel} Welt {world.number} {world.title}
</span> </span>
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}> <span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>
{LESSONS.filter((l) => l.welt === welt.nummer && (progress.lessons[l.id]?.bestSterne ?? 0) >= 2).length} {LESSONS.filter((l) => l.world === world.number && (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length}
/{LESSONS.filter((l) => l.welt === welt.nummer).length} /{LESSONS.filter((l) => l.world === world.number).length}
</span> </span>
</div> </div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{LESSONS.filter((lesson) => lesson.welt === welt.nummer).map((lesson) => { {LESSONS.filter((lesson) => lesson.world === world.number).map((lesson) => {
const fortschritt = progress.lessons[lesson.id]; const entry = progress.lessons[lesson.id];
const locked = !fortschritt?.unlocked; const locked = !entry?.unlocked;
const tier = fortschritt?.bestTier ? tierById(fortschritt.bestTier) : null; const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
const index = LESSONS.indexOf(lesson); const index = LESSONS.indexOf(lesson);
return ( return (
@@ -56,17 +56,17 @@ export function LessonMap({ progress, selected, onPick }: Props) {
> >
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 12, fontWeight: 900, color: "var(--paper)", opacity: 0.7 }}> <span style={{ fontSize: 12, fontWeight: 900, color: "var(--paper)", opacity: 0.7 }}>
{lesson.nummer} {lesson.number}
</span> </span>
<span style={{ fontSize: 26 }}>{locked ? "🔒" : (tier?.emoji ?? "·")}</span> <span style={{ fontSize: 26 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
</div> </div>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--paper)", marginTop: 6 }}> <div style={{ fontSize: 16, fontWeight: 900, color: "var(--paper)", marginTop: 6 }}>
{lesson.titel} {lesson.title}
</div> </div>
{/* The keys themselves, big: for a pre-reader this is the real label {/* The keys themselves, big: for a pre-reader this is the real label
and the title is decoration. An Übung has no new keys, so it says and the title is decoration. A drill has no new keys, so it says
so with a symbol instead of showing an empty line. */} so with a symbol instead of showing an empty line. */}
<div <div
style={{ style={{
@@ -74,21 +74,21 @@ export function LessonMap({ progress, selected, onPick }: Props) {
fontWeight: 800, fontWeight: 800,
color: "var(--paper)", color: "var(--paper)",
opacity: 0.75, opacity: 0.75,
letterSpacing: lesson.istUebung ? "normal" : "0.14em", letterSpacing: lesson.isDrill ? "normal" : "0.14em",
marginTop: 3, marginTop: 3,
minHeight: 20, minHeight: 20,
}} }}
> >
{lesson.istUebung {lesson.isDrill
? "🔁 Übung" ? "🔁 Übung"
: lesson.neueKeys : lesson.newKeys
.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())) .map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase()))
.join(" ")} .join(" ")}
</div> </div>
<div style={{ marginTop: 8, fontSize: 13, letterSpacing: "0.08em" }}> <div style={{ marginTop: 8, fontSize: 13, letterSpacing: "0.08em" }}>
{[1, 2, 3].map((stern) => ( {[1, 2, 3].map((star) => (
<span key={stern} style={{ opacity: (fortschritt?.bestSterne ?? 0) >= stern ? 1 : 0.22 }}> <span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
</span> </span>
))} ))}

View File

@@ -1,35 +1,35 @@
/** Which game a lesson is played as. The Tauchgang is the measured one; the rest are /** Which game a lesson is played as. Dive mode is the measured one; the rest are
* the same drill wearing a costume, which is how variety gets discovered without * the same drill wearing a costume, which is how variety gets discovered without
* splitting the curriculum. */ * splitting the curriculum. */
import type { ModeId } from "../lib/curriculum"; import type { ModeId } from "../lib/curriculum";
interface Props { interface Props {
modi: readonly ModeId[]; modes: readonly ModeId[];
aktiv: ModeId; active: ModeId;
onPick: (modus: ModeId) => void; onPick: (mode: ModeId) => void;
} }
export const MODUS_INFO: Record<ModeId, { emoji: string; name: string }> = { export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
tauchgang: { emoji: "🤿", name: "Tauchgang" }, dive: { emoji: "🤿", name: "Tauchgang" },
blasen: { emoji: "🫧", name: "Blasenplatzen" }, bubbles: { emoji: "🫧", name: "Blasenplatzen" },
quallen: { emoji: "🦑", name: "Quallenalarm" }, jellyfish: { emoji: "🦑", name: "Quallenalarm" },
fuettern: { emoji: "🐟", name: "Fütterungszeit" }, feed: { emoji: "🐟", name: "Fütterungszeit" },
rennen: { emoji: "🐬", name: "Delfinrennen" }, race: { emoji: "🐬", name: "Delfinrennen" },
perlen: { emoji: "🦪", name: "Perlentaucher" }, pearls: { emoji: "🦪", name: "Perlentaucher" },
}; };
export function ModePicker({ modi, aktiv, onPick }: Props) { export function ModePicker({ modes, active, onPick }: Props) {
return ( return (
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
{modi.map((modus) => { {modes.map((mode) => {
const info = MODUS_INFO[modus]; const info = MODE_INFO[mode];
return ( return (
<button <button
key={modus} key={mode}
className="pill" className="pill"
data-active={modus === aktiv} data-active={mode === active}
onClick={() => onPick(modus)} onClick={() => onPick(mode)}
> >
{info.emoji} {info.name} {info.emoji} {info.name}
</button> </button>

View File

@@ -2,50 +2,50 @@
* *
* The rule this screen exists to enforce: there is no losing screen. A bad run shows * The rule this screen exists to enforce: there is no losing screen. A bad run shows
* fewer stars and a slower animal, and the primary button still says "Nochmal" with the * fewer stars and a slower animal, and the primary button still says "Nochmal" with the
* focus already on it. Nothing here ever says "failed", nothing is red, and the Perlen * focus already on it. Nothing here ever says "failed", nothing is red, and the pearls
* always go up - see `perlenFor` in lib/grading.ts. */ * always go up - see `pearlsFor` in lib/grading.ts. */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { kreaturById } from "../lib/aquarium"; import { creatureById } from "../lib/aquarium";
import type { KreaturId } from "../lib/aquarium"; import type { CreatureId } from "../lib/aquarium";
import type { RunResult } from "../lib/grading"; import type { RunResult } from "../lib/grading";
import { STERNE_SCHWELLEN, sichtbareTiers, tierById, tierIndex, tierProgress } from "../lib/grading"; import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
import type { TierId } from "../lib/grading"; import type { AnimalId } from "../lib/grading";
interface Props { interface Props {
result: RunResult; result: RunResult;
/** Set when this run opened the next lesson. */ /** Set when this run opened the next lesson. */
unlockedTitel: string | null; unlockedTitle: string | null;
/** Set when this run released a creature into the aquarium. */ /** Set when this run released a creature into the aquarium. */
neuesTier: KreaturId | null; newCreature: CreatureId | null;
bestseit: boolean; isNewBest: boolean;
/** The fastest animal earned on any lesson so far - decides how much of the ladder /** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */ * may be revealed. */
bestEver: TierId | null; bestEver: AnimalId | null;
onNochmal: () => void; onRetry: () => void;
onWeiter: () => void; onContinue: () => void;
/** Null at the end of the curriculum. */ /** Null at the end of the curriculum. */
weiterLabel: string | null; continueLabel: string | null;
} }
export function ResultSheet({ export function ResultSheet({
result, result,
unlockedTitel, unlockedTitle,
neuesTier, newCreature,
bestseit, isNewBest,
bestEver, bestEver,
onNochmal, onRetry,
onWeiter, onContinue,
weiterLabel, continueLabel,
}: Props) { }: Props) {
const tier = tierById(result.tier); const animal = animalById(result.animal);
const leiter = sichtbareTiers(result.tier, bestEver); const ladder = visibleAnimals(result.animal, bestEver);
const erreicht = tierIndex(result.tier); const reached = animalIndex(result.animal);
const nochmal = useRef<HTMLButtonElement>(null); const retryButton = useRef<HTMLButtonElement>(null);
// Enter repeats the run. Fewest keystrokes between "that was fun" and "again". // Enter repeats the run. Fewest keystrokes between "that was fun" and "again".
useEffect(() => nochmal.current?.focus(), []); useEffect(() => retryButton.current?.focus(), []);
return ( return (
<div className="overlay backdrop-enter"> <div className="overlay backdrop-enter">
@@ -54,21 +54,21 @@ export function ResultSheet({
style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }} style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }}
> >
<div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}> <div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}>
{tier.emoji} {animal.emoji}
</div> </div>
<div style={{ fontSize: 30, fontWeight: 900, color: "var(--ink)", marginTop: 6 }}> <div style={{ fontSize: 30, fontWeight: 900, color: "var(--ink)", marginTop: 6 }}>
{tier.name} {animal.name}
</div> </div>
<div style={{ display: "flex", justifyContent: "center", gap: 8, margin: "14px 0 4px" }}> <div style={{ display: "flex", justifyContent: "center", gap: 8, margin: "14px 0 4px" }}>
{[1, 2, 3].map((stern) => ( {[1, 2, 3].map((star) => (
<span <span
key={stern} key={star}
style={{ style={{
fontSize: 40, fontSize: 40,
animation: `sterneEnter 320ms ease-out ${140 + stern * 130}ms both`, animation: `sterneEnter 320ms ease-out ${140 + star * 130}ms both`,
filter: result.sterne >= stern ? "none" : "grayscale(1)", filter: result.stars >= star ? "none" : "grayscale(1)",
opacity: result.sterne >= stern ? 1 : 0.25, opacity: result.stars >= star ? 1 : 0.25,
}} }}
> >
@@ -77,9 +77,9 @@ export function ResultSheet({
</div> </div>
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}> <div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
<Stat label="Richtig" value={`${Math.round(result.genauigkeit * 100)}%`} /> <Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
<Stat label="Zeichen/Min" value={String(Math.round(result.tempo))} /> <Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
<Stat label="Perlen" value={`+${result.perlen}`} /> <Stat label="Perlen" value={`+${result.pearls}`} />
</div> </div>
{/* How close the next animal is. A near miss is the strongest reason to press {/* How close the next animal is. A near miss is the strongest reason to press
@@ -95,7 +95,7 @@ export function ResultSheet({
> >
<div <div
style={{ style={{
width: `${tierProgress(result.punkte) * 100}%`, width: `${animalProgress(result.points) * 100}%`,
height: "100%", height: "100%",
background: "var(--accent)", background: "var(--accent)",
transition: "width 700ms ease-out", transition: "width 700ms ease-out",
@@ -103,19 +103,19 @@ export function ResultSheet({
/> />
</div> </div>
<TierLeiter tiers={leiter.tiers} erreicht={erreicht} mehrVerborgen={leiter.mehrVerborgen} /> <AnimalLadder animals={ladder.animals} reached={reached} moreHidden={ladder.moreHidden} />
{bestseit && ( {isNewBest && (
<div style={{ color: "var(--accent)", fontWeight: 900, marginTop: 8 }}> <div style={{ color: "var(--accent)", fontWeight: 900, marginTop: 8 }}>
🏅 Neuer Bestwert! 🏅 Neuer Bestwert!
</div> </div>
)} )}
{unlockedTitel && ( {unlockedTitle && (
<div style={{ color: "var(--ink)", fontWeight: 900, marginTop: 8 }}> <div style={{ color: "var(--ink)", fontWeight: 900, marginTop: 8 }}>
🔓 Neu: {unlockedTitel} 🔓 Neu: {unlockedTitle}
</div> </div>
)} )}
{neuesTier && ( {newCreature && (
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -128,34 +128,34 @@ export function ResultSheet({
}} }}
> >
<img <img
src={kreaturById(neuesTier).bild} src={creatureById(newCreature).image}
alt="" alt=""
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }} style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
/> />
{kreaturById(neuesTier).artikel} {kreaturById(neuesTier).name} ist ins Aquarium gezogen! {creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
</div> </div>
)} )}
{!result.bestanden && !unlockedTitel && ( {!result.passed && !unlockedTitle && (
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}> <div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
Mit {Math.round(STERNE_SCHWELLEN.zwei * 100)}% Treffern geht es weiter. Fast! Mit {Math.round(STAR_THRESHOLDS.two * 100)}% Treffern geht es weiter. Fast!
</div> </div>
)} )}
<div style={{ display: "flex", gap: 12, marginTop: 22, justifyContent: "center" }}> <div style={{ display: "flex", gap: 12, marginTop: 22, justifyContent: "center" }}>
<button <button
ref={nochmal} ref={retryButton}
onClick={onNochmal} onClick={onRetry}
style={{ style={{
...knopf, ...buttonStyle,
background: "var(--accent)", background: "var(--accent)",
color: "var(--paper)", color: "var(--paper)",
}} }}
> >
Nochmal Nochmal
</button> </button>
{weiterLabel && ( {continueLabel && (
<button onClick={onWeiter} style={{ ...knopf, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}> <button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
{weiterLabel} {continueLabel}
</button> </button>
)} )}
</div> </div>
@@ -170,17 +170,17 @@ export function ResultSheet({
* rungs below are what shows how far she has come. The rungs above are greyed out but * rungs below are what shows how far she has come. The rungs above are greyed out but
* still legible, because the next animal has to be visible to be worth aiming at. * still legible, because the next animal has to be visible to be worth aiming at.
* *
* Above the Delfin the ladder stops: those animals are not shown at all until they are * Above the dolphin the ladder stops: those animals are not shown at all until they are
* reached. A single "?" says the ladder continues without saying what is on it, which * reached. A single "?" says the ladder continues without saying what is on it, which
* keeps the top end a surprise rather than a distant, discouraging number. */ * keeps the top end a surprise rather than a distant, discouraging number. */
function TierLeiter({ function AnimalLadder({
tiers, animals,
erreicht, reached,
mehrVerborgen, moreHidden,
}: { }: {
tiers: readonly { id: string; name: string; emoji: string }[]; animals: readonly { id: string; name: string; emoji: string }[];
erreicht: number; reached: number;
mehrVerborgen: boolean; moreHidden: boolean;
}) { }) {
return ( return (
<div <div
@@ -195,29 +195,29 @@ function TierLeiter({
}} }}
aria-label="Tier-Leiter" aria-label="Tier-Leiter"
> >
{tiers.map((eintrag, i) => { {animals.map((entry, i) => {
const geschafft = i <= erreicht; const achieved = i <= reached;
const aktuell = i === erreicht; const isCurrent = i === reached;
return ( return (
<div <div
key={eintrag.id} key={entry.id}
title={eintrag.name} title={entry.name}
style={{ style={{
fontSize: aktuell ? 40 : 26, fontSize: isCurrent ? 40 : 26,
lineHeight: 1, lineHeight: 1,
padding: aktuell ? "0 5px" : 0, padding: isCurrent ? "0 5px" : 0,
filter: geschafft ? "none" : "grayscale(1)", filter: achieved ? "none" : "grayscale(1)",
// Bright enough to read as "this is next", dim enough to read as "not yet". // Bright enough to read as "this is next", dim enough to read as "not yet".
opacity: geschafft ? 1 : 0.42, opacity: achieved ? 1 : 0.42,
transform: aktuell ? "translateY(-3px)" : undefined, transform: isCurrent ? "translateY(-3px)" : undefined,
transition: "all 200ms ease", transition: "all 200ms ease",
}} }}
> >
{eintrag.emoji} {entry.emoji}
</div> </div>
); );
})} })}
{mehrVerborgen && ( {moreHidden && (
<div <div
title="Da geht noch was!" title="Da geht noch was!"
style={{ fontSize: 24, opacity: 0.4, marginLeft: 4, filter: "grayscale(1)" }} style={{ fontSize: 24, opacity: 0.4, marginLeft: 4, filter: "grayscale(1)" }}
@@ -229,7 +229,7 @@ function TierLeiter({
); );
} }
const knopf: React.CSSProperties = { const buttonStyle: React.CSSProperties = {
border: "none", border: "none",
borderRadius: 999, borderRadius: 999,
padding: "13px 26px", padding: "13px 26px",

View File

@@ -6,23 +6,23 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { KreaturId } from "../lib/aquarium"; import type { CreatureId } from "../lib/aquarium";
import { SHOW_AQUARIUM_TIERE, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../lib/theme"; import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../lib/theme";
import { AquariumTiere } from "./AquariumTiere"; import { AquariumCreatures } from "./AquariumCreatures";
import { Bubbles } from "./Bubbles"; import { Bubbles } from "./Bubbles";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
/** The pets that have moved in so far. */ /** The pets that have moved in so far. */
tiere: readonly KreaturId[]; creatures: readonly CreatureId[];
/** While she is typing the pets fade back: still there, not competing with the line. */ /** While she is typing the pets fade back: still there, not competing with the line. */
gedaempft: boolean; dimmed: boolean;
} }
export function Stage({ children, tiere, gedaempft }: Props) { export function Stage({ children, creatures, dimmed }: Props) {
return ( return (
<div className="stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}> <div className="stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
{SHOW_AQUARIUM_TIERE && <AquariumTiere tiere={tiere} deckkraft={gedaempft ? 0.25 : 1} />} {SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
{SHOW_BUBBLES && <Bubbles />} {SHOW_BUBBLES && <Bubbles />}
{children} {children}
</div> </div>

View File

@@ -2,8 +2,8 @@
* *
* Chunks matter more than they look: twenty-four letters in an unbroken row is * Chunks matter more than they look: twenty-four letters in an unbroken row is
* something a six-year-old loses her place in, four letters with a gap after them is * something a six-year-old loses her place in, four letters with a gap after them is
* not. In Welt 1 and 2 the gaps are purely visual - the Leertaste has not been taught, * not. The gap is a real space to type from the very first lesson, even before the
* so `spaceActive` is false and the chunks join without one. */ * space-bar lesson formally teaches the thumb. */
import { chunkOffsets } from "../lib/generator"; import { chunkOffsets } from "../lib/generator";
@@ -13,58 +13,57 @@ interface Props {
/** Cursor position in the joined text. */ /** Cursor position in the joined text. */
index: number; index: number;
/** True while a wrong key is still being corrected at the cursor. */ /** True while a wrong key is still being corrected at the cursor. */
daneben: boolean; wrong: boolean;
/** Overrides the size picked from the text's own length. */ /** Overrides the size picked from the text's own length. */
fontSize?: number; fontSize?: number;
} }
/** Type size and column width for a block of `laenge` characters. /** Type size and column width for a block of `length` characters.
* *
* A round is at least five lines of text, and the whole block has to sit on one screen * A round is at least five lines of text, and the whole block has to sit on one screen
* above the keyboard without the stage ever scrolling - a line that scrolls out of view * above the keyboard without the stage ever scrolling - a line that scrolls out of view
* mid-run is worse than no line at all. * mid-run is worse than no line at all.
* *
* The two move together on purpose. Short blocks (Welt 1) get the biggest letters *and* * The two move together on purpose. Short blocks (world 1) get the biggest letters *and*
* the narrowest column, so they still wrap into five lines instead of three long ones - * the narrowest column, so they still wrap into five lines instead of three long ones -
* and a narrow column is easier for a beginner to keep her place in anyway. Long blocks * and a narrow column is easier for a beginner to keep her place in anyway. Long blocks
* (Welt 5's sentences) get smaller type and a wider column so ten sentences still fit. */ * (world 5's sentences) get smaller type and a wider column so ten sentences still fit. */
function satzspiegel(laenge: number): { groesse: number; breite: number } { function layoutFor(length: number): { size: number; width: number } {
if (laenge <= 40) return { groesse: 44, breite: 560 }; if (length <= 40) return { size: 44, width: 560 };
if (laenge <= 80) return { groesse: 38, breite: 600 }; if (length <= 80) return { size: 38, width: 600 };
if (laenge <= 130) return { groesse: 32, breite: 760 }; if (length <= 130) return { size: 32, width: 760 };
if (laenge <= 220) return { groesse: 26, breite: 880 }; if (length <= 220) return { size: 26, width: 880 };
return { groesse: 22, breite: 960 }; return { size: 22, width: 960 };
} }
export function Target({ chunks, spaceActive, index, daneben, fontSize }: Props) { export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
const offsets = chunkOffsets(chunks, spaceActive); const offsets = chunkOffsets(chunks, spaceActive);
const laenge = chunks.join("").length + (spaceActive ? chunks.length - 1 : 0); const length = chunks.join("").length + (spaceActive ? chunks.length - 1 : 0);
const spiegel = satzspiegel(laenge); const layout = layoutFor(length);
const groesse = fontSize ?? spiegel.groesse; const size = fontSize ?? layout.size;
return ( return (
<div className="ziel" style={{ fontSize: groesse, maxWidth: `min(${spiegel.breite}px, 90vw)` }}> <div className="target" style={{ fontSize: size, maxWidth: `min(${layout.width}px, 90vw)` }}>
{chunks.map((chunk, chunkIndex) => { {chunks.map((chunk, chunkIndex) => {
const start = offsets[chunkIndex] ?? 0; const start = offsets[chunkIndex] ?? 0;
return ( return (
<div className="ziel-chunk" key={chunkIndex}> <div className="target-chunk" key={chunkIndex}>
{[...chunk].map((char, i) => { {[...chunk].map((char, i) => {
const at = start + i; const at = start + i;
const state = at < index ? "fertig" : at === index ? "aktuell" : "offen"; const state = at < index ? "done" : at === index ? "current" : "open";
// Welt 5's targets are whole sentences, so a chunk can contain spaces of // World 5's targets are whole sentences, so a chunk can contain spaces of
// its own. They need the same visible body as the ones between chunks, or // its own. They need the same visible body as the ones between chunks, or
// the cursor lands on nothing and looks lost. // the cursor lands on nothing and looks lost.
const leer = char === " "; const isBlank = char === " ";
return ( return (
<span <span
key={i} key={i}
className="ziel-zeichen" className="target-char"
data-state={state} data-state={state}
data-leer={leer} data-blank={isBlank}
data-daneben={state === "aktuell" && daneben} data-wrong={state === "current" && wrong}
style={{ padding: state === "aktuell" ? "0 6px" : undefined }}
> >
{leer ? (state === "aktuell" ? "␣" : "") : char} {isBlank ? (state === "current" ? "␣" : "") : char}
</span> </span>
); );
})} })}
@@ -72,16 +71,16 @@ export function Target({ chunks, spaceActive, index, daneben, fontSize }: Props)
needs to be typeable and to show the cursor. Before that it is a gap. */} needs to be typeable and to show the cursor. Before that it is a gap. */}
{spaceActive && chunkIndex < chunks.length - 1 && ( {spaceActive && chunkIndex < chunks.length - 1 && (
<span <span
className="ziel-zeichen" className="target-char"
data-leer="true" data-blank="true"
data-state={ data-state={
start + chunk.length < index start + chunk.length < index
? "fertig" ? "done"
: start + chunk.length === index : start + chunk.length === index
? "aktuell" ? "current"
: "offen" : "open"
} }
data-daneben={start + chunk.length === index && daneben} data-wrong={start + chunk.length === index && wrong}
> >
{start + chunk.length === index ? "␣" : ""} {start + chunk.length === index ? "␣" : ""}
</span> </span>

View File

@@ -1,4 +1,4 @@
/** Blasenplatzen - the first arcade mode, and the proof that the engine is /** Bubbles mode - the first arcade mode, and the proof that the engine is
* mode-agnostic: same `useRun`, same grading, same unlock. Only the drawing differs. * mode-agnostic: same `useRun`, same grading, same unlock. Only the drawing differs.
* *
* Each letter of the line is a bubble. The bubble at the cursor is the one nearest the * Each letter of the line is a bubble. The bubble at the cursor is the one nearest the
@@ -10,16 +10,14 @@
* ever escapes and nothing is ever lost. At six, "you were too slow" is the fastest way * ever escapes and nothing is ever lost. At six, "you were too slow" is the fastest way
* to end a session. */ * to end a session. */
import { useEffect, useState } from "react"; import { useState } from "react";
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../lib/engine";
import type { RunEvent } from "../../lib/engine"; import type { RunEvent } from "../../lib/engine";
import { fingerOf } from "../../lib/fingers"; import { fingerOf } from "../../lib/fingers";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../lib/progress";
import { say, spellKey } from "../../lib/speech";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
interface Props { interface Props {
@@ -32,21 +30,21 @@ interface Props {
/** How many bubbles are in the water at once. More than five and the column of letters /** How many bubbles are in the water at once. More than five and the column of letters
* reads as a wall of text; fewer and there is nothing to look forward to. */ * reads as a wall of text; fewer and there is nothing to look forward to. */
const SICHTBAR = 5; const VISIBLE_COUNT = 5;
/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */ /** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
const LANES = [50, 28, 68, 38, 60, 46]; const LANES = [50, 28, 68, 38, 60, 46];
export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }: Props) { export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
const text = letters.join(""); const text = letters.join("");
const [geplatzt, setGeplatzt] = useState<number | null>(null); const [popped, setPopped] = useState<number | null>(null);
const onEvent = (event: RunEvent) => { const onEvent = (event: RunEvent) => {
// Remember which bubble just popped so it can play its burst before disappearing. // Remember which bubble just popped so it can play its burst before disappearing.
if (event.type === "correct") setGeplatzt(event.index); if (event.type === "correct") setPopped(event.index);
}; };
const { state, daneben } = useRun({ const { state, wrong } = useRun({
target: text, target: text,
sound: progress.settings.sound, sound: progress.settings.sound,
paused, paused,
@@ -56,12 +54,6 @@ export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }:
const next = currentChar(state); const next = currentChar(state);
useEffect(() => {
if (next) say(spellKey(next), progress.settings.speech);
// Every bubble is spoken here, unlike the Tauchgang: one letter at a time is slow
// enough that the voice keeps up, and this is the mode a pre-reader plays first.
}, [next, progress.settings.speech]);
return ( return (
<div <div
className="view-enter" className="view-enter"
@@ -88,11 +80,11 @@ export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }:
}} }}
/> />
{[...text].map((letter, i) => { {[...text].map((letter, i) => {
const abstand = i - state.index; const distance = i - state.index;
if (abstand < 0 || abstand >= SICHTBAR) return null; if (distance < 0 || distance >= VISIBLE_COUNT) return null;
const finger = fingerOf(letter); const finger = fingerOf(letter);
const aktuell = abstand === 0; const isCurrent = distance === 0;
const groesse = aktuell ? 104 : 68; const size = isCurrent ? 104 : 68;
return ( return (
<div <div
@@ -102,28 +94,28 @@ export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }:
left: `${LANES[i % LANES.length]}%`, left: `${LANES[i % LANES.length]}%`,
// Distance from the cursor is distance from the surface. The transition // Distance from the cursor is distance from the surface. The transition
// is what makes popping one visibly lift the rest. // is what makes popping one visibly lift the rest.
top: `${6 + abstand * 19}%`, top: `${6 + distance * 19}%`,
transform: "translate(-50%, 0)", transform: "translate(-50%, 0)",
width: groesse, width: size,
height: groesse, height: size,
borderRadius: "50%", borderRadius: "50%",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
fontSize: groesse * 0.42, fontSize: size * 0.42,
fontWeight: 900, fontWeight: 900,
color: aktuell ? "oklch(25% 0.05 175)" : "var(--paper)", color: isCurrent ? "oklch(25% 0.05 175)" : "var(--paper)",
background: aktuell background: isCurrent
? "var(--paper)" ? "var(--paper)"
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`, : `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`,
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${aktuell ? 0.9 : 0.35})`, border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${isCurrent ? 0.9 : 0.35})`,
boxShadow: aktuell ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)", boxShadow: isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
opacity: 1 - abstand * 0.13, opacity: 1 - distance * 0.13,
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease", transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
animation: animation:
geplatzt === i popped === i
? "correctPop 200ms ease-out" ? "correctPop 200ms ease-out"
: aktuell && daneben : isCurrent && wrong
? "wrongShake 260ms ease" ? "wrongShake 260ms ease"
: undefined, : undefined,
}} }}
@@ -134,7 +126,6 @@ export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }:
})} })}
</div> </div>
<HandHint nextKey={next} />
<Keyboard <Keyboard
activeKeys={activeKeys} activeKeys={activeKeys}
nextKey={next} nextKey={next}

View File

@@ -1,17 +1,12 @@
/** Tauchgang - the core drill, and the run that counts for the unlock. /** Dive mode - the core drill, and the run that counts for the unlock.
* *
* A line of chunks with a moving cursor, the finger hint above it and the keyboard * A line of chunks with a moving cursor above the keyboard. Everything else in the
* below. Everything else in the game is a variation on this; this is the one that is * game is a variation on this; this is the one that is measured. */
* measured. */
import { useEffect } from "react";
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../lib/engine";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../lib/progress";
import { say, spellKey } from "../../lib/speech";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
import { Target } from "../Target"; import { Target } from "../Target";
@@ -25,7 +20,7 @@ interface Props {
onFinished: (result: RunResult) => void; onFinished: (result: RunResult) => void;
} }
export function TauchgangRun({ export function DiveRun({
chunks, chunks,
text, text,
spaceActive, spaceActive,
@@ -34,7 +29,7 @@ export function TauchgangRun({
paused, paused,
onFinished, onFinished,
}: Props) { }: Props) {
const { state, daneben } = useRun({ const { state, wrong } = useRun({
target: text, target: text,
sound: progress.settings.sound, sound: progress.settings.sound,
paused, paused,
@@ -43,16 +38,6 @@ export function TauchgangRun({
const next = currentChar(state); const next = currentChar(state);
// Say the first key of a line, and any key she gets stuck on. Not every key: a voice
// talking over every keystroke is noise, and it would lag behind a good streak.
useEffect(() => {
if (state.index === 0 && next) say(spellKey(next), progress.settings.speech);
}, [state.index, next, progress.settings.speech]);
useEffect(() => {
if (daneben && next) say(spellKey(next), progress.settings.speech);
}, [daneben, next, progress.settings.speech]);
return ( return (
<div <div
className="view-enter" className="view-enter"
@@ -67,9 +52,8 @@ export function TauchgangRun({
minHeight: 0, minHeight: 0,
}} }}
> >
<HandHint nextKey={next} /> <Target chunks={chunks} spaceActive={spaceActive} index={state.index} wrong={wrong} />
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} daneben={daneben} /> <ProgressBar done={state.index} total={text.length} />
<Fortschritt done={state.index} total={text.length} />
<Keyboard <Keyboard
activeKeys={activeKeys} activeKeys={activeKeys}
nextKey={next} nextKey={next}
@@ -82,7 +66,7 @@ export function TauchgangRun({
/** How far through the line she is - a bar, not a number, because "18 von 24" is a /** How far through the line she is - a bar, not a number, because "18 von 24" is a
* reading task and a filling bar is not. */ * reading task and a filling bar is not. */
function Fortschritt({ done, total }: { done: number; total: number }) { function ProgressBar({ done, total }: { done: number; total: number }) {
return ( return (
<div <div
style={{ style={{

View File

@@ -1,12 +1,12 @@
/** Fütterungszeit - words instead of letters. /** Feed mode - words instead of letters.
* *
* Fish swim past carrying short German words; typing one feeds it to the Delfin. This * Fish swim past carrying short German words; typing one feeds it to the dolphin. This
* is the first mode where the unit is a whole word rather than a key, which is the step * is the first mode where the unit is a whole word rather than a key, which is the step
* from "I can find the letters" to "I can write something", and it is the reason the * from "I can find the letters" to "I can write something", and it is the reason the
* word lists in curriculum.ts are real German words a six-year-old knows rather than * word lists in curriculum.ts are real German words a six-year-old knows rather than
* pronounceable nonsense. * pronounceable nonsense.
* *
* The clock is soft, like Blasenplatzen: the fish queue up and wait. Nothing swims off * The clock is soft, like bubbles mode: the fish queue up and wait. Nothing swims off
* unfed. */ * unfed. */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -16,9 +16,7 @@ import type { RunEvent } from "../../lib/engine";
import { chunkOffsets } from "../../lib/generator"; import { chunkOffsets } from "../../lib/generator";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../lib/progress";
import { say } from "../../lib/speech";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
interface Props { interface Props {
@@ -31,9 +29,9 @@ interface Props {
onFinished: (result: RunResult) => void; onFinished: (result: RunResult) => void;
} }
const FISCHE = ["🐟", "🐠", "🐡", "🦐", "🦀"]; const FISH = ["🐟", "🐠", "🐡", "🦐", "🦀"];
export function FuetternRun({ export function FeedRun({
chunks, chunks,
text, text,
spaceActive, spaceActive,
@@ -42,15 +40,15 @@ export function FuetternRun({
paused, paused,
onFinished, onFinished,
}: Props) { }: Props) {
const [gefuettert, setGefuettert] = useState(0); const [fed, setFed] = useState(0);
const { state, daneben } = useRun({ const { state, wrong } = useRun({
target: text, target: text,
sound: progress.settings.sound, sound: progress.settings.sound,
paused, paused,
onFinished, onFinished,
onEvent: (event: RunEvent) => { onEvent: (event: RunEvent) => {
if (event.type === "correct") setGefuettert((n) => n); if (event.type === "correct") setFed((n) => n);
}, },
}); });
@@ -58,19 +56,14 @@ export function FuetternRun({
const offsets = chunkOffsets(chunks, spaceActive); const offsets = chunkOffsets(chunks, spaceActive);
/** Which word is being typed. A word counts as current until its last letter is done. */ /** Which word is being typed. A word counts as current until its last letter is done. */
const aktuell = Math.max( const currentIndex = Math.max(
0, 0,
offsets.findIndex((start, i) => state.index <= start + (chunks[i]?.length ?? 0)), offsets.findIndex((start, i) => state.index <= start + (chunks[i]?.length ?? 0)),
); );
useEffect(() => { useEffect(() => {
setGefuettert(aktuell); setFed(currentIndex);
}, [aktuell]); }, [currentIndex]);
useEffect(() => {
const wort = chunks[aktuell];
if (wort) say(wort, progress.settings.speech);
}, [aktuell, chunks, progress.settings.speech]);
return ( return (
<div <div
@@ -86,7 +79,7 @@ export function FuetternRun({
}} }}
> >
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0, overflow: "hidden" }}> <div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0, overflow: "hidden" }}>
{/* The Delfin waits on the left; the fish come to it. */} {/* The dolphin waits on the left; the fish come to it. */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@@ -100,69 +93,69 @@ export function FuetternRun({
🐬 🐬
</div> </div>
{chunks.map((wort, i) => { {chunks.map((word, i) => {
const abstand = i - aktuell; const distance = i - currentIndex;
// Three fish in the water. A fourth at this spacing runs off the right edge, // Three fish in the water. A fourth at this spacing runs off the right edge,
// and a queue you cannot see the end of is not a queue. // and a queue you cannot see the end of is not a queue.
if (abstand < 0 || abstand > 2) return null; if (distance < 0 || distance > 2) return null;
const start = offsets[i] ?? 0; const start = offsets[i] ?? 0;
const istZiel = abstand === 0; const isTarget = distance === 0;
return ( return (
<div <div
key={i} key={i}
style={{ style={{
position: "absolute", position: "absolute",
// Distance from the Delfin is distance in the queue. Feeding one lets // Distance from the dolphin is distance in the queue. Feeding one lets
// the rest swim in, via the transition rather than a rAF loop. // the rest swim in, via the transition rather than a rAF loop.
left: `${15 + abstand * 26}%`, left: `${15 + distance * 26}%`,
top: `${30 + (i % 3) * 16}%`, top: `${30 + (i % 3) * 16}%`,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
padding: istZiel ? "10px 18px" : "7px 13px", padding: isTarget ? "10px 18px" : "7px 13px",
borderRadius: 999, borderRadius: 999,
background: istZiel ? "var(--paper)" : "oklch(97% 0.01 175 / .13)", background: isTarget ? "var(--paper)" : "oklch(97% 0.01 175 / .13)",
border: `2px solid oklch(97% 0.01 175 / ${istZiel ? 0.85 : 0.2})`, border: `2px solid oklch(97% 0.01 175 / ${isTarget ? 0.85 : 0.2})`,
boxShadow: istZiel ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)", boxShadow: isTarget ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)",
maxWidth: "34%", maxWidth: "34%",
opacity: 1 - abstand * 0.22, opacity: 1 - distance * 0.22,
transition: "left 420ms cubic-bezier(.2,.7,.3,1), opacity 300ms ease, padding 200ms ease", transition: "left 420ms cubic-bezier(.2,.7,.3,1), opacity 300ms ease, padding 200ms ease",
animation: istZiel && daneben ? "wrongShake 260ms ease" : undefined, animation: isTarget && wrong ? "wrongShake 260ms ease" : undefined,
zIndex: istZiel ? 3 : 1, zIndex: isTarget ? 3 : 1,
}} }}
> >
<span style={{ fontSize: istZiel ? 34 : 24 }}>{FISCHE[i % FISCHE.length]}</span> <span style={{ fontSize: isTarget ? 34 : 24 }}>{FISH[i % FISH.length]}</span>
<span <span
style={{ style={{
display: "flex", display: "flex",
fontSize: istZiel ? 34 : 22, fontSize: isTarget ? 34 : 22,
fontWeight: 800, fontWeight: 800,
color: istZiel ? "oklch(25% 0.05 175)" : "var(--paper)", color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
}} }}
> >
{istZiel {isTarget
? [...wort].map((char, j) => { ? [...word].map((char, j) => {
const at = start + j; const at = start + j;
const zustand = at < state.index ? "fertig" : at === state.index ? "aktuell" : "offen"; const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
return ( return (
<span <span
key={j} key={j}
style={{ style={{
color: color:
zustand === "fertig" charState === "done"
? "oklch(58% 0.15 150)" ? "oklch(58% 0.15 150)"
: zustand === "aktuell" : charState === "current"
? "var(--accent)" ? "var(--accent)"
: "oklch(45% 0.03 175)", : "oklch(45% 0.03 175)",
textDecoration: zustand === "aktuell" ? "underline" : undefined, textDecoration: charState === "current" ? "underline" : undefined,
}} }}
> >
{char === " " ? " " : char} {char === " " ? " " : char}
</span> </span>
); );
}) })
: wort} : word}
</span> </span>
</div> </div>
); );
@@ -170,9 +163,8 @@ export function FuetternRun({
</div> </div>
<div style={{ color: "var(--paper)", fontWeight: 800, opacity: 0.8 }}> <div style={{ color: "var(--paper)", fontWeight: 800, opacity: 0.8 }}>
{gefuettert} von {chunks.length} gefüttert {fed} von {chunks.length} gefüttert
</div> </div>
<HandHint nextKey={next} />
<Keyboard <Keyboard
activeKeys={activeKeys} activeKeys={activeKeys}
nextKey={next} nextKey={next}

View File

@@ -1,24 +1,22 @@
/** Quallenalarm - pure key location, nothing else. /** Jellyfish mode - pure key location, nothing else.
* *
* Six jellyfish drift in the water, each showing a letter. One of them glows: that is * Six jellyfish drift in the water, each showing a letter. One of them glows: that is
* the one to zap. There is no line to read and no word to spell, so the only thing * the one to zap. There is no line to read and no word to spell, so the only thing
* being exercised is "where does this letter live" - which is exactly the skill the * being exercised is "where does this letter live" - which is exactly the skill dive
* Tauchgang hides behind reading. * mode hides behind reading.
* *
* The decoys matter. Showing only the target turns this into the bubble mode; showing * The decoys matter. Showing only the target turns this into the bubble mode; showing
* five wrong letters next to it means she has to find *her* letter before she can type * five wrong letters next to it means she has to find *her* letter before she can type
* it, which is the searching step that eventually goes away. */ * it, which is the searching step that eventually goes away. */
import { useEffect, useMemo } from "react"; import { useMemo } from "react";
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../lib/engine";
import { fingerOf } from "../../lib/fingers"; import { fingerOf } from "../../lib/fingers";
import { mulberry32 } from "../../lib/generator"; import { mulberry32 } from "../../lib/generator";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../lib/progress";
import { say, spellKey } from "../../lib/speech";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
interface Props { interface Props {
@@ -30,18 +28,18 @@ interface Props {
} }
/** How many jellyfish are in the water at once, target included. */ /** How many jellyfish are in the water at once, target included. */
const QUALLEN = 6; const JELLYFISH_COUNT = 6;
interface Qualle { interface Jellyfish {
left: number; left: number;
top: number; top: number;
drift: number; drift: number;
groesse: number; size: number;
} }
export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }: Props) { export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
const text = letters.join(""); const text = letters.join("");
const { state, daneben } = useRun({ const { state, wrong } = useRun({
target: text, target: text,
sound: progress.settings.sound, sound: progress.settings.sound,
paused, paused,
@@ -52,32 +50,28 @@ export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke // Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
// would make the searching step impossible rather than merely hard. // would make the searching step impossible rather than merely hard.
const plaetze = useMemo<Qualle[]>(() => { const spots = useMemo<Jellyfish[]>(() => {
const rng = mulberry32(text.length * 31 + 7); const rng = mulberry32(text.length * 31 + 7);
return Array.from({ length: QUALLEN }, () => ({ return Array.from({ length: JELLYFISH_COUNT }, () => ({
left: 10 + rng() * 76, left: 10 + rng() * 76,
top: 6 + rng() * 66, top: 6 + rng() * 66,
drift: 3 + rng() * 3, drift: 3 + rng() * 3,
groesse: 74 + rng() * 26, size: 74 + rng() * 26,
})); }));
}, [text]); }, [text]);
/** The decoys shown alongside the target: other active keys, never the target itself, /** The decoys shown alongside the target: other active keys, never the target itself,
* and stable for as long as the target is. */ * and stable for as long as the target is. */
const koeder = useMemo(() => { const decoys = useMemo(() => {
if (!next) return []; if (!next) return [];
const rng = mulberry32(state.index * 101 + 13); const rng = mulberry32(state.index * 101 + 13);
const andere = activeKeys.filter((key) => key !== next && key !== " "); const others = activeKeys.filter((key) => key !== next && key !== " ");
const gemischt = [...andere].sort(() => rng() - 0.5); const shuffled = [...others].sort(() => rng() - 0.5);
return gemischt.slice(0, QUALLEN - 1); return shuffled.slice(0, JELLYFISH_COUNT - 1);
}, [next, state.index, activeKeys]); }, [next, state.index, activeKeys]);
useEffect(() => {
if (next) say(spellKey(next), progress.settings.speech);
}, [next, progress.settings.speech]);
// Which jellyfish carries the target. Moves around so it is not always the same one. // Which jellyfish carries the target. Moves around so it is not always the same one.
const zielPlatz = next ? state.index % QUALLEN : -1; const targetSlot = next ? state.index % JELLYFISH_COUNT : -1;
return ( return (
<div <div
@@ -93,44 +87,44 @@ export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }
}} }}
> >
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}> <div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
{plaetze.map((platz, i) => { {spots.map((spot, i) => {
const istZiel = i === zielPlatz; const isTarget = i === targetSlot;
const buchstabe = istZiel ? next : koeder[i > zielPlatz ? i - 1 : i]; const letter = isTarget ? next : decoys[i > targetSlot ? i - 1 : i];
if (!buchstabe) return null; if (!letter) return null;
const finger = fingerOf(buchstabe); const finger = fingerOf(letter);
return ( return (
<div <div
key={i} key={i}
className="qualle" className="jellyfish"
style={{ style={{
position: "absolute", position: "absolute",
left: `${platz.left}%`, left: `${spot.left}%`,
top: `${platz.top}%`, top: `${spot.top}%`,
width: platz.groesse, width: spot.size,
height: platz.groesse, height: spot.size,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
fontSize: platz.groesse * (istZiel ? 0.4 : 0.3), fontSize: spot.size * (isTarget ? 0.4 : 0.3),
fontWeight: 900, fontWeight: 900,
borderRadius: "50% 50% 42% 42%", borderRadius: "50% 50% 42% 42%",
color: istZiel ? "oklch(25% 0.05 175)" : "var(--paper)", color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
background: istZiel background: isTarget
? "var(--paper)" ? "var(--paper)"
: `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`, : `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`,
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${istZiel ? 0.9 : 0.3})`, border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${isTarget ? 0.9 : 0.3})`,
boxShadow: istZiel boxShadow: isTarget
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)" ? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
: "0 4px 14px var(--shadow)", : "0 4px 14px var(--shadow)",
opacity: istZiel ? 1 : 0.55, opacity: isTarget ? 1 : 0.55,
transform: istZiel ? "scale(1.12)" : "scale(1)", transform: isTarget ? "scale(1.12)" : "scale(1)",
animation: `dolphinBob ${platz.drift}s ease-in-out infinite`, animation: `dolphinBob ${spot.drift}s ease-in-out infinite`,
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease", transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
}} }}
> >
{buchstabe === " " ? "␣" : buchstabe} {letter === " " ? "␣" : letter}
{istZiel && daneben && ( {isTarget && wrong && (
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} /> <div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
)} )}
</div> </div>
@@ -138,7 +132,6 @@ export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }
})} })}
</div> </div>
<HandHint nextKey={next} />
<Keyboard <Keyboard
activeKeys={activeKeys} activeKeys={activeKeys}
nextKey={next} nextKey={next}

View File

@@ -1,4 +1,4 @@
/** Perlentaucher - the mode with no clock. /** Pearls mode - the mode with no clock.
* *
* Every other mode measures something. This one deliberately does not show a timer, a * Every other mode measures something. This one deliberately does not show a timer, a
* progress bar, a streak or a speed: an oyster opens, a word is inside it, and each * progress bar, a streak or a speed: an oyster opens, a word is inside it, and each
@@ -10,16 +10,14 @@
* offers it, always. The run is still graded the same way underneath - a calm round and * offers it, always. The run is still graded the same way underneath - a calm round and
* a frantic one land in the same `grade()` - but nothing on screen is urging her on. */ * a frantic one land in the same `grade()` - but nothing on screen is urging her on. */
import { useEffect, useState } from "react"; import { useState } from "react";
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../lib/engine";
import type { RunEvent } from "../../lib/engine"; import type { RunEvent } from "../../lib/engine";
import { chunkOffsets } from "../../lib/generator"; import { chunkOffsets } from "../../lib/generator";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../lib/progress";
import { say } from "../../lib/speech";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
interface Props { interface Props {
@@ -32,7 +30,7 @@ interface Props {
onFinished: (result: RunResult) => void; onFinished: (result: RunResult) => void;
} }
export function PerlenRun({ export function PearlsRun({
chunks, chunks,
text, text,
spaceActive, spaceActive,
@@ -41,16 +39,16 @@ export function PerlenRun({
paused, paused,
onFinished, onFinished,
}: Props) { }: Props) {
const [perlen, setPerlen] = useState(0); const [pearls, setPearls] = useState(0);
const onEvent = (event: RunEvent) => { const onEvent = (event: RunEvent) => {
// A pearl per correct letter, one lost per mistake - but never below zero. Watching // A pearl per correct letter, one lost per mistake - but never below zero. Watching
// the string shrink past empty is the kind of punishment this mode exists to avoid. // the string shrink past empty is the kind of punishment this mode exists to avoid.
if (event.type === "correct") setPerlen((n) => n + 1); if (event.type === "correct") setPearls((n) => n + 1);
else if (event.type === "wrong" && event.firstAt) setPerlen((n) => Math.max(0, n - 1)); else if (event.type === "wrong" && event.firstAt) setPearls((n) => Math.max(0, n - 1));
}; };
const { state, daneben } = useRun({ const { state, wrong } = useRun({
target: text, target: text,
sound: progress.settings.sound, sound: progress.settings.sound,
paused, paused,
@@ -62,16 +60,12 @@ export function PerlenRun({
const offsets = chunkOffsets(chunks, spaceActive); const offsets = chunkOffsets(chunks, spaceActive);
// Which word the oyster is holding right now. // Which word the oyster is holding right now.
const aktuellerChunk = offsets.findIndex((start, i) => { const currentChunkIndex = offsets.findIndex((start, i) => {
const ende = start + (chunks[i]?.length ?? 0); const end = start + (chunks[i]?.length ?? 0);
return state.index <= ende; return state.index <= end;
}); });
const wort = chunks[aktuellerChunk === -1 ? chunks.length - 1 : aktuellerChunk] ?? ""; const word = chunks[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? "";
const wortStart = offsets[aktuellerChunk === -1 ? chunks.length - 1 : aktuellerChunk] ?? 0; const wordStart = offsets[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? 0;
useEffect(() => {
if (wort) say(wort, progress.settings.speech);
}, [wort, progress.settings.speech]);
return ( return (
<div <div
@@ -98,9 +92,9 @@ export function PerlenRun({
minHeight: 26, minHeight: 26,
alignItems: "center", alignItems: "center",
}} }}
aria-label={`${perlen} Perlen`} aria-label={`${pearls} Perlen`}
> >
{Array.from({ length: perlen }, (_, i) => ( {Array.from({ length: pearls }, (_, i) => (
<span <span
key={i} key={i}
style={{ style={{
@@ -109,7 +103,7 @@ export function PerlenRun({
borderRadius: "50%", borderRadius: "50%",
background: "radial-gradient(circle at 32% 30%, oklch(99% 0.01 175), oklch(80% 0.04 300))", background: "radial-gradient(circle at 32% 30%, oklch(99% 0.01 175), oklch(80% 0.04 300))",
boxShadow: "0 2px 7px var(--shadow)", boxShadow: "0 2px 7px var(--shadow)",
animation: i === perlen - 1 ? "correctPop 240ms ease-out" : undefined, animation: i === pearls - 1 ? "correctPop 240ms ease-out" : undefined,
}} }}
/> />
))} ))}
@@ -126,31 +120,29 @@ export function PerlenRun({
borderRadius: 26, borderRadius: 26,
background: "linear-gradient(160deg, oklch(97% 0.01 175 / .2), oklch(97% 0.01 175 / .07))", background: "linear-gradient(160deg, oklch(97% 0.01 175 / .2), oklch(97% 0.01 175 / .07))",
border: "1px solid oklch(97% 0.01 175 / .22)", border: "1px solid oklch(97% 0.01 175 / .22)",
animation: daneben ? "wrongShake 260ms ease" : undefined, animation: wrong ? "wrongShake 260ms ease" : undefined,
}} }}
> >
<div style={{ fontSize: 34 }}>🦪</div> <div style={{ fontSize: 34 }}>🦪</div>
<div style={{ display: "flex", fontSize: 46, fontWeight: 800 }}> <div style={{ display: "flex", fontSize: 46, fontWeight: 800 }}>
{[...wort].map((char, i) => { {[...word].map((char, i) => {
const at = wortStart + i; const at = wordStart + i;
const state_ = at < state.index ? "fertig" : at === state.index ? "aktuell" : "offen"; const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
return ( return (
<span <span
key={i} key={i}
className="ziel-zeichen" className="target-char"
data-state={state_} data-state={charState}
data-leer={char === " "} data-blank={char === " "}
data-daneben={state_ === "aktuell" && daneben} data-wrong={charState === "current" && wrong}
style={{ padding: state_ === "aktuell" ? "0 6px" : undefined }}
> >
{char === " " ? (state_ === "aktuell" ? "␣" : "") : char} {char === " " ? (charState === "current" ? "␣" : "") : char}
</span> </span>
); );
})} })}
</div> </div>
</div> </div>
<HandHint nextKey={next} />
<Keyboard <Keyboard
activeKeys={activeKeys} activeKeys={activeKeys}
nextKey={next} nextKey={next}

View File

@@ -1,4 +1,4 @@
/** Delfinrennen - a race against her own best run. /** Race mode - a race against her own best run.
* *
* The opponent is the ghost of the fastest run she has ever had on this lesson: the * The opponent is the ghost of the fastest run she has ever had on this lesson: the
* keystroke timings are stored in `progress.lessons[id].ghost` and replayed against the * keystroke timings are stored in `progress.lessons[id].ghost` and replayed against the
@@ -6,7 +6,7 @@
* opponent is by definition exactly as good as she was, so the race is always close, and * opponent is by definition exactly as good as she was, so the race is always close, and
* winning means she actually improved. * winning means she actually improved.
* *
* Before a ghost exists there is a Krabbe swimming at a gentle fixed pace. It is beatable * Before a ghost exists there is a crab swimming at a gentle fixed pace. It is beatable
* on the first try on purpose. * on the first try on purpose.
* *
* The ghost is moved by writing to the DOM node directly from a requestAnimationFrame * The ghost is moved by writing to the DOM node directly from a requestAnimationFrame
@@ -20,7 +20,6 @@ import { currentChar } from "../../lib/engine";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../lib/progress";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
import { Target } from "../Target"; import { Target } from "../Target";
@@ -36,11 +35,11 @@ interface Props {
onFinished: (result: RunResult) => void; onFinished: (result: RunResult) => void;
} }
/** The pace of the stand-in opponent, in Zeichen pro Minute. Slow enough that a careful /** The pace of the stand-in opponent, in characters per minute. Slow enough that a
* first-timer beats it - roughly a Schildkröte. */ * careful first-timer beats it - roughly a turtle's pace. */
const KRABBEN_TEMPO = 30; const CRAB_PACE = 30;
export function RennenRun({ export function RaceRun({
chunks, chunks,
text, text,
spaceActive, spaceActive,
@@ -50,7 +49,7 @@ export function RennenRun({
paused, paused,
onFinished, onFinished,
}: Props) { }: Props) {
const { state, daneben } = useRun({ const { state, wrong } = useRun({
target: text, target: text,
sound: progress.settings.sound, sound: progress.settings.sound,
paused, paused,
@@ -58,12 +57,12 @@ export function RennenRun({
}); });
const next = currentChar(state); const next = currentChar(state);
const bahn = useRef<HTMLDivElement>(null); const track = useRef<HTMLDivElement>(null);
const gegner = useRef<HTMLDivElement>(null); const opponent = useRef<HTMLDivElement>(null);
// Everything the loop reads lives in a ref, so it is started once and never restarted. // Everything the loop reads lives in a ref, so it is started once and never restarted.
const latest = useRef({ ghost, startedAt: state.startedAt, laenge: text.length, fertig: state.finishedAt !== null }); const latest = useRef({ ghost, startedAt: state.startedAt, length: text.length, finished: state.finishedAt !== null });
latest.current = { ghost, startedAt: state.startedAt, laenge: text.length, fertig: state.finishedAt !== null }; latest.current = { ghost, startedAt: state.startedAt, length: text.length, finished: state.finishedAt !== null };
useEffect(() => { useEffect(() => {
let frame = 0; let frame = 0;
@@ -71,7 +70,7 @@ export function RennenRun({
const tick = () => { const tick = () => {
frame = requestAnimationFrame(tick); frame = requestAnimationFrame(tick);
const current = latest.current; const current = latest.current;
const node = gegner.current; const node = opponent.current;
if (!node) return; if (!node) return;
// The race starts on her first keystroke, not when the screen opens - the same // The race starts on her first keystroke, not when the screen opens - the same
@@ -81,27 +80,27 @@ export function RennenRun({
return; return;
} }
const vergangen = performance.now() - current.startedAt; const elapsed = performance.now() - current.startedAt;
let anteil: number; let fraction: number;
if (current.ghost && current.ghost.length > 1) { if (current.ghost && current.ghost.length > 1) {
const start = current.ghost[0]!.at; const start = current.ghost[0]!.at;
// How many of the ghost's keystrokes have come due by now. // How many of the ghost's keystrokes have come due by now.
let getippt = 0; let typed = 0;
while (getippt < current.ghost.length && current.ghost[getippt]!.at - start <= vergangen) getippt++; while (typed < current.ghost.length && current.ghost[typed]!.at - start <= elapsed) typed++;
anteil = getippt / current.laenge; fraction = typed / current.length;
} else { } else {
anteil = vergangen / 60000 / (1 / KRABBEN_TEMPO) / current.laenge; fraction = elapsed / 60000 / (1 / CRAB_PACE) / current.length;
} }
node.style.left = `${Math.min(1, Math.max(0, anteil)) * 100}%`; node.style.left = `${Math.min(1, Math.max(0, fraction)) * 100}%`;
}; };
frame = requestAnimationFrame(tick); frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, []); }, []);
const meinAnteil = text.length === 0 ? 0 : state.index / text.length; const myFraction = text.length === 0 ? 0 : state.index / text.length;
return ( return (
<div <div
@@ -117,12 +116,12 @@ export function RennenRun({
minHeight: 0, minHeight: 0,
}} }}
> >
<div ref={bahn} style={{ width: "min(860px, 92%)", display: "flex", flexDirection: "column", gap: 10 }}> <div ref={track} style={{ width: "min(860px, 92%)", display: "flex", flexDirection: "column", gap: 10 }}>
<Bahn label="Du" farbe="var(--paper)"> <Lane label="Du" color="var(--paper)">
<div <div
style={{ style={{
position: "absolute", position: "absolute",
left: `${meinAnteil * 100}%`, left: `${myFraction * 100}%`,
transform: "translateX(-50%)", transform: "translateX(-50%)",
fontSize: 34, fontSize: 34,
transition: "left 140ms ease-out", transition: "left 140ms ease-out",
@@ -130,17 +129,16 @@ export function RennenRun({
> >
🐬 🐬
</div> </div>
</Bahn> </Lane>
<Bahn label={ghost ? "Dein Rekord" : "Die Krabbe"} farbe="oklch(97% 0.01 175 / .55)"> <Lane label={ghost ? "Dein Rekord" : "Die Krabbe"} color="oklch(97% 0.01 175 / .55)">
<div ref={gegner} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}> <div ref={opponent} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}>
{ghost ? "👻" : "🦀"} {ghost ? "👻" : "🦀"}
</div> </div>
</Bahn> </Lane>
</div> </div>
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} daneben={daneben} fontSize={34} /> <Target chunks={chunks} spaceActive={spaceActive} index={state.index} wrong={wrong} fontSize={34} />
<HandHint nextKey={next} />
<Keyboard <Keyboard
activeKeys={activeKeys} activeKeys={activeKeys}
nextKey={next} nextKey={next}
@@ -153,10 +151,10 @@ export function RennenRun({
} }
/** One lane, with the finish line at the right. */ /** One lane, with the finish line at the right. */
function Bahn({ label, farbe, children }: { label: string; farbe: string; children: React.ReactNode }) { function Lane({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ width: 96, flex: "none", fontSize: 13, fontWeight: 800, color: farbe, textAlign: "right" }}> <div style={{ width: 96, flex: "none", fontSize: 13, fontWeight: 800, color, textAlign: "right" }}>
{label} {label}
</div> </div>
<div <div

View File

@@ -14,7 +14,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { isTypingKey, press, startRun } from "../lib/engine"; import { isTypingKey, press, startRun } from "../lib/engine";
import type { RunEvent, RunState } from "../lib/engine"; import type { RunEvent, RunState } from "../lib/engine";
import type { RunResult } from "../lib/grading"; import type { RunResult } from "../lib/grading";
import { playDaneben, playFertig, playRichtig } from "../lib/pop"; import { playWrong, playDone, playCorrect } from "../lib/pop";
interface Options { interface Options {
/** The text to type. Changing it restarts the run. */ /** The text to type. Changing it restarts the run. */
@@ -31,13 +31,13 @@ interface Options {
export interface RunHandle { export interface RunHandle {
state: RunState; state: RunState;
/** True while the cursor is sitting on a key that was just missed. */ /** True while the cursor is sitting on a key that was just missed. */
daneben: boolean; wrong: boolean;
restart: () => void; restart: () => void;
} }
export function useRun({ target, sound, onFinished, onEvent, paused = false }: Options): RunHandle { export function useRun({ target, sound, onFinished, onEvent, paused = false }: Options): RunHandle {
const [state, setState] = useState<RunState>(() => startRun(target)); const [state, setState] = useState<RunState>(() => startRun(target));
const [daneben, setDaneben] = useState(false); const [wrong, setWrong] = useState(false);
// Everything the listener needs, kept current without reinstalling it. // Everything the listener needs, kept current without reinstalling it.
const latest = useRef({ state, sound, onFinished, onEvent, paused }); const latest = useRef({ state, sound, onFinished, onEvent, paused });
@@ -45,7 +45,7 @@ export function useRun({ target, sound, onFinished, onEvent, paused = false }: O
const restart = useCallback(() => { const restart = useCallback(() => {
setState(startRun(target)); setState(startRun(target));
setDaneben(false); setWrong(false);
}, [target]); }, [target]);
// A new target is a new run - the modes swap the line rather than remounting. // A new target is a new run - the modes swap the line rather than remounting.
@@ -69,13 +69,13 @@ export function useRun({ target, sound, onFinished, onEvent, paused = false }: O
setState(next); setState(next);
for (const runEvent of events) { for (const runEvent of events) {
if (runEvent.type === "correct") { if (runEvent.type === "correct") {
setDaneben(false); setWrong(false);
if (current.sound) playRichtig(runEvent.streak); if (current.sound) playCorrect(runEvent.streak);
} else if (runEvent.type === "wrong") { } else if (runEvent.type === "wrong") {
setDaneben(true); setWrong(true);
if (current.sound) playDaneben(); if (current.sound) playWrong();
} else { } else {
if (current.sound) playFertig(); if (current.sound) playDone();
current.onFinished(runEvent.result); current.onFinished(runEvent.result);
} }
current.onEvent?.(runEvent); current.onEvent?.(runEvent);
@@ -86,5 +86,5 @@ export function useRun({ target, sound, onFinished, onEvent, paused = false }: O
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, []); }, []);
return { state, daneben, restart }; return { state, wrong, restart };
} }

View File

@@ -1,98 +1,98 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { KREATUREN, kreaturAus, kreaturById, neuerSchwimmer, pose, schwimme } from "../aquarium"; import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium";
import type { Becken, Schwimmer } from "../aquarium"; import type { Tank, Swimmer } from "../aquarium";
import { WELTEN } from "../curriculum"; import { WORLDS } from "../curriculum";
import { mulberry32 } from "../generator"; import { mulberry32 } from "../generator";
const BECKEN: Becken = { breite: 1280, hoehe: 800 }; const TANK: Tank = { width: 1280, height: 800 };
const RAND = 60; const MARGIN = 60;
const TEMPO = 55; const SPEED = 55;
const DT = 1 / 60; const DT = 1 / 60;
function swim(s: Schwimmer, sekunden: number, becken = BECKEN, rng = mulberry32(7)): Schwimmer { function swim(s: Swimmer, seconds: number, tank = TANK, rng = mulberry32(7)): Swimmer {
for (let t = 0; t < sekunden; t += DT) s = schwimme(s, DT, becken, RAND, TEMPO, rng); for (let t = 0; t < seconds; t += DT) s = stepSwimmer(s, DT, tank, MARGIN, SPEED, rng);
return s; return s;
} }
describe("Kreaturen", () => { describe("Creatures", () => {
it("gives every pet exactly one Welt", () => { it("gives every pet exactly one world", () => {
const belohnungen = WELTEN.map((welt) => welt.belohnung); const rewards = WORLDS.map((world) => world.reward);
expect([...belohnungen].sort()).toEqual(KREATUREN.map((k) => k.id).sort()); expect([...rewards].sort()).toEqual(CREATURES.map((c) => c.id).sort());
}); });
it("looks pets up by id", () => { it("looks pets up by id", () => {
for (const kreatur of KREATUREN) expect(kreaturById(kreatur.id)).toBe(kreatur); for (const creature of CREATURES) expect(creatureById(creature.id)).toBe(creature);
}); });
it("reads today's ids and the emoji old saves stored, and nothing else", () => { it("reads today's ids and the emoji old saves stored, and nothing else", () => {
expect(kreaturAus("krake")).toBe("krake"); expect(creatureFromRaw("octopus")).toBe("octopus");
expect(kreaturAus("🐠")).toBe("clownfisch"); expect(creatureFromRaw("🐠")).toBe("clownfish");
expect(kreaturAus("🧜")).toBe("perlmuschel"); expect(creatureFromRaw("🧜")).toBe("pearlmussel");
expect(kreaturAus("🦈")).toBeNull(); expect(creatureFromRaw("🦈")).toBeNull();
expect(kreaturAus(42)).toBeNull(); expect(creatureFromRaw(42)).toBeNull();
}); });
}); });
describe("schwimme", () => { describe("stepSwimmer", () => {
it("never lets a pet's centre leave the tank", () => { it("never lets a pet's centre leave the tank", () => {
const rng = mulberry32(3); const rng = mulberry32(3);
let s = neuerSchwimmer(BECKEN, RAND, rng); let s = createSwimmer(TANK, MARGIN, rng);
// Ten minutes of swimming, checked every frame. // Ten minutes of swimming, checked every frame.
for (let t = 0; t < 600; t += DT) { for (let t = 0; t < 600; t += DT) {
s = schwimme(s, DT, BECKEN, RAND, TEMPO, rng); s = stepSwimmer(s, DT, TANK, MARGIN, SPEED, rng);
expect(s.x).toBeGreaterThanOrEqual(0); expect(s.x).toBeGreaterThanOrEqual(0);
expect(s.x).toBeLessThanOrEqual(BECKEN.breite); expect(s.x).toBeLessThanOrEqual(TANK.width);
expect(s.y).toBeGreaterThanOrEqual(0); expect(s.y).toBeGreaterThanOrEqual(0);
expect(s.y).toBeLessThanOrEqual(BECKEN.hoehe); expect(s.y).toBeLessThanOrEqual(TANK.height);
} }
}); });
it("keeps moving rather than settling", () => { it("keeps moving rather than settling", () => {
const rng = mulberry32(11); const rng = mulberry32(11);
const start = neuerSchwimmer(BECKEN, RAND, rng); const start = createSwimmer(TANK, MARGIN, rng);
const spaeter = swim(start, 60, BECKEN, rng); const later = swim(start, 60, TANK, rng);
expect(Math.hypot(spaeter.x - start.x, spaeter.y - start.y)).toBeGreaterThan(0); expect(Math.hypot(later.x - start.x, later.y - start.y)).toBeGreaterThan(0);
expect(spaeter.zielX !== start.zielX || spaeter.zielY !== start.zielY).toBe(true); expect(later.targetX !== start.targetX || later.targetY !== start.targetY).toBe(true);
}); });
it("turns to face the way it swims", () => { it("turns to face the way it swims", () => {
const base: Schwimmer = { x: 640, y: 400, vx: 0, vy: 0, zielX: 100, zielY: 400, blick: 1, zeit: 0 }; const base: Swimmer = { x: 640, y: 400, vx: 0, vy: 0, targetX: 100, targetY: 400, facing: 1, age: 0 };
const links = swim(base, 3); const left = swim(base, 3);
expect(links.vx).toBeLessThan(0); expect(left.vx).toBeLessThan(0);
expect(links.blick).toBeLessThan(-0.9); expect(left.facing).toBeLessThan(-0.9);
const rechts = swim({ ...base, zielX: 1180, blick: -1 }, 3); const right = swim({ ...base, targetX: 1180, facing: -1 }, 3);
expect(rechts.vx).toBeGreaterThan(0); expect(right.vx).toBeGreaterThan(0);
expect(rechts.blick).toBeGreaterThan(0.9); expect(right.facing).toBeGreaterThan(0.9);
}); });
it("brings a newly earned pet in from outside the tank", () => { it("brings a newly earned pet in from outside the tank", () => {
const rng = mulberry32(5); const rng = mulberry32(5);
const neu = neuerSchwimmer(BECKEN, RAND, rng, true); const fresh = createSwimmer(TANK, MARGIN, rng, true);
expect(neu.x < 0 || neu.x > BECKEN.breite).toBe(true); expect(fresh.x < 0 || fresh.x > TANK.width).toBe(true);
const drinnen = swim(neu, 40, BECKEN, rng); const inside = swim(fresh, 40, TANK, rng);
expect(drinnen.x).toBeGreaterThan(0); expect(inside.x).toBeGreaterThan(0);
expect(drinnen.x).toBeLessThan(BECKEN.breite); expect(inside.x).toBeLessThan(TANK.width);
}); });
it("finds a new target when the window shrinks under the old one", () => { it("finds a new target when the window shrinks under the old one", () => {
const s: Schwimmer = { x: 200, y: 200, vx: 0, vy: 0, zielX: 1200, zielY: 700, blick: 1, zeit: 0 }; const s: Swimmer = { x: 200, y: 200, vx: 0, vy: 0, targetX: 1200, targetY: 700, facing: 1, age: 0 };
const klein = { breite: 500, hoehe: 400 }; const small = { width: 500, height: 400 };
const next = schwimme(s, DT, klein, RAND, TEMPO, mulberry32(1)); const next = stepSwimmer(s, DT, small, MARGIN, SPEED, mulberry32(1));
expect(next.zielX).toBeLessThanOrEqual(klein.breite - RAND); expect(next.targetX).toBeLessThanOrEqual(small.width - MARGIN);
expect(next.zielY).toBeLessThanOrEqual(klein.hoehe - RAND); expect(next.targetY).toBeLessThanOrEqual(small.height - MARGIN);
}); });
}); });
describe("pose", () => { describe("pose", () => {
const s: Schwimmer = { x: 10, y: 20, vx: -30, vy: 0, zielX: 0, zielY: 0, blick: -1, zeit: 0 }; const s: Swimmer = { x: 10, y: 20, vx: -30, vy: 0, targetX: 0, targetY: 0, facing: -1, age: 0 };
it("mirrors side-view pets to look where they swim", () => { it("mirrors side-view pets to look where they swim", () => {
expect(pose(s, kreaturById("clownfisch"), TEMPO).spiegel).toBe(-1); expect(pose(s, creatureById("clownfish"), SPEED).mirror).toBe(-1);
}); });
it("never mirrors a front-view pet", () => { it("never mirrors a front-view pet", () => {
expect(pose(s, kreaturById("krake"), TEMPO).spiegel).toBe(1); expect(pose(s, creatureById("octopus"), SPEED).mirror).toBe(1);
expect(pose(s, kreaturById("perlmuschel"), TEMPO).spiegel).toBe(1); expect(pose(s, creatureById("pearlmussel"), SPEED).mirror).toBe(1);
}); });
}); });

View File

@@ -1,17 +1,17 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS, WELTEN, lessonById, lessonsOfWelt, nextLesson } from "../curriculum"; import { FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers"; import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => { describe("LESSONS", () => {
it("has unique ids and consecutive numbers", () => { it("has unique ids and consecutive numbers", () => {
const ids = LESSONS.map((lesson) => lesson.id); const ids = LESSONS.map((lesson) => lesson.id);
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
LESSONS.forEach((lesson, i) => expect(lesson.nummer).toBe(i + 1)); LESSONS.forEach((lesson, i) => expect(lesson.number).toBe(i + 1));
}); });
it("starts on the two keys with the tactile bumps", () => { it("starts on the two keys with the tactile bumps", () => {
expect(LESSONS[0]!.neueKeys).toEqual(["f", "j"]); expect(LESSONS[0]!.newKeys).toEqual(["f", "j"]);
expect(FIRST_LESSON_ID).toBe(LESSONS[0]!.id); expect(FIRST_LESSON_ID).toBe(LESSONS[0]!.id);
}); });
@@ -19,50 +19,50 @@ describe("LESSONS", () => {
// The first draft opened with all four left-hand keys at once, which is a steep // The first draft opened with all four left-hand keys at once, which is a steep
// first five minutes for a six-year-old. Two at a time, always. // first five minutes for a six-year-old. Two at a time, always.
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2); expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
} }
}); });
it("teaches Welt 1 as mirrored finger pairs, one finger per hand", () => { it("teaches world 1 as mirrored finger pairs, one finger per hand", () => {
const paare = lessonsOfWelt(1).filter((lesson) => lesson.neueKeys.length === 2); const pairs = lessonsOfWorld(1).filter((lesson) => lesson.newKeys.length === 2);
expect(paare.length).toBe(4); expect(pairs.length).toBe(4);
for (const lesson of paare) { for (const lesson of pairs) {
const [links, rechts] = lesson.neueKeys.map((key) => fingerOf(key)!); const [left, right] = lesson.newKeys.map((key) => fingerOf(key)!);
expect(links!.hand).toBe("links"); expect(left!.hand).toBe("left");
expect(rechts!.hand).toBe("rechts"); expect(right!.hand).toBe("right");
// Same finger on each hand - "die Zeigefinger", "die Mittelfinger", … // Same finger on each hand - "die Zeigefinger", "die Mittelfinger", …
expect(links!.id.replace("links-", "")).toBe(rechts!.id.replace("rechts-", "")); expect(left!.id.replace("left-", "")).toBe(right!.id.replace("right-", ""));
} }
}); });
it("has the whole Grundstellung active by the end of Welt 1", () => { it("has the whole home row active by the end of world 1", () => {
const letzte = lessonsOfWelt(1).at(-1)!; const last = lessonsOfWorld(1).at(-1)!;
for (const key of "asdfjklö") expect(letzte.activeKeys).toContain(key); for (const key of "asdfjklö") expect(last.activeKeys).toContain(key);
expect(letzte.activeKeys).toContain(" "); expect(last.activeKeys).toContain(" ");
}); });
it("makes every round a real block of practice, not a handful of keys", () => { it("makes every round a real block of practice, not a handful of keys", () => {
// The first version ran 12 characters in Welt 1 - long enough to finish, too short // The first version ran 12 characters in world 1 - long enough to finish, too short
// for a rhythm to form or for the speed score to mean anything. // for a rhythm to form or for the speed score to mean anything.
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
if (lesson.art === "saetze") { if (lesson.kind === "sentences") {
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(10); expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(10);
} else if (lesson.art === "woerter") { } else if (lesson.kind === "words") {
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(25); expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(25);
} else { } else {
expect(lesson.chunks * lesson.chunkSize, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(60); expect(lesson.chunks * lesson.chunkSize, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(60);
} }
} }
}); });
it("still starts gentler than it ends", () => { it("still starts gentler than it ends", () => {
const laenge = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize; const length = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
expect(laenge(lessonsOfWelt(3)[0]!)).toBeGreaterThan(laenge(lessonsOfWelt(1)[0]!)); expect(length(lessonsOfWorld(3)[0]!)).toBeGreaterThan(length(lessonsOfWorld(1)[0]!));
}); });
it("has more sentences to draw from than a round uses, so a round never has to repeat", () => { it("has more sentences to draw from than a round uses, so a round never has to repeat", () => {
for (const lesson of LESSONS.filter((l) => l.art === "saetze")) { for (const lesson of LESSONS.filter((l) => l.kind === "sentences")) {
expect(lesson.woerter.length, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(lesson.chunks); expect(lesson.words.length, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(lesson.chunks);
} }
}); });
@@ -76,7 +76,7 @@ describe("LESSONS", () => {
it("introduces every new key into its own activeKeys", () => { it("introduces every new key into its own activeKeys", () => {
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
for (const key of lesson.neueKeys) { for (const key of lesson.newKeys) {
if (key === "⇧") continue; // Shift is not a character the generator can emit. if (key === "⇧") continue; // Shift is not a character the generator can emit.
expect(lesson.activeKeys).toContain(key); expect(lesson.activeKeys).toContain(key);
} }
@@ -99,27 +99,27 @@ describe("LESSONS", () => {
// so a lesson can type them as soon as it has the ß or a key. // so a lesson can type them as soon as it has the ß or a key.
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
const active = new Set(lesson.activeKeys); const active = new Set(lesson.activeKeys);
for (const wort of lesson.woerter) { for (const word of lesson.words) {
for (const char of wort) { for (const char of word) {
expect( expect(
active.has(keyForChar(char)), active.has(keyForChar(char)),
`Lektion ${lesson.nummer} (${lesson.titel}): "${wort}" braucht "${char}" (Taste ${keyForChar(char)})`, `Lektion ${lesson.number} (${lesson.title}): "${word}" braucht "${char}" (Taste ${keyForChar(char)})`,
).toBe(true); ).toBe(true);
} }
} }
} }
}); });
it("only uses shifted characters once the Umschalttaste is taught", () => { it("only uses shifted characters once shift is taught", () => {
const shiftAb = LESSONS.find((lesson) => lesson.neueKeys.includes("⇧"))!.nummer; const shiftFrom = LESSONS.find((lesson) => lesson.newKeys.includes("⇧"))!.number;
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
for (const wort of lesson.woerter) { for (const word of lesson.words) {
for (const char of wort) { for (const char of word) {
if (!needsShift(char)) continue; if (!needsShift(char)) continue;
expect( expect(
lesson.nummer, lesson.number,
`Lektion ${lesson.nummer}: "${wort}" braucht Umschalt für "${char}"`, `Lektion ${lesson.number}: "${word}" braucht Umschalt für "${char}"`,
).toBeGreaterThanOrEqual(shiftAb); ).toBeGreaterThanOrEqual(shiftFrom);
} }
} }
} }
@@ -127,59 +127,59 @@ describe("LESSONS", () => {
it("only offers word modes where words exist", () => { it("only offers word modes where words exist", () => {
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
const wortModi = lesson.modi.some((modus) => modus === "fuettern" || modus === "rennen"); const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race");
expect(wortModi).toBe(lesson.woerter.length > 0); expect(wordModes).toBe(lesson.words.length > 0);
} }
}); });
it("always offers the pressure-free Perlentaucher", () => { it("always offers the pressure-free pearl-diving mode", () => {
for (const lesson of LESSONS) expect(lesson.modi).toContain("perlen"); for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls");
}); });
it("teaches capitals only once the Umschalttaste exists", () => { it("teaches capitals only once shift exists", () => {
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
const hatGross = lesson.woerter.some((wort) => wort !== wort.toLowerCase()); const hasUppercase = lesson.words.some((word) => word !== word.toLowerCase());
if (hatGross) expect(lesson.welt).toBeGreaterThanOrEqual(4); if (hasUppercase) expect(lesson.world).toBeGreaterThanOrEqual(4);
} }
}); });
it("gives every new key a lesson of its own after Welt 1", () => { it("gives every new key a lesson of its own after world 1", () => {
// One key at a time is the pacing decision: Welt 1 pairs a finger across both // One key at a time is the pacing decision: world 1 pairs a finger across both
// hands, everything after introduces exactly one key or none. // hands, everything after introduces exactly one key or none.
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
if (lesson.welt === 1) continue; if (lesson.world === 1) continue;
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2); expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
} }
}); });
it("follows every pair of new keys with an Übung", () => { it("follows every pair of new keys with a drill", () => {
const uebungen = LESSONS.filter((lesson) => lesson.istUebung); const drills = LESSONS.filter((lesson) => lesson.isDrill);
expect(uebungen.length).toBeGreaterThanOrEqual(12); expect(drills.length).toBeGreaterThanOrEqual(12);
// An Übung never introduces anything, and always has something to practise. // A drill never introduces anything, and always has something to practise.
for (const lesson of uebungen) { for (const lesson of drills) {
expect(lesson.neueKeys).toEqual([]); expect(lesson.newKeys).toEqual([]);
expect(lesson.activeKeys.length).toBeGreaterThan(0); expect(lesson.activeKeys.length).toBeGreaterThan(0);
} }
}); });
it("is long enough to be a real course", () => { it("is long enough to be a real course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(40); expect(LESSONS.length).toBeGreaterThanOrEqual(40);
expect(WELTEN.length).toBe(5); expect(WORLDS.length).toBe(5);
}); });
it("never leaves a Welt without a run of at least three lessons", () => { it("never leaves a world without a run of at least three lessons", () => {
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThanOrEqual(3); for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThanOrEqual(3);
}); });
}); });
describe("Welten", () => { describe("Worlds", () => {
it("has lessons in every Welt", () => { it("has lessons in every world", () => {
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThan(0); for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThan(0);
}); });
it("gives every Welt its own aquarium creature", () => { it("gives every world its own aquarium creature", () => {
const belohnungen = WELTEN.map((welt) => welt.belohnung); const rewards = WORLDS.map((world) => world.reward);
expect(new Set(belohnungen).size).toBe(belohnungen.length); expect(new Set(rewards).size).toBe(rewards.length);
}); });
}); });
@@ -193,7 +193,7 @@ describe("navigation", () => {
}); });
it("looks lessons up by id", () => { it("looks lessons up by id", () => {
expect(lessonById(FIRST_LESSON_ID)?.nummer).toBe(1); expect(lessonById(FIRST_LESSON_ID)?.number).toBe(1);
expect(lessonById("gibt-es-nicht")).toBeNull(); expect(lessonById("gibt-es-nicht")).toBeNull();
}); });
}); });

View File

@@ -64,7 +64,7 @@ describe("press", () => {
expect(done.finishedAt).toBe(1000); expect(done.finishedAt).toBe(1000);
const finished = events.find((event) => event.type === "finished"); const finished = events.find((event) => event.type === "finished");
expect(finished).toBeDefined(); expect(finished).toBeDefined();
expect(finished?.type === "finished" && finished.result.zeichen).toBe(2); expect(finished?.type === "finished" && finished.result.characters).toBe(2);
}); });
it("does nothing once the run is over", () => { it("does nothing once the run is over", () => {

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
FINGERS, FINGERS,
GRUNDSTELLUNG, HOME_ROW,
KEYBOARD_ROWS, KEYBOARD_ROWS,
fingerOf, fingerOf,
handOf, handOf,
@@ -23,9 +23,9 @@ describe("fingerOf", () => {
for (const key of ALPHABET) { for (const key of ALPHABET) {
// "ß".toUpperCase() is "SS" - two characters, and not a key. `event.key` never // "ß".toUpperCase() is "SS" - two characters, and not a key. `event.key` never
// reports that, so the single-character case is the one that has to hold. // reports that, so the single-character case is the one that has to hold.
const gross = key.toUpperCase(); const upper = key.toUpperCase();
if ([...gross].length !== 1) continue; if ([...upper].length !== 1) continue;
expect(fingerOf(gross)?.id, gross).toBe(fingerOf(key)?.id); expect(fingerOf(upper)?.id, upper).toBe(fingerOf(key)?.id);
} }
}); });
@@ -33,44 +33,44 @@ describe("fingerOf", () => {
for (const key of ["Enter", "F1", "€", ""]) expect(fingerOf(key)).toBeNull(); for (const key of ["Enter", "F1", "€", ""]) expect(fingerOf(key)).toBeNull();
}); });
it("assigns the Grundstellung to the eight home fingers, left to right", () => { it("assigns the home row to the eight home fingers, left to right", () => {
const expected = [ const expected = [
"links-klein", "left-pinky",
"links-ring", "left-ring",
"links-mitte", "left-middle",
"links-zeige", "left-index",
"rechts-zeige", "right-index",
"rechts-mitte", "right-middle",
"rechts-ring", "right-ring",
"rechts-klein", "right-pinky",
]; ];
GRUNDSTELLUNG.forEach((key, i) => expect(fingerOf(key)?.id).toBe(expected[i])); HOME_ROW.forEach((key, i) => expect(fingerOf(key)?.id).toBe(expected[i]));
}); });
it("gives each finger the home key it actually rests on", () => { it("gives each finger the home key it actually rests on", () => {
for (const key of GRUNDSTELLUNG) expect(homeKeyOf(key)).toBe(key); for (const key of HOME_ROW) expect(homeKeyOf(key)).toBe(key);
expect(homeKeyOf(" ")).toBe(" "); expect(homeKeyOf(" ")).toBe(" ");
}); });
it("sends the two index fingers to their stretch keys", () => { it("sends the two index fingers to their stretch keys", () => {
for (const key of "rtfgvb") expect(fingerOf(key)?.id).toBe("links-zeige"); for (const key of "rtfgvb") expect(fingerOf(key)?.id).toBe("left-index");
for (const key of "zuhjnm") expect(fingerOf(key)?.id).toBe("rechts-zeige"); for (const key of "zuhjnm") expect(fingerOf(key)?.id).toBe("right-index");
}); });
}); });
describe("hands", () => { describe("hands", () => {
it("splits the letters into two disjoint, non-empty sets", () => { it("splits the letters into two disjoint, non-empty sets", () => {
const links = [...ALPHABET].filter((key) => handOf(key) === "links"); const left = [...ALPHABET].filter((key) => handOf(key) === "left");
const rechts = [...ALPHABET].filter((key) => handOf(key) === "rechts"); const right = [...ALPHABET].filter((key) => handOf(key) === "right");
expect(links.length).toBeGreaterThan(0); expect(left.length).toBeGreaterThan(0);
expect(rechts.length).toBeGreaterThan(0); expect(right.length).toBeGreaterThan(0);
expect(links.length + rechts.length).toBe(ALPHABET.length); expect(left.length + right.length).toBe(ALPHABET.length);
expect(links.some((key) => rechts.includes(key))).toBe(false); expect(left.some((key) => right.includes(key))).toBe(false);
}); });
it("shifts with the opposite hand", () => { it("shifts with the opposite hand", () => {
expect(shiftHandFor("a")).toBe("rechts"); expect(shiftHandFor("a")).toBe("right");
expect(shiftHandFor("l")).toBe("links"); expect(shiftHandFor("l")).toBe("left");
expect(shiftHandFor("Enter")).toBeNull(); expect(shiftHandFor("Enter")).toBeNull();
}); });
}); });
@@ -80,8 +80,8 @@ describe("rows", () => {
for (const key of ALPHABET) expect(rowOf(key), key).not.toBeNull(); for (const key of ALPHABET) expect(rowOf(key), key).not.toBeNull();
}); });
it("has the Grundstellung in the Grundreihe", () => { it("has the home row in the home row", () => {
for (const key of GRUNDSTELLUNG) expect(rowOf(key)).toBe("grund"); for (const key of HOME_ROW) expect(rowOf(key)).toBe("home");
}); });
it("draws three rows, each with keys", () => { it("draws three rows, each with keys", () => {

View File

@@ -32,7 +32,7 @@ describe("mulberry32", () => {
describe("drillChunks", () => { describe("drillChunks", () => {
it("only ever emits active keys", () => { it("only ever emits active keys", () => {
for (const lesson of LESSONS) { for (const lesson of LESSONS) {
const chunks = drillChunks(lesson.activeKeys, mulberry32(lesson.nummer), { chunks: 20 }); const chunks = drillChunks(lesson.activeKeys, mulberry32(lesson.number), { chunks: 20 });
const active = new Set(lesson.activeKeys); const active = new Set(lesson.activeKeys);
for (const char of chunks.join("")) expect(active.has(char)).toBe(true); for (const char of chunks.join("")) expect(active.has(char)).toBe(true);
} }
@@ -45,22 +45,22 @@ describe("drillChunks", () => {
}); });
it("over-represents the focus key once there are enough keys to spare", () => { it("over-represents the focus key once there are enough keys to spare", () => {
const viele = [..."asdfjklöei"]; const many = [..."asdfjklöei"];
const plain = drillChunks(viele, mulberry32(42), { chunks: 200 }).join(""); const plain = drillChunks(many, mulberry32(42), { chunks: 200 }).join("");
const focused = drillChunks(viele, mulberry32(42), { chunks: 200, focusKey: "e" }).join(""); const focused = drillChunks(many, mulberry32(42), { chunks: 200, focusKey: "e" }).join("");
const count = (text: string) => [...text].filter((char) => char === "e").length; const count = (text: string) => [...text].filter((char) => char === "e").length;
expect(count(focused)).toBeGreaterThan(count(plain)); expect(count(focused)).toBeGreaterThan(count(plain));
}); });
it("never lets one key take over a line", () => { it("never lets one key take over a line", () => {
// The failure this guards against: on a fresh profile every key looks equally // The failure this guards against: on a fresh profile every key looks equally
// unpractised, and an unchecked focus weight drilled `a` for half of Lektion 1 // unpractised, and an unchecked focus weight drilled `a` for half of lesson 1
// while three other fingers went untrained. // while three other fingers went untrained.
for (const set of [[..."asdf"], [..."asdfjklö"], [..."asdfjklöei"]]) { for (const set of [[..."asdf"], [..."asdfjklö"], [..."asdfjklöei"]]) {
for (const focusKey of set) { for (const focusKey of set) {
const text = drillChunks(set, mulberry32(3), { chunks: 200, focusKey }).join(""); const text = drillChunks(set, mulberry32(3), { chunks: 200, focusKey }).join("");
const anteil = [...text].filter((char) => char === focusKey).length / text.length; const share = [...text].filter((char) => char === focusKey).length / text.length;
expect(anteil, `${focusKey} in ${set.join("")}`).toBeLessThanOrEqual(0.35); expect(share, `${focusKey} in ${set.join("")}`).toBeLessThanOrEqual(0.35);
} }
} }
}); });
@@ -68,8 +68,8 @@ describe("drillChunks", () => {
it("spreads a small key set evenly - every finger gets a turn", () => { it("spreads a small key set evenly - every finger gets a turn", () => {
const text = drillChunks(keys, mulberry32(11), { chunks: 200, focusKey: "a" }).join(""); const text = drillChunks(keys, mulberry32(11), { chunks: 200, focusKey: "a" }).join("");
for (const key of keys) { for (const key of keys) {
const anteil = [...text].filter((char) => char === key).length / text.length; const share = [...text].filter((char) => char === key).length / text.length;
expect(anteil, key).toBeGreaterThan(0.15); expect(share, key).toBeGreaterThan(0.15);
} }
}); });
@@ -80,8 +80,8 @@ describe("drillChunks", () => {
for (let seed = 0; seed < 60; seed++) { for (let seed = 0; seed < 60; seed++) {
const text = drillChunks(keys, mulberry32(seed), { chunks: 6, chunkSize: 4 }).join(""); const text = drillChunks(keys, mulberry32(seed), { chunks: 6, chunkSize: 4 }).join("");
for (const key of keys) { for (const key of keys) {
const wie_oft = [...text].filter((char) => char === key).length; const count = [...text].filter((char) => char === key).length;
expect(wie_oft, `"${key}" in "${text}" (seed ${seed})`).toBeGreaterThanOrEqual(4); expect(count, `"${key}" in "${text}" (seed ${seed})`).toBeGreaterThanOrEqual(4);
} }
} }
}); });
@@ -114,9 +114,9 @@ describe("wordChunks", () => {
}); });
it("draws only from the given list", () => { it("draws only from the given list", () => {
const woerter = ["die", "ei", "elf"]; const words = ["die", "ei", "elf"];
const chunks = wordChunks(woerter, mulberry32(2), { chunks: 10 })!; const chunks = wordChunks(words, mulberry32(2), { chunks: 10 })!;
for (const chunk of chunks) expect(woerter).toContain(chunk); for (const chunk of chunks) expect(words).toContain(chunk);
}); });
it("survives a single-word list", () => { it("survives a single-word list", () => {
@@ -125,18 +125,18 @@ describe("wordChunks", () => {
it("uses every word once before repeating any, across a long round", () => { it("uses every word once before repeating any, across a long round", () => {
// A real ten-sentence round from a four-sentence list showed one sentence four times. // A real ten-sentence round from a four-sentence list showed one sentence four times.
const woerter = ["a1", "b2", "c3", "d4", "e5", "f6", "g7", "h8"]; const words = ["a1", "b2", "c3", "d4", "e5", "f6", "g7", "h8"];
for (let seed = 0; seed < 40; seed++) { for (let seed = 0; seed < 40; seed++) {
const runde = wordChunks(woerter, mulberry32(seed), { chunks: 8 })!; const round = wordChunks(words, mulberry32(seed), { chunks: 8 })!;
expect(new Set(runde).size, `seed ${seed}: ${runde.join(" ")}`).toBe(8); expect(new Set(round).size, `seed ${seed}: ${round.join(" ")}`).toBe(8);
} }
}); });
it("never puts the same word twice in a row, even across a bag refill", () => { it("never puts the same word twice in a row, even across a bag refill", () => {
for (let seed = 0; seed < 60; seed++) { for (let seed = 0; seed < 60; seed++) {
const runde = wordChunks(["eins", "zwei", "drei"], mulberry32(seed), { chunks: 30 })!; const round = wordChunks(["eins", "zwei", "drei"], mulberry32(seed), { chunks: 30 })!;
for (let i = 1; i < runde.length; i++) { for (let i = 1; i < round.length; i++) {
expect(runde[i], `seed ${seed} bei ${i}: ${runde.join(" ")}`).not.toBe(runde[i - 1]); expect(round[i], `seed ${seed} at ${i}: ${round.join(" ")}`).not.toBe(round[i - 1]);
} }
} }
}); });
@@ -144,12 +144,12 @@ describe("wordChunks", () => {
describe("lineFor", () => { describe("lineFor", () => {
it("prefers real words once a lesson has them", () => { it("prefers real words once a lesson has them", () => {
const lesson = LESSONS.find((l) => l.woerter.length > 0)!; const lesson = LESSONS.find((l) => l.words.length > 0)!;
const chunks = lineFor(lesson, mulberry32(1)); const chunks = lineFor(lesson, mulberry32(1));
for (const chunk of chunks) expect(lesson.woerter).toContain(chunk); for (const chunk of chunks) expect(lesson.words).toContain(chunk);
}); });
it("falls back to letters for the Grundstellung lessons", () => { it("falls back to letters for the home-row lessons", () => {
const lesson = LESSONS[0]!; const lesson = LESSONS[0]!;
const chunks = lineFor(lesson, mulberry32(1)); const chunks = lineFor(lesson, mulberry32(1));
expect(chunks.length).toBeGreaterThan(0); expect(chunks.length).toBeGreaterThan(0);
@@ -159,12 +159,12 @@ describe("lineFor", () => {
it("can be asked for letters even in a word lesson", () => { it("can be asked for letters even in a word lesson", () => {
const lesson = LESSONS.at(-1)!; const lesson = LESSONS.at(-1)!;
const chunks = lineFor(lesson, mulberry32(1), { preferWords: false, chunks: 4 }); const chunks = lineFor(lesson, mulberry32(1), { preferWords: false, chunks: 4 });
for (const chunk of chunks) expect(lesson.woerter).not.toContain(chunk); for (const chunk of chunks) expect(lesson.words).not.toContain(chunk);
}); });
}); });
describe("lineText and chunkOffsets", () => { describe("lineText and chunkOffsets", () => {
it("joins with spaces only once the Leertaste is taught", () => { it("joins with spaces only once the space bar is taught", () => {
expect(lineText(["as", "df"], true)).toBe("as df"); expect(lineText(["as", "df"], true)).toBe("as df");
expect(lineText(["as", "df"], false)).toBe("asdf"); expect(lineText(["as", "df"], false)).toBe("asdf");
}); });

View File

@@ -3,24 +3,24 @@ import { describe, expect, it } from "vitest";
import { press, startRun } from "../engine"; import { press, startRun } from "../engine";
import type { RunState } from "../engine"; import type { RunState } from "../engine";
import { import {
TIERS, ANIMALS,
UEBERRASCHUNG_AB, SURPRISE_FROM,
grade, grade,
isBetter, isBetter,
isPassed, isPassed,
sichtbareTiers, visibleAnimals,
sterneFor, starsFor,
tierFor, animalFor,
tierIndex, animalIndex,
tierProgress, animalProgress,
} from "../grading"; } from "../grading";
/** A run of `target` where `wrong` positions get one wrong key first, paced so the whole /** A run of `target` where `wrong` positions get one wrong key first, paced so the whole
* run takes exactly `dauerMs`. */ * run takes exactly `durationMs`. */
function run(target: string, wrongAt: number[] = [], dauerMs = 60000): RunState { function run(target: string, wrongAt: number[] = [], durationMs = 60000): RunState {
let state = startRun(target); let state = startRun(target);
const steps = target.length + wrongAt.length; const steps = target.length + wrongAt.length;
const tick = dauerMs / Math.max(1, steps - 1); const tick = durationMs / Math.max(1, steps - 1);
let t = 0; let t = 0;
for (let i = 0; i < target.length; i++) { for (let i = 0; i < target.length; i++) {
if (wrongAt.includes(i)) { if (wrongAt.includes(i)) {
@@ -34,53 +34,53 @@ function run(target: string, wrongAt: number[] = [], dauerMs = 60000): RunState
} }
describe("grade", () => { describe("grade", () => {
it("measures Zeichen pro Minute over exactly one minute", () => { it("measures characters per minute over exactly one minute", () => {
const result = grade(run("a".repeat(60))); const result = grade(run("a".repeat(60)));
expect(result.zeichen).toBe(60); expect(result.characters).toBe(60);
expect(result.tempo).toBeCloseTo(60, 0); expect(result.speed).toBeCloseTo(60, 0);
expect(result.genauigkeit).toBe(1); expect(result.accuracy).toBe(1);
}); });
it("weights accuracy cubically", () => { it("weights accuracy cubically", () => {
// 90 correct, 10 wrong -> 90% accuracy, so 0.9^3 = 0.729 of the raw speed survives. // 90 correct, 10 wrong -> 90% accuracy, so 0.9^3 = 0.729 of the raw speed survives.
const result = grade(run("a".repeat(90), Array.from({ length: 10 }, (_, i) => i))); const result = grade(run("a".repeat(90), Array.from({ length: 10 }, (_, i) => i)));
expect(result.genauigkeit).toBeCloseTo(0.9, 2); expect(result.accuracy).toBeCloseTo(0.9, 2);
expect(result.punkte / result.tempo).toBeCloseTo(0.729, 3); expect(result.points / result.speed).toBeCloseTo(0.729, 3);
}); });
it("never produces a negative score, however bad the run", () => { it("never produces a negative score, however bad the run", () => {
const result = grade(run("asdf", [0, 1, 2, 3])); const result = grade(run("asdf", [0, 1, 2, 3]));
expect(result.punkte).toBeGreaterThanOrEqual(0); expect(result.points).toBeGreaterThanOrEqual(0);
}); });
it("does not divide by zero on an instant run", () => { it("does not divide by zero on an instant run", () => {
let state = startRun("a"); let state = startRun("a");
[state] = press(state, "a", 1000); [state] = press(state, "a", 1000);
const result = grade(state); const result = grade(state);
expect(Number.isFinite(result.tempo)).toBe(true); expect(Number.isFinite(result.speed)).toBe(true);
expect(result.tempo).toBeLessThanOrEqual(60); expect(result.speed).toBeLessThanOrEqual(60);
}); });
it("counts a hammered wrong key once", () => { it("counts a hammered wrong key once", () => {
let state = startRun("as"); let state = startRun("as");
for (const key of ["x", "x", "x", "a", "s"]) [state] = press(state, key, 0); for (const key of ["x", "x", "x", "a", "s"]) [state] = press(state, key, 0);
expect(grade(state).fehler).toBe(1); expect(grade(state).errors).toBe(1);
}); });
it("awards Perlen even for a bad run", () => { it("awards pearls even for a bad run", () => {
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).perlen).toBeGreaterThan(0); expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
}); });
}); });
describe("sterne", () => { describe("stars", () => {
it("uses the documented accuracy thresholds", () => { it("uses the documented accuracy thresholds", () => {
expect(sterneFor(1)).toBe(3); expect(starsFor(1)).toBe(3);
expect(sterneFor(0.97)).toBe(3); expect(starsFor(0.97)).toBe(3);
expect(sterneFor(0.969)).toBe(2); expect(starsFor(0.969)).toBe(2);
expect(sterneFor(0.93)).toBe(2); expect(starsFor(0.93)).toBe(2);
expect(sterneFor(0.929)).toBe(1); expect(starsFor(0.929)).toBe(1);
expect(sterneFor(0.85)).toBe(1); expect(starsFor(0.85)).toBe(1);
expect(sterneFor(0.849)).toBe(0); expect(starsFor(0.849)).toBe(0);
}); });
it("gates the unlock at two stars and ignores speed entirely", () => { it("gates the unlock at two stars and ignores speed entirely", () => {
@@ -89,96 +89,96 @@ describe("sterne", () => {
}); });
}); });
describe("tierFor", () => { describe("animalFor", () => {
it("returns the right animal at every boundary", () => { it("returns the right animal at every boundary", () => {
for (const tier of TIERS) { for (const animal of ANIMALS) {
expect(tierFor(tier.ab).id).toBe(tier.id); expect(animalFor(animal.from).id).toBe(animal.id);
} }
}); });
it("clamps below the slowest and above the fastest", () => { it("clamps below the slowest and above the fastest", () => {
expect(tierFor(0).id).toBe("schnecke"); expect(animalFor(0).id).toBe("snail");
expect(tierFor(-5).id).toBe("schnecke"); expect(animalFor(-5).id).toBe("snail");
expect(tierFor(9999).id).toBe("schwertwal"); expect(animalFor(9999).id).toBe("orca");
}); });
it("is monotone - more Punkte never means a slower animal", () => { it("is monotone - more points never means a slower animal", () => {
let seen = 0; let seen = 0;
for (let punkte = 0; punkte < 200; punkte += 1) { for (let points = 0; points < 200; points += 1) {
const index = TIERS.findIndex((tier) => tier.id === tierFor(punkte).id); const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id);
expect(index).toBeGreaterThanOrEqual(seen); expect(index).toBeGreaterThanOrEqual(seen);
seen = index; seen = index;
} }
}); });
it("reports progress toward the next animal", () => { it("reports progress toward the next animal", () => {
expect(tierProgress(15)).toBeCloseTo(0, 5); expect(animalProgress(15)).toBeCloseTo(0, 5);
expect(tierProgress(20)).toBeCloseTo(0.5, 5); expect(animalProgress(20)).toBeCloseTo(0.5, 5);
expect(tierProgress(9999)).toBe(1); expect(animalProgress(9999)).toBe(1);
}); });
}); });
describe("TIERS", () => { describe("ANIMALS", () => {
it("gives every tier a distinct name and emoji", () => { it("gives every animal a distinct name and emoji", () => {
expect(new Set(TIERS.map((tier) => tier.emoji)).size).toBe(TIERS.length); expect(new Set(ANIMALS.map((animal) => animal.emoji)).size).toBe(ANIMALS.length);
expect(new Set(TIERS.map((tier) => tier.name)).size).toBe(TIERS.length); expect(new Set(ANIMALS.map((animal) => animal.name)).size).toBe(ANIMALS.length);
expect(new Set(TIERS.map((tier) => tier.id)).size).toBe(TIERS.length); expect(new Set(ANIMALS.map((animal) => animal.id)).size).toBe(ANIMALS.length);
}); });
it("rises in speed with no gaps", () => { it("rises in speed with no gaps", () => {
for (let i = 1; i < TIERS.length; i++) { for (let i = 1; i < ANIMALS.length; i++) {
expect(TIERS[i]!.ab).toBeGreaterThan(TIERS[i - 1]!.ab); expect(ANIMALS[i]!.from).toBeGreaterThan(ANIMALS[i - 1]!.from);
} }
expect(TIERS[0]!.ab).toBe(0); expect(ANIMALS[0]!.from).toBe(0);
}); });
it("does not use the rating star as an animal", () => { it("does not use the rating star as an animal", () => {
for (const tier of TIERS) expect(tier.emoji).not.toBe("\u2b50"); for (const animal of ANIMALS) expect(animal.emoji).not.toBe("");
}); });
}); });
describe("sichtbareTiers", () => { describe("visibleAnimals", () => {
it("always shows the ladder up to the Delfin, however slow the run", () => { it("always shows the ladder up to the dolphin, however slow the run", () => {
const { tiers } = sichtbareTiers("schnecke", null); const { animals } = visibleAnimals("snail", null);
expect(tiers.at(-1)!.id).toBe(UEBERRASCHUNG_AB); expect(animals.at(-1)!.id).toBe(SURPRISE_FROM);
expect(tiers.map((tier) => tier.id)).toContain("schnecke"); expect(animals.map((animal) => animal.id)).toContain("snail");
}); });
it("keeps the animals above the Delfin hidden until they are reached", () => { it("keeps the animals above the dolphin hidden until they are reached", () => {
const { tiers, mehrVerborgen } = sichtbareTiers("qualle", "delfin"); const { animals, moreHidden } = visibleAnimals("jellyfish", "dolphin");
expect(tiers.some((tier) => tier.id === "hai")).toBe(false); expect(animals.some((animal) => animal.id === "shark")).toBe(false);
expect(tiers.some((tier) => tier.id === "schwertwal")).toBe(false); expect(animals.some((animal) => animal.id === "orca")).toBe(false);
expect(mehrVerborgen).toBe(true); expect(moreHidden).toBe(true);
}); });
it("reveals a surprise animal once this run earns it", () => { it("reveals a surprise animal once this run earns it", () => {
const { tiers } = sichtbareTiers("hai", null); const { animals } = visibleAnimals("shark", null);
expect(tiers.at(-1)!.id).toBe("hai"); expect(animals.at(-1)!.id).toBe("shark");
}); });
it("keeps a surprise animal revealed on later, slower runs", () => { it("keeps a surprise animal revealed on later, slower runs", () => {
const { tiers, mehrVerborgen } = sichtbareTiers("krabbe", "hai"); const { animals, moreHidden } = visibleAnimals("crab", "shark");
expect(tiers.at(-1)!.id).toBe("hai"); expect(animals.at(-1)!.id).toBe("shark");
expect(mehrVerborgen).toBe(true); expect(moreHidden).toBe(true);
}); });
it("stops promising more once the ladder is complete", () => { it("stops promising more once the ladder is complete", () => {
expect(sichtbareTiers("schwertwal", "schwertwal").mehrVerborgen).toBe(false); expect(visibleAnimals("orca", "orca").moreHidden).toBe(false);
}); });
it("has something to keep secret in the first place", () => { it("has something to keep secret in the first place", () => {
expect(tierIndex(UEBERRASCHUNG_AB)).toBeLessThan(TIERS.length - 1); expect(animalIndex(SURPRISE_FROM)).toBeLessThan(ANIMALS.length - 1);
}); });
}); });
describe("isBetter", () => { describe("isBetter", () => {
it("prefers more stars over more Punkte", () => { it("prefers more stars over more points", () => {
expect(isBetter({ sterne: 3, punkte: 10 } as never, { sterne: 2, punkte: 500 })).toBe(true); expect(isBetter({ stars: 3, points: 10 } as never, { stars: 2, points: 500 })).toBe(true);
expect(isBetter({ sterne: 2, punkte: 500 } as never, { sterne: 3, punkte: 10 })).toBe(false); expect(isBetter({ stars: 2, points: 500 } as never, { stars: 3, points: 10 })).toBe(false);
}); });
it("breaks a tie on Punkte", () => { it("breaks a tie on points", () => {
expect(isBetter({ sterne: 2, punkte: 50 } as never, { sterne: 2, punkte: 49 })).toBe(true); expect(isBetter({ stars: 2, points: 50 } as never, { stars: 2, points: 49 })).toBe(true);
expect(isBetter({ sterne: 2, punkte: 49 } as never, { sterne: 2, punkte: 50 })).toBe(false); expect(isBetter({ stars: 2, points: 49 } as never, { stars: 2, points: 50 })).toBe(false);
}); });
}); });

View File

@@ -5,7 +5,7 @@ import { press, startRun } from "../engine";
import { grade } from "../grading"; import { grade } from "../grading";
import type { RunResult } from "../grading"; import type { RunResult } from "../grading";
import { import {
FLEISS_VERSUCHE, DILIGENCE_ATTEMPTS,
focusKeyFor, focusKeyFor,
freshProgress, freshProgress,
mastery, mastery,
@@ -33,15 +33,15 @@ function runResult(target: string, wrongAt: number[] = [], tickMs = 1000): RunRe
return grade(state); return grade(state);
} }
const perfekt = () => runResult("asdfasdfasdfasdf"); const perfect = () => runResult("asdfasdfasdfasdf");
const schlecht = () => runResult("asdfasdfasdfasdf", [0, 1, 2, 3, 4, 5]); const bad = () => runResult("asdfasdfasdfasdf", [0, 1, 2, 3, 4, 5]);
describe("freshProgress", () => { describe("freshProgress", () => {
it("unlocks the first lesson and nothing else", () => { it("unlocks the first lesson and nothing else", () => {
const progress = freshProgress(); const progress = freshProgress();
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true); expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
const offen = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked); const unlocked = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked);
expect(offen).toHaveLength(1); expect(unlocked).toHaveLength(1);
}); });
it("knows every lesson in the curriculum", () => { it("knows every lesson in the curriculum", () => {
@@ -52,89 +52,89 @@ describe("freshProgress", () => {
describe("recordRun", () => { describe("recordRun", () => {
it("unlocks the next lesson on two stars", () => { it("unlocks the next lesson on two stars", () => {
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, perfekt()); const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, perfect());
expect(unlockedLessonId).toBe(L2); expect(unlockedLessonId).toBe(L2);
expect(progress.lessons[L2]!.unlocked).toBe(true); expect(progress.lessons[L2]!.unlocked).toBe(true);
}); });
it("does not unlock below two stars", () => { it("does not unlock below two stars", () => {
const result = schlecht(); const result = bad();
expect(result.sterne).toBeLessThan(2); expect(result.stars).toBeLessThan(2);
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, result); const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, result);
expect(unlockedLessonId).toBeNull(); expect(unlockedLessonId).toBeNull();
expect(progress.lessons[L2]!.unlocked).toBe(false); expect(progress.lessons[L2]!.unlocked).toBe(false);
}); });
it("unlocks after enough tries however bad the score - the Fleiß rule", () => { it("unlocks after enough tries however bad the score - the diligence rule", () => {
let progress = freshProgress(); let progress = freshProgress();
for (let i = 0; i < FLEISS_VERSUCHE - 1; i++) { for (let i = 0; i < DILIGENCE_ATTEMPTS - 1; i++) {
progress = recordRun(progress, L1, schlecht()).progress; progress = recordRun(progress, L1, bad()).progress;
expect(progress.lessons[L2]!.unlocked).toBe(false); expect(progress.lessons[L2]!.unlocked).toBe(false);
} }
const { progress: last, unlockedLessonId } = recordRun(progress, L1, schlecht()); const { progress: last, unlockedLessonId } = recordRun(progress, L1, bad());
expect(unlockedLessonId).toBe(L2); expect(unlockedLessonId).toBe(L2);
expect(last.lessons[L2]!.unlocked).toBe(true); expect(last.lessons[L2]!.unlocked).toBe(true);
}); });
it("never lets the best animal or star count go down", () => { it("never lets the best animal or star count go down", () => {
let progress = recordRun(freshProgress(), L1, perfekt()).progress; let progress = recordRun(freshProgress(), L1, perfect()).progress;
const best = progress.lessons[L1]!; const best = progress.lessons[L1]!;
progress = recordRun(progress, L1, schlecht()).progress; progress = recordRun(progress, L1, bad()).progress;
expect(progress.lessons[L1]!.bestSterne).toBe(best.bestSterne); expect(progress.lessons[L1]!.bestStars).toBe(best.bestStars);
expect(progress.lessons[L1]!.bestPunkte).toBe(best.bestPunkte); expect(progress.lessons[L1]!.bestPoints).toBe(best.bestPoints);
expect(progress.lessons[L1]!.bestTier).toBe(best.bestTier); expect(progress.lessons[L1]!.bestAnimal).toBe(best.bestAnimal);
}); });
it("counts every run, good or bad", () => { it("counts every run, good or bad", () => {
let progress = freshProgress(); let progress = freshProgress();
for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, schlecht()).progress; for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, bad()).progress;
expect(progress.lessons[L1]!.runs).toBe(3); expect(progress.lessons[L1]!.runs).toBe(3);
}); });
it("adds Perlen on every run", () => { it("adds pearls on every run", () => {
const first = recordRun(freshProgress(), L1, schlecht()); const first = recordRun(freshProgress(), L1, bad());
expect(first.progress.perlen).toBeGreaterThan(0); expect(first.progress.pearls).toBeGreaterThan(0);
const second = recordRun(first.progress, L1, schlecht()); const second = recordRun(first.progress, L1, bad());
expect(second.progress.perlen).toBeGreaterThan(first.progress.perlen); expect(second.progress.pearls).toBeGreaterThan(first.progress.pearls);
}); });
it("releases a creature only when a Welt is finished, and only once", () => { it("releases a creature only when a world is finished, and only once", () => {
let progress = freshProgress(); let progress = freshProgress();
// Welt 1 is lessons 1-3, so finishing lesson 3 is what crosses into Welt 2. // World 1 is lessons 1-3, so finishing lesson 3 is what crosses into world 2.
const welt1 = LESSONS.filter((lesson) => lesson.welt === 1); const world1 = LESSONS.filter((lesson) => lesson.world === 1);
let released: string[] = []; let released: string[] = [];
for (const lesson of welt1) { for (const lesson of world1) {
const outcome = recordRun(progress, lesson.id, perfekt()); const outcome = recordRun(progress, lesson.id, perfect());
progress = outcome.progress; progress = outcome.progress;
if (outcome.neuesTier) released.push(outcome.neuesTier); if (outcome.newCreature) released.push(outcome.newCreature);
} }
expect(released).toHaveLength(1); expect(released).toHaveLength(1);
expect(progress.aquarium).toEqual(released); expect(progress.aquarium).toEqual(released);
// Replaying the same lesson must not hand out a second copy. // Replaying the same lesson must not hand out a second copy.
const again = recordRun(progress, welt1.at(-1)!.id, perfekt()); const again = recordRun(progress, world1.at(-1)!.id, perfect());
expect(again.neuesTier).toBeNull(); expect(again.newCreature).toBeNull();
expect(again.progress.aquarium).toEqual(released); expect(again.progress.aquarium).toEqual(released);
}); });
it("records a ghost of the best run for the race", () => { it("records a ghost of the best run for the race", () => {
const { progress } = recordRun(freshProgress(), L1, perfekt()); const { progress } = recordRun(freshProgress(), L1, perfect());
expect(progress.lessons[L1]!.ghost?.length).toBe(perfekt().zeichen); expect(progress.lessons[L1]!.ghost?.length).toBe(perfect().characters);
}); });
it("builds the daily streak and restarts it after a gap", () => { it("builds the daily streak and restarts it after a gap", () => {
let progress = recordRun(freshProgress(), L1, perfekt(), "2026-09-11").progress; let progress = recordRun(freshProgress(), L1, perfect(), "2026-09-11").progress;
expect(progress.streak).toEqual({ days: 1, lastPlayed: "2026-09-11" }); expect(progress.streak).toEqual({ days: 1, lastPlayed: "2026-09-11" });
// A second run the same day does not double-count. // A second run the same day does not double-count.
progress = recordRun(progress, L1, perfekt(), "2026-09-11").progress; progress = recordRun(progress, L1, perfect(), "2026-09-11").progress;
expect(progress.streak.days).toBe(1); expect(progress.streak.days).toBe(1);
progress = recordRun(progress, L1, perfekt(), "2026-09-12").progress; progress = recordRun(progress, L1, perfect(), "2026-09-12").progress;
expect(progress.streak.days).toBe(2); expect(progress.streak.days).toBe(2);
// A missed day restarts at 1, never at 0. // A missed day restarts at 1, never at 0.
progress = recordRun(progress, L1, perfekt(), "2026-09-20").progress; progress = recordRun(progress, L1, perfect(), "2026-09-20").progress;
expect(progress.streak.days).toBe(1); expect(progress.streak.days).toBe(1);
}); });
@@ -156,20 +156,20 @@ describe("migrate", () => {
}); });
it("keeps a valid save", () => { it("keeps a valid save", () => {
const stored = recordRun(freshProgress(), L1, perfekt()).progress; const stored = recordRun(freshProgress(), L1, perfect()).progress;
const restored = migrate(JSON.parse(JSON.stringify(stored))); const restored = migrate(JSON.parse(JSON.stringify(stored)));
expect(restored.lessons[L2]!.unlocked).toBe(true); expect(restored.lessons[L2]!.unlocked).toBe(true);
expect(restored.perlen).toBe(stored.perlen); expect(restored.pearls).toBe(stored.pearls);
}); });
it("drops lessons that no longer exist", () => { it("drops lessons that no longer exist", () => {
const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, alteLektion: {} } }; const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, oldLesson: {} } };
expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["alteLektion"]).toBeUndefined(); expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["oldLesson"]).toBeUndefined();
}); });
it("moves the emoji pets of old saves into today's aquarium", () => { it("moves the emoji pets of old saves into today's aquarium", () => {
const alt = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] }; const old = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] };
expect(migrate(JSON.parse(JSON.stringify(alt))).aquarium).toEqual(["clownfisch", "krake"]); expect(migrate(JSON.parse(JSON.stringify(old))).aquarium).toEqual(["clownfish", "octopus"]);
}); });
it("re-unlocks the first lesson even if the save says otherwise", () => { it("re-unlocks the first lesson even if the save says otherwise", () => {
@@ -182,7 +182,7 @@ describe("migrate", () => {
describe("focusKeyFor", () => { describe("focusKeyFor", () => {
it("has no focus key on a lesson that has never been played", () => { it("has no focus key on a lesson that has never been played", () => {
// Otherwise "pick an unpractised key" picks whichever sorts first and drills it half // Otherwise "pick an unpractised key" picks whichever sorts first and drills it half
// the line, starving the other three fingers on Lektion 1. // the line, starving the other three fingers on lesson 1.
expect(focusKeyFor(freshProgress(), ["a", "s", "d", "f"])).toBeNull(); expect(focusKeyFor(freshProgress(), ["a", "s", "d", "f"])).toBeNull();
}); });
@@ -205,7 +205,7 @@ describe("focusKeyFor", () => {
expect(focusKeyFor(progress, ["a", "s"])).toBe("s"); expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
}); });
it("ignores the Leertaste and copes with an empty lesson", () => { it("ignores the space bar and copes with an empty lesson", () => {
expect(focusKeyFor(freshProgress(), [" "])).toBeNull(); expect(focusKeyFor(freshProgress(), [" "])).toBeNull();
expect(focusKeyFor(freshProgress(), [])).toBeNull(); expect(focusKeyFor(freshProgress(), [])).toBeNull();
}); });

View File

@@ -1,81 +1,81 @@
/** The aquarium's pets, and how they swim. /** The aquarium's pets, and how they swim.
* *
* A pet moves in when a Welt is finished and then stays - not in a list on the home * A pet moves in when a world is finished and then stays - not in a list on the home
* screen, but swimming around behind every screen of the game. A reward that is always * screen, but swimming around behind every screen of the game. A reward that is always
* in view, drifting past while she types, is the strongest version of "the reward * in view, drifting past while she types, is the strongest version of "the reward
* persists", and it costs nothing to look at. * persists", and it costs nothing to look at.
* *
* Pets are illustrations, speed trophies are emoji. The ladder in grading.ts changes * Pets are illustrations, speed trophies are emoji. The ladder in grading.ts changes
* with every run; a pet arrives once and never leaves. Keeping the two in different * with every run; a pet arrives once and never leaves. Keeping the two in different
* visual languages is what lets a Schildkröte be both a speed trophy (🐢) and a pet * visual languages is what lets a turtle be both a speed trophy (🐢) and a pet (the
* (the drawing) without a six-year-old having to work out which is which. * drawing) without a six-year-old having to work out which is which.
* *
* The swimming lives here rather than in the component because it is the part worth * The swimming lives here rather than in the component because it is the part worth
* testing: `schwimme` is a pure step - a swimmer and a time slice in, a swimmer out - so * testing: `stepSwimmer` is a pure step - a swimmer and a time slice in, a swimmer out -
* "never leaves the tank" and "looks where it is going" are checkable without a DOM or a * so "never leaves the tank" and "looks where it is going" are checkable without a DOM
* clock. The component only calls it once per frame and writes the transform. */ * or a clock. The component only calls it once per frame and writes the transform. */
export type KreaturId = "clownfisch" | "krake" | "seepferdchen" | "schildkroete" | "perlmuschel"; export type CreatureId = "clownfish" | "octopus" | "seahorse" | "turtle" | "pearlmussel";
export interface Kreatur { export interface Creature {
id: KreaturId; id: CreatureId;
name: string; name: string;
/** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */ /** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */
artikel: "Der" | "Die" | "Das"; article: "Der" | "Die" | "Das";
/** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */ /** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */
bild: string; image: string;
/** Height as a fraction of the stage height - so a pet is the same size relative to /** Height as a fraction of the stage height - so a pet is the same size relative to
* the sea on a small laptop and on a big screen. */ * the sea on a small laptop and on a big screen. */
groesse: number; size: number;
/** Cruising speed as a fraction of the stage width per second. Slow on purpose: these /** Cruising speed as a fraction of the stage width per second. Slow on purpose: these
* are in the background of a typing drill, and anything darting reads as an event. */ * are in the background of a typing drill, and anything darting reads as an event. */
tempo: number; speed: number;
/** Which way the drawing faces. Side-view creatures are mirrored to look where they /** Which way the drawing faces. Side-view creatures are mirrored to look where they
* swim; front-view ones never are - a krake flipping on every turn looks broken. */ * swim; front-view ones never are - an octopus flipping on every turn looks broken. */
blick: "seite" | "vorne"; facing: "side" | "front";
} }
export const KREATUREN: readonly Kreatur[] = [ export const CREATURES: readonly Creature[] = [
{ id: "clownfisch", name: "Clownfisch", artikel: "Der", bild: "/aquarium/clownfisch.webp", groesse: 0.13, tempo: 0.045, blick: "seite" }, { id: "clownfish", name: "Clownfisch", article: "Der", image: "/aquarium/clownfisch.webp", size: 0.13, speed: 0.045, facing: "side" },
{ id: "krake", name: "Krake", artikel: "Die", bild: "/aquarium/krake.webp", groesse: 0.17, tempo: 0.025, blick: "vorne" }, { id: "octopus", name: "Krake", article: "Die", image: "/aquarium/krake.webp", size: 0.17, speed: 0.025, facing: "front" },
{ id: "seepferdchen", name: "Seepferdchen", artikel: "Das", bild: "/aquarium/seepferdchen.webp", groesse: 0.19, tempo: 0.02, blick: "seite" }, { id: "seahorse", name: "Seepferdchen", article: "Das", image: "/aquarium/seepferdchen.webp", size: 0.19, speed: 0.02, facing: "side" },
{ id: "schildkroete", name: "Schildkröte", artikel: "Die", bild: "/aquarium/schildkroete.webp", groesse: 0.17, tempo: 0.032, blick: "seite" }, { id: "turtle", name: "Schildkröte", article: "Die", image: "/aquarium/schildkroete.webp", size: 0.17, speed: 0.032, facing: "side" },
{ id: "perlmuschel", name: "Perlmuschel", artikel: "Die", bild: "/aquarium/perlmuschel.webp", groesse: 0.12, tempo: 0.016, blick: "vorne" }, { id: "pearlmussel", name: "Perlmuschel", article: "Die", image: "/aquarium/perlmuschel.webp", size: 0.12, speed: 0.016, facing: "front" },
]; ];
const KREATUR_BY_ID = new Map(KREATUREN.map((kreatur) => [kreatur.id, kreatur])); const CREATURE_BY_ID = new Map(CREATURES.map((creature) => [creature.id, creature]));
export function kreaturById(id: KreaturId): Kreatur { export function creatureById(id: CreatureId): Creature {
// Every KreaturId is in KREATUREN, so this cannot miss; the fallback only satisfies // Every CreatureId is in CREATURES, so this cannot miss; the fallback only satisfies
// `noUncheckedIndexedAccess`. // `noUncheckedIndexedAccess`.
return KREATUR_BY_ID.get(id) ?? KREATUREN[0]!; return CREATURE_BY_ID.get(id) ?? CREATURES[0]!;
} }
/** Saves from before the pets were drawings stored the Welt's emoji. Each Welt kept its /** Saves from before the pets were drawings stored the world's emoji. Each world kept
* slot, so the old emoji map one-to-one onto the creature that now fills it. */ * its slot, so the old emoji map one-to-one onto the creature that now fills it. */
const ALTE_EMOJI: Readonly<Record<string, KreaturId>> = { const LEGACY_EMOJI: Readonly<Record<string, CreatureId>> = {
"🐠": "clownfisch", "🐠": "clownfish",
"🐙": "krake", "🐙": "octopus",
"🦑": "seepferdchen", "🦑": "seahorse",
"🐳": "schildkroete", "🐳": "turtle",
"🧜": "perlmuschel", "🧜": "pearlmussel",
}; };
/** A stored aquarium entry as a creature, or `null` for anything unrecognisable. */ /** A stored aquarium entry as a creature, or `null` for anything unrecognisable. */
export function kreaturAus(raw: unknown): KreaturId | null { export function creatureFromRaw(raw: unknown): CreatureId | null {
if (typeof raw !== "string") return null; if (typeof raw !== "string") return null;
if (KREATUR_BY_ID.has(raw as KreaturId)) return raw as KreaturId; if (CREATURE_BY_ID.has(raw as CreatureId)) return raw as CreatureId;
return ALTE_EMOJI[raw] ?? null; return LEGACY_EMOJI[raw] ?? null;
} }
// --- swimming --------------------------------------------------------------- // --- swimming ---------------------------------------------------------------
export interface Becken { export interface Tank {
breite: number; width: number;
hoehe: number; height: number;
} }
export interface Schwimmer { export interface Swimmer {
/** Centre, in px. */ /** Centre, in px. */
x: number; x: number;
y: number; y: number;
@@ -83,126 +83,126 @@ export interface Schwimmer {
vx: number; vx: number;
vy: number; vy: number;
/** Where it is currently drifting towards. */ /** Where it is currently drifting towards. */
zielX: number; targetX: number;
zielY: number; targetY: number;
/** -1 looking left … 1 looking right. Eased rather than switched, so a turn is a /** -1 looking left … 1 looking right. Eased rather than switched, so a turn is a
* visible flip through the middle instead of a jump. */ * visible flip through the middle instead of a jump. */
blick: number; facing: number;
/** Seconds swum, for the bob. Started at a random offset so pets do not bob in step. */ /** Seconds swum, for the bob. Started at a random offset so pets do not bob in step. */
zeit: number; age: number;
} }
/** How long the velocity takes to swing round to a new heading. Over a second, so every /** How long the velocity takes to swing round to a new heading. Over a second, so every
* change of course is a lazy curve and nothing ever jerks. */ * change of course is a lazy curve and nothing ever jerks. */
const LENK_TAU = 1.4; const STEER_TAU = 1.4;
/** How long a turn-around takes. */ /** How long a turn-around takes. */
const WENDE_TAU = 0.25; const TURN_TAU = 0.25;
/** One gentle bob per this many seconds. */ /** One gentle bob per this many seconds. */
const WIPP_PERIODE = 3.6; const BOB_PERIOD = 3.6;
function zufallsZiel(becken: Becken, rand: number, rng: () => number): { x: number; y: number } { function randomTarget(tank: Tank, margin: number, rng: () => number): { x: number; y: number } {
// A tank smaller than the creature (a collapsed window) still needs a valid target: // A tank smaller than the creature (a collapsed window) still needs a valid target:
// the middle. // the middle.
const spanne = (laenge: number) => Math.max(0, laenge - 2 * rand); const span = (length: number) => Math.max(0, length - 2 * margin);
return { return {
x: rand + rng() * spanne(becken.breite), x: margin + rng() * span(tank.width),
y: rand + rng() * spanne(becken.hoehe), y: margin + rng() * span(tank.height),
}; };
} }
/** A new swimmer. `vonAussen` starts it just past a side edge, so a pet that has only /** A new swimmer. `fromOutside` starts it just past a side edge, so a pet that has only
* just been earned visibly swims in rather than popping into existence mid-screen. */ * just been earned visibly swims in rather than popping into existence mid-screen. */
export function neuerSchwimmer( export function createSwimmer(
becken: Becken, tank: Tank,
rand: number, margin: number,
rng: () => number, rng: () => number,
vonAussen = false, fromOutside = false,
): Schwimmer { ): Swimmer {
const ziel = zufallsZiel(becken, rand, rng); const target = randomTarget(tank, margin, rng);
const start = vonAussen const start = fromOutside
? { x: rng() < 0.5 ? -rand : becken.breite + rand, y: ziel.y } ? { x: rng() < 0.5 ? -margin : tank.width + margin, y: target.y }
: zufallsZiel(becken, rand, rng); : randomTarget(tank, margin, rng);
return { return {
x: start.x, x: start.x,
y: start.y, y: start.y,
vx: 0, vx: 0,
vy: 0, vy: 0,
zielX: ziel.x, targetX: target.x,
zielY: ziel.y, targetY: target.y,
blick: ziel.x >= start.x ? 1 : -1, facing: target.x >= start.x ? 1 : -1,
zeit: rng() * WIPP_PERIODE, age: rng() * BOB_PERIOD,
}; };
} }
/** One time slice of swimming: steer towards the target, pick a new one on arrival, and /** One time slice of swimming: steer towards the target, pick a new one on arrival, and
* turn to face the direction of travel. * turn to face the direction of travel.
* *
* `rand` is half the creature's size - how far its centre stays from the edges - and * `margin` is half the creature's size - how far its centre stays from the edges - and
* `tempo` its cruising speed in px/s. */ * `speed` its cruising speed in px/s. */
export function schwimme( export function stepSwimmer(
s: Schwimmer, s: Swimmer,
dt: number, dt: number,
becken: Becken, tank: Tank,
rand: number, margin: number,
tempo: number, speed: number,
rng: () => number, rng: () => number,
): Schwimmer { ): Swimmer {
let { zielX, zielY } = s; let { targetX, targetY } = s;
const imBecken = (x: number, y: number) => const inTank = (x: number, y: number) =>
x >= rand && x <= becken.breite - rand && y >= rand && y <= becken.hoehe - rand; x >= margin && x <= tank.width - margin && y >= margin && y <= tank.height - margin;
// Arrived, or the window shrank and the target is now outside it: drift somewhere new. // Arrived, or the window shrank and the target is now outside it: drift somewhere new.
const dx = zielX - s.x; const dx = targetX - s.x;
const dy = zielY - s.y; const dy = targetY - s.y;
if (Math.hypot(dx, dy) < Math.max(rand, 24) || !imBecken(zielX, zielY)) { if (Math.hypot(dx, dy) < Math.max(margin, 24) || !inTank(targetX, targetY)) {
const ziel = zufallsZiel(becken, rand, rng); const target = randomTarget(tank, margin, rng);
zielX = ziel.x; targetX = target.x;
zielY = ziel.y; targetY = target.y;
} }
const richtungX = zielX - s.x; const directionX = targetX - s.x;
const richtungY = zielY - s.y; const directionY = targetY - s.y;
const abstand = Math.hypot(richtungX, richtungY) || 1; const distance = Math.hypot(directionX, directionY) || 1;
// Vertical drift at half speed: fish cruise, they do not climb. // Vertical drift at half speed: fish cruise, they do not climb.
const sollVx = (richtungX / abstand) * tempo; const targetVx = (directionX / distance) * speed;
const sollVy = (richtungY / abstand) * tempo * 0.5; const targetVy = (directionY / distance) * speed * 0.5;
const lenk = 1 - Math.exp(-dt / LENK_TAU); const steer = 1 - Math.exp(-dt / STEER_TAU);
const vx = s.vx + (sollVx - s.vx) * lenk; const vx = s.vx + (targetVx - s.vx) * steer;
const vy = s.vy + (sollVy - s.vy) * lenk; const vy = s.vy + (targetVy - s.vy) * steer;
// Only turn round once it is really swimming that way - hovering on the spot must not // Only turn round once it is really swimming that way - hovering on the spot must not
// make it flicker left and right. // make it flicker left and right.
const sollBlick = Math.abs(vx) > tempo * 0.2 ? Math.sign(vx) : Math.sign(s.blick) || 1; const targetFacing = Math.abs(vx) > speed * 0.2 ? Math.sign(vx) : Math.sign(s.facing) || 1;
const blick = s.blick + (sollBlick - s.blick) * (1 - Math.exp(-dt / WENDE_TAU)); const facing = s.facing + (targetFacing - s.facing) * (1 - Math.exp(-dt / TURN_TAU));
return { return {
x: s.x + vx * dt, x: s.x + vx * dt,
y: s.y + vy * dt, y: s.y + vy * dt,
vx, vx,
vy, vy,
zielX, targetX,
zielY, targetY,
blick, facing,
zeit: s.zeit + dt, age: s.age + dt,
}; };
} }
/** What the component draws for a swimmer: the bob and the tilt layered on top of the /** What the component draws for a swimmer: the bob and the tilt layered on top of the
* position, and the mirroring for side-view drawings. */ * position, and the mirroring for side-view drawings. */
export function pose( export function pose(
s: Schwimmer, s: Swimmer,
kreatur: Kreatur, creature: Creature,
tempo: number, speed: number,
): { x: number; y: number; spiegel: number; drehung: number } { ): { x: number; y: number; mirror: number; rotation: number } {
const wippen = Math.sin((s.zeit / WIPP_PERIODE) * 2 * Math.PI); const bob = Math.sin((s.age / BOB_PERIOD) * 2 * Math.PI);
const seite = kreatur.blick === "seite"; const isSide = creature.facing === "side";
// Nose up when rising, down when sinking - a few degrees, in the direction it faces. // Nose up when rising, down when sinking - a few degrees, in the direction it faces.
const neigung = tempo > 0 ? Math.max(-1, Math.min(1, s.vy / tempo)) : 0; const tilt = speed > 0 ? Math.max(-1, Math.min(1, s.vy / speed)) : 0;
return { return {
x: s.x, x: s.x,
y: s.y + wippen * 7, y: s.y + bob * 7,
spiegel: seite ? s.blick : 1, mirror: isSide ? s.facing : 1,
drehung: seite ? neigung * 10 * Math.sign(s.blick || 1) : wippen * 3, rotation: isSide ? tilt * 10 * Math.sign(s.facing || 1) : bob * 3,
}; };
} }

View File

@@ -1,246 +1,256 @@
/** The lesson plan: 48 Lektionen in 5 Welten. /** The lesson plan: 48 lessons in 5 worlds.
* *
* The shape follows what every serious Zehnfinger-Kurs does - start on the * The shape follows what every serious ten-finger course does - start on the
* Grundstellung `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S, * home row `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S,
* R, A, T, D, H, U, L …), and drill everything learned so far each time. TIPP10's * R, A, T, D, H, U, L …), and drill everything learned so far each time. TIPP10's
* German course is the same idea in 18 lessons. * German course is the same idea in 18 lessons.
* *
* Where this deviates, it deviates for the age, and the deviation is *pace*. A * Where this deviates, it deviates for the age, and the deviation is *pace*. A
* six-year-old gets: * six-year-old gets:
* *
* - **one new key per lesson** from Welt 2 onward (Welt 1 pairs the same finger on * - **one new key per lesson** from world 2 onward (world 1 pairs the same finger on
* both hands, which is one motion, not two); * both hands, which is one motion, not two);
* - **an Übung lesson after every pair of new keys** - no new keys at all, just the * - **a drill lesson after every pair of new keys** - no new keys at all, just the
* ones she has. Consolidation is where typing actually becomes automatic, and a * ones she has. Consolidation is where typing actually becomes automatic, and a
* course that only ever moves forward never gives it room; * course that only ever moves forward never gives it room;
* - **short rounds**, growing from twelve characters in Welt 1 to whole sentences in * - **short rounds**, growing from twelve characters in world 1 to whole sentences in
* Welt 5. * world 5.
* *
* That is 48 short lessons rather than 17 big ones. The curriculum is the same; the * That is 48 short lessons rather than 17 big ones. The curriculum is the same; the
* steps between are small enough to climb. * steps between are small enough to climb.
* *
* `activeKeys` is cumulative on purpose: Lektion 30 still drills `f`, or the first * `activeKeys` is cumulative on purpose: lesson 30 still drills `f`, or the first
* lessons rot while the last ones are learned. */ * lessons rot while the last ones are learned. */
import type { KreaturId } from "./aquarium"; import type { CreatureId } from "./aquarium";
import { GRUNDSTELLUNG, LEERTASTE } from "./fingers"; import { HOME_ROW, SPACE_KEY } from "./fingers";
export type ModeId = export type ModeId =
| "tauchgang" | "dive"
| "blasen" | "bubbles"
| "quallen" | "jellyfish"
| "fuettern" | "feed"
| "rennen" | "race"
| "perlen"; | "pearls";
/** What a lesson's targets are made of. Decides which modes make sense: Quallenalarm /** What a lesson's targets are made of. Decides which modes make sense: jellyfish mode
* cannot show a sentence, and Fütterungszeit cannot put one on a fish. */ * cannot show a sentence, and feed mode cannot put one on a fish. */
export type LessonArt = "buchstaben" | "woerter" | "saetze"; export type LessonKind = "letters" | "words" | "sentences";
export interface Lesson { export interface Lesson {
id: string; id: string;
welt: number; world: number;
nummer: number; number: number;
titel: string; title: string;
/** What this lesson is about, in words a six-year-old hears read aloud. */ /** What this lesson is about, in words a six-year-old hears read aloud. */
untertitel: string; subtitle: string;
/** The keys introduced here - what the generator weights toward. Empty for an Übung. */ /** The keys introduced here - what the generator weights toward. Empty for a drill. */
neueKeys: readonly string[]; newKeys: readonly string[];
/** Everything typable in this lesson, cumulative. */ /** Everything typable in this lesson, cumulative. */
activeKeys: readonly string[]; activeKeys: readonly string[];
/** Which game modes this lesson offers, in carousel order. */ /** Which game modes this lesson offers, in carousel order. */
modi: readonly ModeId[]; modes: readonly ModeId[];
/** Real German words (or sentences) for the word modes. */ /** Real German words (or sentences) for the word modes. */
woerter: readonly string[]; words: readonly string[];
art: LessonArt; kind: LessonKind;
/** True for a consolidation lesson - no new keys, just practice. */ /** True for a consolidation lesson - no new keys, just practice. */
istUebung: boolean; isDrill: boolean;
/** How long one run is. Grows with the curriculum - see `laengeFuer`. */ /** How long one run is. Grows with the curriculum - see `lengthFor`. */
chunks: number; chunks: number;
chunkSize: number; chunkSize: number;
} }
export interface Welt { export interface World {
nummer: number; number: number;
titel: string; title: string;
emoji: string; emoji: string;
/** The creature that moves into the aquarium when this Welt is finished. */ /** The creature that moves into the aquarium when this world is finished. */
belohnung: KreaturId; reward: CreatureId;
} }
/** A Welt's creature is a pet that stays - a drawing that swims behind every screen from /** A world's creature is a pet that stays - a drawing that swims behind every screen
* then on. A tier animal in grading.ts is a speed trophy that changes, and is an emoji. * from then on. An animal in grading.ts is a speed trophy that changes, and is an
* lib/aquarium.ts has why the two are kept in different visual languages. The pets grow * emoji. lib/aquarium.ts has why the two are kept in different visual languages. The
* with the Welten: a small fish first, the pearl clam - the Perlen's own home - last. */ * pets grow with the worlds: a small fish first, the pearl mussel - the pearls' own
export const WELTEN: readonly Welt[] = [ * home - last. */
{ nummer: 1, titel: "Die Grundstellung", emoji: "🏝️", belohnung: "clownfisch" }, export const WORLDS: readonly World[] = [
{ nummer: 2, titel: "Nach oben", emoji: "🌊", belohnung: "krake" }, { number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
{ nummer: 3, titel: "Nach unten", emoji: "🪸", belohnung: "seepferdchen" }, { number: 2, title: "Nach oben", emoji: "🌊", reward: "octopus" },
{ nummer: 4, titel: "Große Buchstaben", emoji: "👑", belohnung: "schildkroete" }, { number: 3, title: "Nach unten", emoji: "🪸", reward: "seahorse" },
{ nummer: 5, titel: "Ganze Sätze", emoji: "📖", belohnung: "perlmuschel" }, { number: 4, title: "Große Buchstaben", emoji: "👑", reward: "turtle" },
{ number: 5, title: "Ganze Sätze", emoji: "📖", reward: "pearlmussel" },
]; ];
/** Letters only - no words can be spelled yet. */ /** Letters only - no words can be spelled yet. */
const BUCHSTABEN_MODI: readonly ModeId[] = ["tauchgang", "blasen", "quallen", "perlen"]; const LETTER_MODES: readonly ModeId[] = ["dive", "bubbles", "jellyfish", "pearls"];
/** Real words exist: the word modes and the race join in. */ /** Real words exist: the word modes and the race join in. */
const WORT_MODI: readonly ModeId[] = ["tauchgang", "fuettern", "blasen", "rennen", "quallen", "perlen"]; const WORD_MODES: readonly ModeId[] = ["dive", "feed", "bubbles", "race", "jellyfish", "pearls"];
/** Sentences do not fit on a bubble or a fish. */ /** Sentences do not fit on a bubble or a fish. */
const SATZ_MODI: readonly ModeId[] = ["tauchgang", "rennen", "perlen"]; const SENTENCE_MODES: readonly ModeId[] = ["dive", "race", "pearls"];
/** Line length by Welt - a full block of text per round, at least five lines of it. /** Line length by world - a full block of text per round, at least five lines of it.
* *
* Deliberately five times what the first version used. Twelve characters was long * Deliberately five times what the first version used. Twelve characters was long
* enough to *finish*, which is what the first day needs, but far too short to build * enough to *finish*, which is what the first day needs, but far too short to build
* anything: a round that ends before the hands settle measures reaction time rather * anything: a round that ends before the hands settle measures reaction time rather
* than typing, and the score bounces around so much that getting better is invisible. * than typing, and the score bounces around so much that getting better is invisible.
* Sixty-plus characters is long enough for a rhythm to appear and for the Zeichen pro * Sixty-plus characters is long enough for a rhythm to appear and for the characters
* Minute to mean something. * per minute to mean something.
* *
* Welt 1 is still the gentlest by a wide margin, and the rounds still grow from there. */ * World 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
function laengeFuer(welt: number): { chunks: number; chunkSize: number } { function lengthFor(world: number, kind: LessonKind): { chunks: number; chunkSize: number } {
if (welt === 1) return { chunks: 24, chunkSize: 3 }; // 72 Zeichen if (world === 1) return kind === "words" ? { chunks: 25, chunkSize: 3 } : { chunks: 24, chunkSize: 3 }; // 72 characters
if (welt === 2) return { chunks: 25, chunkSize: 4 }; // 100 if (world === 2) return { chunks: 25, chunkSize: 4 }; // 100
if (welt === 3) return { chunks: 30, chunkSize: 4 }; // 120 if (world === 3) return { chunks: 30, chunkSize: 4 }; // 120
if (welt === 4) return { chunks: 25, chunkSize: 4 }; // 25 Wörter if (world === 4) return { chunks: 25, chunkSize: 4 }; // 25 words
return { chunks: 10, chunkSize: 4 }; // Welt 5: zehn ganze Sätze return { chunks: 10, chunkSize: 4 }; // world 5: ten whole sentences
} }
interface PlanEntry { interface PlanEntry {
welt: number; world: number;
titel: string; title: string;
untertitel: string; subtitle: string;
/** Empty marks an Übung - consolidation, no new keys. */ /** Empty marks a drill - consolidation, no new keys. */
neu: readonly string[]; newKeys: readonly string[];
woerter?: readonly string[]; words?: readonly string[];
art?: LessonArt; kind?: LessonKind;
} }
/** The plan. Everything else is derived from this, so a curriculum change is one edit. */ /** The plan. Everything else is derived from this, so a curriculum change is one edit. */
const PLAN: readonly PlanEntry[] = [ const PLAN: readonly PlanEntry[] = [
// ---------------------------------------------------------------- Welt 1 -- // ---------------------------------------------------------------- World 1 --
// Two keys per lesson, always the same finger on each hand - one motion, mirrored. // Two keys per lesson, always the same finger on each hand - one motion, mirrored.
// F and J first because they carry the tactile bumps: they are the two keys a child // F and J first because they carry the tactile bumps: they are the two keys a child
// can find without looking, and every other key is taught as an offset from them. // can find without looking, and every other key is taught as an offset from them.
{ welt: 1, titel: "F und J", untertitel: "Die Zeigefinger - die Tasten mit den Punkten", neu: ["f", "j"] }, { world: 1, title: "F und J", subtitle: "Die Zeigefinger - die Tasten mit den Punkten", newKeys: ["f", "j"] },
{ welt: 1, titel: "D und K", untertitel: "Die Mittelfinger", neu: ["d", "k"] }, { world: 1, title: "D und K", subtitle: "Die Mittelfinger", newKeys: ["d", "k"] },
{ welt: 1, titel: "Übung: F J D K", untertitel: "Die vier Tasten zusammen", neu: [] }, { world: 1, title: "Übung: F J D K", subtitle: "Die vier Tasten zusammen", newKeys: [] },
{ welt: 1, titel: "S und L", untertitel: "Die Ringfinger", neu: ["s", "l"] }, { world: 1, title: "S und L", subtitle: "Die Ringfinger", newKeys: ["s", "l"] },
{ welt: 1, titel: "Übung: sechs Tasten", untertitel: "Alles bisher zusammen", neu: [] }, { world: 1, title: "Übung: sechs Tasten", subtitle: "Alles bisher zusammen", newKeys: [] },
{ welt: 1, titel: "A und Ö", untertitel: "Die kleinen Finger", neu: ["a", "ö"] }, { world: 1, title: "A und Ö", subtitle: "Die kleinen Finger", newKeys: ["a", "ö"] },
{ welt: 1, titel: "Übung: die Grundstellung", untertitel: "Alle acht Finger", neu: [] }, { world: 1, title: "Übung: die Grundstellung", subtitle: "Alle acht Finger", newKeys: [],
{ welt: 1, titel: "Die Leertaste", untertitel: "Der Daumen kommt dazu", neu: [LEERTASTE] }, words: [
"da", "das", "dass", "ja", "als", "all", "fall", "falls", "lass",
"aal", "aas", "as", "ass", "fass", "saal", "kalk", "salsa", "jass",
"asa", "sas", "sad", "sal", "dal", "fad", "fal", "jak", "jas", "kal",
"kas", "lak", "las", "lad", "dasa", "sala", "kala", "jala", "fasa",
"daka", "kasa", "salla", "dalla", "jassa", "fassa", "kalla", "falla",
"salak", "dalas", "jasal", "kalas",
"lö", "döl", "söl", "jöl", "köl", "löl",
] },
{ world: 1, title: "Die Leertaste", subtitle: "Der Daumen kommt dazu", newKeys: [SPACE_KEY] },
// ---------------------------------------------------------------- Welt 2 -- // ---------------------------------------------------------------- World 2 --
// One key per lesson from here on, an Übung after every two. // One key per lesson from here on, an Übung after every two.
{ welt: 2, titel: "Das E", untertitel: "Mittelfinger links nach oben", neu: ["e"], { world: 2, title: "Das E", subtitle: "Mittelfinger links nach oben", newKeys: ["e"],
woerter: ["elf", "alle", "esel", "see", "keks", "fell"] }, words: ["elf", "alle", "esel", "see", "keks", "fell"] },
{ welt: 2, titel: "Das I", untertitel: "Mittelfinger rechts nach oben", neu: ["i"], { world: 2, title: "Das I", subtitle: "Mittelfinger rechts nach oben", newKeys: ["i"],
woerter: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] }, words: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
{ welt: 2, titel: "Übung: E und I", untertitel: "Die neuen Tasten festigen", neu: [], { world: 2, title: "Übung: E und I", subtitle: "Die neuen Tasten festigen", newKeys: [],
woerter: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] }, words: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
{ welt: 2, titel: "Das R", untertitel: "Zeigefinger links nach oben", neu: ["r"], { world: 2, title: "Das R", subtitle: "Zeigefinger links nach oben", newKeys: ["r"],
woerter: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] }, words: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
{ welt: 2, titel: "Das U", untertitel: "Zeigefinger rechts nach oben", neu: ["u"], { world: 2, title: "Das U", subtitle: "Zeigefinger rechts nach oben", newKeys: ["u"],
woerter: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] }, words: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
{ welt: 2, titel: "Übung: R und U", untertitel: "Die Zeigefinger nach oben", neu: [], { world: 2, title: "Übung: R und U", subtitle: "Die Zeigefinger nach oben", newKeys: [],
woerter: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] }, words: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
{ welt: 2, titel: "Das T", untertitel: "Zeigefinger links weit nach oben", neu: ["t"], { world: 2, title: "Das T", subtitle: "Zeigefinger links weit nach oben", newKeys: ["t"],
woerter: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] }, words: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
{ welt: 2, titel: "Das Z", untertitel: "Zeigefinger rechts weit nach oben", neu: ["z"], { world: 2, title: "Das Z", subtitle: "Zeigefinger rechts weit nach oben", newKeys: ["z"],
woerter: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] }, words: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
{ welt: 2, titel: "Übung: T und Z", untertitel: "Weit nach oben greifen", neu: [], { world: 2, title: "Übung: T und Z", subtitle: "Weit nach oben greifen", newKeys: [],
woerter: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] }, words: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
{ welt: 2, titel: "Das O", untertitel: "Ringfinger rechts nach oben", neu: ["o"], { world: 2, title: "Das O", subtitle: "Ringfinger rechts nach oben", newKeys: ["o"],
woerter: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] }, words: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
{ welt: 2, titel: "Das W", untertitel: "Ringfinger links nach oben", neu: ["w"], { world: 2, title: "Das W", subtitle: "Ringfinger links nach oben", newKeys: ["w"],
woerter: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] }, words: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
{ welt: 2, titel: "Übung: W und O", untertitel: "Die Ringfinger nach oben", neu: [], { world: 2, title: "Übung: W und O", subtitle: "Die Ringfinger nach oben", newKeys: [],
woerter: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] }, words: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
{ welt: 2, titel: "Das P", untertitel: "Kleiner Finger rechts nach oben", neu: ["p"], { world: 2, title: "Das P", subtitle: "Kleiner Finger rechts nach oben", newKeys: ["p"],
woerter: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] }, words: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
{ welt: 2, titel: "Das Q", untertitel: "Kleiner Finger links nach oben", neu: ["q"], { world: 2, title: "Das Q", subtitle: "Kleiner Finger links nach oben", newKeys: ["q"],
woerter: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] }, words: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
{ welt: 2, titel: "Das Ü", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["ü"], { world: 2, title: "Das Ü", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ü"],
woerter: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] }, words: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] },
{ welt: 2, titel: "Übung: die obere Reihe", untertitel: "Die ganze Reihe zusammen", neu: [], { world: 2, title: "Übung: die obere Reihe", subtitle: "Die ganze Reihe zusammen", newKeys: [],
woerter: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] }, words: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
// ---------------------------------------------------------------- Welt 3 -- // ---------------------------------------------------------------- World 3 --
{ welt: 3, titel: "Das N", untertitel: "Zeigefinger rechts nach unten", neu: ["n"], { world: 3, title: "Das N", subtitle: "Zeigefinger rechts nach unten", newKeys: ["n"],
woerter: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] }, words: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] },
{ welt: 3, titel: "Das M", untertitel: "Zeigefinger rechts, neben dem N", neu: ["m"], { world: 3, title: "Das M", subtitle: "Zeigefinger rechts, neben dem N", newKeys: ["m"],
woerter: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] }, words: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] },
{ welt: 3, titel: "Übung: N und M", untertitel: "Die neuen Tasten festigen", neu: [], { world: 3, title: "Übung: N und M", subtitle: "Die neuen Tasten festigen", newKeys: [],
woerter: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] }, words: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] },
{ welt: 3, titel: "Das G", untertitel: "Zeigefinger links, in der Mitte", neu: ["g"], { world: 3, title: "Das G", subtitle: "Zeigefinger links, in der Mitte", newKeys: ["g"],
woerter: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] }, words: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] },
{ welt: 3, titel: "Das H", untertitel: "Zeigefinger rechts, in der Mitte", neu: ["h"], { world: 3, title: "Das H", subtitle: "Zeigefinger rechts, in der Mitte", newKeys: ["h"],
woerter: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] }, words: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
{ welt: 3, titel: "Übung: G und H", untertitel: "Die Mitte der Grundreihe", neu: [], { world: 3, title: "Übung: G und H", subtitle: "Die Mitte der Grundreihe", newKeys: [],
woerter: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] }, words: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
{ welt: 3, titel: "Das C", untertitel: "Mittelfinger links nach unten", neu: ["c"], { world: 3, title: "Das C", subtitle: "Mittelfinger links nach unten", newKeys: ["c"],
woerter: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] }, words: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
{ welt: 3, titel: "Das V", untertitel: "Zeigefinger links nach unten", neu: ["v"], { world: 3, title: "Das V", subtitle: "Zeigefinger links nach unten", newKeys: ["v"],
woerter: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] }, words: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
{ welt: 3, titel: "Übung: C und V", untertitel: "Nach unten greifen", neu: [], { world: 3, title: "Übung: C und V", subtitle: "Nach unten greifen", newKeys: [],
woerter: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] }, words: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
{ welt: 3, titel: "Das B", untertitel: "Zeigefinger links, neben dem V", neu: ["b"], { world: 3, title: "Das B", subtitle: "Zeigefinger links, neben dem V", newKeys: ["b"],
woerter: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] }, words: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
{ welt: 3, titel: "Das Y", untertitel: "Kleiner Finger links nach unten", neu: ["y"], { world: 3, title: "Das Y", subtitle: "Kleiner Finger links nach unten", newKeys: ["y"],
woerter: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] }, words: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
{ welt: 3, titel: "Übung: B und Y", untertitel: "Ganz unten links", neu: [], { world: 3, title: "Übung: B und Y", subtitle: "Ganz unten links", newKeys: [],
woerter: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] }, words: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
{ welt: 3, titel: "Das X", untertitel: "Ringfinger links nach unten", neu: ["x"], { world: 3, title: "Das X", subtitle: "Ringfinger links nach unten", newKeys: ["x"],
woerter: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] }, words: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
{ welt: 3, titel: "Das Ä", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["ä"], { world: 3, title: "Das Ä", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ä"],
woerter: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] }, words: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] },
{ welt: 3, titel: "Übung: alle Buchstaben", untertitel: "Das ganze Alphabet", neu: [], { world: 3, title: "Übung: alle Buchstaben", subtitle: "Das ganze Alphabet", newKeys: [],
woerter: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] }, words: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
// ---------------------------------------------------------------- Welt 4 -- // ---------------------------------------------------------------- World 4 --
{ welt: 4, titel: "Umschalttaste rechts", untertitel: "Große Buchstaben der linken Hand", neu: ["⇧"], { world: 4, title: "Umschalttaste rechts", subtitle: "Große Buchstaben der linken Hand", newKeys: ["⇧"],
woerter: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff", words: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff",
"Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] }, "Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] },
{ welt: 4, titel: "Umschalttaste links", untertitel: "Große Buchstaben der rechten Hand", neu: [], { world: 4, title: "Umschalttaste links", subtitle: "Große Buchstaben der rechten Hand", newKeys: [],
woerter: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe", words: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe",
"Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] }, "Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] },
{ welt: 4, titel: "Übung: Namen", untertitel: "Namen fangen groß an", neu: [], { world: 4, title: "Übung: Namen", subtitle: "Namen fangen groß an", newKeys: [],
woerter: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa", words: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa",
"Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] }, "Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] },
{ welt: 4, titel: "Übung: große und kleine", untertitel: "Beides gemischt", neu: [], { world: 4, title: "Übung: große und kleine", subtitle: "Beides gemischt", newKeys: [],
woerter: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel",
"Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] }, "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] },
// ---------------------------------------------------------------- Welt 5 -- // ---------------------------------------------------------------- World 5 --
{ welt: 5, titel: "Der Punkt", untertitel: "Ringfinger rechts nach unten", neu: ["."], art: "saetze", { world: 5, title: "Der Punkt", subtitle: "Ringfinger rechts nach unten", newKeys: ["."], kind: "sentences",
woerter: [ words: [
"Das Meer ist tief.", "Der Hund bellt.", "Ich mag Kekse.", "Die Sonne scheint.", "Das Meer ist tief.", "Der Hund bellt.", "Ich mag Kekse.", "Die Sonne scheint.",
"Der Wal ist riesig.", "Wir gehen baden.", "Mama liest ein Buch.", "Der Fisch schwimmt.", "Der Wal ist riesig.", "Wir gehen baden.", "Mama liest ein Buch.", "Der Fisch schwimmt.",
"Heute ist es warm.", "Ich habe einen Ball.", "Die Welle ist hoch.", "Papa kocht Suppe.", "Heute ist es warm.", "Ich habe einen Ball.", "Die Welle ist hoch.", "Papa kocht Suppe.",
] }, ] },
{ welt: 5, titel: "Das Komma", untertitel: "Mittelfinger rechts nach unten", neu: [","], art: "saetze", { world: 5, title: "Das Komma", subtitle: "Mittelfinger rechts nach unten", newKeys: [","], kind: "sentences",
woerter: [ words: [
"Ich mag Wale, Delfine und Fische.", "Erst lesen, dann tippen.", "Es ist warm, also baden wir.", "Ich mag Wale, Delfine und Fische.", "Erst lesen, dann tippen.", "Es ist warm, also baden wir.",
"Rot, gelb und blau sind Farben.", "Wenn es regnet, bleiben wir drinnen.", "Rot, gelb und blau sind Farben.", "Wenn es regnet, bleiben wir drinnen.",
"Der Delfin springt, taucht und spielt.", "Morgen, sagt Papa, fahren wir los.", "Der Delfin springt, taucht und spielt.", "Morgen, sagt Papa, fahren wir los.",
"Eins, zwei, drei, vier.", "Oma, Opa und ich gehen schwimmen.", "Eins, zwei, drei, vier.", "Oma, Opa und ich gehen schwimmen.",
"Die Sonne scheint, das Meer glitzert.", "Muscheln, Steine und Sand liegen am Strand.", "Die Sonne scheint, das Meer glitzert.", "Muscheln, Steine und Sand liegen am Strand.",
] }, ] },
{ welt: 5, titel: "Der Bindestrich", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["-"], art: "saetze", { world: 5, title: "Der Bindestrich", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["-"], kind: "sentences",
woerter: [ words: [
"Wir spielen mit dem Wasser-Ball.", "Das ist ein Delfin-Baby.", "Meine Ur-Oma kommt heute.", "Wir spielen mit dem Wasser-Ball.", "Das ist ein Delfin-Baby.", "Meine Ur-Oma kommt heute.",
"Wir bauen eine Sand-Burg.", "Der Fisch-Schwarm ist riesig.", "Ich trage mein T-Shirt.", "Wir bauen eine Sand-Burg.", "Der Fisch-Schwarm ist riesig.", "Ich trage mein T-Shirt.",
"Das Schwimm-Bad ist offen.", "Die Bade-Hose ist nass.", "Wir essen ein Eis-Hörnchen.", "Das Schwimm-Bad ist offen.", "Die Bade-Hose ist nass.", "Wir essen ein Eis-Hörnchen.",
"Das Segel-Boot ist blau.", "Mein Lieblings-Tier ist der Delfin.", "Das Segel-Boot ist blau.", "Mein Lieblings-Tier ist der Delfin.",
] }, ] },
{ welt: 5, titel: "Fragezeichen und Ausrufezeichen", untertitel: "Mit der Umschalttaste", neu: ["ß", "1"], art: "saetze", { world: 5, title: "Fragezeichen und Ausrufezeichen", subtitle: "Mit der Umschalttaste", newKeys: ["ß", "1"], kind: "sentences",
woerter: [ words: [
"Wo ist der Delfin?", "Das war toll!", "Wie geht es dir?", "Pass auf!", "Wo ist der Delfin?", "Das war toll!", "Wie geht es dir?", "Pass auf!",
"Kommst du mit?", "Der Wal ist so groß!", "Hast du Hunger?", "Hurra, Ferien!", "Kommst du mit?", "Der Wal ist so groß!", "Hast du Hunger?", "Hurra, Ferien!",
"Was schwimmt da?", "Schau mal, ein Hai!", "Wie tief ist das Meer?", "Wir haben es geschafft!", "Was schwimmt da?", "Schau mal, ein Hai!", "Wie tief ist das Meer?", "Wir haben es geschafft!",
] }, ] },
{ welt: 5, titel: "Übung: ganze Sätze", untertitel: "Alles zusammen", neu: [], art: "saetze", { world: 5, title: "Übung: ganze Sätze", subtitle: "Alles zusammen", newKeys: [], kind: "sentences",
woerter: [ words: [
"Der Delfin schwimmt sehr schnell.", "Wo ist mein Boot?", "Ich tippe jetzt mit zehn Fingern!", "Der Delfin schwimmt sehr schnell.", "Wo ist mein Boot?", "Ich tippe jetzt mit zehn Fingern!",
"Das Meer ist blau, tief und kalt.", "Kannst du das auch?", "Wir bauen eine Sand-Burg am Strand.", "Das Meer ist blau, tief und kalt.", "Kannst du das auch?", "Wir bauen eine Sand-Burg am Strand.",
"Die Möwe fliegt über das Wasser.", "Oma, Opa und ich gehen schwimmen.", "Das ist ja super!", "Die Möwe fliegt über das Wasser.", "Oma, Opa und ich gehen schwimmen.", "Das ist ja super!",
@@ -253,33 +263,33 @@ function buildLessons(): Lesson[] {
const active = new Set<string>(); const active = new Set<string>();
PLAN.forEach((entry, i) => { PLAN.forEach((entry, i) => {
for (const key of entry.neu) active.add(key); for (const key of entry.newKeys) active.add(key);
// The Leertaste lesson is where the whole Grundstellung comes together, so it // The space-bar lesson is where the whole home row comes together, so it
// activates every home key - a belt-and-braces guarantee that the four finger-pair // activates every home key - a belt-and-braces guarantee that the four finger-pair
// lessons before it really did cover all eight. // lessons before it really did cover all eight.
if (entry.neu.includes(LEERTASTE)) for (const key of GRUNDSTELLUNG) active.add(key); if (entry.newKeys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
// Shift is not a character the generator can emit - the capitals in the word list // Shift is not a character the generator can emit - the capitals in the word list
// are what teaches it - so it never enters activeKeys. The Fragezeichen lesson // are what teaches it - so it never enters activeKeys. The question-mark lesson
// reaches its marks with Shift too, so ß and 1 are active as *keys* even though the // reaches its marks with Shift too, so ß and 1 are active as *keys* even though the
// characters that appear are ? and !. // characters that appear are ? and !.
const activeKeys = [...active].filter((key) => key !== "⇧").sort(); const activeKeys = [...active].filter((key) => key !== "⇧").sort();
const woerter = entry.woerter ?? []; const words = entry.words ?? [];
const art: LessonArt = entry.art ?? (woerter.length > 0 ? "woerter" : "buchstaben"); const kind: LessonKind = entry.kind ?? (words.length > 0 ? "words" : "letters");
lessons.push({ lessons.push({
id: `l${String(i + 1).padStart(2, "0")}`, id: `l${String(i + 1).padStart(2, "0")}`,
welt: entry.welt, world: entry.world,
nummer: i + 1, number: i + 1,
titel: entry.titel, title: entry.title,
untertitel: entry.untertitel, subtitle: entry.subtitle,
neueKeys: entry.neu, newKeys: entry.newKeys,
activeKeys, activeKeys,
modi: art === "saetze" ? SATZ_MODI : art === "woerter" ? WORT_MODI : BUCHSTABEN_MODI, modes: kind === "sentences" ? SENTENCE_MODES : kind === "words" ? WORD_MODES : LETTER_MODES,
woerter, words,
art, kind,
istUebung: entry.neu.length === 0, isDrill: entry.newKeys.length === 0,
...laengeFuer(entry.welt), ...lengthFor(entry.world, kind),
}); });
}); });
@@ -294,8 +304,8 @@ export function lessonById(id: string): Lesson | null {
return BY_ID.get(id) ?? null; return BY_ID.get(id) ?? null;
} }
export function lessonsOfWelt(welt: number): readonly Lesson[] { export function lessonsOfWorld(world: number): readonly Lesson[] {
return LESSONS.filter((lesson) => lesson.welt === welt); return LESSONS.filter((lesson) => lesson.world === world);
} }
/** The lesson after this one, or null at the end of the curriculum. */ /** The lesson after this one, or null at the end of the curriculum. */

View File

@@ -86,7 +86,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
if (expected === undefined) return [state, []]; if (expected === undefined) return [state, []];
// The layout is what decides case, not the run: typing "A" where "a" is wanted is // The layout is what decides case, not the run: typing "A" where "a" is wanted is
// correct. Capitals are their own lesson (Welt 4), and that lesson's target text // correct. Capitals are their own lesson (world 4), and that lesson's target text
// carries the capital, so this comparison still teaches Shift where it matters. // carries the capital, so this comparison still teaches Shift where it matters.
const correct = key.toLowerCase() === expected.toLowerCase(); const correct = key.toLowerCase() === expected.toLowerCase();
const startedAt = state.startedAt ?? now; const startedAt = state.startedAt ?? now;
@@ -117,7 +117,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
} }
/** Give up on the rest of the line - what Escape does. The run is still graded on what /** Give up on the rest of the line - what Escape does. The run is still graded on what
* was typed, so a half-finished Blasenplatzen round still earns its Perlen. */ * was typed, so a half-finished bubbles round still earns its pearls. */
export function abandonRun(state: RunState, now: number): RunState { export function abandonRun(state: RunState, now: number): RunState {
if (isFinished(state) || state.startedAt === null) return state; if (isFinished(state) || state.startedAt === null) return state;
return { ...state, finishedAt: now }; return { ...state, finishedAt: now };

View File

@@ -8,69 +8,69 @@
* Keys are stored lowercase and compared lowercase - `event.key` for a capital letter * Keys are stored lowercase and compared lowercase - `event.key` for a capital letter
* is "A", but it is still typed with the same finger as "a". */ * is "A", but it is still typed with the same finger as "a". */
export type Hand = "links" | "rechts"; export type Hand = "left" | "right";
/** Finger ids, left pinky through right pinky, thumbs last. The order matters: it is /** Finger ids, left pinky through right pinky, thumbs last. The order matters: it is
* the left-to-right order the parent screen lists them in. */ * the left-to-right order the parent screen lists them in. */
export type FingerId = export type FingerId =
| "links-klein" | "left-pinky"
| "links-ring" | "left-ring"
| "links-mitte" | "left-middle"
| "links-zeige" | "left-index"
| "rechts-zeige" | "right-index"
| "rechts-mitte" | "right-middle"
| "rechts-ring" | "right-ring"
| "rechts-klein" | "right-pinky"
| "daumen"; | "thumb";
export interface Finger { export interface Finger {
id: FingerId; id: FingerId;
hand: Hand; hand: Hand;
/** What a six-year-old is told out loud: "der kleine Finger links". */ /** What a six-year-old is told out loud: "der kleine Finger links". */
label: string; label: string;
/** The key this finger rests on in the Grundstellung. */ /** The key this finger rests on in the home row. */
home: string; home: string;
/** oklch hue for the keyboard overlay, so "der grüne Finger" is a thing you can say. */ /** oklch hue for the keyboard overlay, so "der grüne Finger" is a thing you can say. */
hue: number; hue: number;
} }
export const FINGERS: Record<FingerId, Finger> = { export const FINGERS: Record<FingerId, Finger> = {
"links-klein": { id: "links-klein", hand: "links", label: "kleiner Finger links", home: "a", hue: 25 }, "left-pinky": { id: "left-pinky", hand: "left", label: "kleiner Finger links", home: "a", hue: 25 },
"links-ring": { id: "links-ring", hand: "links", label: "Ringfinger links", home: "s", hue: 70 }, "left-ring": { id: "left-ring", hand: "left", label: "Ringfinger links", home: "s", hue: 70 },
"links-mitte": { id: "links-mitte", hand: "links", label: "Mittelfinger links", home: "d", hue: 140 }, "left-middle": { id: "left-middle", hand: "left", label: "Mittelfinger links", home: "d", hue: 140 },
"links-zeige": { id: "links-zeige", hand: "links", label: "Zeigefinger links", home: "f", hue: 195 }, "left-index": { id: "left-index", hand: "left", label: "Zeigefinger links", home: "f", hue: 195 },
"rechts-zeige": { id: "rechts-zeige", hand: "rechts", label: "Zeigefinger rechts", home: "j", hue: 250 }, "right-index": { id: "right-index", hand: "right", label: "Zeigefinger rechts", home: "j", hue: 250 },
"rechts-mitte": { id: "rechts-mitte", hand: "rechts", label: "Mittelfinger rechts", home: "k", hue: 290 }, "right-middle": { id: "right-middle", hand: "right", label: "Mittelfinger rechts", home: "k", hue: 290 },
"rechts-ring": { id: "rechts-ring", hand: "rechts", label: "Ringfinger rechts", home: "l", hue: 330 }, "right-ring": { id: "right-ring", hand: "right", label: "Ringfinger rechts", home: "l", hue: 330 },
"rechts-klein": { id: "rechts-klein", hand: "rechts", label: "kleiner Finger rechts", home: "ö", hue: 10 }, "right-pinky": { id: "right-pinky", hand: "right", label: "kleiner Finger rechts", home: "ö", hue: 10 },
daumen: { id: "daumen", hand: "rechts", label: "Daumen", home: " ", hue: 220 }, thumb: { id: "thumb", hand: "right", label: "Daumen", home: " ", hue: 220 },
}; };
/** Which keys each finger owns, in the standard German assignment. The index fingers /** Which keys each finger owns, in the standard German assignment. The index fingers
* carry two columns each (their home column plus the stretch inward), which is why * carry two columns each (their home column plus the stretch inward), which is why
* `links-zeige` has r/t and `rechts-zeige` has z/u. */ * `left-index` has r/t and `right-index` has z/u. */
const OWNED: Record<FingerId, string> = { const OWNED: Record<FingerId, string> = {
"links-klein": "^1qay<", "left-pinky": "^1qay<",
"links-ring": "2wsx", "left-ring": "2wsx",
"links-mitte": "3edc", "left-middle": "3edc",
"links-zeige": "45rtfgvb", "left-index": "45rtfgvb",
"rechts-zeige": "67zuhjnm", "right-index": "67zuhjnm",
"rechts-mitte": "8ik,", "right-middle": "8ik,",
"rechts-ring": "9ol.", "right-ring": "9ol.",
"rechts-klein": "0ßpüöä-+#", "right-pinky": "0ßpüöä-+#",
daumen: " ", thumb: " ",
}; };
/** Which row a key sits in, for the on-screen keyboard's layout and for the lesson /** Which row a key sits in, for the on-screen keyboard's layout and for the lesson
* titles ("nach oben", "nach unten"). */ * titles ("nach oben", "nach unten"). */
export type RowId = "zahlen" | "oben" | "grund" | "unten" | "leer"; export type RowId = "numbers" | "top" | "home" | "bottom" | "space";
const ROWS: Record<RowId, string> = { const ROWS: Record<RowId, string> = {
zahlen: "^1234567890ß", numbers: "^1234567890ß",
oben: "qwertzuiopü+", top: "qwertzuiopü+",
grund: "asdfghjklöä#", home: "asdfghjklöä#",
unten: "<yxcvbnm,.-", bottom: "<yxcvbnm,.-",
leer: " ", space: " ",
}; };
const KEY_TO_FINGER = new Map<string, FingerId>(); const KEY_TO_FINGER = new Map<string, FingerId>();
@@ -83,8 +83,8 @@ for (const [row, keys] of Object.entries(ROWS) as [RowId, string][]) {
for (const key of keys) KEY_TO_ROW.set(key, row); for (const key of keys) KEY_TO_ROW.set(key, row);
} }
/** The four rows as the on-screen keyboard draws them, top to bottom. */ /** The three letter rows as the on-screen keyboard draws them, top to bottom. */
export const KEYBOARD_ROWS: readonly RowId[] = ["oben", "grund", "unten"]; export const KEYBOARD_ROWS: readonly RowId[] = ["top", "home", "bottom"];
export function keysInRow(row: RowId): readonly string[] { export function keysInRow(row: RowId): readonly string[] {
return [...(ROWS[row] ?? "")]; return [...(ROWS[row] ?? "")];
@@ -92,7 +92,7 @@ export function keysInRow(row: RowId): readonly string[] {
/** Characters that need Shift on a German layout, mapped to the physical key that /** Characters that need Shift on a German layout, mapped to the physical key that
* carries them. Only the ones this course teaches; the rest of the number row can be * carries them. Only the ones this course teaches; the rest of the number row can be
* added when Welt 6 exists. */ * added when world 6 exists. */
const SHIFTED: Record<string, string> = { const SHIFTED: Record<string, string> = {
"!": "1", "!": "1",
'"': "2", '"': "2",
@@ -120,8 +120,8 @@ const SHIFTED: Record<string, string> = {
* which finger the hint names, and whether a lesson can actually type a word. Without * which finger the hint names, and whether a lesson can actually type a word. Without
* it, "Wo ist der Delfin?" looks untypable and lights up nothing. */ * it, "Wo ist der Delfin?" looks untypable and lights up nothing. */
export function keyForChar(char: string): string { export function keyForChar(char: string): string {
const klein = char.toLowerCase(); const lower = char.toLowerCase();
if (klein !== char) return klein; if (lower !== char) return lower;
return SHIFTED[char] ?? char; return SHIFTED[char] ?? char;
} }
@@ -151,15 +151,15 @@ export function homeKeyOf(key: string): string | null {
return fingerOf(key)?.home ?? null; return fingerOf(key)?.home ?? null;
} }
/** The Grundstellung itself, left to right. `LEERTASTE` is separate because the thumb /** The home row itself, left to right. `SPACE_KEY` is separate because the thumb
* is the one finger that does not rest on a letter. */ * is the one finger that does not rest on a letter. */
export const GRUNDSTELLUNG = ["a", "s", "d", "f", "j", "k", "l", "ö"] as const; export const HOME_ROW = ["a", "s", "d", "f", "j", "k", "l", "ö"] as const;
export const LEERTASTE = " "; export const SPACE_KEY = " ";
/** A shifted character is typed with the Shift on the *opposite* hand - the single rule /** A shifted character is typed with the Shift on the *opposite* hand - the single rule
* that separates real touch typing from hunt-and-peck with a pinky cramp. */ * that separates real touch typing from hunt-and-peck with a pinky cramp. */
export function shiftHandFor(key: string): Hand | null { export function shiftHandFor(key: string): Hand | null {
const hand = handOf(key); const hand = handOf(key);
if (hand === null) return null; if (hand === null) return null;
return hand === "links" ? "rechts" : "links"; return hand === "left" ? "right" : "left";
} }

View File

@@ -1,21 +1,21 @@
/** What the child actually types: drill lines built from a lesson's active keys. /** What the child actually types: drill lines built from a lesson's active keys.
* *
* Seeded throughout (`mulberry32`), so a line is reproducible - which is what makes it * Seeded throughout (`mulberry32`), so a line is reproducible - which is what makes it
* testable, and what lets the Delfinrennen replay a ghost against the identical text. * testable, and what lets the race mode replay a ghost against the identical text.
* *
* Two ideas borrowed from keybr, simplified to what a six-year-old needs: * Two ideas borrowed from keybr, simplified to what a six-year-old needs:
* *
* - a *focus key* gets roughly double its natural share of the line, so the letter she * - a *focus key* gets roughly double its natural share of the line, so the letter she
* is slowest on is the letter she sees most; * is slowest on is the letter she sees most;
* - real words beat pseudo-words for motivation, so as soon as a lesson's active keys * - real words beat pseudo-words for motivation, so as soon as a lesson's active keys
* can spell something real, the curated `woerter` list is preferred and `fjfj dkdk` * can spell something real, the curated `words` list is preferred and `fjfj dkdk`
* stops appearing. * stops appearing.
* *
* Chunks, not one long string: the line is returned as short groups, because four * Chunks, not one long string: the line is returned as short groups, because four
* letters with a gap after them is something a six-year-old can find her place in and * letters with a gap after them is something a six-year-old can find her place in and
* twenty-four letters in a row is not. Lessons 1 and 2 have no Leertaste yet, so the * twenty-four letters in a row is not. The gap is a real space from lesson 1 on, even
* gap there is visual only - `lineText` joins without one and she never has to type a * before the space-bar lesson formally teaches the thumb - a gap she can see but is
* space she has not been taught. */ * never asked to type would be more confusing, not less. */
/** A small, fast, seedable PRNG. Identical seed, identical line. */ /** A small, fast, seedable PRNG. Identical seed, identical line. */
export function mulberry32(seed: number): () => number { export function mulberry32(seed: number): () => number {
@@ -45,32 +45,32 @@ export interface LineOptions {
/** The most of a line one key may ever occupy. Above this it stops being practice and /** The most of a line one key may ever occupy. Above this it stops being practice and
* starts being a stutter - and on a small key set it starves the other fingers. */ * starts being a stutter - and on a small key set it starves the other fingers. */
const MAX_FOKUS_ANTEIL = 0.3; const MAX_FOCUS_SHARE = 0.3;
/** How much of a line the lesson's *new* keys should take. The rest is review of /** How much of a line the lesson's *new* keys should take. The rest is review of
* everything learned so far, which is what stops the early lessons rotting while the * everything learned so far, which is what stops the early lessons rotting while the
* late ones are learned. * late ones are learned.
* *
* Without this, Lektion 2 ("Die rechte Hand") drew evenly from all eight home keys and * Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and
* spent half the line on the left hand it had already taught - which is not what a * spent half the line on the left hand it had already taught - which is not what a
* lesson called "die rechte Hand" should drill. */ * lesson called "the right hand" should drill. */
const NEU_ANTEIL = 0.6; const NEW_KEY_SHARE = 0.6;
/** Copies of each new key needed to reach `NEU_ANTEIL` of the pool, clamped so a lesson /** Copies of each new key needed to reach `NEW_KEY_SHARE` of the pool, clamped so a
* with one new key and many old ones does not bury the review entirely. */ * lesson with one new key and many old ones does not bury the review entirely. */
function neuKopien(neu: number, alt: number): number { function newKeyCopies(newCount: number, oldCount: number): number {
if (neu === 0 || alt === 0) return 1; if (newCount === 0 || oldCount === 0) return 1;
const exakt = (NEU_ANTEIL * alt) / (neu * (1 - NEU_ANTEIL)); const exact = (NEW_KEY_SHARE * oldCount) / (newCount * (1 - NEW_KEY_SHARE));
return Math.max(1, Math.min(6, Math.round(exakt))); return Math.max(1, Math.min(6, Math.round(exact)));
} }
/** How many extra copies of the focus key to add to a pool of `n` letters without its /** How many extra copies of the focus key to add to a pool of `n` letters without its
* share passing `MAX_FOKUS_ANTEIL`. Small sets get no boost at all: with four active * share passing `MAX_FOCUS_SHARE`. Small sets get no boost at all: with four active
* keys every one of them is already drilled constantly. */ * keys every one of them is already drilled constantly. */
function fokusKopien(n: number): number { function focusKeyCopies(n: number): number {
let kopien = 0; let copies = 0;
while (kopien < 2 && (1 + kopien + 1) / (n + kopien + 1) <= MAX_FOKUS_ANTEIL) kopien++; while (copies < 2 && (1 + copies + 1) / (n + copies + 1) <= MAX_FOCUS_SHARE) copies++;
return kopien; return copies;
} }
/** Build a weighted alphabet: every active key at least once, the lesson's new keys /** Build a weighted alphabet: every active key at least once, the lesson's new keys
@@ -85,18 +85,18 @@ function weighted(
const letters = activeKeys.filter((key) => key !== " "); const letters = activeKeys.filter((key) => key !== " ");
if (letters.length === 0) return []; if (letters.length === 0) return [];
// Shift and the Leertaste are taught by the target text, not by the letter pool. // Shift and the space bar are taught by the target text, not by the letter pool.
const neu = newKeys.filter((key) => letters.includes(key)); const newActive = newKeys.filter((key) => letters.includes(key));
const alt = letters.filter((key) => !neu.includes(key)); const oldActive = letters.filter((key) => !newActive.includes(key));
const pool = [...letters]; const pool = [...letters];
if (neu.length > 0 && alt.length > 0) { if (newActive.length > 0 && oldActive.length > 0) {
const kopien = neuKopien(neu.length, alt.length); const copies = newKeyCopies(newActive.length, oldActive.length);
for (const key of neu) for (let i = 1; i < kopien; i++) pool.push(key); for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
} }
if (focusKey && letters.includes(focusKey)) { if (focusKey && letters.includes(focusKey)) {
for (let i = 0; i < fokusKopien(letters.length); i++) pool.push(focusKey); for (let i = 0; i < focusKeyCopies(letters.length); i++) pool.push(focusKey);
} }
return pool; return pool;
} }
@@ -104,7 +104,7 @@ function weighted(
/** Draw from a shuffled bag rather than sampling independently. /** Draw from a shuffled bag rather than sampling independently.
* *
* Independent sampling is lumpy over the length of one line, and lumpy is not a * Independent sampling is lumpy over the length of one line, and lumpy is not a
* cosmetic problem here: a real generated Lektion-1 line came out as * cosmetic problem here: a real generated lesson-1 line came out as
* `saadaaafasaassfssffsafsf` - `d` once in twenty-four characters, so the middle finger * `saadaaafasaassfssffsafsf` - `d` once in twenty-four characters, so the middle finger
* got one repetition while the little finger got nine. Averaged over a hundred lines * got one repetition while the little finger got nine. Averaged over a hundred lines
* that is fine; the child types one line. * that is fine; the child types one line.
@@ -131,7 +131,7 @@ function bagDraw(pool: readonly string[], count: number, rng: Rng): string[] {
} }
/** A line of pseudo-word chunks over the lesson's active keys. Used by every lesson, /** A line of pseudo-word chunks over the lesson's active keys. Used by every lesson,
* and the only option for Welt 1 where nothing real can be spelled yet. */ * and the only option for world 1 where nothing real can be spelled yet. */
export function drillChunks( export function drillChunks(
activeKeys: readonly string[], activeKeys: readonly string[],
rng: Rng, rng: Rng,
@@ -152,23 +152,23 @@ export function drillChunks(
/** A line of real German words (or sentences), or `null` when the lesson has none yet. /** A line of real German words (or sentences), or `null` when the lesson has none yet.
* *
* Drawn from the same shuffled bag as the letters. Once a round became twenty-five * Drawn from the same shuffled bag as the letters. Once a round became twenty-five
* words long, independent picks from an eight-word list started clumping: a real Welt 5 * words long, independent picks from an eight-word list started clumping: a real
* round came out with "Das Meer ist blau, tief und kalt." four times in ten sentences. * world-5 round came out with "Das Meer ist blau, tief und kalt." four times in ten
* The bag uses every word once before any word twice, so repeats are as far apart as the * sentences. The bag uses every word once before any word twice, so repeats are as far
* list allows. */ * apart as the list allows. */
export function wordChunks( export function wordChunks(
woerter: readonly string[], words: readonly string[],
rng: Rng, rng: Rng,
options: LineOptions = {}, options: LineOptions = {},
): string[] | null { ): string[] | null {
if (woerter.length === 0) return null; if (words.length === 0) return null;
const { chunks = 5, focusKey = null } = options; const { chunks = 5, focusKey = null } = options;
// Words containing the focus key go in twice, same trick as the letter pool. // Words containing the focus key go in twice, same trick as the letter pool.
const pool = [...woerter]; const pool = [...words];
if (focusKey) { if (focusKey) {
for (const wort of woerter) { for (const word of words) {
if (wort.toLowerCase().includes(focusKey.toLowerCase())) pool.push(wort); if (word.toLowerCase().includes(focusKey.toLowerCase())) pool.push(word);
} }
} }
@@ -177,32 +177,30 @@ export function wordChunks(
// later word when there is one - but never loop, since a one-word list must still work. // later word when there is one - but never loop, since a one-word list must still work.
for (let i = 1; i < out.length; i++) { for (let i = 1; i < out.length; i++) {
if (out[i] !== out[i - 1]) continue; if (out[i] !== out[i - 1]) continue;
const tausch = out.findIndex((wort, j) => j > i && wort !== out[i]); const swapIndex = out.findIndex((word, j) => j > i && word !== out[i]);
if (tausch > 0) [out[i], out[tausch]] = [out[tausch]!, out[i]!]; if (swapIndex > 0) [out[i], out[swapIndex]] = [out[swapIndex]!, out[i]!];
} }
return out; return out;
} }
/** The line a lesson should show, given what it can spell. Word lessons alternate: /** The line a lesson should show, given what it can spell. Word lessons alternate:
* `preferWords` lets a mode ask for letters even in a late lesson (Quallenalarm is * `preferWords` lets a mode ask for letters even in a late lesson (jellyfish mode is
* always single letters) or for words wherever they exist (Fütterungszeit). */ * always single letters) or for words wherever they exist (feed mode). */
export function lineFor( export function lineFor(
lesson: { activeKeys: readonly string[]; woerter: readonly string[]; neueKeys?: readonly string[] }, lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
rng: Rng, rng: Rng,
options: LineOptions & { preferWords?: boolean } = {}, options: LineOptions & { preferWords?: boolean } = {},
): string[] { ): string[] {
const { preferWords = true, ...rest } = options; const { preferWords = true, ...rest } = options;
const withNew = { newKeys: lesson.neueKeys ?? [], ...rest }; const withNew = { newKeys: lesson.newKeys ?? [], ...rest };
if (preferWords) { if (preferWords) {
const words = wordChunks(lesson.woerter, rng, withNew); const words = wordChunks(lesson.words, rng, withNew);
if (words) return words; if (words) return words;
} }
return drillChunks(lesson.activeKeys, rng, withNew); return drillChunks(lesson.activeKeys, rng, withNew);
} }
/** Join chunks into the string the engine types against. With the Leertaste active the /** Join chunks into the string the engine types against. */
* gaps are real spaces she must type; before that they are purely visual and the text
* runs together. */
export function lineText(chunks: readonly string[], spaceActive: boolean): string { export function lineText(chunks: readonly string[], spaceActive: boolean): string {
return chunks.join(spaceActive ? " " : ""); return chunks.join(spaceActive ? " " : "");
} }
@@ -219,8 +217,8 @@ export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): n
return offsets; return offsets;
} }
/** Single letters for Blasenplatzen and Quallenalarm: one key per bubble, drawn from the /** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from
* same bag, so the arcade modes drill the same spread as the Tauchgang. */ * the same bag, so the arcade modes drill the same spread as the dive mode. */
export function letterStream( export function letterStream(
activeKeys: readonly string[], activeKeys: readonly string[],
rng: Rng, rng: Rng,

View File

@@ -2,13 +2,13 @@
* *
* Two decisions here are the whole pedagogy of the game, so they are worth stating: * Two decisions here are the whole pedagogy of the game, so they are worth stating:
* *
* *Zeichen pro Minute, not Wörter pro Minute.* German words are long and a six-year-old * *Characters per minute, not words per minute.* German words are long and a
* types around four words a minute honestly measured. "4" on a results screen reads as * six-year-old types around four words a minute honestly measured. "4" on a results
* failure; "38 Zeichen pro Minute" reads as a number that visibly grows. Same data, * screen reads as failure; "38 Zeichen pro Minute" reads as a number that visibly
* different message. * grows. Same data, different message.
* *
* *punkte = tempo × genauigkeit³, not the textbook Netto-WPM.* The standard formula is * *points = speed × accuracy³, not the textbook net WPM.* The standard formula is
* `netto = brutto fehler/minute`, which goes negative for a beginner - the one result * `net = gross errors/minute`, which goes negative for a beginner - the one result
* that must never appear. The multiplicative form cannot: 95% accuracy keeps 86% of the * that must never appear. The multiplicative form cannot: 95% accuracy keeps 86% of the
* speed, 90% keeps 73%, 80% keeps 51%. Careful-and-slow beats fast-and-sloppy, which is * speed, 90% keeps 73%, 80% keeps 51%. Careful-and-slow beats fast-and-sloppy, which is
* the habit worth building at this age. * the habit worth building at this age.
@@ -20,191 +20,191 @@ import type { RunState, Stroke } from "./engine";
export interface RunResult { export interface RunResult {
/** Correct keystrokes. */ /** Correct keystrokes. */
zeichen: number; characters: number;
/** Wrong keystrokes, counted once per position (see engine.ts rule 2). */ /** Wrong keystrokes, counted once per position (see engine.ts rule 2). */
fehler: number; errors: number;
/** ms from the first keystroke to the last. */ /** ms from the first keystroke to the last. */
dauer: number; duration: number;
/** Zeichen pro Minute. */ /** Characters per minute. */
tempo: number; speed: number;
/** 0..1 */ /** 0..1 */
genauigkeit: number; accuracy: number;
/** `tempo × genauigkeit³`, the number the animal is read off. */ /** `speed × accuracy³`, the number the animal is read off. */
punkte: number; points: number;
sterne: 0 | 1 | 2 | 3; stars: 0 | 1 | 2 | 3;
tier: TierId; animal: AnimalId;
/** Whether this run unlocks the next lesson on its own. */ /** Whether this run unlocks the next lesson on its own. */
bestanden: boolean; passed: boolean;
/** Perlen earned - the aquarium currency. */ /** Pearls earned - the aquarium currency. */
perlen: number; pearls: number;
/** Kept for the Delfinrennen ghost and the per-key stats. */ /** Kept for the race-mode ghost and the per-key stats. */
strokes: readonly Stroke[]; strokes: readonly Stroke[];
} }
export type TierId = export type AnimalId =
| "schnecke" | "snail"
| "krabbe" | "crab"
| "schildkroete" | "turtle"
| "qualle" | "jellyfish"
| "fisch" | "fish"
| "pinguin" | "penguin"
| "robbe" | "seal"
| "delfin" | "dolphin"
| "hai" | "shark"
| "schwertwal"; | "orca";
export interface Tier { export interface Animal {
id: TierId; id: AnimalId;
name: string; name: string;
emoji: string; emoji: string;
/** Lower bound in Punkte (Zeichen pro Minute, accuracy-weighted). */ /** Lower bound in points (characters per minute, accuracy-weighted). */
ab: number; from: number;
/** What the Sprachausgabe says when this tier is reached. */ /** What the speech synthesis says when this animal is reached. */
lob: string; praise: string;
} }
/** Nine Meerestiere, slowest first. Thresholds are calibrated against the "5 WPM pro /** Nine sea animals, slowest first. Thresholds are calibrated against the "5 WPM pro
* Klassenstufe" school rule - roughly 25 Zeichen/min at the end of first grade - with * Klassenstufe" school rule - roughly 25 characters/min at the end of first grade -
* plenty of headroom above it. * with plenty of headroom above it.
* *
* The Delfin sits deliberately high, at about 24 WPM, one below the top. It is a * The dolphin sits deliberately high, at about 24 WPM, one below the top. It is a
* realistic second- or third-grade target, which means it stays out of reach and * realistic second- or third-grade target, which means it stays out of reach and
* therefore worth chasing for a long time. Reaching it should feel like an event. * therefore worth chasing for a long time. Reaching it should feel like an event.
* *
* Every emoji here is checked to render as the animal it names - the first draft used * Every emoji here is checked to render as the animal it names - the first draft used
* 🗡️ for a sailfish (it is a dagger) and 🎐 for a jellyfish (it is a wind chime), and * 🗡️ for a sailfish (it is a dagger) and 🎐 for a jellyfish (it is a wind chime), and
* ⭐ for a starfish, which collided with the star rating three lines below it. */ * ⭐ for a starfish, which collided with the star rating three lines below it. */
export const TIERS: readonly Tier[] = [ export const ANIMALS: readonly Animal[] = [
{ id: "schnecke", name: "Meeresschnecke", emoji: "🐌", ab: 0, lob: "Die Schnecke ist losgekrochen!" }, { id: "snail", name: "Meeresschnecke", emoji: "🐌", from: 0, praise: "Die Schnecke ist losgekrochen!" },
{ id: "krabbe", name: "Krabbe", emoji: "🦀", ab: 15, lob: "Eine Krabbe! Die krabbelt schon los." }, { id: "crab", name: "Krabbe", emoji: "🦀", from: 15, praise: "Eine Krabbe! Die krabbelt schon los." },
{ id: "schildkroete", name: "Schildkröte", emoji: "🐢", ab: 25, lob: "Die Schildkröte ist stetig und sicher." }, { id: "turtle", name: "Schildkröte", emoji: "🐢", from: 25, praise: "Die Schildkröte ist stetig und sicher." },
{ id: "qualle", name: "Qualle", emoji: "🪼", ab: 40, lob: "Die Qualle gleitet dahin!" }, { id: "jellyfish", name: "Qualle", emoji: "🪼", from: 40, praise: "Die Qualle gleitet dahin!" },
{ id: "fisch", name: "Fisch", emoji: "🐟", ab: 55, lob: "Ein Fisch! Der schwimmt richtig flott." }, { id: "fish", name: "Fisch", emoji: "🐟", from: 55, praise: "Ein Fisch! Der schwimmt richtig flott." },
{ id: "pinguin", name: "Pinguin", emoji: "🐧", ab: 75, lob: "Ein Pinguin! Der flitzt durchs Wasser." }, { id: "penguin", name: "Pinguin", emoji: "🐧", from: 75, praise: "Ein Pinguin! Der flitzt durchs Wasser." },
{ id: "robbe", name: "Robbe", emoji: "🦭", ab: 95, lob: "Die Robbe ist schnell und geschickt!" }, { id: "seal", name: "Robbe", emoji: "🦭", from: 95, praise: "Die Robbe ist schnell und geschickt!" },
{ id: "delfin", name: "Delfin", emoji: "🐬", ab: 120, lob: "Ein Delfin! Das ist richtig, richtig schnell." }, { id: "dolphin", name: "Delfin", emoji: "🐬", from: 120, praise: "Ein Delfin! Das ist richtig, richtig schnell." },
{ id: "hai", name: "Hai", emoji: "🦈", ab: 150, lob: "Ein Hai! Unglaublich schnell." }, { id: "shark", name: "Hai", emoji: "🦈", from: 150, praise: "Ein Hai! Unglaublich schnell." },
{ id: "schwertwal", name: "Schwertwal", emoji: "🐋", ab: 190, lob: "Ein Schwertwal! Schneller wird es im Meer nicht." }, { id: "orca", name: "Schwertwal", emoji: "🐋", from: 190, praise: "Ein Schwertwal! Schneller wird es im Meer nicht." },
]; ];
/** The last animal shown on the ladder before a run has been fast enough to earn it. /** The last animal shown on the ladder before a run has been fast enough to earn it.
* Everything above the Delfin stays hidden until it is actually reached - see * Everything above the dolphin stays hidden until it is actually reached - see
* `sichtbareTiers`. */ * `visibleAnimals`. */
export const UEBERRASCHUNG_AB: TierId = "delfin"; export const SURPRISE_FROM: AnimalId = "dolphin";
export function tierIndex(id: TierId): number { export function animalIndex(id: AnimalId): number {
return TIERS.findIndex((tier) => tier.id === id); return ANIMALS.findIndex((animal) => animal.id === id);
} }
/** Which slice of the ladder a result screen may show. /** Which slice of the ladder a result screen may show.
* *
* Everything up to the Delfin is always visible, earned or not: seeing the animals you * Everything up to the dolphin is always visible, earned or not: seeing the animals you
* have not reached yet is the whole reason to try again, and a six-year-old needs the * have not reached yet is the whole reason to try again, and a six-year-old needs the
* next rung to be visible to aim at it. Above the Delfin the ladder goes dark - those * next rung to be visible to aim at it. Above the dolphin the ladder goes dark - those
* are a surprise, revealed only once they have actually been reached, and then they stay * are a surprise, revealed only once they have actually been reached, and then they stay
* revealed. `bestEver` is the fastest animal earned on any lesson so far. */ * revealed. `bestEver` is the fastest animal earned on any lesson so far. */
export function sichtbareTiers(earned: TierId, bestEver: TierId | null): { export function visibleAnimals(earned: AnimalId, bestEver: AnimalId | null): {
tiers: readonly Tier[]; animals: readonly Animal[];
/** True when faster animals exist that have not been revealed yet. */ /** True when faster animals exist that have not been revealed yet. */
mehrVerborgen: boolean; moreHidden: boolean;
} { } {
const grenze = Math.max( const boundary = Math.max(
tierIndex(UEBERRASCHUNG_AB), animalIndex(SURPRISE_FROM),
tierIndex(earned), animalIndex(earned),
bestEver ? tierIndex(bestEver) : -1, bestEver ? animalIndex(bestEver) : -1,
); );
return { return {
tiers: TIERS.slice(0, grenze + 1), animals: ANIMALS.slice(0, boundary + 1),
mehrVerborgen: grenze < TIERS.length - 1, moreHidden: boundary < ANIMALS.length - 1,
}; };
} }
const TIER_BY_ID = new Map(TIERS.map((tier) => [tier.id, tier])); const ANIMAL_BY_ID = new Map(ANIMALS.map((animal) => [animal.id, animal]));
export function tierById(id: TierId): Tier { export function animalById(id: AnimalId): Animal {
// Every TierId comes from TIERS itself, so this cannot miss - but the map lookup is // Every AnimalId comes from ANIMALS itself, so this cannot miss - but the map lookup
// typed as possibly-undefined and `noUncheckedIndexedAccess` is on. // is typed as possibly-undefined and `noUncheckedIndexedAccess` is on.
return TIER_BY_ID.get(id) ?? TIERS[0]!; return ANIMAL_BY_ID.get(id) ?? ANIMALS[0]!;
} }
/** The animal for a score. Walks from the fastest down, so the first match wins. */ /** The animal for a score. Walks from the fastest down, so the first match wins. */
export function tierFor(punkte: number): Tier { export function animalFor(points: number): Animal {
for (let i = TIERS.length - 1; i >= 0; i--) { for (let i = ANIMALS.length - 1; i >= 0; i--) {
const tier = TIERS[i]!; const animal = ANIMALS[i]!;
if (punkte >= tier.ab) return tier; if (points >= animal.from) return animal;
} }
return TIERS[0]!; return ANIMALS[0]!;
} }
/** How far along the current tier this score is, 0..1 - drives the progress bar that /** How far along the current animal this score is, 0..1 - drives the progress bar that
* shows how close the next animal is. The top tier is always full. */ * shows how close the next animal is. The top animal is always full. */
export function tierProgress(punkte: number): number { export function animalProgress(points: number): number {
const index = TIERS.findIndex((tier) => tier.id === tierFor(punkte).id); const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id);
const next = TIERS[index + 1]; const next = ANIMALS[index + 1];
if (!next) return 1; if (!next) return 1;
const floor = TIERS[index]!.ab; const floor = ANIMALS[index]!.from;
return Math.min(1, Math.max(0, (punkte - floor) / (next.ab - floor))); return Math.min(1, Math.max(0, (points - floor) / (next.from - floor)));
} }
/** Star thresholds, on accuracy alone. The familiar 1/2/3 pattern from every other /** Star thresholds, on accuracy alone. The familiar 1/2/3 pattern from every other
* game she will ever play, so it needs no explaining. */ * game she will ever play, so it needs no explaining. */
export const STERNE_SCHWELLEN = { eins: 0.85, zwei: 0.93, drei: 0.97 } as const; export const STAR_THRESHOLDS = { one: 0.85, two: 0.93, three: 0.97 } as const;
export function sterneFor(genauigkeit: number): 0 | 1 | 2 | 3 { export function starsFor(accuracy: number): 0 | 1 | 2 | 3 {
if (genauigkeit >= STERNE_SCHWELLEN.drei) return 3; if (accuracy >= STAR_THRESHOLDS.three) return 3;
if (genauigkeit >= STERNE_SCHWELLEN.zwei) return 2; if (accuracy >= STAR_THRESHOLDS.two) return 2;
if (genauigkeit >= STERNE_SCHWELLEN.eins) return 1; if (accuracy >= STAR_THRESHOLDS.one) return 1;
return 0; return 0;
} }
/** Two stars unlocks the next lesson. No speed condition anywhere - that is the point. */ /** Two stars unlocks the next lesson. No speed condition anywhere - that is the point. */
export function isPassed(genauigkeit: number): boolean { export function isPassed(accuracy: number): boolean {
return genauigkeit >= STERNE_SCHWELLEN.zwei; return accuracy >= STAR_THRESHOLDS.two;
} }
/** One Perle per five correct keys, plus a bonus per star. Small numbers that go up /** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
* every single run, including a bad one - the aquarium should never stall. */ * every single run, including a bad one - the aquarium should never stall. */
function perlenFor(zeichen: number, sterne: number): number { function pearlsFor(characters: number, stars: number): number {
return Math.floor(zeichen / 5) + sterne * 2; return Math.floor(characters / 5) + stars * 2;
} }
export function grade(state: RunState): RunResult { export function grade(state: RunState): RunResult {
const zeichen = state.strokes.filter((stroke) => stroke.correct).length; const characters = state.strokes.filter((stroke) => stroke.correct).length;
const fehler = state.missed.size; const errors = state.missed.size;
const start = state.startedAt; const start = state.startedAt;
const end = state.finishedAt ?? state.strokes.at(-1)?.at ?? start; const end = state.finishedAt ?? state.strokes.at(-1)?.at ?? start;
// A run of one keystroke has no elapsed time between first and last. Treating that as // A run of one keystroke has no elapsed time between first and last. Treating that as
// "infinitely fast" would hand out a Segelfisch for a single letter, so anything under // "infinitely fast" would hand out a top-tier animal for a single letter, so anything
// a second of real typing is scored as if it took a second. // under a second of real typing is scored as if it took a second.
const dauer = start !== null && end !== null ? Math.max(0, end - start) : 0; const duration = start !== null && end !== null ? Math.max(0, end - start) : 0;
const minuten = Math.max(dauer, 1000) / 60000; const minutes = Math.max(duration, 1000) / 60000;
const tempo = zeichen / minuten; const speed = characters / minutes;
const versuche = zeichen + fehler; const attempts = characters + errors;
const genauigkeit = versuche === 0 ? 0 : zeichen / versuche; const accuracy = attempts === 0 ? 0 : characters / attempts;
const punkte = tempo * genauigkeit ** 3; const points = speed * accuracy ** 3;
const sterne = sterneFor(genauigkeit); const stars = starsFor(accuracy);
return { return {
zeichen, characters,
fehler, errors,
dauer, duration,
tempo, speed,
genauigkeit, accuracy,
punkte, points,
sterne, stars,
tier: tierFor(punkte).id, animal: animalFor(points).id,
bestanden: isPassed(genauigkeit), passed: isPassed(accuracy),
perlen: perlenFor(zeichen, sterne), pearls: pearlsFor(characters, stars),
strokes: state.strokes, strokes: state.strokes,
}; };
} }
/** Which of two results is the better one, for the per-lesson personal best. Stars come /** Which of two results is the better one, for the per-lesson personal best. Stars come
* first, Punkte break the tie - so a careful run is never displaced by a sloppy fast * first, points break the tie - so a careful run is never displaced by a sloppy fast
* one, and the best animal on a lesson card can never go down. */ * one, and the best animal on a lesson card can never go down. */
export function isBetter(candidate: RunResult, best: { sterne: number; punkte: number }): boolean { export function isBetter(candidate: RunResult, best: { stars: number; points: number }): boolean {
if (candidate.sterne !== best.sterne) return candidate.sterne > best.sterne; if (candidate.stars !== best.stars) return candidate.stars > best.stars;
return candidate.punkte > best.punkte; return candidate.points > best.points;
} }

View File

@@ -7,7 +7,7 @@
let context: AudioContext | null = null; let context: AudioContext | null = null;
function blip(from: number, to: number, gainStart: number, dauer: number): void { function blip(from: number, to: number, gainStart: number, duration: number): void {
try { try {
context ??= new AudioContext(); context ??= new AudioContext();
const now = context.currentTime; const now = context.currentTime;
@@ -15,12 +15,12 @@ function blip(from: number, to: number, gainStart: number, dauer: number): void
const gain = context.createGain(); const gain = context.createGain();
oscillator.type = "sine"; oscillator.type = "sine";
oscillator.frequency.setValueAtTime(from, now); oscillator.frequency.setValueAtTime(from, now);
oscillator.frequency.exponentialRampToValueAtTime(to, now + dauer * 0.55); oscillator.frequency.exponentialRampToValueAtTime(to, now + duration * 0.55);
gain.gain.setValueAtTime(gainStart, now); gain.gain.setValueAtTime(gainStart, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + dauer); gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
oscillator.connect(gain).connect(context.destination); oscillator.connect(gain).connect(context.destination);
oscillator.start(); oscillator.start();
oscillator.stop(now + dauer + 0.01); oscillator.stop(now + duration + 0.01);
} catch { } catch {
// No audio context before the first user gesture, and on some browsers never. // No audio context before the first user gesture, and on some browsers never.
} }
@@ -33,18 +33,18 @@ export function playPop(frequency: number): void {
/** A correct key. The pitch climbs with the streak, so a good run audibly builds - it /** A correct key. The pitch climbs with the streak, so a good run audibly builds - it
* caps at an octave up, past which it just sounds shrill. */ * caps at an octave up, past which it just sounds shrill. */
export function playRichtig(streak: number): void { export function playCorrect(streak: number): void {
const halbtoene = Math.min(streak, 12); const semitones = Math.min(streak, 12);
blip(440 * 2 ** (halbtoene / 12), 660 * 2 ** (halbtoene / 12), 0.1, 0.09); blip(440 * 2 ** (semitones / 12), 660 * 2 ** (semitones / 12), 0.1, 0.09);
} }
/** A wrong key: low, falling, quiet. */ /** A wrong key: low, falling, quiet. */
export function playDaneben(): void { export function playWrong(): void {
blip(200, 150, 0.06, 0.12); blip(200, 150, 0.06, 0.12);
} }
/** Finishing a line. */ /** Finishing a line. */
export function playFertig(): void { export function playDone(): void {
blip(520, 900, 0.16, 0.4); blip(520, 900, 0.16, 0.4);
} }

View File

@@ -9,10 +9,10 @@
* a quota that is full - none of those may stop the game from being playable. They just * a quota that is full - none of those may stop the game from being playable. They just
* make it forgetful. */ * make it forgetful. */
import { kreaturAus } from "./aquarium"; import { creatureFromRaw } from "./aquarium";
import type { KreaturId } from "./aquarium"; import type { CreatureId } from "./aquarium";
import { FIRST_LESSON_ID, LESSONS, WELTEN, nextLesson } from "./curriculum"; import { FIRST_LESSON_ID, LESSONS, WORLDS, nextLesson } from "./curriculum";
import type { RunResult, TierId } from "./grading"; import type { RunResult, AnimalId } from "./grading";
import { isBetter } from "./grading"; import { isBetter } from "./grading";
const STORAGE_KEY = "delfin-tippen:v1"; const STORAGE_KEY = "delfin-tippen:v1";
@@ -20,15 +20,15 @@ const STORAGE_KEY = "delfin-tippen:v1";
/** How many attempts at one lesson unlock the next regardless of score. The safety /** How many attempts at one lesson unlock the next regardless of score. The safety
* valve against getting stuck on a single stubborn key - which at six is the difference * valve against getting stuck on a single stubborn key - which at six is the difference
* between a game she returns to and one she does not. */ * between a game she returns to and one she does not. */
export const FLEISS_VERSUCHE = 5; export const DILIGENCE_ATTEMPTS = 5;
export interface LessonProgress { export interface LessonProgress {
unlocked: boolean; unlocked: boolean;
runs: number; runs: number;
bestSterne: 0 | 1 | 2 | 3; bestStars: 0 | 1 | 2 | 3;
bestTier: TierId | null; bestAnimal: AnimalId | null;
bestPunkte: number; bestPoints: number;
/** Best-run keystrokes, replayed as the opponent in Delfinrennen. */ /** Best-run keystrokes, replayed as the opponent in race mode. */
ghost: { key: string; at: number }[] | null; ghost: { key: string; at: number }[] | null;
} }
@@ -41,7 +41,6 @@ export interface KeyStat {
} }
export interface Settings { export interface Settings {
speech: boolean;
sound: boolean; sound: boolean;
keyboardHint: "auto" | "on" | "off"; keyboardHint: "auto" | "on" | "off";
} }
@@ -50,15 +49,15 @@ export interface Progress {
version: 1; version: 1;
lessons: Record<string, LessonProgress>; lessons: Record<string, LessonProgress>;
keyStats: Record<string, KeyStat>; keyStats: Record<string, KeyStat>;
perlen: number; pearls: number;
/** Pets that have moved into the aquarium, in the order they arrived. */ /** Pets that have moved into the aquarium, in the order they arrived. */
aquarium: KreaturId[]; aquarium: CreatureId[];
streak: { days: number; lastPlayed: string | null }; streak: { days: number; lastPlayed: string | null };
settings: Settings; settings: Settings;
} }
function emptyLesson(unlocked: boolean): LessonProgress { function emptyLesson(unlocked: boolean): LessonProgress {
return { unlocked, runs: 0, bestSterne: 0, bestTier: null, bestPunkte: 0, ghost: null }; return { unlocked, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null };
} }
export function freshProgress(): Progress { export function freshProgress(): Progress {
@@ -70,10 +69,10 @@ export function freshProgress(): Progress {
version: 1, version: 1,
lessons, lessons,
keyStats: {}, keyStats: {},
perlen: 0, pearls: 0,
aquarium: [], aquarium: [],
streak: { days: 0, lastPlayed: null }, streak: { days: 0, lastPlayed: null },
settings: { speech: true, sound: true, keyboardHint: "auto" }, settings: { sound: true, keyboardHint: "auto" },
}; };
} }
@@ -101,7 +100,7 @@ export function migrate(raw: unknown): Progress {
version: 1, version: 1,
lessons, lessons,
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {}, keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {},
perlen: typeof stored.perlen === "number" ? stored.perlen : 0, pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [], aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
streak: streak:
typeof stored.streak === "object" && stored.streak !== null typeof stored.streak === "object" && stored.streak !== null
@@ -112,15 +111,15 @@ export function migrate(raw: unknown): Progress {
} }
/** Saves from before the pets were drawings hold emoji here. Those map onto the creature /** Saves from before the pets were drawings hold emoji here. Those map onto the creature
* that fills the same Welt today; duplicates and anything unrecognisable are dropped, so * that fills the same world today; duplicates and anything unrecognisable are dropped,
* one stray entry cannot put a broken image in the tank. */ * so one stray entry cannot put a broken image in the tank. */
function migrateAquarium(raw: readonly unknown[]): KreaturId[] { function migrateAquarium(raw: readonly unknown[]): CreatureId[] {
const tiere: KreaturId[] = []; const creatures: CreatureId[] = [];
for (const eintrag of raw) { for (const entry of raw) {
const id = kreaturAus(eintrag); const id = creatureFromRaw(entry);
if (id && !tiere.includes(id)) tiere.push(id); if (id && !creatures.includes(id)) creatures.push(id);
} }
return tiere; return creatures;
} }
export function loadProgress(): Progress { export function loadProgress(): Progress {
@@ -181,20 +180,20 @@ function foldKeyStats(stats: Record<string, KeyStat>, result: RunResult): Record
return next; return next;
} }
/** Welt number to the creature it releases, read straight off the Welten table so it /** World number to the creature it releases, read straight off the worlds table so it
* cannot drift from curriculum.ts. */ * cannot drift from curriculum.ts. */
const WELT_BELOHNUNG = new Map(WELTEN.map((welt) => [welt.nummer, welt.belohnung])); const WORLD_REWARD = new Map(WORLDS.map((world) => [world.number, world.reward]));
export interface RecordOutcome { export interface RecordOutcome {
progress: Progress; progress: Progress;
/** Set when this run unlocked the following lesson, for the celebration. */ /** Set when this run unlocked the following lesson, for the celebration. */
unlockedLessonId: string | null; unlockedLessonId: string | null;
/** Set when this run finished a Welt, for the creature that moved in. */ /** Set when this run finished a world, for the creature that moved in. */
neuesTier: KreaturId | null; newCreature: CreatureId | null;
bestseit: boolean; isNewBest: boolean;
} }
/** Record a finished run: stars, animal, Perlen, key stats, streak, and the unlock. /** Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
* *
* The unlock rule, in one place: two stars unlocks the next lesson, and so does the * The unlock rule, in one place: two stars unlocks the next lesson, and so does the
* fifth attempt whatever the score. Speed is nowhere in it. */ * fifth attempt whatever the score. Speed is nowhere in it. */
@@ -207,38 +206,38 @@ export function recordRun(
const before = progress.lessons[lessonId] ?? emptyLesson(true); const before = progress.lessons[lessonId] ?? emptyLesson(true);
const runs = before.runs + 1; const runs = before.runs + 1;
const improved = isBetter(result, { sterne: before.bestSterne, punkte: before.bestPunkte }); const improved = isBetter(result, { stars: before.bestStars, points: before.bestPoints });
const lessons = { ...progress.lessons }; const lessons = { ...progress.lessons };
lessons[lessonId] = { lessons[lessonId] = {
...before, ...before,
runs, runs,
bestSterne: improved ? result.sterne : before.bestSterne, bestStars: improved ? result.stars : before.bestStars,
bestTier: improved ? result.tier : before.bestTier, bestAnimal: improved ? result.animal : before.bestAnimal,
bestPunkte: improved ? result.punkte : before.bestPunkte, bestPoints: improved ? result.points : before.bestPoints,
ghost: improved ghost: improved
? result.strokes.filter((s) => s.correct).map((s) => ({ key: s.key, at: s.at })) ? result.strokes.filter((s) => s.correct).map((s) => ({ key: s.key, at: s.at }))
: before.ghost, : before.ghost,
}; };
const verdient = result.bestanden || runs >= FLEISS_VERSUCHE; const earned = result.passed || runs >= DILIGENCE_ATTEMPTS;
const folgend = nextLesson(lessonId); const next = nextLesson(lessonId);
let unlockedLessonId: string | null = null; let unlockedLessonId: string | null = null;
if (verdient && folgend && !lessons[folgend.id]?.unlocked) { if (earned && next && !lessons[next.id]?.unlocked) {
lessons[folgend.id] = { ...(lessons[folgend.id] ?? emptyLesson(false)), unlocked: true }; lessons[next.id] = { ...(lessons[next.id] ?? emptyLesson(false)), unlocked: true };
unlockedLessonId = folgend.id; unlockedLessonId = next.id;
} }
// Finishing the last lesson of a Welt releases that Welt's creature. Checked against // Finishing the last lesson of a world releases that world's creature. Checked
// the aquarium so it is only ever awarded once. // against the aquarium so it is only ever awarded once.
const aquarium = [...progress.aquarium]; const aquarium = [...progress.aquarium];
let neuesTier: KreaturId | null = null; let newCreature: CreatureId | null = null;
if (unlockedLessonId && folgend) { if (unlockedLessonId && next) {
const beendet = LESSONS.find((l) => l.id === lessonId); const finished = LESSONS.find((l) => l.id === lessonId);
if (beendet && folgend.welt !== beendet.welt) { if (finished && next.world !== finished.world) {
const belohnung = WELT_BELOHNUNG.get(beendet.welt); const reward = WORLD_REWARD.get(finished.world);
if (belohnung && !aquarium.includes(belohnung)) { if (reward && !aquarium.includes(reward)) {
aquarium.push(belohnung); aquarium.push(reward);
neuesTier = belohnung; newCreature = reward;
} }
} }
} }
@@ -249,37 +248,37 @@ export function recordRun(
lessons, lessons,
aquarium, aquarium,
keyStats: foldKeyStats(progress.keyStats, result), keyStats: foldKeyStats(progress.keyStats, result),
perlen: progress.perlen + result.perlen, pearls: progress.pearls + result.pearls,
streak: bumpStreak(progress.streak, day), streak: bumpStreak(progress.streak, day),
}, },
unlockedLessonId, unlockedLessonId,
neuesTier, newCreature,
bestseit: improved, isNewBest: improved,
}; };
} }
/** How many times a key must be typed before its stats mean anything. */ /** How many times a key must be typed before its stats mean anything. */
const GENUG_VERSUCHE = 3; const ENOUGH_ATTEMPTS = 3;
/** The key a lesson should drill hardest - the generator's focus key, or `null` for an /** The key a lesson should drill hardest - the generator's focus key, or `null` for an
* even spread. * even spread.
* *
* `null` on a brand-new lesson is the important case. Every key starts unpractised, so * `null` on a brand-new lesson is the important case. Every key starts unpractised, so
* "pick an unpractised key" would pick whichever sorted first and drill it half the * "pick an unpractised key" would pick whichever sorted first and drill it half the
* line - which on Lektion 1 means typing `a` thirteen times out of twenty-four while * line - which on lesson 1 means typing `a` thirteen times out of twenty-four while
* three other fingers go untrained. A lesson she has never played gets an even spread; * three other fingers go untrained. A lesson she has never played gets an even spread;
* a focus key only emerges once there is evidence of what she is actually slow at. */ * a focus key only emerges once there is evidence of what she is actually slow at. */
export function focusKeyFor(progress: Progress, activeKeys: readonly string[]): string | null { export function focusKeyFor(progress: Progress, activeKeys: readonly string[]): string | null {
const keys = activeKeys.filter((key) => key !== " "); const keys = activeKeys.filter((key) => key !== " ");
if (keys.length === 0) return null; if (keys.length === 0) return null;
const geuebt = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= GENUG_VERSUCHE); const practiced = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= ENOUGH_ATTEMPTS);
if (geuebt.length === 0) return null; if (practiced.length === 0) return null;
// Some keys practised and some not: the gap is the most useful thing to close. // Some keys practised and some not: the gap is the most useful thing to close.
const unbekannt = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < GENUG_VERSUCHE); const unpracticed = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < ENOUGH_ATTEMPTS);
if (unbekannt) return unbekannt; if (unpracticed) return unpracticed;
let worst: string | null = null; let worst: string | null = null;
let worstScore = -Infinity; let worstScore = -Infinity;
@@ -298,13 +297,13 @@ export function focusKeyFor(progress: Progress, activeKeys: readonly string[]):
/** The fastest animal earned on any lesson so far. Drives the aquarium's headline stat /** The fastest animal earned on any lesson so far. Drives the aquarium's headline stat
* and, on the result screen, how far up the ladder is allowed to be revealed. */ * and, on the result screen, how far up the ladder is allowed to be revealed. */
export function besteTier(progress: Progress): TierId | null { export function overallBestAnimal(progress: Progress): AnimalId | null {
let best: TierId | null = null; let best: AnimalId | null = null;
let bestPunkte = -1; let bestPoints = -1;
for (const lesson of Object.values(progress.lessons)) { for (const lesson of Object.values(progress.lessons)) {
if (lesson.bestTier && lesson.bestPunkte > bestPunkte) { if (lesson.bestAnimal && lesson.bestPoints > bestPoints) {
best = lesson.bestTier; best = lesson.bestAnimal;
bestPunkte = lesson.bestPunkte; bestPoints = lesson.bestPoints;
} }
} }
return best; return best;

View File

@@ -1,77 +0,0 @@
/** German speech, for a player who cannot reliably read yet.
*
* This is the accessibility feature that makes the game usable at six: the target letter
* or word is spoken, so not being able to read it is never what stops her. It doubles as
* phonics practice - hearing "eff" while pressing f is exactly the association to build.
*
* Everything here degrades to silence. `speechSynthesis` is missing in some browsers,
* has no German voice in others, and on several platforms refuses to speak until the
* user has interacted with the page. None of that may throw. */
let stimme: SpeechSynthesisVoice | null = null;
let gesucht = false;
/** Pick a German voice once. Voices load asynchronously, so this may find nothing on
* the first call and succeed later - hence the retry rather than a cached `null`. */
function germanVoice(): SpeechSynthesisVoice | null {
if (stimme) return stimme;
try {
const voices = window.speechSynthesis.getVoices();
if (voices.length === 0) {
if (!gesucht) {
gesucht = true;
window.speechSynthesis.addEventListener("voiceschanged", () => {
stimme = null;
germanVoice();
});
}
return null;
}
stimme = voices.find((voice) => voice.lang.startsWith("de")) ?? null;
return stimme;
} catch {
return null;
}
}
export function speechAvailable(): boolean {
return typeof window !== "undefined" && "speechSynthesis" in window;
}
/** Say something, cancelling whatever was being said. A six-year-old clicks fast; a
* queue of four stale sentences talking over the game is worse than silence. */
export function say(text: string, enabled: boolean): void {
if (!enabled || !speechAvailable()) return;
try {
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "de-DE";
// Slower than default: the instruction has to be followable by someone who is also
// hunting for a key.
utterance.rate = 0.9;
utterance.pitch = 1.1;
const voice = germanVoice();
if (voice) utterance.voice = voice;
window.speechSynthesis.speak(utterance);
} catch {
// Blocked, unsupported, or no voice. The game reads fine on screen.
}
}
/** How a single key should be *said*, which is not how it is written. "a" spoken alone
* is the letter name; the Leertaste has no letter name at all. */
export function spellKey(key: string): string {
if (key === " ") return "Leertaste";
if (key === "⇧") return "Umschalttaste";
const upper = key.toUpperCase();
// A capital in a target means Shift is the lesson, so name it.
return key !== key.toLowerCase() ? `großes ${upper}` : upper;
}
export function stop(): void {
try {
window.speechSynthesis.cancel();
} catch {
// Nothing was speaking.
}
}

View File

@@ -15,7 +15,7 @@ export const SHOW_BUBBLES = true;
/** The earned pets swimming behind every screen. The reward that is always in view - off /** The earned pets swimming behind every screen. The reward that is always in view - off
* only to rule it out when chasing a performance problem. */ * only to rule it out when chasing a performance problem. */
export const SHOW_AQUARIUM_TIERE = true; export const SHOW_AQUARIUM_CREATURES = true;
/** Fade screens in on entry. */ /** Fade screens in on entry. */
export const ANIMATE_VIEW_TRANSITIONS = true; export const ANIMATE_VIEW_TRANSITIONS = true;
@@ -31,9 +31,9 @@ export const SPEECH_DEFAULT = true;
export const SOUND_DEFAULT = true; export const SOUND_DEFAULT = true;
/** How many letters one Blasenplatzen or Quallenalarm round sends up - matched to the /** How many letters one bubbles or jellyfish round sends up - matched to the dive
* Tauchgang's length so a mode swap is not also a difficulty swap. Tauchgang line * mode's length so a mode swap is not also a difficulty swap. Dive-mode line length
* length lives on the lesson itself (`laengeFuer` in lib/curriculum.ts). */ * lives on the lesson itself (`lengthFor` in lib/curriculum.ts). */
export function blasenAnzahlFuer(welt: number): number { export function bubbleCountFor(world: number): number {
return welt === 1 ? 50 : 100; return world === 1 ? 50 : 100;
} }

View File

@@ -16,8 +16,8 @@
/* This app's own additions: the feedback colours. Note there is no red - a wrong key /* This app's own additions: the feedback colours. Note there is no red - a wrong key
is amber and gentle, never an alarm. See `wrongShake` below. */ is amber and gentle, never an alarm. See `wrongShake` below. */
--richtig: oklch(80% 0.17 150); --correct: oklch(80% 0.17 150);
--daneben: oklch(80% 0.13 75); --wrong: oklch(80% 0.13 75);
} }
* { * {
@@ -230,7 +230,7 @@ button {
text-align: center; text-align: center;
} }
/* ------------------------------------------------- die Bildschirmtastatur -- */ /* ------------------------------------------------------- the on-screen keyboard -- */
.kb { .kb {
display: flex; display: flex;
@@ -313,9 +313,9 @@ button {
width: calc(var(--kb-size, 42px) * 6); width: calc(var(--kb-size, 42px) * 6);
} }
/* ---------------------------------------------------------- die Zielzeile -- */ /* ------------------------------------------------------------- the target line -- */
.ziel { .target {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
@@ -324,29 +324,33 @@ button {
font-weight: 800; font-weight: 800;
letter-spacing: 0.02em; letter-spacing: 0.02em;
/* Wrapping beats shrinking: the line must never run into the edges of the screen. /* Wrapping beats shrinking: the line must never run into the edges of the screen.
Target.tsx overrides this per block length (see `satzspiegel`); this is only the Target.tsx overrides this per block length (see `layoutFor`); this is only the
fallback for a caller that does not. */ fallback for a caller that does not. */
max-width: min(900px, 88vw); max-width: min(900px, 88vw);
} }
.ziel-chunk { .target-chunk {
display: flex; display: flex;
} }
.ziel-zeichen { .target-char {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 0.78em; min-width: 0.78em;
/* Fixed at all times, not just while "current": if the current letter alone grew
wider, the whole line would reflow around the cursor as she types, and chunks
would visibly jump between lines mid-word. */
padding: 0 6px;
color: oklch(97% 0.01 175 / 0.45); color: oklch(97% 0.01 175 / 0.45);
transition: color 0.12s ease; transition: color 0.12s ease;
} }
.ziel-zeichen[data-state="fertig"] { .target-char[data-state="done"] {
color: var(--richtig); color: var(--correct);
} }
.ziel-zeichen[data-state="aktuell"] { .target-char[data-state="current"] {
color: oklch(25% 0.05 175); color: oklch(25% 0.05 175);
background: var(--paper); background: var(--paper);
border-radius: 10px; border-radius: 10px;
@@ -354,19 +358,19 @@ button {
animation: correctPop 200ms ease-out; animation: correctPop 200ms ease-out;
} }
.ziel-zeichen[data-state="aktuell"][data-daneben="true"] { .target-char[data-state="current"][data-wrong="true"] {
background: var(--daneben); background: var(--wrong);
animation: wrongShake 260ms ease; animation: wrongShake 260ms ease;
} }
/* A space inside the target needs a visible body, or the cursor lands on nothing. Only /* A space inside the target needs a visible body, or the cursor lands on nothing. Only
the space at the cursor shows ␣: marking every upcoming one turned a sentence into the space at the cursor shows ␣: marking every upcoming one turned a sentence into
"Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */ "Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */
.ziel-zeichen[data-leer="true"] { .target-char[data-blank="true"] {
min-width: 0.9em; min-width: 0.9em;
} }
/* The two Shift keys, drawn either side of the Leertaste. Wider than a letter key so /* The two Shift keys, drawn either side of the space bar. Wider than a letter key so
they read as the modifier they are, and dim until a capital actually needs one. */ they read as the modifier they are, and dim until a capital actually needs one. */
.kb-shift { .kb-shift {
width: calc(var(--kb-size, 42px) * 1.8); width: calc(var(--kb-size, 42px) * 1.8);
@@ -374,10 +378,10 @@ button {
font-size: calc(var(--kb-size, 42px) * 0.4); font-size: calc(var(--kb-size, 42px) * 0.4);
} }
/* A dome plus three trailing tentacles. Without them the Quallen read as plain circles, /* A dome plus three trailing tentacles. Without them the jellyfish read as plain
and "Quallenalarm" stops being a picture of anything. Drawn in CSS rather than as an circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather
emoji so the letter stays centred and legible inside the dome. */ than as an emoji so the letter stays centred and legible inside the dome. */
.qualle::after { .jellyfish::after {
content: ""; content: "";
position: absolute; position: absolute;
bottom: -11px; bottom: -11px;