First implementation of 10 finger typing

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

2
tippen/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

107
tippen/README.md Normal file
View File

@@ -0,0 +1,107 @@
# Delfin Tippen
Ein Lernspiel fürs Zehnfingerschreiben auf einer deutschen QWERTZ-Tastatur, gebaut für
eine Sechsjährige, die Delfine mag. Alles auf Deutsch.
Technisch ein Geschwister des Musik-Players in `../web` — React 19, TypeScript, Vite,
keine Laufzeit-Abhängigkeiten außer React, dieselbe oklch-Glasoptik und dieselbe
Nunito — aber **vollständig entkoppelt**: eigenes Vite-Projekt, eigener Dev-Server,
kein Backend, kein gemeinsamer Build. Was geteilt werden sollte, wurde kopiert.
## Loslegen
```sh
npm install
npm run dev # http://localhost:5174
npm run test # vitest über src/lib
npx tsc --noEmit # der eigentliche Check
npm run build # -> dist/
```
Es braucht eine echte QWERTZ-Tastatur. Der Fortschritt liegt im `localStorage`
(`delfin-tippen:v1`); zum Zurücksetzen den Key löschen.
## Wie es aufgebaut ist
Die Logik ist DOM-frei und getestet, die Komponenten sind Layout. Das ist dieselbe
Aufteilung wie in `../web` und der Grund, warum sechs Spielmodi dieselbe Mechanik teilen
können, ohne sie sechsmal zu implementieren.
| Datei | Wofür |
|---|---|
| `lib/fingers.ts` | Die QWERTZ-Belegung: welcher Finger welche Taste, welche Farbe, welche Grundstellungstaste. |
| `lib/curriculum.ts` | 48 Lektionen in 5 Welten. Höchstens zwei neue Tasten pro Lektion, mit Übungslektionen dazwischen. |
| `lib/generator.ts` | Was getippt wird — Übungszeilen aus den aktiven Tasten, echte Wörter sobald möglich. |
| `lib/engine.ts` | Die Tippmechanik: ein Tastendruck rein, ein neuer Zustand und Ereignisse raus. Pur. |
| `lib/grading.ts` | Zeichen pro Minute, Genauigkeit, Sterne, Tier. |
| `lib/progress.ts` | Was das Spiel sich merkt, und wann die nächste Lektion aufgeht. |
| `hooks/useRun.ts` | Der dünne Adapter zwischen echten Tastendrücken und `engine.ts`. |
## Die Entscheidungen, die das Spiel ausmachen
Vier davon sind bewusst anders, als man es zuerst bauen würde. Sie stehen ausführlicher
in den Dateikommentaren:
**Tempo schaltet nichts frei.** Die nächste Lektion geht bei 93 % Treffern auf — ohne
jede Geschwindigkeitsbedingung. Wer langsam und sorgfältig tippt, kommt durch den ganzen
Kurs. Tempo bringt nur das Tier. Zusätzlich geht die nächste Lektion nach fünf Versuchen
ohnehin auf, damit niemand an einer Taste hängen bleibt.
**`punkte = tempo × genauigkeit³`.** Die Lehrbuchformel `netto = brutto fehler/minute`
wird bei einem Anfänger negativ, und ein negatives Ergebnis darf hier nie erscheinen.
Multiplikativ kann das nicht passieren: 95 % Genauigkeit behalten 86 % des Tempos, 90 %
behalten 73 %.
**Eine falsche Taste rückt nicht vor und zählt pro Stelle nur einmal.** Es gibt kein
Backspace und keine zerschossene Zeile zum Anstarren, und zehnmal panisch dieselbe
falsche Taste ist ein Fehler, nicht zehn.
**Es gibt keinen Verloren-Bildschirm.** Eine schwache Runde zeigt weniger Sterne und ein
langsameres Tier — nie ein rotes X, nie einen Summer, und die Perlen steigen immer.
## Die sechs Spielmodi
Alle sechs laufen über dieselbe Mechanik (`lib/engine.ts`) und landen in derselben
Bewertung. Sie unterscheiden sich nur darin, *wie* das Ziel gezeigt wird — deshalb ist
ein siebter Modus eine Datei und eine Zeile, keine zweite Spiellogik.
| Modus | Was man sieht | Wofür |
|---|---|---|
| 🤿 Tauchgang | Eine Zeile mit Cursor | Die Kernübung. Sie zählt fürs Freischalten. |
| 🫧 Blasenplatzen | Blasen steigen auf, eine pro Buchstabe | Tastenlage, weiche Uhr |
| 🦑 Quallenalarm | Sechs Quallen, eine leuchtet | Reine Tastenlage — erst suchen, dann tippen |
| 🐟 Fütterungszeit | Fische mit Wörtern schwimmen zum Delfin | Ganze Wörter statt Buchstaben |
| 🐬 Delfinrennen | Zwei Bahnen: du gegen deinen Rekord | Tempo, gegen den einzigen fairen Gegner |
| 🦪 Perlentaucher | Eine Muschel, ein Wort, eine Perlenkette | Genauigkeit — **ohne jede Uhr** |
Beim Delfinrennen ist der Gegner der Geist des eigenen besten Laufs: die Tastenzeiten
liegen in `progress.lessons[id].ghost` und werden gegen die Uhr abgespielt. Gegen sich
selbst zu rennen ist das einzige Wettkampfformat, das nicht entmutigen kann — der Gegner
ist per Definition genau so gut, wie man selbst war. Vor dem ersten Rekord schwimmt eine
absichtlich schlagbare Krabbe.
## Das Aquarium
Wer eine Welt schafft, bekommt ein Haustier — Clownfisch, Krake, Seepferdchen,
Schildkröte, Perlmuschel. Es zieht für immer ein und schwimmt ab dann frei hinter jedem
Bildschirm herum; während einer Runde nur blass, damit es nicht mit der Zeile
konkurriert. Das Schwimmen ist ein purer, getesteter Schritt in `lib/aquarium.ts`,
`components/AquariumTiere.tsx` zeichnet nur.
Ein neues Tier: die Zeichnung (auf schwarzem Hintergrund) nach `art/aquarium/` legen,
```sh
scripts/aquarium-bild.sh art/aquarium/qualle.png public/aquarium/qualle.webp
```
und in `KREATUREN` eintragen. Das Skript entfernt den Hintergrund als zusammenhängende
Fläche statt als Farbe — schwarze Augen und Wimpern bleiben stehen — und rechnet die
Kanten aus dem Schwarz heraus, damit kein dunkler Rand bleibt.
## Stand
Fertig und spielbar: alle 48 Lektionen, alle sechs Modi, Freischalten, Bewertung,
Tierleiter, Aquarium, Sprachausgabe und die Bildschirmtastatur.
Noch offen: die Geschichten am Ende jeder Welt, der Eltern-Bildschirm (`?eltern=1`) und
eine Welt 6 mit der Zahlenreihe.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

19
tippen/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Delfin Tippen</title>
<meta name="theme-color" content="#0b2f2c" />
<meta name="description" content="Zehnfingerschreiben lernen mit Delfinen" />
<link rel="icon" href="/icon-192.png" type="image/png" />
<link rel="stylesheet" href="/fonts/nunito.css" />
<link rel="preload" href="/fonts/nunito-latin.woff2" as="font" type="font/woff2" crossorigin />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2333
tippen/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
tippen/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "delfin-tippen",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.9.0",
"vite": "^7.1.0",
"vitest": "^3.2.0"
},
"allowScripts": {
"esbuild@0.28.2": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
/* Nunito, self-hosted: one variable file per subset covers 500-900.
Google Fonts would be a network round trip the mouse may not have, and a
cached app shell rendered in the fallback face looks broken. latin-ext is
what carries "Hörbücher" and "Käpt'n"; the Cyrillic and Vietnamese subsets
Google also offers are dropped, since nothing in this UI can produce them. */
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 500 900;
font-display: swap;
src: url("/fonts/nunito-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 500 900;
font-display: swap;
src: url("/fonts/nunito-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

BIN
tippen/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

57
tippen/scripts/aquarium-bild.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Turns a creature illustration on solid black into a transparent aquarium sprite.
#
# scripts/aquarium-bild.sh bild.png public/aquarium/krake.webp
#
# The illustrations come out of the image generator as RGB on pure black, with no alpha
# channel. "Black becomes transparent" is not enough: the eyes and eyelashes are black
# too, and a creature with see-through eyes looks haunted on a turquoise sea. So the
# background is found as a *region*, not a colour - every dark area big enough to not be
# a facial feature - and only that region is removed.
#
# The edge is the other half. The art was antialiased onto black, so its outline pixels
# are the creature's colour already mixed with black. Cutting them hard leaves a dark
# halo; instead, near the background the opacity follows the brightness and the colour
# is divided back out of the black (un-premultiplied), which is what the mix actually was.
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "usage: $0 input.png output.webp [size]" >&2
exit 1
fi
src=$1
out=$2
size=${3:-512}
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# How far each pixel is from black: its brightest channel.
magick "$src" -alpha off -channel RGB -separate -evaluate-sequence max "$tmp/hell.png"
# Dark pixels, then drop every dark island under 5000 px back into the body. Measured on
# the first five creatures: the background is one region of ~1M px, the largest facial
# feature is ~2000 px, and nothing sits in between.
magick "$tmp/hell.png" -threshold 8% -negate \
-define connected-components:area-threshold=5000 \
-define connected-components:mean-color=true \
-connected-components 8 "$tmp/grund.png"
# Opacity: solid inside the body, zero out in the background, and in a thin band along
# the outline a ramp on brightness - the band is what catches the antialiased edge. The
# ramp starts a little above black, or the generator's faint noise in the background
# would come back as a faint haze around every creature.
magick "$tmp/grund.png" -morphology Dilate Disk:3 -negate "$tmp/koerper.png"
magick "$tmp/grund.png" -negate -morphology Dilate Disk:5 "$tmp/umriss.png"
magick "$tmp/hell.png" -evaluate Subtract 4% -evaluate Multiply 3.8 "$tmp/rampe.png"
magick "$tmp/rampe.png" "$tmp/koerper.png" -compose Lighten -composite \
"$tmp/umriss.png" -compose Multiply -composite "$tmp/alpha.png"
# Divide the black back out of the edge colours, attach the opacity, cut to the creature
# and scale down - the sprites are drawn at a few hundred px at most. `-alpha background`
# blanks the colour of fully transparent pixels, which is what lets `-trim` see them as
# border at all.
magick "$src" -alpha off "$tmp/alpha.png" -compose DivideSrc -composite \
"$tmp/alpha.png" -alpha off -compose CopyOpacity -composite \
-background none -alpha background -trim +repage -resize "${size}x${size}>" \
-quality 90 -define webp:alpha-quality=100 "$out"

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

@@ -0,0 +1,333 @@
/** The whole app: one state object, one keydown listener for navigation, four screens.
*
* The same shape as ../../web/src/App.tsx - state lives here, components are layout, and
* the keyboard is handled in one place rather than scattered through the tree.
*
* One rule is worth stating because it is easy to break later: **while a run is going,
* every key belongs to the run**. Only Escape and F1 are intercepted here. Otherwise a
* lesson that happens to teach `m` would mute the game every time she typed it, and the
* mute would look to her like the game breaking. The run's own listener lives in
* hooks/useRun.ts and does the typing; this one only handles the screens around it. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Aquarium } from "./components/Aquarium";
import { AppHeader } from "./components/AppHeader";
import { HelpOverlay } from "./components/HelpOverlay";
import { LessonMap } from "./components/LessonMap";
import { ModePicker } from "./components/ModePicker";
import { ResultSheet } from "./components/ResultSheet";
import { Stage } from "./components/Stage";
import { BlasenRun } from "./components/modes/BlasenRun";
import { FuetternRun } from "./components/modes/FuetternRun";
import { PerlenRun } from "./components/modes/PerlenRun";
import { QuallenRun } from "./components/modes/QuallenRun";
import { RennenRun } from "./components/modes/RennenRun";
import { TauchgangRun } from "./components/modes/TauchgangRun";
import type { KreaturId } from "./lib/aquarium";
import { LESSONS, lessonById, nextLesson } from "./lib/curriculum";
import type { Lesson, ModeId } from "./lib/curriculum";
import { lineFor, lineText, letterStream, mulberry32 } from "./lib/generator";
import type { RunResult } from "./lib/grading";
import { playFanfare, playPop } from "./lib/pop";
import { besteTier, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress";
import type { Progress } from "./lib/progress";
import { say, stop as stopSpeech } from "./lib/speech";
import { blasenAnzahlFuer } from "./lib/theme";
type Screen = "aquarium" | "karte" | "lauf";
/** What a mode is handed to draw. Tagged rather than optional-fielded so the render
* below narrows on `art` instead of guessing from which keys are present.
*
* Only two shapes, for six modes: the arcade modes want a stream of single letters, and
* everything else wants a line of chunks. That is the whole reason the modes are cheap
* to add - they are presentations of one of two targets, all driven by the same engine. */
type Lauf =
| { art: "buchstaben"; letters: readonly string[] }
| { art: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
/** Modes that drill one key at a time rather than a line. */
const BUCHSTABEN_MODI: readonly ModeId[] = ["blasen", "quallen"];
interface Ergebnis {
result: RunResult;
unlockedTitel: string | null;
neuesTier: KreaturId | null;
bestseit: boolean;
}
export function App() {
const [progress, setProgress] = useState<Progress>(loadProgress);
const [screen, setScreen] = useState<Screen>("aquarium");
const [lessonId, setLessonId] = useState<string | null>(null);
const [modus, setModus] = useState<ModeId>("tauchgang");
const [ergebnis, setErgebnis] = useState<Ergebnis | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [selected, setSelected] = useState(0);
/** Bumped to generate a fresh line - a new seed for the same lesson. */
const [runde, setRunde] = useState(0);
useEffect(() => saveProgress(progress), [progress]);
const lesson = lessonId === null ? null : lessonById(lessonId);
/** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
const weiter = useMemo(() => {
const offen = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked);
return offen.find((l) => (progress.lessons[l.id]?.bestSterne ?? 0) < 2) ?? offen.at(-1) ?? null;
}, [progress]);
/** The line for this run. Reproducible from the lesson, the mode and the round
* counter, so a re-render never reshuffles the text mid-run. */
const lauf = useMemo((): Lauf | null => {
if (!lesson) return null;
const seed = lesson.nummer * 1000 + runde * 7 + (modus === "blasen" ? 3 : 0);
const rng = mulberry32(seed);
const focusKey = focusKeyFor(progress, lesson.activeKeys);
const spaceActive = lesson.activeKeys.includes(" ");
if (BUCHSTABEN_MODI.includes(modus)) {
return {
art: "buchstaben",
letters: letterStream(lesson.activeKeys, rng, blasenAnzahlFuer(lesson.welt), focusKey, lesson.neueKeys),
};
}
const chunks = lineFor(lesson, rng, {
chunks: lesson.chunks,
chunkSize: lesson.chunkSize,
focusKey,
});
return { art: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
// `progress` is deliberately not a dependency: the focus key is read once when the
// line is built, and re-reading it after every keystroke would rebuild the line
// underneath her fingers.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lesson, modus, runde]);
const starte = useCallback(
(ziel: Lesson) => {
setLessonId(ziel.id);
setModus(ziel.modi.includes(modus) ? modus : "tauchgang");
setErgebnis(null);
setRunde((r) => r + 1);
setScreen("lauf");
say(ziel.titel, progress.settings.speech);
},
[modus, progress.settings.speech],
);
const onFinished = useCallback(
(result: RunResult) => {
if (!lessonId) return;
const outcome = recordRun(progress, lessonId, result);
setProgress(outcome.progress);
setErgebnis({
result,
unlockedTitel: outcome.unlockedLessonId
? (lessonById(outcome.unlockedLessonId)?.titel ?? null)
: null,
neuesTier: outcome.neuesTier,
bestseit: outcome.bestseit,
});
if (progress.settings.sound && (outcome.unlockedLessonId || outcome.neuesTier)) {
playFanfare();
}
},
[lessonId, progress],
);
const nochmal = useCallback(() => {
setErgebnis(null);
setRunde((r) => r + 1);
}, []);
const weiterNachErgebnis = useCallback(() => {
const folgend = lessonId ? nextLesson(lessonId) : null;
setErgebnis(null);
if (folgend && progress.lessons[folgend.id]?.unlocked) starte(folgend);
else setScreen("karte");
}, [lessonId, progress, starte]);
const zurueck = useCallback(() => {
stopSpeech();
if (ergebnis) return setErgebnis(null);
if (screen === "lauf") return setScreen("karte");
if (screen === "karte") return setScreen("aquarium");
}, [ergebnis, screen]);
// --- navigation keys -----------------------------------------------------
const latest = useRef({ screen, ergebnis, selected, zurueck, nochmal, weiter, starte });
latest.current = { screen, ergebnis, selected, zurueck, nochmal, weiter, starte };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const current = latest.current;
if (event.key === "F1") {
event.preventDefault();
setShowHelp((open) => !open);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setShowHelp(false);
current.zurueck();
return;
}
// Enter repeats a finished run; the result sheet's own button has focus, so this
// is only a fallback for when focus has been lost.
if (current.ergebnis) {
if (event.key === "Enter") {
event.preventDefault();
current.nochmal();
}
return;
}
// Everything below is navigation, and must not fire while typing.
if (current.screen === "lauf") return;
if (event.key === "Enter") {
event.preventDefault();
if (current.screen === "aquarium") {
if (current.weiter) current.starte(current.weiter);
} else {
const lesson = LESSONS[current.selected];
if (lesson) current.starte(lesson);
}
return;
}
if (current.screen !== "karte") return;
const schritt =
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (schritt !== 0) {
event.preventDefault();
playPop(340);
setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + schritt)));
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Settings keys live outside a run, where they cannot collide with the alphabet.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (screen === "lauf" && !ergebnis) return;
const key = event.key.toLowerCase();
if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } }));
if (key === "s") setProgress((p) => ({ ...p, settings: { ...p.settings, speech: !p.settings.speech } }));
if (key === "h") {
setProgress((p) => ({
...p,
settings: {
...p.settings,
keyboardHint: p.settings.keyboardHint === "off" ? "auto" : "off",
},
}));
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [screen, ergebnis]);
// --- render --------------------------------------------------------------
const folgend = lessonId ? nextLesson(lessonId) : null;
// Every mode takes the same bundle; only the drawing differs. Built here so adding a
// seventh mode is one line in the switch below rather than eight repeated props.
const gemeinsam = {
activeKeys: lesson?.activeKeys ?? [],
progress,
paused: ergebnis !== null,
onFinished,
};
const buchstabenProps = (letters: readonly string[]) => ({ letters, ...gemeinsam });
const textProps = (l: Extract<Lauf, { art: "text" }>) => ({
chunks: l.chunks,
text: l.text,
spaceActive: l.spaceActive,
...gemeinsam,
});
return (
<Stage tiere={progress.aquarium} gedaempft={screen === "lauf"}>
<AppHeader
title={screen === "lauf" && lesson ? lesson.titel : "Delfin Tippen"}
compact={screen === "lauf"}
status={
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
<span>🦪 {progress.perlen}</span>
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
</div>
}
/>
{screen === "aquarium" && (
<Aquarium
progress={progress}
weiter={weiter}
onWeiter={() => weiter && starte(weiter)}
onKarte={() => setScreen("karte")}
/>
)}
{screen === "karte" && (
<LessonMap progress={progress} selected={selected} onPick={starte} />
)}
{screen === "lauf" && lesson && lauf && (
<>
{lauf.art === "buchstaben" ? (
modus === "quallen" ? (
<QuallenRun {...buchstabenProps(lauf.letters)} />
) : (
<BlasenRun {...buchstabenProps(lauf.letters)} />
)
) : modus === "fuettern" ? (
<FuetternRun {...textProps(lauf)} />
) : modus === "perlen" ? (
<PerlenRun {...textProps(lauf)} />
) : modus === "rennen" ? (
<RennenRun {...textProps(lauf)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
) : (
<TauchgangRun {...textProps(lauf)} />
)}
<div style={{ padding: "0 32px 18px", flex: "none" }}>
<ModePicker
modi={lesson.modi}
aktiv={modus}
onPick={(gewaehlt) => {
setModus(gewaehlt);
setRunde((r) => r + 1);
}}
/>
</div>
</>
)}
{ergebnis && (
<ResultSheet
result={ergebnis.result}
unlockedTitel={ergebnis.unlockedTitel}
neuesTier={ergebnis.neuesTier}
bestseit={ergebnis.bestseit}
bestEver={besteTier(progress)}
onNochmal={nochmal}
onWeiter={weiterNachErgebnis}
weiterLabel={
folgend && progress.lessons[folgend.id]?.unlocked ? `${folgend.titel}` : "Zur Karte"
}
/>
)}
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</Stage>
);
}

View File

@@ -0,0 +1,54 @@
/** The header, matching ../../web/src/components/AppHeader.tsx so the two apps open the
* same way. The mascot bobs; `SHOW_BUBBLES` doubles as the decorative-motion switch. */
import type { ReactNode } from "react";
interface Props {
title?: string;
/** Shown on the right - Perlen, streak, a back hint. */
status?: ReactNode;
/** Smaller header while a lesson is running, so the target line gets the room. */
compact?: boolean;
onMascotClick?: () => void;
}
export function AppHeader({ title = "Delfin Tippen", status, compact = false, onMascotClick }: Props) {
const size = compact ? 44 : 64;
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: compact ? "12px 28px 4px" : "calc(18px + env(safe-area-inset-top)) 32px 6px",
flex: "none",
position: "relative",
}}
>
<img
src="/dolphin-mascot.png"
alt=""
onClick={onMascotClick}
style={{
width: size,
height: size,
objectFit: "contain",
animation: "dolphinBob 4s ease-in-out infinite",
cursor: onMascotClick ? "pointer" : "default",
}}
/>
<div
style={{
fontSize: compact ? 24 : 34,
fontWeight: 900,
color: "var(--paper)",
textShadow: "0 3px 14px oklch(15% 0.05 175 / .5)",
}}
>
{title}
</div>
{status && <div style={{ position: "absolute", right: 32 }}>{status}</div>}
</div>
);
}

View File

@@ -0,0 +1,136 @@
/** 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
* 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
* you are told about afterwards.
*
* 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
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
import { kreaturById } from "../lib/aquarium";
import type { Lesson } from "../lib/curriculum";
import { WELTEN } from "../lib/curriculum";
import { tierById } from "../lib/grading";
import { besteTier } from "../lib/progress";
import type { Progress } from "../lib/progress";
interface Props {
progress: Progress;
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
weiter: Lesson | null;
onWeiter: () => void;
onKarte: () => void;
}
export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
const bestesTier = besteTier(progress);
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: "0 32px 32px",
minHeight: 0,
}}
>
<div
className="glass-panel"
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
>
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
Dein Aquarium
</div>
<div
aria-label="Deine Tiere"
style={{
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
gap: 18,
margin: "16px 0 6px",
alignItems: "center",
}}
>
{WELTEN.map((welt) => {
const kreatur = kreaturById(welt.belohnung);
const daheim = progress.aquarium.includes(kreatur.id);
return (
<img
key={kreatur.id}
src={kreatur.bild}
alt={daheim ? kreatur.name : `Noch nicht da - Welt ${welt.nummer}`}
title={daheim ? kreatur.name : `Welt ${welt.nummer}`}
style={{
width: 62,
height: 62,
objectFit: "contain",
// Not yet earned: a pale outline of the shape, no colours given away.
filter: daheim ? "none" : "brightness(0) invert(1)",
opacity: daheim ? 1 : 0.2,
animation: daheim ? `dolphinBob ${3 + welt.nummer * 0.4}s ease-in-out infinite` : undefined,
}}
/>
);
})}
</div>
{progress.aquarium.length === 0 && (
<div style={{ fontSize: 15, fontWeight: 800, color: "var(--paper)", opacity: 0.65 }}>
Schaffe eine ganze Welt und dein erstes Tier zieht ein!
</div>
)}
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
<Stat label="Perlen" value={`🦪 ${progress.perlen}`} />
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
<Stat
label="Schnellstes Tier"
value={bestesTier ? `${tierById(bestesTier).emoji} ${tierById(bestesTier).name}` : "—"}
/>
</div>
</div>
<div style={{ display: "flex", gap: 12 }}>
{weiter && (
<button
onClick={onWeiter}
style={{
border: "none",
borderRadius: 999,
padding: "15px 32px",
fontSize: 19,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
}}
>
{weiter.titel}
</button>
)}
<button className="pill" onClick={onKarte} style={{ fontSize: 16, padding: "15px 26px" }}>
🗺 Alle Lektionen
</button>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>{value}</div>
<div style={{ fontSize: 11, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>{label}</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
/** The pets, swimming freely behind every screen.
*
* One requestAnimationFrame loop, started once and never restarted - the same approach as
* ../../web/src/components/Ambience.tsx. Swimmers live in a ref and each frame is written
* straight to its image's `transform`, so sixty frames a second never touch React. React
* renders this component only when a pet moves in or the layer is dimmed.
*
* The layer sits at z-index -1 inside the stage's own stacking context (`isolation` on
* .stage): above the gradient, below every screen. That is also what makes the glass
* panels frost a pet that drifts behind one, instead of it swimming over the buttons.
*
* Pets already home when the app opens start somewhere inside the tank; a pet earned
* while the app is open swims in from the side, so its arrival is something to see. */
import { useEffect, useRef } from "react";
import { kreaturById, neuerSchwimmer, pose, schwimme } from "../lib/aquarium";
import type { KreaturId, Schwimmer } from "../lib/aquarium";
interface Props {
tiere: readonly KreaturId[];
/** 0..1, eased - the pets fade back while she types. */
deckkraft: number;
}
/** 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. */
const MAX_SCHRITT_S = 0.1;
export function AquariumTiere({ tiere, deckkraft }: Props) {
const becken = useRef<HTMLDivElement>(null);
const bilder = useRef(new Map<KreaturId, HTMLImageElement>());
const schwimmer = useRef(new Map<KreaturId, Schwimmer>());
const aktuell = useRef(tiere);
aktuell.current = tiere;
/** The pets present at first render - everyone after them is a newcomer. */
const daheim = useRef<ReadonlySet<KreaturId>>(new Set(tiere));
useEffect(() => {
// Reduced motion: every pet is still placed and shown, it just holds still.
const ruhig = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let frame = 0;
let zuletzt = performance.now();
const schritt = (jetzt: number) => {
const dt = Math.min(MAX_SCHRITT_S, Math.max(0, (jetzt - zuletzt) / 1000));
zuletzt = jetzt;
const el = becken.current;
if (el) {
const groesse = { breite: el.clientWidth, hoehe: el.clientHeight };
for (const id of aktuell.current) {
const bild = bilder.current.get(id);
if (!bild) continue;
const kreatur = kreaturById(id);
const hoehe = kreatur.groesse * groesse.hoehe;
const rand = hoehe / 2;
const tempo = kreatur.tempo * groesse.breite;
const vorher = schwimmer.current.get(id);
const s = vorher
? ruhig
? vorher
: schwimme(vorher, dt, groesse, rand, tempo, Math.random)
: neuerSchwimmer(groesse, rand, Math.random, !ruhig && !daheim.current.has(id));
schwimmer.current.set(id, s);
const p = pose(s, kreatur, tempo);
bild.style.height = `${hoehe}px`;
bild.style.transform =
`translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
`rotate(${p.drehung}deg) scaleX(${p.spiegel})`;
}
}
frame = requestAnimationFrame(schritt);
};
frame = requestAnimationFrame(schritt);
return () => cancelAnimationFrame(frame);
}, []);
return (
<div
ref={becken}
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
pointerEvents: "none",
zIndex: -1,
opacity: deckkraft,
transition: "opacity 700ms ease",
}}
>
{tiere.map((id) => (
<img
key={id}
ref={(bild) => {
if (bild) bilder.current.set(id, bild);
else bilder.current.delete(id);
}}
src={kreaturById(id).bild}
alt=""
draggable={false}
style={{
position: "absolute",
left: 0,
top: 0,
height: 0,
// Off-stage until the loop has placed it, so nothing flashes in the corner.
transform: "translate(-200vw, 0)",
willChange: "transform",
filter: "drop-shadow(0 10px 16px oklch(15% 0.05 175 / 0.35))",
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,41 @@
/** The decorative bubble field, copied from ../../web/src/components/Bubbles.tsx.
* Positions are a fixed table rather than random so they do not reshuffle on render. */
interface Props {
count?: number;
}
/** [left %, size px, opacity, seconds, delay seconds] */
const BUBBLES: readonly [number, number, number, number, number][] = [
[6, 14, 0.18, 13, 0], [17, 9, 0.14, 16, 2.5], [28, 20, 0.12, 11, 5],
[39, 7, 0.2, 18, 1.2], [51, 16, 0.13, 14, 7], [62, 11, 0.17, 12, 3.4],
[73, 22, 0.1, 17, 6.1], [84, 8, 0.19, 15, 0.8], [93, 13, 0.14, 13, 4.2],
[11, 18, 0.11, 19, 8.5], [45, 10, 0.16, 15, 9.3], [68, 6, 0.21, 20, 2],
];
export function Bubbles({ count = BUBBLES.length }: Props) {
// The wrapper is load-bearing, not tidiness. Each bubble starts at `bottom: -40px`,
// and an absolutely positioned child still counts toward its container's scrollHeight
// - so without this, twelve decorative bubbles made `.stage` 40px taller than the
// viewport. `.stage` hides that overflow, but a hidden overflow is still scrollable:
// focusing the result sheet's button scrolled the whole app up and pushed the header
// off the top of the screen. Clipping them here keeps the stage exactly one screen.
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
{BUBBLES.slice(0, count).map(([left, size, alpha, seconds, delay], i) => (
<div
key={i}
className="bubble"
style={{
left: `${left}%`,
width: size,
height: size,
background: `oklch(97% 0.01 175 / ${alpha})`,
animationDuration: `${seconds}s`,
animationDelay: `${delay}s`,
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,54 @@
/** 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

@@ -0,0 +1,36 @@
/** The key sheet, on F1. Mirrors what App.tsx actually binds - if a binding changes
* there, it changes here. */
interface Props {
onClose: () => void;
}
const TASTEN: readonly [string, string][] = [
["⏎", "Lektion starten · Runde wiederholen"],
["Esc", "Eine Ebene zurück"],
["← →", "Lektion auswählen"],
["H", "Tastatur-Hilfe ein- und ausblenden"],
["M", "Ton an und aus"],
["S", "Sprache an und aus"],
["F1", "Diese Übersicht"],
];
export function HelpOverlay({ onClose }: Props) {
return (
<div className="overlay backdrop-enter" onClick={onClose}>
<div className="sheet sheet-enter" style={{ padding: "26px 32px", width: "min(460px, 100%)" }}>
<div style={{ fontSize: 24, fontWeight: 900, color: "var(--ink)", marginBottom: 16 }}>
Zaubertasten
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{TASTEN.map(([taste, was]) => (
<div key={taste} style={{ display: "flex", alignItems: "center", gap: 14 }}>
<span className="key-cap">{taste}</span>
<span style={{ fontWeight: 700, color: "var(--ink)" }}>{was}</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
/** The on-screen QWERTZ keyboard: the scaffold that teaches itself away.
*
* Every key is tinted with the hue of the finger that types it (lib/fingers.ts), the
* next key is highlighted and pulsing, and keys this lesson does not use yet fade back
* without disappearing - a map with holes in it is harder to read than a complete one.
*
* The important behaviour is the fade. In "auto" mode each key's opacity is driven by
* how well she knows it (`mastery`), so the hint quietly withdraws exactly where it is
* no longer needed and stays put where it is. That is the difference between a crutch
* and a scaffold: looking at the screen has to stop being worth it before looking at the
* hands does. `H` forces it back on. */
import { KEYBOARD_ROWS, fingerOf, keyForChar, keysInRow, needsShift, shiftHandFor } from "../lib/fingers";
import type { Progress } from "../lib/progress";
import { mastery } from "../lib/progress";
interface Props {
/** Keys this lesson uses - everything else is dimmed. */
activeKeys: readonly string[];
/** The key to highlight, or null when nothing is expected. */
nextKey: string | null;
progress: Progress;
mode: "auto" | "on" | "off";
/** Key size in px; the whole keyboard scales off it. */
size?: number;
}
/** Rows are offset the way a real keyboard staggers them. */
const ROW_INDENT: Record<string, number> = { oben: 0, grund: 14, unten: 30 };
export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Props) {
if (mode === "off") return null;
const active = new Set(activeKeys.map((key) => keyForChar(key)));
// "?" is Shift+ß - the key to light up is ß, not a key called "?".
const next = nextKey === null ? null : keyForChar(nextKey);
const shift = nextKey !== null && needsShift(nextKey);
const spaceActive = active.has(" ");
const shiftHand = shift && nextKey !== null ? shiftHandFor(nextKey) : null;
return (
<div className="kb" style={{ ["--kb-size" as string]: `${size}px` }} aria-hidden="true">
{KEYBOARD_ROWS.map((row) => (
<div className="kb-row" key={row} style={{ marginLeft: ROW_INDENT[row] ?? 0 }}>
{keysInRow(row).map((key) => {
const finger = fingerOf(key);
const istAktiv = active.has(key);
// 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
// very thing she needs to find.
const gelernt = mode === "auto" && istAktiv ? mastery(progress, key) : 0;
return (
<div
key={key}
className="kb-key"
data-active={istAktiv}
data-next={key === next}
data-finger={finger?.id}
data-home={key === "f" || key === "j"}
style={{
["--finger-hue" as string]: finger?.hue ?? 0,
// Never below 0.25: the keyboard stops helping, it does not vanish
// mid-lesson and leave her staring at a blank strip.
opacity: istAktiv ? Math.max(0.25, 1 - gelernt * 0.75) : undefined,
}}
>
{key.toUpperCase()}
</div>
);
})}
</div>
))}
<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
the letter, which is the rule Welt 4 exists to teach. */}
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "links"}>
</div>
<div
className="kb-key kb-space"
data-active={spaceActive}
data-next={next === " "}
data-finger="daumen"
style={{ ["--finger-hue" as string]: 220 }}
/>
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "rechts"}>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
/** The Karte: four Welten, each a row of lesson cards.
*
* Locked lessons are dimmed rather than hidden - seeing that there is a Welt called
* "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
* cabinet. */
import { LESSONS, WELTEN } from "../lib/curriculum";
import type { Lesson } from "../lib/curriculum";
import { tierById } from "../lib/grading";
import type { Progress } from "../lib/progress";
interface Props {
progress: Progress;
/** Which card the keyboard selection is on. */
selected: number;
onPick: (lesson: Lesson) => void;
}
export function LessonMap({ progress, selected, onPick }: Props) {
return (
<div
className="view-enter"
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 32px", minHeight: 0 }}
>
<div style={{ maxWidth: 1180, margin: "0 auto", display: "flex", flexDirection: "column", gap: 22 }}>
{WELTEN.map((welt) => (
<div key={welt.nummer} className="glass-panel" style={{ padding: "16px 20px 20px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 22 }}>{welt.emoji}</span>
<span style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>
Welt {welt.nummer} {welt.titel}
</span>
<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.welt === welt.nummer).length}
</span>
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{LESSONS.filter((lesson) => lesson.welt === welt.nummer).map((lesson) => {
const fortschritt = progress.lessons[lesson.id];
const locked = !fortschritt?.unlocked;
const tier = fortschritt?.bestTier ? tierById(fortschritt.bestTier) : null;
const index = LESSONS.indexOf(lesson);
return (
<button
key={lesson.id}
className="card glass-panel"
data-selected={index === selected}
data-locked={locked}
disabled={locked}
onClick={() => onPick(lesson)}
style={{ width: 150, padding: 12, flex: "none" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 12, fontWeight: 900, color: "var(--paper)", opacity: 0.7 }}>
{lesson.nummer}
</span>
<span style={{ fontSize: 26 }}>{locked ? "🔒" : (tier?.emoji ?? "·")}</span>
</div>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--paper)", marginTop: 6 }}>
{lesson.titel}
</div>
{/* 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
so with a symbol instead of showing an empty line. */}
<div
style={{
fontSize: 15,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.75,
letterSpacing: lesson.istUebung ? "normal" : "0.14em",
marginTop: 3,
minHeight: 20,
}}
>
{lesson.istUebung
? "🔁 Übung"
: lesson.neueKeys
.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase()))
.join(" ")}
</div>
<div style={{ marginTop: 8, fontSize: 13, letterSpacing: "0.08em" }}>
{[1, 2, 3].map((stern) => (
<span key={stern} style={{ opacity: (fortschritt?.bestSterne ?? 0) >= stern ? 1 : 0.22 }}>
</span>
))}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,248 @@
/** What a run earned.
*
* 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
* focus already on it. Nothing here ever says "failed", nothing is red, and the Perlen
* always go up - see `perlenFor` in lib/grading.ts. */
import { useEffect, useRef } from "react";
import { kreaturById } from "../lib/aquarium";
import type { KreaturId } from "../lib/aquarium";
import type { RunResult } from "../lib/grading";
import { STERNE_SCHWELLEN, sichtbareTiers, tierById, tierIndex, tierProgress } from "../lib/grading";
import type { TierId } from "../lib/grading";
interface Props {
result: RunResult;
/** Set when this run opened the next lesson. */
unlockedTitel: string | null;
/** Set when this run released a creature into the aquarium. */
neuesTier: KreaturId | null;
bestseit: boolean;
/** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */
bestEver: TierId | null;
onNochmal: () => void;
onWeiter: () => void;
/** Null at the end of the curriculum. */
weiterLabel: string | null;
}
export function ResultSheet({
result,
unlockedTitel,
neuesTier,
bestseit,
bestEver,
onNochmal,
onWeiter,
weiterLabel,
}: Props) {
const tier = tierById(result.tier);
const leiter = sichtbareTiers(result.tier, bestEver);
const erreicht = tierIndex(result.tier);
const nochmal = useRef<HTMLButtonElement>(null);
// Enter repeats the run. Fewest keystrokes between "that was fun" and "again".
useEffect(() => nochmal.current?.focus(), []);
return (
<div className="overlay backdrop-enter">
<div
className="sheet sheet-enter"
style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }}
>
<div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}>
{tier.emoji}
</div>
<div style={{ fontSize: 30, fontWeight: 900, color: "var(--ink)", marginTop: 6 }}>
{tier.name}
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 8, margin: "14px 0 4px" }}>
{[1, 2, 3].map((stern) => (
<span
key={stern}
style={{
fontSize: 40,
animation: `sterneEnter 320ms ease-out ${140 + stern * 130}ms both`,
filter: result.sterne >= stern ? "none" : "grayscale(1)",
opacity: result.sterne >= stern ? 1 : 0.25,
}}
>
</span>
))}
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
<Stat label="Richtig" value={`${Math.round(result.genauigkeit * 100)}%`} />
<Stat label="Zeichen/Min" value={String(Math.round(result.tempo))} />
<Stat label="Perlen" value={`+${result.perlen}`} />
</div>
{/* How close the next animal is. A near miss is the strongest reason to press
Nochmal, so it is worth showing explicitly. */}
<div
style={{
height: 9,
borderRadius: 999,
background: "oklch(88% 0.02 175)",
margin: "18px 0 6px",
overflow: "hidden",
}}
>
<div
style={{
width: `${tierProgress(result.punkte) * 100}%`,
height: "100%",
background: "var(--accent)",
transition: "width 700ms ease-out",
}}
/>
</div>
<TierLeiter tiers={leiter.tiers} erreicht={erreicht} mehrVerborgen={leiter.mehrVerborgen} />
{bestseit && (
<div style={{ color: "var(--accent)", fontWeight: 900, marginTop: 8 }}>
🏅 Neuer Bestwert!
</div>
)}
{unlockedTitel && (
<div style={{ color: "var(--ink)", fontWeight: 900, marginTop: 8 }}>
🔓 Neu: {unlockedTitel}
</div>
)}
{neuesTier && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--ink)",
fontWeight: 900,
marginTop: 8,
}}
>
<img
src={kreaturById(neuesTier).bild}
alt=""
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
/>
{kreaturById(neuesTier).artikel} {kreaturById(neuesTier).name} ist ins Aquarium gezogen!
</div>
)}
{!result.bestanden && !unlockedTitel && (
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
Mit {Math.round(STERNE_SCHWELLEN.zwei * 100)}% Treffern geht es weiter. Fast!
</div>
)}
<div style={{ display: "flex", gap: 12, marginTop: 22, justifyContent: "center" }}>
<button
ref={nochmal}
onClick={onNochmal}
style={{
...knopf,
background: "var(--accent)",
color: "var(--paper)",
}}
>
Nochmal
</button>
{weiterLabel && (
<button onClick={onWeiter} style={{ ...knopf, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
{weiterLabel}
</button>
)}
</div>
</div>
</div>
);
}
/** The animal ladder.
*
* Everything she has already passed stays in full colour - the run is a climb, and the
* 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.
*
* Above the Delfin 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
* keeps the top end a surprise rather than a distant, discouraging number. */
function TierLeiter({
tiers,
erreicht,
mehrVerborgen,
}: {
tiers: readonly { id: string; name: string; emoji: string }[];
erreicht: number;
mehrVerborgen: boolean;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-end",
gap: 4,
flexWrap: "wrap",
marginTop: 14,
minHeight: 44,
}}
aria-label="Tier-Leiter"
>
{tiers.map((eintrag, i) => {
const geschafft = i <= erreicht;
const aktuell = i === erreicht;
return (
<div
key={eintrag.id}
title={eintrag.name}
style={{
fontSize: aktuell ? 40 : 26,
lineHeight: 1,
padding: aktuell ? "0 5px" : 0,
filter: geschafft ? "none" : "grayscale(1)",
// Bright enough to read as "this is next", dim enough to read as "not yet".
opacity: geschafft ? 1 : 0.42,
transform: aktuell ? "translateY(-3px)" : undefined,
transition: "all 200ms ease",
}}
>
{eintrag.emoji}
</div>
);
})}
{mehrVerborgen && (
<div
title="Da geht noch was!"
style={{ fontSize: 24, opacity: 0.4, marginLeft: 4, filter: "grayscale(1)" }}
>
</div>
)}
</div>
);
}
const knopf: React.CSSProperties = {
border: "none",
borderRadius: 999,
padding: "13px 26px",
fontSize: 17,
fontWeight: 900,
cursor: "pointer",
};
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: 25, fontWeight: 900, color: "var(--ink)" }}>{value}</div>
<div style={{ fontSize: 12, fontWeight: 800, color: "var(--ink)", opacity: 0.6 }}>{label}</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
/** The gradient stage every screen sits on - the app's one full-height container.
* `data-blur` is read by app.css to drop the backdrop filters wholesale.
*
* The pets swim here rather than on the home screen, so they stay with her on the lesson
* map and during a run too. */
import type { ReactNode } from "react";
import type { KreaturId } from "../lib/aquarium";
import { SHOW_AQUARIUM_TIERE, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../lib/theme";
import { AquariumTiere } from "./AquariumTiere";
import { Bubbles } from "./Bubbles";
interface Props {
children: ReactNode;
/** The pets that have moved in so far. */
tiere: readonly KreaturId[];
/** While she is typing the pets fade back: still there, not competing with the line. */
gedaempft: boolean;
}
export function Stage({ children, tiere, gedaempft }: Props) {
return (
<div className="stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
{SHOW_AQUARIUM_TIERE && <AquariumTiere tiere={tiere} deckkraft={gedaempft ? 0.25 : 1} />}
{SHOW_BUBBLES && <Bubbles />}
{children}
</div>
);
}

View File

@@ -0,0 +1,94 @@
/** The line being typed, drawn chunk by chunk.
*
* 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
* not. In Welt 1 and 2 the gaps are purely visual - the Leertaste has not been taught,
* so `spaceActive` is false and the chunks join without one. */
import { chunkOffsets } from "../lib/generator";
interface Props {
chunks: readonly string[];
spaceActive: boolean;
/** Cursor position in the joined text. */
index: number;
/** True while a wrong key is still being corrected at the cursor. */
daneben: boolean;
/** Overrides the size picked from the text's own length. */
fontSize?: number;
}
/** Type size and column width for a block of `laenge` characters.
*
* 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
* 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 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
* (Welt 5's sentences) get smaller type and a wider column so ten sentences still fit. */
function satzspiegel(laenge: number): { groesse: number; breite: number } {
if (laenge <= 40) return { groesse: 44, breite: 560 };
if (laenge <= 80) return { groesse: 38, breite: 600 };
if (laenge <= 130) return { groesse: 32, breite: 760 };
if (laenge <= 220) return { groesse: 26, breite: 880 };
return { groesse: 22, breite: 960 };
}
export function Target({ chunks, spaceActive, index, daneben, fontSize }: Props) {
const offsets = chunkOffsets(chunks, spaceActive);
const laenge = chunks.join("").length + (spaceActive ? chunks.length - 1 : 0);
const spiegel = satzspiegel(laenge);
const groesse = fontSize ?? spiegel.groesse;
return (
<div className="ziel" style={{ fontSize: groesse, maxWidth: `min(${spiegel.breite}px, 90vw)` }}>
{chunks.map((chunk, chunkIndex) => {
const start = offsets[chunkIndex] ?? 0;
return (
<div className="ziel-chunk" key={chunkIndex}>
{[...chunk].map((char, i) => {
const at = start + i;
const state = at < index ? "fertig" : at === index ? "aktuell" : "offen";
// Welt 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
// the cursor lands on nothing and looks lost.
const leer = char === " ";
return (
<span
key={i}
className="ziel-zeichen"
data-state={state}
data-leer={leer}
data-daneben={state === "aktuell" && daneben}
style={{ padding: state === "aktuell" ? "0 6px" : undefined }}
>
{leer ? (state === "aktuell" ? "␣" : "") : char}
</span>
);
})}
{/* The space between chunks is a real character once it is taught, so it
needs to be typeable and to show the cursor. Before that it is a gap. */}
{spaceActive && chunkIndex < chunks.length - 1 && (
<span
className="ziel-zeichen"
data-leer="true"
data-state={
start + chunk.length < index
? "fertig"
: start + chunk.length === index
? "aktuell"
: "offen"
}
data-daneben={start + chunk.length === index && daneben}
>
{start + chunk.length === index ? "␣" : ""}
</span>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,147 @@
/** Blasenplatzen - the first arcade mode, and the proof that the engine is
* 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
* surface; popping it lets the ones below rise. Position is derived from distance to the
* cursor rather than from a clock, which has two consequences worth stating: the rise is
* a CSS transition instead of a requestAnimationFrame loop, and - more importantly -
* taking your time costs nothing. The plan calls this a soft clock. Speed still shows up
* in the animal, because `grade` is measuring the keystrokes either way, but no bubble
* ever escapes and nothing is ever lost. At six, "you were too slow" is the fastest way
* to end a session. */
import { useEffect, useState } from "react";
import { currentChar } from "../../lib/engine";
import type { RunEvent } from "../../lib/engine";
import { fingerOf } from "../../lib/fingers";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { say, spellKey } from "../../lib/speech";
import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard";
interface Props {
letters: readonly string[];
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
/** 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. */
const SICHTBAR = 5;
/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
const LANES = [50, 28, 68, 38, 60, 46];
export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
const text = letters.join("");
const [geplatzt, setGeplatzt] = useState<number | null>(null);
const onEvent = (event: RunEvent) => {
// Remember which bubble just popped so it can play its burst before disappearing.
if (event.type === "correct") setGeplatzt(event.index);
};
const { state, daneben } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
onEvent,
});
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 (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 18,
padding: "0 32px 8px",
minHeight: 0,
}}
>
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
{/* The surface line the bubbles rise toward. */}
<div
style={{
position: "absolute",
top: 8,
left: 0,
right: 0,
height: 2,
background: "oklch(97% 0.01 175 / 0.25)",
}}
/>
{[...text].map((letter, i) => {
const abstand = i - state.index;
if (abstand < 0 || abstand >= SICHTBAR) return null;
const finger = fingerOf(letter);
const aktuell = abstand === 0;
const groesse = aktuell ? 104 : 68;
return (
<div
key={i}
style={{
position: "absolute",
left: `${LANES[i % LANES.length]}%`,
// Distance from the cursor is distance from the surface. The transition
// is what makes popping one visibly lift the rest.
top: `${6 + abstand * 19}%`,
transform: "translate(-50%, 0)",
width: groesse,
height: groesse,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: groesse * 0.42,
fontWeight: 900,
color: aktuell ? "oklch(25% 0.05 175)" : "var(--paper)",
background: aktuell
? "var(--paper)"
: `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})`,
boxShadow: aktuell ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
opacity: 1 - abstand * 0.13,
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
animation:
geplatzt === i
? "correctPop 200ms ease-out"
: aktuell && daneben
? "wrongShake 260ms ease"
: undefined,
}}
>
{letter === " " ? "␣" : letter}
</div>
);
})}
</div>
<HandHint nextKey={next} />
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={38}
/>
</div>
);
}

View File

@@ -0,0 +1,185 @@
/** Fütterungszeit - words instead of letters.
*
* Fish swim past carrying short German words; typing one feeds it to the Delfin. This
* 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
* word lists in curriculum.ts are real German words a six-year-old knows rather than
* pronounceable nonsense.
*
* The clock is soft, like Blasenplatzen: the fish queue up and wait. Nothing swims off
* unfed. */
import { useEffect, useState } from "react";
import { currentChar } from "../../lib/engine";
import type { RunEvent } from "../../lib/engine";
import { chunkOffsets } from "../../lib/generator";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { say } from "../../lib/speech";
import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard";
interface Props {
chunks: readonly string[];
text: string;
spaceActive: boolean;
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
const FISCHE = ["🐟", "🐠", "🐡", "🦐", "🦀"];
export function FuetternRun({
chunks,
text,
spaceActive,
activeKeys,
progress,
paused,
onFinished,
}: Props) {
const [gefuettert, setGefuettert] = useState(0);
const { state, daneben } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
onEvent: (event: RunEvent) => {
if (event.type === "correct") setGefuettert((n) => n);
},
});
const next = currentChar(state);
const offsets = chunkOffsets(chunks, spaceActive);
/** Which word is being typed. A word counts as current until its last letter is done. */
const aktuell = Math.max(
0,
offsets.findIndex((start, i) => state.index <= start + (chunks[i]?.length ?? 0)),
);
useEffect(() => {
setGefuettert(aktuell);
}, [aktuell]);
useEffect(() => {
const wort = chunks[aktuell];
if (wort) say(wort, progress.settings.speech);
}, [aktuell, chunks, progress.settings.speech]);
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 14,
padding: "0 32px 8px",
minHeight: 0,
}}
>
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0, overflow: "hidden" }}>
{/* The Delfin waits on the left; the fish come to it. */}
<div
style={{
position: "absolute",
left: 8,
top: "42%",
fontSize: 66,
animation: "dolphinBob 3.2s ease-in-out infinite",
zIndex: 2,
}}
>
🐬
</div>
{chunks.map((wort, i) => {
const abstand = i - aktuell;
// 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.
if (abstand < 0 || abstand > 2) return null;
const start = offsets[i] ?? 0;
const istZiel = abstand === 0;
return (
<div
key={i}
style={{
position: "absolute",
// Distance from the Delfin is distance in the queue. Feeding one lets
// the rest swim in, via the transition rather than a rAF loop.
left: `${15 + abstand * 26}%`,
top: `${30 + (i % 3) * 16}%`,
display: "flex",
alignItems: "center",
gap: 10,
padding: istZiel ? "10px 18px" : "7px 13px",
borderRadius: 999,
background: istZiel ? "var(--paper)" : "oklch(97% 0.01 175 / .13)",
border: `2px solid oklch(97% 0.01 175 / ${istZiel ? 0.85 : 0.2})`,
boxShadow: istZiel ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)",
maxWidth: "34%",
opacity: 1 - abstand * 0.22,
transition: "left 420ms cubic-bezier(.2,.7,.3,1), opacity 300ms ease, padding 200ms ease",
animation: istZiel && daneben ? "wrongShake 260ms ease" : undefined,
zIndex: istZiel ? 3 : 1,
}}
>
<span style={{ fontSize: istZiel ? 34 : 24 }}>{FISCHE[i % FISCHE.length]}</span>
<span
style={{
display: "flex",
fontSize: istZiel ? 34 : 22,
fontWeight: 800,
color: istZiel ? "oklch(25% 0.05 175)" : "var(--paper)",
}}
>
{istZiel
? [...wort].map((char, j) => {
const at = start + j;
const zustand = at < state.index ? "fertig" : at === state.index ? "aktuell" : "offen";
return (
<span
key={j}
style={{
color:
zustand === "fertig"
? "oklch(58% 0.15 150)"
: zustand === "aktuell"
? "var(--accent)"
: "oklch(45% 0.03 175)",
textDecoration: zustand === "aktuell" ? "underline" : undefined,
}}
>
{char === " " ? " " : char}
</span>
);
})
: wort}
</span>
</div>
);
})}
</div>
<div style={{ color: "var(--paper)", fontWeight: 800, opacity: 0.8 }}>
{gefuettert} von {chunks.length} gefüttert
</div>
<HandHint nextKey={next} />
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={34}
/>
</div>
);
}

View File

@@ -0,0 +1,163 @@
/** Perlentaucher - the mode with no clock.
*
* 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
* correct letter is a pearl on the string. A mistake costs a pearl, and that is the
* only pressure there is.
*
* It exists because a six-year-old having a bad afternoon needs somewhere to go that is
* still the same lesson and still counts, but cannot be failed at quickly. Every lesson
* 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. */
import { useEffect, useState } from "react";
import { currentChar } from "../../lib/engine";
import type { RunEvent } from "../../lib/engine";
import { chunkOffsets } from "../../lib/generator";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { say } from "../../lib/speech";
import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard";
interface Props {
chunks: readonly string[];
text: string;
spaceActive: boolean;
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
export function PerlenRun({
chunks,
text,
spaceActive,
activeKeys,
progress,
paused,
onFinished,
}: Props) {
const [perlen, setPerlen] = useState(0);
const onEvent = (event: RunEvent) => {
// 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.
if (event.type === "correct") setPerlen((n) => n + 1);
else if (event.type === "wrong" && event.firstAt) setPerlen((n) => Math.max(0, n - 1));
};
const { state, daneben } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
onEvent,
});
const next = currentChar(state);
const offsets = chunkOffsets(chunks, spaceActive);
// Which word the oyster is holding right now.
const aktuellerChunk = offsets.findIndex((start, i) => {
const ende = start + (chunks[i]?.length ?? 0);
return state.index <= ende;
});
const wort = chunks[aktuellerChunk === -1 ? chunks.length - 1 : aktuellerChunk] ?? "";
const wortStart = offsets[aktuellerChunk === -1 ? chunks.length - 1 : aktuellerChunk] ?? 0;
useEffect(() => {
if (wort) say(wort, progress.settings.speech);
}, [wort, progress.settings.speech]);
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: "0 32px",
minHeight: 0,
}}
>
{/* The pearl string. It only ever grows, one bead per letter. */}
<div
style={{
display: "flex",
gap: 5,
flexWrap: "wrap",
justifyContent: "center",
maxWidth: "70%",
minHeight: 26,
alignItems: "center",
}}
aria-label={`${perlen} Perlen`}
>
{Array.from({ length: perlen }, (_, i) => (
<span
key={i}
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: "radial-gradient(circle at 32% 30%, oklch(99% 0.01 175), oklch(80% 0.04 300))",
boxShadow: "0 2px 7px var(--shadow)",
animation: i === perlen - 1 ? "correctPop 240ms ease-out" : undefined,
}}
/>
))}
</div>
{/* The oyster, holding one word at a time. */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
padding: "26px 42px",
borderRadius: 26,
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)",
animation: daneben ? "wrongShake 260ms ease" : undefined,
}}
>
<div style={{ fontSize: 34 }}>🦪</div>
<div style={{ display: "flex", fontSize: 46, fontWeight: 800 }}>
{[...wort].map((char, i) => {
const at = wortStart + i;
const state_ = at < state.index ? "fertig" : at === state.index ? "aktuell" : "offen";
return (
<span
key={i}
className="ziel-zeichen"
data-state={state_}
data-leer={char === " "}
data-daneben={state_ === "aktuell" && daneben}
style={{ padding: state_ === "aktuell" ? "0 6px" : undefined }}
>
{char === " " ? (state_ === "aktuell" ? "␣" : "") : char}
</span>
);
})}
</div>
</div>
<HandHint nextKey={next} />
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={38}
/>
</div>
);
}

View File

@@ -0,0 +1,151 @@
/** Quallenalarm - pure key location, nothing else.
*
* 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
* being exercised is "where does this letter live" - which is exactly the skill the
* Tauchgang hides behind reading.
*
* 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
* it, which is the searching step that eventually goes away. */
import { useEffect, useMemo } from "react";
import { currentChar } from "../../lib/engine";
import { fingerOf } from "../../lib/fingers";
import { mulberry32 } from "../../lib/generator";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { say, spellKey } from "../../lib/speech";
import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard";
interface Props {
letters: readonly string[];
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
/** How many jellyfish are in the water at once, target included. */
const QUALLEN = 6;
interface Qualle {
left: number;
top: number;
drift: number;
groesse: number;
}
export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
const text = letters.join("");
const { state, daneben } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
});
const next = currentChar(state);
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
// would make the searching step impossible rather than merely hard.
const plaetze = useMemo<Qualle[]>(() => {
const rng = mulberry32(text.length * 31 + 7);
return Array.from({ length: QUALLEN }, () => ({
left: 10 + rng() * 76,
top: 6 + rng() * 66,
drift: 3 + rng() * 3,
groesse: 74 + rng() * 26,
}));
}, [text]);
/** The decoys shown alongside the target: other active keys, never the target itself,
* and stable for as long as the target is. */
const koeder = useMemo(() => {
if (!next) return [];
const rng = mulberry32(state.index * 101 + 13);
const andere = activeKeys.filter((key) => key !== next && key !== " ");
const gemischt = [...andere].sort(() => rng() - 0.5);
return gemischt.slice(0, QUALLEN - 1);
}, [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.
const zielPlatz = next ? state.index % QUALLEN : -1;
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
padding: "0 32px 8px",
minHeight: 0,
}}
>
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
{plaetze.map((platz, i) => {
const istZiel = i === zielPlatz;
const buchstabe = istZiel ? next : koeder[i > zielPlatz ? i - 1 : i];
if (!buchstabe) return null;
const finger = fingerOf(buchstabe);
return (
<div
key={i}
className="qualle"
style={{
position: "absolute",
left: `${platz.left}%`,
top: `${platz.top}%`,
width: platz.groesse,
height: platz.groesse,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: platz.groesse * (istZiel ? 0.4 : 0.3),
fontWeight: 900,
borderRadius: "50% 50% 42% 42%",
color: istZiel ? "oklch(25% 0.05 175)" : "var(--paper)",
background: istZiel
? "var(--paper)"
: `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})`,
boxShadow: istZiel
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
: "0 4px 14px var(--shadow)",
opacity: istZiel ? 1 : 0.55,
transform: istZiel ? "scale(1.12)" : "scale(1)",
animation: `dolphinBob ${platz.drift}s ease-in-out infinite`,
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
}}
>
{buchstabe === " " ? "␣" : buchstabe}
{istZiel && daneben && (
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
)}
</div>
);
})}
</div>
<HandHint nextKey={next} />
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={36}
/>
</div>
);
}

View File

@@ -0,0 +1,181 @@
/** Delfinrennen - a race against her own best run.
*
* 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
* clock. Racing yourself is the one competitive format that cannot be demoralising - the
* opponent is by definition exactly as good as she was, so the race is always close, and
* winning means she actually improved.
*
* Before a ghost exists there is a Krabbe swimming at a gentle fixed pace. It is beatable
* on the first try on purpose.
*
* The ghost is moved by writing to the DOM node directly from a requestAnimationFrame
* loop, the way ../../web/src/components/Ambience.tsx does it - React never re-renders
* for it, so the animation cannot compete with the keystroke handling. The player's own
* dolphin needs no loop: it only moves when a key is pressed, which is a render anyway. */
import { useEffect, useRef } from "react";
import { currentChar } from "../../lib/engine";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard";
import { Target } from "../Target";
interface Props {
chunks: readonly string[];
text: string;
spaceActive: boolean;
activeKeys: readonly string[];
progress: Progress;
/** The best run's correct keystrokes, or null for a first attempt. */
ghost: readonly { key: string; at: number }[] | null;
paused: boolean;
onFinished: (result: RunResult) => void;
}
/** The pace of the stand-in opponent, in Zeichen pro Minute. Slow enough that a careful
* first-timer beats it - roughly a Schildkröte. */
const KRABBEN_TEMPO = 30;
export function RennenRun({
chunks,
text,
spaceActive,
activeKeys,
progress,
ghost,
paused,
onFinished,
}: Props) {
const { state, daneben } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
});
const next = currentChar(state);
const bahn = useRef<HTMLDivElement>(null);
const gegner = useRef<HTMLDivElement>(null);
// 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 });
latest.current = { ghost, startedAt: state.startedAt, laenge: text.length, fertig: state.finishedAt !== null };
useEffect(() => {
let frame = 0;
const tick = () => {
frame = requestAnimationFrame(tick);
const current = latest.current;
const node = gegner.current;
if (!node) return;
// The race starts on her first keystroke, not when the screen opens - the same
// rule the grading uses, so a pause before starting costs nothing here either.
if (current.startedAt === null) {
node.style.left = "0%";
return;
}
const vergangen = performance.now() - current.startedAt;
let anteil: number;
if (current.ghost && current.ghost.length > 1) {
const start = current.ghost[0]!.at;
// How many of the ghost's keystrokes have come due by now.
let getippt = 0;
while (getippt < current.ghost.length && current.ghost[getippt]!.at - start <= vergangen) getippt++;
anteil = getippt / current.laenge;
} else {
anteil = vergangen / 60000 / (1 / KRABBEN_TEMPO) / current.laenge;
}
node.style.left = `${Math.min(1, Math.max(0, anteil)) * 100}%`;
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, []);
const meinAnteil = text.length === 0 ? 0 : state.index / text.length;
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 20,
padding: "0 32px",
minHeight: 0,
}}
>
<div ref={bahn} style={{ width: "min(860px, 92%)", display: "flex", flexDirection: "column", gap: 10 }}>
<Bahn label="Du" farbe="var(--paper)">
<div
style={{
position: "absolute",
left: `${meinAnteil * 100}%`,
transform: "translateX(-50%)",
fontSize: 34,
transition: "left 140ms ease-out",
}}
>
🐬
</div>
</Bahn>
<Bahn label={ghost ? "Dein Rekord" : "Die Krabbe"} farbe="oklch(97% 0.01 175 / .55)">
<div ref={gegner} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}>
{ghost ? "👻" : "🦀"}
</div>
</Bahn>
</div>
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} daneben={daneben} fontSize={34} />
<HandHint nextKey={next} />
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={34}
/>
</div>
);
}
/** One lane, with the finish line at the right. */
function Bahn({ label, farbe, children }: { label: string; farbe: string; children: React.ReactNode }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ width: 96, flex: "none", fontSize: 13, fontWeight: 800, color: farbe, textAlign: "right" }}>
{label}
</div>
<div
style={{
position: "relative",
flex: 1,
height: 44,
borderRadius: 999,
background: "oklch(97% 0.01 175 / .1)",
border: "1px solid oklch(97% 0.01 175 / .16)",
display: "flex",
alignItems: "center",
}}
>
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", paddingLeft: 10, paddingRight: 10 }}>
<div style={{ position: "relative", width: "100%", display: "flex", alignItems: "center" }}>{children}</div>
</div>
<div style={{ position: "absolute", right: 8, fontSize: 18, opacity: 0.7 }}>🏁</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
/** Tauchgang - 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
* below. Everything else in the game is a variation on this; this is the one that is
* measured. */
import { useEffect } from "react";
import { currentChar } from "../../lib/engine";
import type { RunResult } from "../../lib/grading";
import type { Progress } from "../../lib/progress";
import { say, spellKey } from "../../lib/speech";
import { useRun } from "../../hooks/useRun";
import { HandHint } from "../HandHint";
import { Keyboard } from "../Keyboard";
import { Target } from "../Target";
interface Props {
chunks: readonly string[];
text: string;
spaceActive: boolean;
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
export function TauchgangRun({
chunks,
text,
spaceActive,
activeKeys,
progress,
paused,
onFinished,
}: Props) {
const { state, daneben } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
});
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 (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 26,
padding: "0 32px",
minHeight: 0,
}}
>
<HandHint nextKey={next} />
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} daneben={daneben} />
<Fortschritt done={state.index} total={text.length} />
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
/>
</div>
);
}
/** 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. */
function Fortschritt({ done, total }: { done: number; total: number }) {
return (
<div
style={{
width: "min(520px, 80%)",
height: 8,
borderRadius: 999,
background: "oklch(97% 0.01 175 / 0.16)",
overflow: "hidden",
}}
>
<div
style={{
width: `${total === 0 ? 0 : (done / total) * 100}%`,
height: "100%",
background: "var(--paper)",
transition: "width 120ms ease-out",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,90 @@
/** Mounts the pure engine against real keystrokes.
*
* This is the thin adapter the plan calls for, and the same shape the music player uses
* in ../../web/src/App.tsx: everything the listener reads lives in a ref, so the
* listener is installed exactly once and a re-render never reattaches it. That matters
* more here than there - a listener that is torn down and rebuilt between keystrokes
* drops keys, and dropping a six-year-old's keystroke looks to her like the game is
* broken.
*
* Every mode drives this. They differ in what they draw, not in what typing means. */
import { useCallback, useEffect, useRef, useState } from "react";
import { isTypingKey, press, startRun } from "../lib/engine";
import type { RunEvent, RunState } from "../lib/engine";
import type { RunResult } from "../lib/grading";
import { playDaneben, playFertig, playRichtig } from "../lib/pop";
interface Options {
/** The text to type. Changing it restarts the run. */
target: string;
sound: boolean;
/** Called once, when the last character lands. */
onFinished: (result: RunResult) => void;
/** Extra per-event hook for a mode that needs it (popping a bubble, say). */
onEvent?: (event: RunEvent) => void;
/** Paused runs ignore keystrokes - used while the result sheet is up. */
paused?: boolean;
}
export interface RunHandle {
state: RunState;
/** True while the cursor is sitting on a key that was just missed. */
daneben: boolean;
restart: () => void;
}
export function useRun({ target, sound, onFinished, onEvent, paused = false }: Options): RunHandle {
const [state, setState] = useState<RunState>(() => startRun(target));
const [daneben, setDaneben] = useState(false);
// Everything the listener needs, kept current without reinstalling it.
const latest = useRef({ state, sound, onFinished, onEvent, paused });
latest.current = { state, sound, onFinished, onEvent, paused };
const restart = useCallback(() => {
setState(startRun(target));
setDaneben(false);
}, [target]);
// A new target is a new run - the modes swap the line rather than remounting.
useEffect(() => restart(), [restart]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const current = latest.current;
if (current.paused) return;
// Let the app keep its own keys: Escape leaves, F1 helps, and a browser shortcut
// with a modifier held is the browser's business, not the run's.
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (!isTypingKey(event.key)) return;
// Space scrolls the page and Tab leaves it; both are typing input here.
event.preventDefault();
const [next, events] = press(current.state, event.key, performance.now());
if (events.length === 0) return;
setState(next);
for (const runEvent of events) {
if (runEvent.type === "correct") {
setDaneben(false);
if (current.sound) playRichtig(runEvent.streak);
} else if (runEvent.type === "wrong") {
setDaneben(true);
if (current.sound) playDaneben();
} else {
if (current.sound) playFertig();
current.onFinished(runEvent.result);
}
current.onEvent?.(runEvent);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return { state, daneben, restart };
}

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { KREATUREN, kreaturAus, kreaturById, neuerSchwimmer, pose, schwimme } from "../aquarium";
import type { Becken, Schwimmer } from "../aquarium";
import { WELTEN } from "../curriculum";
import { mulberry32 } from "../generator";
const BECKEN: Becken = { breite: 1280, hoehe: 800 };
const RAND = 60;
const TEMPO = 55;
const DT = 1 / 60;
function swim(s: Schwimmer, sekunden: number, becken = BECKEN, rng = mulberry32(7)): Schwimmer {
for (let t = 0; t < sekunden; t += DT) s = schwimme(s, DT, becken, RAND, TEMPO, rng);
return s;
}
describe("Kreaturen", () => {
it("gives every pet exactly one Welt", () => {
const belohnungen = WELTEN.map((welt) => welt.belohnung);
expect([...belohnungen].sort()).toEqual(KREATUREN.map((k) => k.id).sort());
});
it("looks pets up by id", () => {
for (const kreatur of KREATUREN) expect(kreaturById(kreatur.id)).toBe(kreatur);
});
it("reads today's ids and the emoji old saves stored, and nothing else", () => {
expect(kreaturAus("krake")).toBe("krake");
expect(kreaturAus("🐠")).toBe("clownfisch");
expect(kreaturAus("🧜")).toBe("perlmuschel");
expect(kreaturAus("🦈")).toBeNull();
expect(kreaturAus(42)).toBeNull();
});
});
describe("schwimme", () => {
it("never lets a pet's centre leave the tank", () => {
const rng = mulberry32(3);
let s = neuerSchwimmer(BECKEN, RAND, rng);
// Ten minutes of swimming, checked every frame.
for (let t = 0; t < 600; t += DT) {
s = schwimme(s, DT, BECKEN, RAND, TEMPO, rng);
expect(s.x).toBeGreaterThanOrEqual(0);
expect(s.x).toBeLessThanOrEqual(BECKEN.breite);
expect(s.y).toBeGreaterThanOrEqual(0);
expect(s.y).toBeLessThanOrEqual(BECKEN.hoehe);
}
});
it("keeps moving rather than settling", () => {
const rng = mulberry32(11);
const start = neuerSchwimmer(BECKEN, RAND, rng);
const spaeter = swim(start, 60, BECKEN, rng);
expect(Math.hypot(spaeter.x - start.x, spaeter.y - start.y)).toBeGreaterThan(0);
expect(spaeter.zielX !== start.zielX || spaeter.zielY !== start.zielY).toBe(true);
});
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 links = swim(base, 3);
expect(links.vx).toBeLessThan(0);
expect(links.blick).toBeLessThan(-0.9);
const rechts = swim({ ...base, zielX: 1180, blick: -1 }, 3);
expect(rechts.vx).toBeGreaterThan(0);
expect(rechts.blick).toBeGreaterThan(0.9);
});
it("brings a newly earned pet in from outside the tank", () => {
const rng = mulberry32(5);
const neu = neuerSchwimmer(BECKEN, RAND, rng, true);
expect(neu.x < 0 || neu.x > BECKEN.breite).toBe(true);
const drinnen = swim(neu, 40, BECKEN, rng);
expect(drinnen.x).toBeGreaterThan(0);
expect(drinnen.x).toBeLessThan(BECKEN.breite);
});
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 klein = { breite: 500, hoehe: 400 };
const next = schwimme(s, DT, klein, RAND, TEMPO, mulberry32(1));
expect(next.zielX).toBeLessThanOrEqual(klein.breite - RAND);
expect(next.zielY).toBeLessThanOrEqual(klein.hoehe - RAND);
});
});
describe("pose", () => {
const s: Schwimmer = { x: 10, y: 20, vx: -30, vy: 0, zielX: 0, zielY: 0, blick: -1, zeit: 0 };
it("mirrors side-view pets to look where they swim", () => {
expect(pose(s, kreaturById("clownfisch"), TEMPO).spiegel).toBe(-1);
});
it("never mirrors a front-view pet", () => {
expect(pose(s, kreaturById("krake"), TEMPO).spiegel).toBe(1);
expect(pose(s, kreaturById("perlmuschel"), TEMPO).spiegel).toBe(1);
});
});

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS, WELTEN, lessonById, lessonsOfWelt, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => {
it("has unique ids and consecutive numbers", () => {
const ids = LESSONS.map((lesson) => lesson.id);
expect(new Set(ids).size).toBe(ids.length);
LESSONS.forEach((lesson, i) => expect(lesson.nummer).toBe(i + 1));
});
it("starts on the two keys with the tactile bumps", () => {
expect(LESSONS[0]!.neueKeys).toEqual(["f", "j"]);
expect(FIRST_LESSON_ID).toBe(LESSONS[0]!.id);
});
it("eases in: at most two new keys per lesson, anywhere in the course", () => {
// 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.
for (const lesson of LESSONS) {
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
}
});
it("teaches Welt 1 as mirrored finger pairs, one finger per hand", () => {
const paare = lessonsOfWelt(1).filter((lesson) => lesson.neueKeys.length === 2);
expect(paare.length).toBe(4);
for (const lesson of paare) {
const [links, rechts] = lesson.neueKeys.map((key) => fingerOf(key)!);
expect(links!.hand).toBe("links");
expect(rechts!.hand).toBe("rechts");
// Same finger on each hand - "die Zeigefinger", "die Mittelfinger", …
expect(links!.id.replace("links-", "")).toBe(rechts!.id.replace("rechts-", ""));
}
});
it("has the whole Grundstellung active by the end of Welt 1", () => {
const letzte = lessonsOfWelt(1).at(-1)!;
for (const key of "asdfjklö") expect(letzte.activeKeys).toContain(key);
expect(letzte.activeKeys).toContain(" ");
});
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
// for a rhythm to form or for the speed score to mean anything.
for (const lesson of LESSONS) {
if (lesson.art === "saetze") {
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(10);
} else if (lesson.art === "woerter") {
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(25);
} else {
expect(lesson.chunks * lesson.chunkSize, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(60);
}
}
});
it("still starts gentler than it ends", () => {
const laenge = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
expect(laenge(lessonsOfWelt(3)[0]!)).toBeGreaterThan(laenge(lessonsOfWelt(1)[0]!));
});
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")) {
expect(lesson.woerter.length, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(lesson.chunks);
}
});
it("grows activeKeys monotonically - no lesson ever loses a key", () => {
let previous: string[] = [];
for (const lesson of LESSONS) {
for (const key of previous) expect(lesson.activeKeys).toContain(key);
previous = [...lesson.activeKeys];
}
});
it("introduces every new key into its own activeKeys", () => {
for (const lesson of LESSONS) {
for (const key of lesson.neueKeys) {
if (key === "⇧") continue; // Shift is not a character the generator can emit.
expect(lesson.activeKeys).toContain(key);
}
}
});
it("covers the whole German alphabet plus the umlauts by the end", () => {
const final = new Set(LESSONS.at(-1)!.activeKeys);
for (const key of "abcdefghijklmnopqrstuvwxyzäöü") expect(final.has(key)).toBe(true);
});
it("assigns every active key to a real finger", () => {
for (const lesson of LESSONS) {
for (const key of lesson.activeKeys) expect(fingerOf(key)).not.toBeNull();
}
});
it("only lists words its own activeKeys can type", () => {
// Compared by physical key, not by character: "?" is Shift+ß and "A" is Shift+a,
// so a lesson can type them as soon as it has the ß or a key.
for (const lesson of LESSONS) {
const active = new Set(lesson.activeKeys);
for (const wort of lesson.woerter) {
for (const char of wort) {
expect(
active.has(keyForChar(char)),
`Lektion ${lesson.nummer} (${lesson.titel}): "${wort}" braucht "${char}" (Taste ${keyForChar(char)})`,
).toBe(true);
}
}
}
});
it("only uses shifted characters once the Umschalttaste is taught", () => {
const shiftAb = LESSONS.find((lesson) => lesson.neueKeys.includes("⇧"))!.nummer;
for (const lesson of LESSONS) {
for (const wort of lesson.woerter) {
for (const char of wort) {
if (!needsShift(char)) continue;
expect(
lesson.nummer,
`Lektion ${lesson.nummer}: "${wort}" braucht Umschalt für "${char}"`,
).toBeGreaterThanOrEqual(shiftAb);
}
}
}
});
it("only offers word modes where words exist", () => {
for (const lesson of LESSONS) {
const wortModi = lesson.modi.some((modus) => modus === "fuettern" || modus === "rennen");
expect(wortModi).toBe(lesson.woerter.length > 0);
}
});
it("always offers the pressure-free Perlentaucher", () => {
for (const lesson of LESSONS) expect(lesson.modi).toContain("perlen");
});
it("teaches capitals only once the Umschalttaste exists", () => {
for (const lesson of LESSONS) {
const hatGross = lesson.woerter.some((wort) => wort !== wort.toLowerCase());
if (hatGross) expect(lesson.welt).toBeGreaterThanOrEqual(4);
}
});
it("gives every new key a lesson of its own after Welt 1", () => {
// One key at a time is the pacing decision: Welt 1 pairs a finger across both
// hands, everything after introduces exactly one key or none.
for (const lesson of LESSONS) {
if (lesson.welt === 1) continue;
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
}
});
it("follows every pair of new keys with an Übung", () => {
const uebungen = LESSONS.filter((lesson) => lesson.istUebung);
expect(uebungen.length).toBeGreaterThanOrEqual(12);
// An Übung never introduces anything, and always has something to practise.
for (const lesson of uebungen) {
expect(lesson.neueKeys).toEqual([]);
expect(lesson.activeKeys.length).toBeGreaterThan(0);
}
});
it("is long enough to be a real course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(40);
expect(WELTEN.length).toBe(5);
});
it("never leaves a Welt without a run of at least three lessons", () => {
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThanOrEqual(3);
});
});
describe("Welten", () => {
it("has lessons in every Welt", () => {
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThan(0);
});
it("gives every Welt its own aquarium creature", () => {
const belohnungen = WELTEN.map((welt) => welt.belohnung);
expect(new Set(belohnungen).size).toBe(belohnungen.length);
});
});
describe("navigation", () => {
it("chains every lesson to the next and stops at the end", () => {
for (let i = 0; i < LESSONS.length - 1; i++) {
expect(nextLesson(LESSONS[i]!.id)?.id).toBe(LESSONS[i + 1]!.id);
}
expect(nextLesson(LESSONS.at(-1)!.id)).toBeNull();
expect(nextLesson("gibt-es-nicht")).toBeNull();
});
it("looks lessons up by id", () => {
expect(lessonById(FIRST_LESSON_ID)?.nummer).toBe(1);
expect(lessonById("gibt-es-nicht")).toBeNull();
});
});

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { abandonRun, isTypingKey, press, startRun } from "../engine";
import type { RunState } from "../engine";
/** Type a whole string, one key per 100ms, and hand back the final state. */
function typeAll(target: string, keys: string, from = 1000): RunState {
let state = startRun(target);
[...keys].forEach((key, i) => {
[state] = press(state, key, from + i * 100);
});
return state;
}
describe("press", () => {
it("advances on the right key", () => {
const [state, events] = press(startRun("asdf"), "a", 0);
expect(state.index).toBe(1);
expect(events).toEqual([{ type: "correct", key: "a", index: 0, streak: 1 }]);
});
it("does not advance on the wrong key", () => {
const [state, events] = press(startRun("asdf"), "x", 0);
expect(state.index).toBe(0);
expect(events[0]).toMatchObject({ type: "wrong", key: "x", expected: "a", firstAt: true });
});
it("counts a repeated wrong key at one position only once", () => {
const state = typeAll("asdf", "xxxxx");
expect(state.missed.size).toBe(1);
expect(state.strokes).toHaveLength(5);
});
it("counts wrong keys at different positions separately", () => {
const state = typeAll("asdf", "xaysz");
expect(state.missed.size).toBe(3);
});
it("accepts a capital where a lowercase letter is wanted", () => {
const [state] = press(startRun("asdf"), "A", 0);
expect(state.index).toBe(1);
});
it("ignores modifiers and named keys", () => {
const start = startRun("asdf");
for (const key of ["Shift", "Control", "Backspace", "ArrowLeft", "F1", "Enter"]) {
const [state, events] = press(start, key, 0);
expect(state).toBe(start);
expect(events).toEqual([]);
}
});
it("starts the clock on the first keystroke, not before", () => {
const fresh = startRun("as");
expect(fresh.startedAt).toBeNull();
const [state] = press(fresh, "a", 5000);
expect(state.startedAt).toBe(5000);
});
it("finishes on the last character and reports a result", () => {
let state = startRun("as");
[state] = press(state, "a", 0);
const [done, events] = press(state, "s", 1000);
expect(done.finishedAt).toBe(1000);
const finished = events.find((event) => event.type === "finished");
expect(finished).toBeDefined();
expect(finished?.type === "finished" && finished.result.zeichen).toBe(2);
});
it("does nothing once the run is over", () => {
const done = typeAll("as", "as");
const [state, events] = press(done, "a", 9999);
expect(state).toBe(done);
expect(events).toEqual([]);
});
it("tracks and resets the streak", () => {
expect(typeAll("asdf", "asd").streak).toBe(3);
expect(typeAll("asdf", "asx").streak).toBe(0);
});
});
describe("isTypingKey", () => {
it("accepts single characters including umlauts and space", () => {
for (const key of ["a", "ö", "ü", "ß", " ", "A"]) expect(isTypingKey(key)).toBe(true);
});
it("rejects named keys", () => {
for (const key of ["Enter", "Shift", "Tab", "ArrowUp"]) expect(isTypingKey(key)).toBe(false);
});
});
describe("abandonRun", () => {
it("grades what was typed so far", () => {
const partial = typeAll("asdfjklö", "asdf");
const stopped = abandonRun(partial, 2000);
expect(stopped.finishedAt).toBe(2000);
expect(stopped.index).toBe(4);
});
it("leaves an untouched run alone", () => {
const fresh = startRun("asdf");
expect(abandonRun(fresh, 100)).toBe(fresh);
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
FINGERS,
GRUNDSTELLUNG,
KEYBOARD_ROWS,
fingerOf,
handOf,
homeKeyOf,
keysInRow,
rowOf,
shiftHandFor,
} from "../fingers";
const ALPHABET = "abcdefghijklmnopqrstuvwxyzäöüß";
describe("fingerOf", () => {
it("maps every German letter to exactly one finger", () => {
for (const key of ALPHABET) expect(fingerOf(key), key).not.toBeNull();
});
it("is case-insensitive", () => {
for (const key of ALPHABET) {
// "ß".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.
const gross = key.toUpperCase();
if ([...gross].length !== 1) continue;
expect(fingerOf(gross)?.id, gross).toBe(fingerOf(key)?.id);
}
});
it("returns null for keys that are not on the layout", () => {
for (const key of ["Enter", "F1", "€", ""]) expect(fingerOf(key)).toBeNull();
});
it("assigns the Grundstellung to the eight home fingers, left to right", () => {
const expected = [
"links-klein",
"links-ring",
"links-mitte",
"links-zeige",
"rechts-zeige",
"rechts-mitte",
"rechts-ring",
"rechts-klein",
];
GRUNDSTELLUNG.forEach((key, i) => expect(fingerOf(key)?.id).toBe(expected[i]));
});
it("gives each finger the home key it actually rests on", () => {
for (const key of GRUNDSTELLUNG) expect(homeKeyOf(key)).toBe(key);
expect(homeKeyOf(" ")).toBe(" ");
});
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 "zuhjnm") expect(fingerOf(key)?.id).toBe("rechts-zeige");
});
});
describe("hands", () => {
it("splits the letters into two disjoint, non-empty sets", () => {
const links = [...ALPHABET].filter((key) => handOf(key) === "links");
const rechts = [...ALPHABET].filter((key) => handOf(key) === "rechts");
expect(links.length).toBeGreaterThan(0);
expect(rechts.length).toBeGreaterThan(0);
expect(links.length + rechts.length).toBe(ALPHABET.length);
expect(links.some((key) => rechts.includes(key))).toBe(false);
});
it("shifts with the opposite hand", () => {
expect(shiftHandFor("a")).toBe("rechts");
expect(shiftHandFor("l")).toBe("links");
expect(shiftHandFor("Enter")).toBeNull();
});
});
describe("rows", () => {
it("places every letter in a row", () => {
for (const key of ALPHABET) expect(rowOf(key), key).not.toBeNull();
});
it("has the Grundstellung in the Grundreihe", () => {
for (const key of GRUNDSTELLUNG) expect(rowOf(key)).toBe("grund");
});
it("draws three rows, each with keys", () => {
expect(KEYBOARD_ROWS).toHaveLength(3);
for (const row of KEYBOARD_ROWS) expect(keysInRow(row).length).toBeGreaterThan(0);
});
});
describe("FINGERS", () => {
it("gives every finger a distinct hue so the colours can be named", () => {
const hues = Object.values(FINGERS).map((finger) => finger.hue);
expect(new Set(hues).size).toBe(hues.length);
});
it("keeps each finger's id and key consistent", () => {
for (const [id, finger] of Object.entries(FINGERS)) expect(finger.id).toBe(id);
});
});

View File

@@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import { LESSONS } from "../curriculum";
import {
chunkOffsets,
drillChunks,
letterStream,
lineFor,
lineText,
mulberry32,
wordChunks,
} from "../generator";
const keys = ["a", "s", "d", "f"];
describe("mulberry32", () => {
it("is deterministic for a seed and different across seeds", () => {
expect(drillChunks(keys, mulberry32(7))).toEqual(drillChunks(keys, mulberry32(7)));
expect(drillChunks(keys, mulberry32(7))).not.toEqual(drillChunks(keys, mulberry32(8)));
});
it("stays in [0, 1)", () => {
const rng = mulberry32(3);
for (let i = 0; i < 1000; i++) {
const value = rng();
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(1);
}
});
});
describe("drillChunks", () => {
it("only ever emits active keys", () => {
for (const lesson of LESSONS) {
const chunks = drillChunks(lesson.activeKeys, mulberry32(lesson.nummer), { chunks: 20 });
const active = new Set(lesson.activeKeys);
for (const char of chunks.join("")) expect(active.has(char)).toBe(true);
}
});
it("honours the requested shape", () => {
const chunks = drillChunks(keys, mulberry32(1), { chunks: 7, chunkSize: 3 });
expect(chunks).toHaveLength(7);
for (const chunk of chunks) expect(chunk).toHaveLength(3);
});
it("over-represents the focus key once there are enough keys to spare", () => {
const viele = [..."asdfjklöei"];
const plain = drillChunks(viele, mulberry32(42), { chunks: 200 }).join("");
const focused = drillChunks(viele, mulberry32(42), { chunks: 200, focusKey: "e" }).join("");
const count = (text: string) => [...text].filter((char) => char === "e").length;
expect(count(focused)).toBeGreaterThan(count(plain));
});
it("never lets one key take over a line", () => {
// 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
// while three other fingers went untrained.
for (const set of [[..."asdf"], [..."asdfjklö"], [..."asdfjklöei"]]) {
for (const focusKey of set) {
const text = drillChunks(set, mulberry32(3), { chunks: 200, focusKey }).join("");
const anteil = [...text].filter((char) => char === focusKey).length / text.length;
expect(anteil, `${focusKey} in ${set.join("")}`).toBeLessThanOrEqual(0.35);
}
}
});
it("spreads a small key set evenly - every finger gets a turn", () => {
const text = drillChunks(keys, mulberry32(11), { chunks: 200, focusKey: "a" }).join("");
for (const key of keys) {
const anteil = [...text].filter((char) => char === key).length / text.length;
expect(anteil, key).toBeGreaterThan(0.15);
}
});
it("gives every finger a turn inside a single line, not just on average", () => {
// The bag draw exists for exactly this. A real sampled line came out as
// `saadaaafasaassfssffsafsf` - one `d` in twenty-four characters - which is
// acceptable averaged over a hundred lines and useless for the one line she types.
for (let seed = 0; seed < 60; seed++) {
const text = drillChunks(keys, mulberry32(seed), { chunks: 6, chunkSize: 4 }).join("");
for (const key of keys) {
const wie_oft = [...text].filter((char) => char === key).length;
expect(wie_oft, `"${key}" in "${text}" (seed ${seed})`).toBeGreaterThanOrEqual(4);
}
}
});
it("still varies the order between seeds", () => {
const a = drillChunks(keys, mulberry32(1), { chunks: 6 }).join("");
const b = drillChunks(keys, mulberry32(2), { chunks: 6 }).join("");
expect(a).not.toBe(b);
});
it("ignores a focus key that is not active, rather than looping", () => {
const chunks = drillChunks(keys, mulberry32(1), { chunks: 5, focusKey: "z" });
expect(chunks.join("")).not.toContain("z");
});
it("returns nothing when there is nothing to type", () => {
expect(drillChunks([], mulberry32(1))).toEqual([]);
expect(drillChunks([" "], mulberry32(1))).toEqual([]);
});
it("never emits a space inside a chunk", () => {
const withSpace = ["a", "s", " "];
expect(drillChunks(withSpace, mulberry32(5), { chunks: 20 }).join("")).not.toContain(" ");
});
});
describe("wordChunks", () => {
it("returns null when a lesson has no words yet", () => {
expect(wordChunks([], mulberry32(1))).toBeNull();
});
it("draws only from the given list", () => {
const woerter = ["die", "ei", "elf"];
const chunks = wordChunks(woerter, mulberry32(2), { chunks: 10 })!;
for (const chunk of chunks) expect(woerter).toContain(chunk);
});
it("survives a single-word list", () => {
expect(wordChunks(["ei"], mulberry32(1), { chunks: 4 })).toEqual(["ei", "ei", "ei", "ei"]);
});
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.
const woerter = ["a1", "b2", "c3", "d4", "e5", "f6", "g7", "h8"];
for (let seed = 0; seed < 40; seed++) {
const runde = wordChunks(woerter, mulberry32(seed), { chunks: 8 })!;
expect(new Set(runde).size, `seed ${seed}: ${runde.join(" ")}`).toBe(8);
}
});
it("never puts the same word twice in a row, even across a bag refill", () => {
for (let seed = 0; seed < 60; seed++) {
const runde = wordChunks(["eins", "zwei", "drei"], mulberry32(seed), { chunks: 30 })!;
for (let i = 1; i < runde.length; i++) {
expect(runde[i], `seed ${seed} bei ${i}: ${runde.join(" ")}`).not.toBe(runde[i - 1]);
}
}
});
});
describe("lineFor", () => {
it("prefers real words once a lesson has them", () => {
const lesson = LESSONS.find((l) => l.woerter.length > 0)!;
const chunks = lineFor(lesson, mulberry32(1));
for (const chunk of chunks) expect(lesson.woerter).toContain(chunk);
});
it("falls back to letters for the Grundstellung lessons", () => {
const lesson = LESSONS[0]!;
const chunks = lineFor(lesson, mulberry32(1));
expect(chunks.length).toBeGreaterThan(0);
for (const char of chunks.join("")) expect(lesson.activeKeys).toContain(char);
});
it("can be asked for letters even in a word lesson", () => {
const lesson = LESSONS.at(-1)!;
const chunks = lineFor(lesson, mulberry32(1), { preferWords: false, chunks: 4 });
for (const chunk of chunks) expect(lesson.woerter).not.toContain(chunk);
});
});
describe("lineText and chunkOffsets", () => {
it("joins with spaces only once the Leertaste is taught", () => {
expect(lineText(["as", "df"], true)).toBe("as df");
expect(lineText(["as", "df"], false)).toBe("asdf");
});
it("points each chunk at its own first character", () => {
const chunks = ["as", "df", "jk"];
for (const spaced of [true, false]) {
const text = lineText(chunks, spaced);
chunkOffsets(chunks, spaced).forEach((offset, i) => {
expect(text.slice(offset, offset + chunks[i]!.length)).toBe(chunks[i]);
});
}
});
});
describe("letterStream", () => {
it("produces one active letter per bubble", () => {
const stream = letterStream(keys, mulberry32(9), 25);
expect(stream).toHaveLength(25);
for (const letter of stream) expect(keys).toContain(letter);
});
it("shows every active letter across a round of bubbles", () => {
const stream = letterStream(keys, mulberry32(9), 20);
for (const key of keys) expect(stream).toContain(key);
});
it("is empty when there is nothing to type", () => {
expect(letterStream([], mulberry32(1), 10)).toEqual([]);
});
});

View File

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

View File

@@ -0,0 +1,238 @@
import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS } from "../curriculum";
import { press, startRun } from "../engine";
import { grade } from "../grading";
import type { RunResult } from "../grading";
import {
FLEISS_VERSUCHE,
focusKeyFor,
freshProgress,
mastery,
migrate,
recordRun,
today,
} from "../progress";
import type { Progress } from "../progress";
const L1 = LESSONS[0]!.id;
const L2 = LESSONS[1]!.id;
/** A graded run of `target` with a wrong key at each of `wrongAt`, paced at `tickMs`. */
function runResult(target: string, wrongAt: number[] = [], tickMs = 1000): RunResult {
let state = startRun(target);
let t = 0;
for (let i = 0; i < target.length; i++) {
if (wrongAt.includes(i)) {
[state] = press(state, target[i] === "x" ? "q" : "x", t);
t += tickMs;
}
[state] = press(state, target[i]!, t);
t += tickMs;
}
return grade(state);
}
const perfekt = () => runResult("asdfasdfasdfasdf");
const schlecht = () => runResult("asdfasdfasdfasdf", [0, 1, 2, 3, 4, 5]);
describe("freshProgress", () => {
it("unlocks the first lesson and nothing else", () => {
const progress = freshProgress();
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
const offen = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked);
expect(offen).toHaveLength(1);
});
it("knows every lesson in the curriculum", () => {
const progress = freshProgress();
for (const lesson of LESSONS) expect(progress.lessons[lesson.id]).toBeDefined();
});
});
describe("recordRun", () => {
it("unlocks the next lesson on two stars", () => {
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, perfekt());
expect(unlockedLessonId).toBe(L2);
expect(progress.lessons[L2]!.unlocked).toBe(true);
});
it("does not unlock below two stars", () => {
const result = schlecht();
expect(result.sterne).toBeLessThan(2);
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, result);
expect(unlockedLessonId).toBeNull();
expect(progress.lessons[L2]!.unlocked).toBe(false);
});
it("unlocks after enough tries however bad the score - the Fleiß rule", () => {
let progress = freshProgress();
for (let i = 0; i < FLEISS_VERSUCHE - 1; i++) {
progress = recordRun(progress, L1, schlecht()).progress;
expect(progress.lessons[L2]!.unlocked).toBe(false);
}
const { progress: last, unlockedLessonId } = recordRun(progress, L1, schlecht());
expect(unlockedLessonId).toBe(L2);
expect(last.lessons[L2]!.unlocked).toBe(true);
});
it("never lets the best animal or star count go down", () => {
let progress = recordRun(freshProgress(), L1, perfekt()).progress;
const best = progress.lessons[L1]!;
progress = recordRun(progress, L1, schlecht()).progress;
expect(progress.lessons[L1]!.bestSterne).toBe(best.bestSterne);
expect(progress.lessons[L1]!.bestPunkte).toBe(best.bestPunkte);
expect(progress.lessons[L1]!.bestTier).toBe(best.bestTier);
});
it("counts every run, good or bad", () => {
let progress = freshProgress();
for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, schlecht()).progress;
expect(progress.lessons[L1]!.runs).toBe(3);
});
it("adds Perlen on every run", () => {
const first = recordRun(freshProgress(), L1, schlecht());
expect(first.progress.perlen).toBeGreaterThan(0);
const second = recordRun(first.progress, L1, schlecht());
expect(second.progress.perlen).toBeGreaterThan(first.progress.perlen);
});
it("releases a creature only when a Welt is finished, and only once", () => {
let progress = freshProgress();
// Welt 1 is lessons 1-3, so finishing lesson 3 is what crosses into Welt 2.
const welt1 = LESSONS.filter((lesson) => lesson.welt === 1);
let released: string[] = [];
for (const lesson of welt1) {
const outcome = recordRun(progress, lesson.id, perfekt());
progress = outcome.progress;
if (outcome.neuesTier) released.push(outcome.neuesTier);
}
expect(released).toHaveLength(1);
expect(progress.aquarium).toEqual(released);
// Replaying the same lesson must not hand out a second copy.
const again = recordRun(progress, welt1.at(-1)!.id, perfekt());
expect(again.neuesTier).toBeNull();
expect(again.progress.aquarium).toEqual(released);
});
it("records a ghost of the best run for the race", () => {
const { progress } = recordRun(freshProgress(), L1, perfekt());
expect(progress.lessons[L1]!.ghost?.length).toBe(perfekt().zeichen);
});
it("builds the daily streak and restarts it after a gap", () => {
let progress = recordRun(freshProgress(), L1, perfekt(), "2026-09-11").progress;
expect(progress.streak).toEqual({ days: 1, lastPlayed: "2026-09-11" });
// A second run the same day does not double-count.
progress = recordRun(progress, L1, perfekt(), "2026-09-11").progress;
expect(progress.streak.days).toBe(1);
progress = recordRun(progress, L1, perfekt(), "2026-09-12").progress;
expect(progress.streak.days).toBe(2);
// A missed day restarts at 1, never at 0.
progress = recordRun(progress, L1, perfekt(), "2026-09-20").progress;
expect(progress.streak.days).toBe(1);
});
it("folds keystrokes into the per-key stats", () => {
const { progress } = recordRun(freshProgress(), L1, runResult("asdf", [1]));
expect(progress.keyStats["a"]?.attempts).toBe(1);
expect(progress.keyStats["s"]?.errors).toBe(1);
expect(progress.keyStats["s"]?.attempts).toBe(2);
});
});
describe("migrate", () => {
it("returns a fresh profile for anything unusable", () => {
for (const raw of [null, undefined, 42, "nope", {}, { version: 99 }, []]) {
const progress = migrate(raw);
expect(progress.version).toBe(1);
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
}
});
it("keeps a valid save", () => {
const stored = recordRun(freshProgress(), L1, perfekt()).progress;
const restored = migrate(JSON.parse(JSON.stringify(stored)));
expect(restored.lessons[L2]!.unlocked).toBe(true);
expect(restored.perlen).toBe(stored.perlen);
});
it("drops lessons that no longer exist", () => {
const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, alteLektion: {} } };
expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["alteLektion"]).toBeUndefined();
});
it("moves the emoji pets of old saves into today's aquarium", () => {
const alt = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] };
expect(migrate(JSON.parse(JSON.stringify(alt))).aquarium).toEqual(["clownfisch", "krake"]);
});
it("re-unlocks the first lesson even if the save says otherwise", () => {
const broken = freshProgress();
broken.lessons[FIRST_LESSON_ID] = { ...broken.lessons[FIRST_LESSON_ID]!, unlocked: false };
expect(migrate(broken).lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
});
});
describe("focusKeyFor", () => {
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
// the line, starving the other three fingers on Lektion 1.
expect(focusKeyFor(freshProgress(), ["a", "s", "d", "f"])).toBeNull();
});
it("picks an unpractised key before a merely slow one", () => {
const progress: Progress = {
...freshProgress(),
keyStats: { a: { ema: 5000, attempts: 50, errors: 20 } },
};
expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
});
it("picks the slowest and most error-prone once all are practised", () => {
const progress: Progress = {
...freshProgress(),
keyStats: {
a: { ema: 300, attempts: 50, errors: 0 },
s: { ema: 900, attempts: 50, errors: 10 },
},
};
expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
});
it("ignores the Leertaste and copes with an empty lesson", () => {
expect(focusKeyFor(freshProgress(), [" "])).toBeNull();
expect(focusKeyFor(freshProgress(), [])).toBeNull();
});
});
describe("mastery", () => {
it("is zero until a key has been seen enough times", () => {
const progress: Progress = { ...freshProgress(), keyStats: { a: { ema: 200, attempts: 2, errors: 0 } } };
expect(mastery(progress, "a")).toBe(0);
expect(mastery(progress, "q")).toBe(0);
});
it("rises with speed and accuracy, and stays within 0..1", () => {
const stat = (ema: number, errors: number) => ({
...freshProgress(),
keyStats: { a: { ema, attempts: 100, errors } },
});
expect(mastery(stat(1500, 0), "a")).toBe(0);
expect(mastery(stat(300, 0), "a")).toBe(1);
expect(mastery(stat(900, 0), "a")).toBeCloseTo(0.5, 5);
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
});
});
describe("today", () => {
it("formats local time, not UTC", () => {
expect(today(new Date(2026, 8, 11, 23, 30))).toBe("2026-09-11");
expect(today(new Date(2026, 0, 1, 0, 5))).toBe("2026-01-01");
});
});

208
tippen/src/lib/aquarium.ts Normal file
View File

@@ -0,0 +1,208 @@
/** 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
* 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
* persists", and it costs nothing to look at.
*
* 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
* visual languages is what lets a Schildkröte be both a speed trophy (🐢) and a pet
* (the 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
* testing: `schwimme` is a pure step - a swimmer and a time slice in, a swimmer out - so
* "never leaves the tank" and "looks where it is going" are checkable without a DOM or a
* clock. The component only calls it once per frame and writes the transform. */
export type KreaturId = "clownfisch" | "krake" | "seepferdchen" | "schildkroete" | "perlmuschel";
export interface Kreatur {
id: KreaturId;
name: string;
/** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */
artikel: "Der" | "Die" | "Das";
/** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */
bild: string;
/** 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. */
groesse: number;
/** 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. */
tempo: number;
/** 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. */
blick: "seite" | "vorne";
}
export const KREATUREN: readonly Kreatur[] = [
{ id: "clownfisch", name: "Clownfisch", artikel: "Der", bild: "/aquarium/clownfisch.webp", groesse: 0.13, tempo: 0.045, blick: "seite" },
{ id: "krake", name: "Krake", artikel: "Die", bild: "/aquarium/krake.webp", groesse: 0.17, tempo: 0.025, blick: "vorne" },
{ id: "seepferdchen", name: "Seepferdchen", artikel: "Das", bild: "/aquarium/seepferdchen.webp", groesse: 0.19, tempo: 0.02, blick: "seite" },
{ id: "schildkroete", name: "Schildkröte", artikel: "Die", bild: "/aquarium/schildkroete.webp", groesse: 0.17, tempo: 0.032, blick: "seite" },
{ id: "perlmuschel", name: "Perlmuschel", artikel: "Die", bild: "/aquarium/perlmuschel.webp", groesse: 0.12, tempo: 0.016, blick: "vorne" },
];
const KREATUR_BY_ID = new Map(KREATUREN.map((kreatur) => [kreatur.id, kreatur]));
export function kreaturById(id: KreaturId): Kreatur {
// Every KreaturId is in KREATUREN, so this cannot miss; the fallback only satisfies
// `noUncheckedIndexedAccess`.
return KREATUR_BY_ID.get(id) ?? KREATUREN[0]!;
}
/** Saves from before the pets were drawings stored the Welt's emoji. Each Welt kept its
* slot, so the old emoji map one-to-one onto the creature that now fills it. */
const ALTE_EMOJI: Readonly<Record<string, KreaturId>> = {
"🐠": "clownfisch",
"🐙": "krake",
"🦑": "seepferdchen",
"🐳": "schildkroete",
"🧜": "perlmuschel",
};
/** A stored aquarium entry as a creature, or `null` for anything unrecognisable. */
export function kreaturAus(raw: unknown): KreaturId | null {
if (typeof raw !== "string") return null;
if (KREATUR_BY_ID.has(raw as KreaturId)) return raw as KreaturId;
return ALTE_EMOJI[raw] ?? null;
}
// --- swimming ---------------------------------------------------------------
export interface Becken {
breite: number;
hoehe: number;
}
export interface Schwimmer {
/** Centre, in px. */
x: number;
y: number;
/** px per second. */
vx: number;
vy: number;
/** Where it is currently drifting towards. */
zielX: number;
zielY: number;
/** -1 looking left … 1 looking right. Eased rather than switched, so a turn is a
* visible flip through the middle instead of a jump. */
blick: number;
/** Seconds swum, for the bob. Started at a random offset so pets do not bob in step. */
zeit: number;
}
/** 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. */
const LENK_TAU = 1.4;
/** How long a turn-around takes. */
const WENDE_TAU = 0.25;
/** One gentle bob per this many seconds. */
const WIPP_PERIODE = 3.6;
function zufallsZiel(becken: Becken, rand: number, rng: () => number): { x: number; y: number } {
// A tank smaller than the creature (a collapsed window) still needs a valid target:
// the middle.
const spanne = (laenge: number) => Math.max(0, laenge - 2 * rand);
return {
x: rand + rng() * spanne(becken.breite),
y: rand + rng() * spanne(becken.hoehe),
};
}
/** A new swimmer. `vonAussen` 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. */
export function neuerSchwimmer(
becken: Becken,
rand: number,
rng: () => number,
vonAussen = false,
): Schwimmer {
const ziel = zufallsZiel(becken, rand, rng);
const start = vonAussen
? { x: rng() < 0.5 ? -rand : becken.breite + rand, y: ziel.y }
: zufallsZiel(becken, rand, rng);
return {
x: start.x,
y: start.y,
vx: 0,
vy: 0,
zielX: ziel.x,
zielY: ziel.y,
blick: ziel.x >= start.x ? 1 : -1,
zeit: rng() * WIPP_PERIODE,
};
}
/** One time slice of swimming: steer towards the target, pick a new one on arrival, and
* turn to face the direction of travel.
*
* `rand` is half the creature's size - how far its centre stays from the edges - and
* `tempo` its cruising speed in px/s. */
export function schwimme(
s: Schwimmer,
dt: number,
becken: Becken,
rand: number,
tempo: number,
rng: () => number,
): Schwimmer {
let { zielX, zielY } = s;
const imBecken = (x: number, y: number) =>
x >= rand && x <= becken.breite - rand && y >= rand && y <= becken.hoehe - rand;
// Arrived, or the window shrank and the target is now outside it: drift somewhere new.
const dx = zielX - s.x;
const dy = zielY - s.y;
if (Math.hypot(dx, dy) < Math.max(rand, 24) || !imBecken(zielX, zielY)) {
const ziel = zufallsZiel(becken, rand, rng);
zielX = ziel.x;
zielY = ziel.y;
}
const richtungX = zielX - s.x;
const richtungY = zielY - s.y;
const abstand = Math.hypot(richtungX, richtungY) || 1;
// Vertical drift at half speed: fish cruise, they do not climb.
const sollVx = (richtungX / abstand) * tempo;
const sollVy = (richtungY / abstand) * tempo * 0.5;
const lenk = 1 - Math.exp(-dt / LENK_TAU);
const vx = s.vx + (sollVx - s.vx) * lenk;
const vy = s.vy + (sollVy - s.vy) * lenk;
// Only turn round once it is really swimming that way - hovering on the spot must not
// make it flicker left and right.
const sollBlick = Math.abs(vx) > tempo * 0.2 ? Math.sign(vx) : Math.sign(s.blick) || 1;
const blick = s.blick + (sollBlick - s.blick) * (1 - Math.exp(-dt / WENDE_TAU));
return {
x: s.x + vx * dt,
y: s.y + vy * dt,
vx,
vy,
zielX,
zielY,
blick,
zeit: s.zeit + dt,
};
}
/** 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. */
export function pose(
s: Schwimmer,
kreatur: Kreatur,
tempo: number,
): { x: number; y: number; spiegel: number; drehung: number } {
const wippen = Math.sin((s.zeit / WIPP_PERIODE) * 2 * Math.PI);
const seite = kreatur.blick === "seite";
// 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;
return {
x: s.x,
y: s.y + wippen * 7,
spiegel: seite ? s.blick : 1,
drehung: seite ? neigung * 10 * Math.sign(s.blick || 1) : wippen * 3,
};
}

View File

@@ -0,0 +1,308 @@
/** The lesson plan: 48 Lektionen in 5 Welten.
*
* The shape follows what every serious Zehnfinger-Kurs does - start on the
* Grundstellung `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
* German course is the same idea in 18 lessons.
*
* Where this deviates, it deviates for the age, and the deviation is *pace*. A
* six-year-old gets:
*
* - **one new key per lesson** from Welt 2 onward (Welt 1 pairs the same finger on
* both hands, which is one motion, not two);
* - **an Übung 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
* course that only ever moves forward never gives it room;
* - **short rounds**, growing from twelve characters in Welt 1 to whole sentences in
* Welt 5.
*
* That is 48 short lessons rather than 17 big ones. The curriculum is the same; the
* steps between are small enough to climb.
*
* `activeKeys` is cumulative on purpose: Lektion 30 still drills `f`, or the first
* lessons rot while the last ones are learned. */
import type { KreaturId } from "./aquarium";
import { GRUNDSTELLUNG, LEERTASTE } from "./fingers";
export type ModeId =
| "tauchgang"
| "blasen"
| "quallen"
| "fuettern"
| "rennen"
| "perlen";
/** What a lesson's targets are made of. Decides which modes make sense: Quallenalarm
* cannot show a sentence, and Fütterungszeit cannot put one on a fish. */
export type LessonArt = "buchstaben" | "woerter" | "saetze";
export interface Lesson {
id: string;
welt: number;
nummer: number;
titel: string;
/** What this lesson is about, in words a six-year-old hears read aloud. */
untertitel: string;
/** The keys introduced here - what the generator weights toward. Empty for an Übung. */
neueKeys: readonly string[];
/** Everything typable in this lesson, cumulative. */
activeKeys: readonly string[];
/** Which game modes this lesson offers, in carousel order. */
modi: readonly ModeId[];
/** Real German words (or sentences) for the word modes. */
woerter: readonly string[];
art: LessonArt;
/** True for a consolidation lesson - no new keys, just practice. */
istUebung: boolean;
/** How long one run is. Grows with the curriculum - see `laengeFuer`. */
chunks: number;
chunkSize: number;
}
export interface Welt {
nummer: number;
titel: string;
emoji: string;
/** The creature that moves into the aquarium when this Welt is finished. */
belohnung: KreaturId;
}
/** A Welt's creature is a pet that stays - a drawing that swims behind every screen from
* then on. A tier animal in grading.ts is a speed trophy that changes, and is an emoji.
* lib/aquarium.ts has why the two are kept in different visual languages. The pets grow
* with the Welten: a small fish first, the pearl clam - the Perlen's own home - last. */
export const WELTEN: readonly Welt[] = [
{ nummer: 1, titel: "Die Grundstellung", emoji: "🏝️", belohnung: "clownfisch" },
{ nummer: 2, titel: "Nach oben", emoji: "🌊", belohnung: "krake" },
{ nummer: 3, titel: "Nach unten", emoji: "🪸", belohnung: "seepferdchen" },
{ nummer: 4, titel: "Große Buchstaben", emoji: "👑", belohnung: "schildkroete" },
{ nummer: 5, titel: "Ganze Sätze", emoji: "📖", belohnung: "perlmuschel" },
];
/** Letters only - no words can be spelled yet. */
const BUCHSTABEN_MODI: readonly ModeId[] = ["tauchgang", "blasen", "quallen", "perlen"];
/** Real words exist: the word modes and the race join in. */
const WORT_MODI: readonly ModeId[] = ["tauchgang", "fuettern", "blasen", "rennen", "quallen", "perlen"];
/** Sentences do not fit on a bubble or a fish. */
const SATZ_MODI: readonly ModeId[] = ["tauchgang", "rennen", "perlen"];
/** Line length by Welt - 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
* 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
* 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
* Minute to mean something.
*
* Welt 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
function laengeFuer(welt: number): { chunks: number; chunkSize: number } {
if (welt === 1) return { chunks: 24, chunkSize: 3 }; // 72 Zeichen
if (welt === 2) return { chunks: 25, chunkSize: 4 }; // 100
if (welt === 3) return { chunks: 30, chunkSize: 4 }; // 120
if (welt === 4) return { chunks: 25, chunkSize: 4 }; // 25 Wörter
return { chunks: 10, chunkSize: 4 }; // Welt 5: zehn ganze Sätze
}
interface PlanEntry {
welt: number;
titel: string;
untertitel: string;
/** Empty marks an Übung - consolidation, no new keys. */
neu: readonly string[];
woerter?: readonly string[];
art?: LessonArt;
}
/** The plan. Everything else is derived from this, so a curriculum change is one edit. */
const PLAN: readonly PlanEntry[] = [
// ---------------------------------------------------------------- Welt 1 --
// 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
// 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"] },
{ welt: 1, titel: "D und K", untertitel: "Die Mittelfinger", neu: ["d", "k"] },
{ welt: 1, titel: "Übung: F J D K", untertitel: "Die vier Tasten zusammen", neu: [] },
{ welt: 1, titel: "S und L", untertitel: "Die Ringfinger", neu: ["s", "l"] },
{ welt: 1, titel: "Übung: sechs Tasten", untertitel: "Alles bisher zusammen", neu: [] },
{ welt: 1, titel: "A und Ö", untertitel: "Die kleinen Finger", neu: ["a", "ö"] },
{ welt: 1, titel: "Übung: die Grundstellung", untertitel: "Alle acht Finger", neu: [] },
{ welt: 1, titel: "Die Leertaste", untertitel: "Der Daumen kommt dazu", neu: [LEERTASTE] },
// ---------------------------------------------------------------- Welt 2 --
// One key per lesson from here on, an Übung after every two.
{ welt: 2, titel: "Das E", untertitel: "Mittelfinger links nach oben", neu: ["e"],
woerter: ["elf", "alle", "esel", "see", "keks", "fell"] },
{ welt: 2, titel: "Das I", untertitel: "Mittelfinger rechts nach oben", neu: ["i"],
woerter: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
{ welt: 2, titel: "Übung: E und I", untertitel: "Die neuen Tasten festigen", neu: [],
woerter: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
{ welt: 2, titel: "Das R", untertitel: "Zeigefinger links nach oben", neu: ["r"],
woerter: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
{ welt: 2, titel: "Das U", untertitel: "Zeigefinger rechts nach oben", neu: ["u"],
woerter: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
{ welt: 2, titel: "Übung: R und U", untertitel: "Die Zeigefinger nach oben", neu: [],
woerter: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
{ welt: 2, titel: "Das T", untertitel: "Zeigefinger links weit nach oben", neu: ["t"],
woerter: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
{ welt: 2, titel: "Das Z", untertitel: "Zeigefinger rechts weit nach oben", neu: ["z"],
woerter: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
{ welt: 2, titel: "Übung: T und Z", untertitel: "Weit nach oben greifen", neu: [],
woerter: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
{ welt: 2, titel: "Das O", untertitel: "Ringfinger rechts nach oben", neu: ["o"],
woerter: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
{ welt: 2, titel: "Das W", untertitel: "Ringfinger links nach oben", neu: ["w"],
woerter: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
{ welt: 2, titel: "Übung: W und O", untertitel: "Die Ringfinger nach oben", neu: [],
woerter: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
{ welt: 2, titel: "Das P", untertitel: "Kleiner Finger rechts nach oben", neu: ["p"],
woerter: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
{ welt: 2, titel: "Das Q", untertitel: "Kleiner Finger links nach oben", neu: ["q"],
woerter: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
{ welt: 2, titel: "Das Ü", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["ü"],
woerter: ["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: [],
woerter: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
// ---------------------------------------------------------------- Welt 3 --
{ welt: 3, titel: "Das N", untertitel: "Zeigefinger rechts nach unten", neu: ["n"],
woerter: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] },
{ welt: 3, titel: "Das M", untertitel: "Zeigefinger rechts, neben dem N", neu: ["m"],
woerter: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] },
{ welt: 3, titel: "Übung: N und M", untertitel: "Die neuen Tasten festigen", neu: [],
woerter: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] },
{ welt: 3, titel: "Das G", untertitel: "Zeigefinger links, in der Mitte", neu: ["g"],
woerter: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] },
{ welt: 3, titel: "Das H", untertitel: "Zeigefinger rechts, in der Mitte", neu: ["h"],
woerter: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
{ welt: 3, titel: "Übung: G und H", untertitel: "Die Mitte der Grundreihe", neu: [],
woerter: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
{ welt: 3, titel: "Das C", untertitel: "Mittelfinger links nach unten", neu: ["c"],
woerter: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
{ welt: 3, titel: "Das V", untertitel: "Zeigefinger links nach unten", neu: ["v"],
woerter: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
{ welt: 3, titel: "Übung: C und V", untertitel: "Nach unten greifen", neu: [],
woerter: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
{ welt: 3, titel: "Das B", untertitel: "Zeigefinger links, neben dem V", neu: ["b"],
woerter: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
{ welt: 3, titel: "Das Y", untertitel: "Kleiner Finger links nach unten", neu: ["y"],
woerter: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
{ welt: 3, titel: "Übung: B und Y", untertitel: "Ganz unten links", neu: [],
woerter: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
{ welt: 3, titel: "Das X", untertitel: "Ringfinger links nach unten", neu: ["x"],
woerter: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
{ welt: 3, titel: "Das Ä", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["ä"],
woerter: ["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: [],
woerter: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
// ---------------------------------------------------------------- Welt 4 --
{ welt: 4, titel: "Umschalttaste rechts", untertitel: "Große Buchstaben der linken Hand", neu: ["⇧"],
woerter: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff",
"Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] },
{ welt: 4, titel: "Umschalttaste links", untertitel: "Große Buchstaben der rechten Hand", neu: [],
woerter: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe",
"Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] },
{ welt: 4, titel: "Übung: Namen", untertitel: "Namen fangen groß an", neu: [],
woerter: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa",
"Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] },
{ welt: 4, titel: "Übung: große und kleine", untertitel: "Beides gemischt", neu: [],
woerter: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel",
"Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] },
// ---------------------------------------------------------------- Welt 5 --
{ welt: 5, titel: "Der Punkt", untertitel: "Ringfinger rechts nach unten", neu: ["."], art: "saetze",
woerter: [
"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.",
"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",
woerter: [
"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.",
"Der Delfin springt, taucht und spielt.", "Morgen, sagt Papa, fahren wir los.",
"Eins, zwei, drei, vier.", "Oma, Opa und ich gehen schwimmen.",
"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",
woerter: [
"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.",
"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.",
] },
{ welt: 5, titel: "Fragezeichen und Ausrufezeichen", untertitel: "Mit der Umschalttaste", neu: ["ß", "1"], art: "saetze",
woerter: [
"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!",
"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",
woerter: [
"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.",
"Die Möwe fliegt über das Wasser.", "Oma, Opa und ich gehen schwimmen.", "Das ist ja super!",
"Wie heißt der große Wal?", "Im Riff wohnen bunte Fische.", "Der Krake hat acht Arme.",
] },
];
function buildLessons(): Lesson[] {
const lessons: Lesson[] = [];
const active = new Set<string>();
PLAN.forEach((entry, i) => {
for (const key of entry.neu) active.add(key);
// The Leertaste lesson is where the whole Grundstellung comes together, so it
// activates every home key - a belt-and-braces guarantee that the four finger-pair
// lessons before it really did cover all eight.
if (entry.neu.includes(LEERTASTE)) for (const key of GRUNDSTELLUNG) active.add(key);
// 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
// reaches its marks with Shift too, so ß and 1 are active as *keys* even though the
// characters that appear are ? and !.
const activeKeys = [...active].filter((key) => key !== "⇧").sort();
const woerter = entry.woerter ?? [];
const art: LessonArt = entry.art ?? (woerter.length > 0 ? "woerter" : "buchstaben");
lessons.push({
id: `l${String(i + 1).padStart(2, "0")}`,
welt: entry.welt,
nummer: i + 1,
titel: entry.titel,
untertitel: entry.untertitel,
neueKeys: entry.neu,
activeKeys,
modi: art === "saetze" ? SATZ_MODI : art === "woerter" ? WORT_MODI : BUCHSTABEN_MODI,
woerter,
art,
istUebung: entry.neu.length === 0,
...laengeFuer(entry.welt),
});
});
return lessons;
}
export const LESSONS: readonly Lesson[] = buildLessons();
const BY_ID = new Map(LESSONS.map((lesson) => [lesson.id, lesson]));
export function lessonById(id: string): Lesson | null {
return BY_ID.get(id) ?? null;
}
export function lessonsOfWelt(welt: number): readonly Lesson[] {
return LESSONS.filter((lesson) => lesson.welt === welt);
}
/** The lesson after this one, or null at the end of the curriculum. */
export function nextLesson(id: string): Lesson | null {
const index = LESSONS.findIndex((lesson) => lesson.id === id);
if (index < 0) return null;
return LESSONS[index + 1] ?? null;
}
export const FIRST_LESSON_ID = LESSONS[0]!.id;

124
tippen/src/lib/engine.ts Normal file
View File

@@ -0,0 +1,124 @@
/** The typing engine: one keystroke in, a new state and a list of events out.
*
* Kept pure, the way ../../../web/src/lib/keyboard.ts keeps the player's key map pure -
* so every rule below is testable without a DOM, and so the six game modes can all
* drive the same logic while differing only in how they *draw* the target.
*
* Three rules here are deliberate choices for a six-year-old rather than the obvious
* implementation, and each one is load-bearing:
*
* 1. A wrong key does not advance and does not insert. There is no backspace to
* manage and no corrupted line to read back; the right key still has to be found.
* 2. A wrong key counts once per position. Hammering the same wrong key five times in
* a moment of panic is one mistake, not five, so one bad second cannot wreck a run.
* 3. The clock starts on the first keystroke, not when the screen opens. Staring at
* the screen, getting distracted, or being called away mid-thought is free. */
import { grade, type RunResult } from "./grading";
export interface Stroke {
/** What was actually pressed, lowercased for letters. */
key: string;
/** What was wanted at that position. */
expected: string;
correct: boolean;
/** ms timestamp, from the same clock `press` is called with. */
at: number;
}
export interface RunState {
/** The full line being typed. */
target: string;
/** How far in we are - always an index into `target`, never past its length. */
index: number;
strokes: Stroke[];
/** Positions where at least one wrong key has already been counted. Rule 2. */
missed: ReadonlySet<number>;
/** Consecutive correct keys, for the streak sound and the bubble chain. */
streak: number;
startedAt: number | null;
finishedAt: number | null;
}
export type RunEvent =
| { type: "correct"; key: string; index: number; streak: number }
| { type: "wrong"; key: string; expected: string; index: number; firstAt: boolean }
| { type: "finished"; result: RunResult };
export function startRun(target: string): RunState {
return {
target,
index: 0,
strokes: [],
missed: new Set(),
streak: 0,
startedAt: null,
finishedAt: null,
};
}
/** Keys that are never typing input: pressing Shift to reach a capital must not count
* as a stroke of its own, and neither must a stray Alt or a browser shortcut's Meta. */
const MODIFIERS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]);
/** Is this a key the engine should look at at all? Anything longer than one code point
* is a named key ("Enter", "ArrowLeft", "F1") and belongs to the app, not the run.
* Backspace is swallowed on purpose: rule 1 means there is nothing to delete. */
export function isTypingKey(key: string): boolean {
if (MODIFIERS.has(key)) return false;
return [...key].length === 1;
}
export function isFinished(state: RunState): boolean {
return state.finishedAt !== null;
}
export function currentChar(state: RunState): string | null {
return state.target[state.index] ?? null;
}
/** Apply one keystroke. Returns the state unchanged (and no events) for anything that
* is not typing input, or once the run is over, so the caller can stay dumb. */
export function press(state: RunState, key: string, now: number): [RunState, RunEvent[]] {
if (isFinished(state) || !isTypingKey(key)) return [state, []];
const expected = state.target[state.index];
if (expected === undefined) return [state, []];
// 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
// carries the capital, so this comparison still teaches Shift where it matters.
const correct = key.toLowerCase() === expected.toLowerCase();
const startedAt = state.startedAt ?? now;
const stroke: Stroke = { key, expected, correct, at: now };
const strokes = [...state.strokes, stroke];
if (!correct) {
const firstAt = !state.missed.has(state.index);
const missed = firstAt ? new Set(state.missed).add(state.index) : state.missed;
const next: RunState = { ...state, strokes, missed, streak: 0, startedAt };
return [next, [{ type: "wrong", key, expected, index: state.index, firstAt }]];
}
const index = state.index + 1;
const streak = state.streak + 1;
const done = index >= state.target.length;
const next: RunState = {
...state,
index,
strokes,
streak,
startedAt,
finishedAt: done ? now : null,
};
const events: RunEvent[] = [{ type: "correct", key, index: state.index, streak }];
if (done) events.push({ type: "finished", result: grade(next) });
return [next, events];
}
/** 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. */
export function abandonRun(state: RunState, now: number): RunState {
if (isFinished(state) || state.startedAt === null) return state;
return { ...state, finishedAt: now };
}

165
tippen/src/lib/fingers.ts Normal file
View File

@@ -0,0 +1,165 @@
/** The German QWERTZ layout, as a finger map.
*
* This is the source of truth for three things that must never disagree: which finger
* the on-screen keyboard colours a key with, which hand the hint names, and which home
* key that finger returns to. Keeping them in one frozen table means a wrong finger
* assignment is one edit to fix, not three.
*
* 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". */
export type Hand = "links" | "rechts";
/** 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. */
export type FingerId =
| "links-klein"
| "links-ring"
| "links-mitte"
| "links-zeige"
| "rechts-zeige"
| "rechts-mitte"
| "rechts-ring"
| "rechts-klein"
| "daumen";
export interface Finger {
id: FingerId;
hand: Hand;
/** What a six-year-old is told out loud: "der kleine Finger links". */
label: string;
/** The key this finger rests on in the Grundstellung. */
home: string;
/** oklch hue for the keyboard overlay, so "der grüne Finger" is a thing you can say. */
hue: number;
}
export const FINGERS: Record<FingerId, Finger> = {
"links-klein": { id: "links-klein", hand: "links", label: "kleiner Finger links", home: "a", hue: 25 },
"links-ring": { id: "links-ring", hand: "links", label: "Ringfinger links", home: "s", hue: 70 },
"links-mitte": { id: "links-mitte", hand: "links", label: "Mittelfinger links", home: "d", hue: 140 },
"links-zeige": { id: "links-zeige", hand: "links", label: "Zeigefinger links", home: "f", hue: 195 },
"rechts-zeige": { id: "rechts-zeige", hand: "rechts", label: "Zeigefinger rechts", home: "j", hue: 250 },
"rechts-mitte": { id: "rechts-mitte", hand: "rechts", label: "Mittelfinger rechts", home: "k", hue: 290 },
"rechts-ring": { id: "rechts-ring", hand: "rechts", label: "Ringfinger rechts", home: "l", hue: 330 },
"rechts-klein": { id: "rechts-klein", hand: "rechts", label: "kleiner Finger rechts", home: "ö", hue: 10 },
daumen: { id: "daumen", hand: "rechts", label: "Daumen", home: " ", hue: 220 },
};
/** 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
* `links-zeige` has r/t and `rechts-zeige` has z/u. */
const OWNED: Record<FingerId, string> = {
"links-klein": "^1qay<",
"links-ring": "2wsx",
"links-mitte": "3edc",
"links-zeige": "45rtfgvb",
"rechts-zeige": "67zuhjnm",
"rechts-mitte": "8ik,",
"rechts-ring": "9ol.",
"rechts-klein": "0ßpüöä-+#",
daumen: " ",
};
/** Which row a key sits in, for the on-screen keyboard's layout and for the lesson
* titles ("nach oben", "nach unten"). */
export type RowId = "zahlen" | "oben" | "grund" | "unten" | "leer";
const ROWS: Record<RowId, string> = {
zahlen: "^1234567890ß",
oben: "qwertzuiopü+",
grund: "asdfghjklöä#",
unten: "<yxcvbnm,.-",
leer: " ",
};
const KEY_TO_FINGER = new Map<string, FingerId>();
for (const [finger, keys] of Object.entries(OWNED) as [FingerId, string][]) {
for (const key of keys) KEY_TO_FINGER.set(key, finger);
}
const KEY_TO_ROW = new Map<string, RowId>();
for (const [row, keys] of Object.entries(ROWS) as [RowId, string][]) {
for (const key of keys) KEY_TO_ROW.set(key, row);
}
/** The four rows as the on-screen keyboard draws them, top to bottom. */
export const KEYBOARD_ROWS: readonly RowId[] = ["oben", "grund", "unten"];
export function keysInRow(row: RowId): readonly string[] {
return [...(ROWS[row] ?? "")];
}
/** 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
* added when Welt 6 exists. */
const SHIFTED: Record<string, string> = {
"!": "1",
'"': "2",
"§": "3",
$: "4",
"%": "5",
"&": "6",
"/": "7",
"(": "8",
")": "9",
"=": "0",
"?": "ß",
"*": "+",
";": ",",
":": ".",
_: "-",
"'": "#",
">": "<",
};
/** The physical key that produces `char`.
*
* A capital is its own lowercase key plus Shift, and "?" is the ß key plus Shift. The
* distinction matters in three places: which key the on-screen keyboard lights up,
* 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. */
export function keyForChar(char: string): string {
const klein = char.toLowerCase();
if (klein !== char) return klein;
return SHIFTED[char] ?? char;
}
/** Whether reaching `char` needs a Shift held. */
export function needsShift(char: string): boolean {
return char.toLowerCase() !== char || char in SHIFTED;
}
/** The finger that types `key`, or `null` for anything off the layout (Enter, F1, …).
* Takes either a physical key or a character it produces - "A", "a" and "?" all
* resolve. */
export function fingerOf(key: string): Finger | null {
const id = KEY_TO_FINGER.get(keyForChar(key));
return id ? FINGERS[id] : null;
}
export function handOf(key: string): Hand | null {
return fingerOf(key)?.hand ?? null;
}
export function rowOf(key: string): RowId | null {
return KEY_TO_ROW.get(keyForChar(key)) ?? null;
}
/** The home key the typing finger came from - what the hint shows as "zurück nach …". */
export function homeKeyOf(key: string): string | null {
return fingerOf(key)?.home ?? null;
}
/** The Grundstellung itself, left to right. `LEERTASTE` is separate because the thumb
* 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 LEERTASTE = " ";
/** 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. */
export function shiftHandFor(key: string): Hand | null {
const hand = handOf(key);
if (hand === null) return null;
return hand === "links" ? "rechts" : "links";
}

234
tippen/src/lib/generator.ts Normal file
View File

@@ -0,0 +1,234 @@
/** 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
* testable, and what lets the Delfinrennen replay a ghost against the identical text.
*
* 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
* 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
* can spell something real, the curated `woerter` list is preferred and `fjfj dkdk`
* stops appearing.
*
* 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
* twenty-four letters in a row is not. Lessons 1 and 2 have no Leertaste yet, so the
* gap there is visual only - `lineText` joins without one and she never has to type a
* space she has not been taught. */
/** A small, fast, seedable PRNG. Identical seed, identical line. */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export type Rng = () => number;
export interface LineOptions {
/** How many chunks the line has. */
chunks?: number;
/** Letters per chunk. */
chunkSize?: number;
/** The key to over-represent, if any. */
focusKey?: string | null;
/** The keys this lesson introduces. They take the majority of the line; everything
* learned earlier keeps appearing as review. */
newKeys?: readonly string[];
}
/** 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. */
const MAX_FOKUS_ANTEIL = 0.3;
/** 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
* late ones are learned.
*
* Without this, Lektion 2 ("Die rechte 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
* lesson called "die rechte Hand" should drill. */
const NEU_ANTEIL = 0.6;
/** Copies of each new key needed to reach `NEU_ANTEIL` of the pool, clamped so a lesson
* with one new key and many old ones does not bury the review entirely. */
function neuKopien(neu: number, alt: number): number {
if (neu === 0 || alt === 0) return 1;
const exakt = (NEU_ANTEIL * alt) / (neu * (1 - NEU_ANTEIL));
return Math.max(1, Math.min(6, Math.round(exakt)));
}
/** 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
* keys every one of them is already drilled constantly. */
function fokusKopien(n: number): number {
let kopien = 0;
while (kopien < 2 && (1 + kopien + 1) / (n + kopien + 1) <= MAX_FOKUS_ANTEIL) kopien++;
return kopien;
}
/** Build a weighted alphabet: every active key at least once, the lesson's new keys
* several times, plus a few extra copies of the focus key. Drawing from this is the
* whole weighting mechanic - no rejection sampling, and no chance of an endless loop
* when the focus key is the only active key. */
function weighted(
activeKeys: readonly string[],
focusKey: string | null | undefined,
newKeys: readonly string[] = [],
): string[] {
const letters = activeKeys.filter((key) => key !== " ");
if (letters.length === 0) return [];
// Shift and the Leertaste are taught by the target text, not by the letter pool.
const neu = newKeys.filter((key) => letters.includes(key));
const alt = letters.filter((key) => !neu.includes(key));
const pool = [...letters];
if (neu.length > 0 && alt.length > 0) {
const kopien = neuKopien(neu.length, alt.length);
for (const key of neu) for (let i = 1; i < kopien; i++) pool.push(key);
}
if (focusKey && letters.includes(focusKey)) {
for (let i = 0; i < fokusKopien(letters.length); i++) pool.push(focusKey);
}
return pool;
}
/** Draw from a shuffled bag rather than sampling independently.
*
* 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
* `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
* that is fine; the child types one line.
*
* A bag fixes it by construction. Every key is drawn once before any key is drawn
* twice, so every finger gets its turn within each pass, and the order inside a pass is
* still random. Refilling on empty keeps it going for as long as the line needs. */
function bagDraw(pool: readonly string[], count: number, rng: Rng): string[] {
const out: string[] = [];
let bag: string[] = [];
for (let i = 0; i < count; i++) {
if (bag.length === 0) {
bag = [...pool];
// Fisher-Yates, so every ordering of the bag is equally likely.
for (let j = bag.length - 1; j > 0; j--) {
const k = Math.floor(rng() * (j + 1));
[bag[j], bag[k]] = [bag[k]!, bag[j]!];
}
}
out.push(bag.pop()!);
}
return out;
}
/** 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. */
export function drillChunks(
activeKeys: readonly string[],
rng: Rng,
options: LineOptions = {},
): string[] {
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [] } = options;
const pool = weighted(activeKeys, focusKey, newKeys);
if (pool.length === 0) return [];
const letters = bagDraw(pool, chunks * chunkSize, rng);
const out: string[] = [];
for (let i = 0; i < chunks; i++) {
out.push(letters.slice(i * chunkSize, (i + 1) * chunkSize).join(""));
}
return out;
}
/** 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
* words long, independent picks from an eight-word list started clumping: a real Welt 5
* round came out with "Das Meer ist blau, tief und kalt." four times in ten sentences.
* The bag uses every word once before any word twice, so repeats are as far apart as the
* list allows. */
export function wordChunks(
woerter: readonly string[],
rng: Rng,
options: LineOptions = {},
): string[] | null {
if (woerter.length === 0) return null;
const { chunks = 5, focusKey = null } = options;
// Words containing the focus key go in twice, same trick as the letter pool.
const pool = [...woerter];
if (focusKey) {
for (const wort of woerter) {
if (wort.toLowerCase().includes(focusKey.toLowerCase())) pool.push(wort);
}
}
const out = bagDraw(pool, chunks, rng);
// A bag refill can put the last word of one pass straight after itself. Swap it with a
// 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++) {
if (out[i] !== out[i - 1]) continue;
const tausch = out.findIndex((wort, j) => j > i && wort !== out[i]);
if (tausch > 0) [out[i], out[tausch]] = [out[tausch]!, out[i]!];
}
return out;
}
/** 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
* always single letters) or for words wherever they exist (Fütterungszeit). */
export function lineFor(
lesson: { activeKeys: readonly string[]; woerter: readonly string[]; neueKeys?: readonly string[] },
rng: Rng,
options: LineOptions & { preferWords?: boolean } = {},
): string[] {
const { preferWords = true, ...rest } = options;
const withNew = { newKeys: lesson.neueKeys ?? [], ...rest };
if (preferWords) {
const words = wordChunks(lesson.woerter, rng, withNew);
if (words) return words;
}
return drillChunks(lesson.activeKeys, rng, withNew);
}
/** Join chunks into the string the engine types against. With the Leertaste active the
* 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 {
return chunks.join(spaceActive ? " " : "");
}
/** Where each chunk starts in `lineText(chunks, spaceActive)` - what the Target
* component needs to draw the gaps in the right places. */
export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): number[] {
const offsets: number[] = [];
let at = 0;
for (const chunk of chunks) {
offsets.push(at);
at += chunk.length + (spaceActive ? 1 : 0);
}
return offsets;
}
/** Single letters for Blasenplatzen and Quallenalarm: one key per bubble, drawn from the
* same bag, so the arcade modes drill the same spread as the Tauchgang. */
export function letterStream(
activeKeys: readonly string[],
rng: Rng,
count: number,
focusKey?: string | null,
newKeys: readonly string[] = [],
): string[] {
const pool = weighted(activeKeys, focusKey, newKeys);
if (pool.length === 0) return [];
return bagDraw(pool, count, rng);
}

210
tippen/src/lib/grading.ts Normal file
View File

@@ -0,0 +1,210 @@
/** Turning a run into a number, a star count and an animal.
*
* 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
* types around four words a minute honestly measured. "4" on a results screen reads as
* failure; "38 Zeichen pro Minute" reads as a number that visibly grows. Same data,
* different message.
*
* *punkte = tempo × genauigkeit³, not the textbook Netto-WPM.* The standard formula is
* `netto = brutto fehler/minute`, which goes negative for a beginner - the one result
* 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
* the habit worth building at this age.
*
* Crucially, speed only ever buys the *animal*. The unlock gate below is accuracy-only
* (see `isPassed`), so a slow, careful child still reaches the end of the curriculum. */
import type { RunState, Stroke } from "./engine";
export interface RunResult {
/** Correct keystrokes. */
zeichen: number;
/** Wrong keystrokes, counted once per position (see engine.ts rule 2). */
fehler: number;
/** ms from the first keystroke to the last. */
dauer: number;
/** Zeichen pro Minute. */
tempo: number;
/** 0..1 */
genauigkeit: number;
/** `tempo × genauigkeit³`, the number the animal is read off. */
punkte: number;
sterne: 0 | 1 | 2 | 3;
tier: TierId;
/** Whether this run unlocks the next lesson on its own. */
bestanden: boolean;
/** Perlen earned - the aquarium currency. */
perlen: number;
/** Kept for the Delfinrennen ghost and the per-key stats. */
strokes: readonly Stroke[];
}
export type TierId =
| "schnecke"
| "krabbe"
| "schildkroete"
| "qualle"
| "fisch"
| "pinguin"
| "robbe"
| "delfin"
| "hai"
| "schwertwal";
export interface Tier {
id: TierId;
name: string;
emoji: string;
/** Lower bound in Punkte (Zeichen pro Minute, accuracy-weighted). */
ab: number;
/** What the Sprachausgabe says when this tier is reached. */
lob: string;
}
/** Nine Meerestiere, slowest first. Thresholds are calibrated against the "5 WPM pro
* Klassenstufe" school rule - roughly 25 Zeichen/min at the end of first grade - with
* plenty of headroom above it.
*
* The Delfin 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
* 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
* 🗡️ 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. */
export const TIERS: readonly Tier[] = [
{ id: "schnecke", name: "Meeresschnecke", emoji: "🐌", ab: 0, lob: "Die Schnecke ist losgekrochen!" },
{ id: "krabbe", name: "Krabbe", emoji: "🦀", ab: 15, lob: "Eine Krabbe! Die krabbelt schon los." },
{ id: "schildkroete", name: "Schildkröte", emoji: "🐢", ab: 25, lob: "Die Schildkröte ist stetig und sicher." },
{ id: "qualle", name: "Qualle", emoji: "🪼", ab: 40, lob: "Die Qualle gleitet dahin!" },
{ id: "fisch", name: "Fisch", emoji: "🐟", ab: 55, lob: "Ein Fisch! Der schwimmt richtig flott." },
{ id: "pinguin", name: "Pinguin", emoji: "🐧", ab: 75, lob: "Ein Pinguin! Der flitzt durchs Wasser." },
{ id: "robbe", name: "Robbe", emoji: "🦭", ab: 95, lob: "Die Robbe ist schnell und geschickt!" },
{ id: "delfin", name: "Delfin", emoji: "🐬", ab: 120, lob: "Ein Delfin! Das ist richtig, richtig schnell." },
{ id: "hai", name: "Hai", emoji: "🦈", ab: 150, lob: "Ein Hai! Unglaublich schnell." },
{ id: "schwertwal", name: "Schwertwal", emoji: "🐋", ab: 190, lob: "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.
* Everything above the Delfin stays hidden until it is actually reached - see
* `sichtbareTiers`. */
export const UEBERRASCHUNG_AB: TierId = "delfin";
export function tierIndex(id: TierId): number {
return TIERS.findIndex((tier) => tier.id === id);
}
/** 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
* 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
* 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. */
export function sichtbareTiers(earned: TierId, bestEver: TierId | null): {
tiers: readonly Tier[];
/** True when faster animals exist that have not been revealed yet. */
mehrVerborgen: boolean;
} {
const grenze = Math.max(
tierIndex(UEBERRASCHUNG_AB),
tierIndex(earned),
bestEver ? tierIndex(bestEver) : -1,
);
return {
tiers: TIERS.slice(0, grenze + 1),
mehrVerborgen: grenze < TIERS.length - 1,
};
}
const TIER_BY_ID = new Map(TIERS.map((tier) => [tier.id, tier]));
export function tierById(id: TierId): Tier {
// Every TierId comes from TIERS itself, so this cannot miss - but the map lookup is
// typed as possibly-undefined and `noUncheckedIndexedAccess` is on.
return TIER_BY_ID.get(id) ?? TIERS[0]!;
}
/** The animal for a score. Walks from the fastest down, so the first match wins. */
export function tierFor(punkte: number): Tier {
for (let i = TIERS.length - 1; i >= 0; i--) {
const tier = TIERS[i]!;
if (punkte >= tier.ab) return tier;
}
return TIERS[0]!;
}
/** How far along the current tier this score is, 0..1 - drives the progress bar that
* shows how close the next animal is. The top tier is always full. */
export function tierProgress(punkte: number): number {
const index = TIERS.findIndex((tier) => tier.id === tierFor(punkte).id);
const next = TIERS[index + 1];
if (!next) return 1;
const floor = TIERS[index]!.ab;
return Math.min(1, Math.max(0, (punkte - floor) / (next.ab - floor)));
}
/** Star thresholds, on accuracy alone. The familiar 1/2/3 pattern from every other
* 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 function sterneFor(genauigkeit: number): 0 | 1 | 2 | 3 {
if (genauigkeit >= STERNE_SCHWELLEN.drei) return 3;
if (genauigkeit >= STERNE_SCHWELLEN.zwei) return 2;
if (genauigkeit >= STERNE_SCHWELLEN.eins) return 1;
return 0;
}
/** Two stars unlocks the next lesson. No speed condition anywhere - that is the point. */
export function isPassed(genauigkeit: number): boolean {
return genauigkeit >= STERNE_SCHWELLEN.zwei;
}
/** One Perle 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. */
function perlenFor(zeichen: number, sterne: number): number {
return Math.floor(zeichen / 5) + sterne * 2;
}
export function grade(state: RunState): RunResult {
const zeichen = state.strokes.filter((stroke) => stroke.correct).length;
const fehler = state.missed.size;
const start = state.startedAt;
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
// "infinitely fast" would hand out a Segelfisch for a single letter, so anything 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 minuten = Math.max(dauer, 1000) / 60000;
const tempo = zeichen / minuten;
const versuche = zeichen + fehler;
const genauigkeit = versuche === 0 ? 0 : zeichen / versuche;
const punkte = tempo * genauigkeit ** 3;
const sterne = sterneFor(genauigkeit);
return {
zeichen,
fehler,
dauer,
tempo,
genauigkeit,
punkte,
sterne,
tier: tierFor(punkte).id,
bestanden: isPassed(genauigkeit),
perlen: perlenFor(zeichen, sterne),
strokes: state.strokes,
};
}
/** 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
* one, and the best animal on a lesson card can never go down. */
export function isBetter(candidate: RunResult, best: { sterne: number; punkte: number }): boolean {
if (candidate.sterne !== best.sterne) return candidate.sterne > best.sterne;
return candidate.punkte > best.punkte;
}

56
tippen/src/lib/pop.ts Normal file
View File

@@ -0,0 +1,56 @@
/** Sound feedback, ported verbatim in spirit from ../../web/src/lib/pop.ts - the same
* short rising blip the music player uses, so the two apps sound like one family.
*
* The error sound is the one addition, and it is deliberately not a buzzer: a low, soft,
* *falling* blip at a fraction of the volume. A six-year-old who is told "wrong!" twenty
* times a minute stops playing. One that hears a quiet "bloop" just tries the next key. */
let context: AudioContext | null = null;
function blip(from: number, to: number, gainStart: number, dauer: number): void {
try {
context ??= new AudioContext();
const now = context.currentTime;
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(from, now);
oscillator.frequency.exponentialRampToValueAtTime(to, now + dauer * 0.55);
gain.gain.setValueAtTime(gainStart, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + dauer);
oscillator.connect(gain).connect(context.destination);
oscillator.start();
oscillator.stop(now + dauer + 0.01);
} catch {
// No audio context before the first user gesture, and on some browsers never.
}
}
/** The music player's interaction blip: rising, bright. */
export function playPop(frequency: number): void {
blip(frequency, frequency * 1.8, 0.15, 0.15);
}
/** 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. */
export function playRichtig(streak: number): void {
const halbtoene = Math.min(streak, 12);
blip(440 * 2 ** (halbtoene / 12), 660 * 2 ** (halbtoene / 12), 0.1, 0.09);
}
/** A wrong key: low, falling, quiet. */
export function playDaneben(): void {
blip(200, 150, 0.06, 0.12);
}
/** Finishing a line. */
export function playFertig(): void {
blip(520, 900, 0.16, 0.4);
}
/** A new lesson, a new animal, a new creature in the aquarium. */
export function playFanfare(): void {
[523, 659, 784, 1047].forEach((frequency, i) => {
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
});
}

322
tippen/src/lib/progress.ts Normal file
View File

@@ -0,0 +1,322 @@
/** Everything the game remembers, in one localStorage blob.
*
* The music player deliberately keeps no client state - what is playing lives on the
* backend. This app has no backend at all, so localStorage is the whole store. One key,
* one versioned object, and a `migrate` on read: when the shape changes later, old
* saves upgrade instead of a six-year-old losing her aquarium.
*
* Every read and write is wrapped. A private window, a browser with site data blocked,
* a quota that is full - none of those may stop the game from being playable. They just
* make it forgetful. */
import { kreaturAus } from "./aquarium";
import type { KreaturId } from "./aquarium";
import { FIRST_LESSON_ID, LESSONS, WELTEN, nextLesson } from "./curriculum";
import type { RunResult, TierId } from "./grading";
import { isBetter } from "./grading";
const STORAGE_KEY = "delfin-tippen:v1";
/** 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
* between a game she returns to and one she does not. */
export const FLEISS_VERSUCHE = 5;
export interface LessonProgress {
unlocked: boolean;
runs: number;
bestSterne: 0 | 1 | 2 | 3;
bestTier: TierId | null;
bestPunkte: number;
/** Best-run keystrokes, replayed as the opponent in Delfinrennen. */
ghost: { key: string; at: number }[] | null;
}
export interface KeyStat {
/** Smoothed reaction time in ms - keybr's exponential moving average. Drives the
* focus key and how fast the on-screen keyboard hint fades out. */
ema: number;
attempts: number;
errors: number;
}
export interface Settings {
speech: boolean;
sound: boolean;
keyboardHint: "auto" | "on" | "off";
}
export interface Progress {
version: 1;
lessons: Record<string, LessonProgress>;
keyStats: Record<string, KeyStat>;
perlen: number;
/** Pets that have moved into the aquarium, in the order they arrived. */
aquarium: KreaturId[];
streak: { days: number; lastPlayed: string | null };
settings: Settings;
}
function emptyLesson(unlocked: boolean): LessonProgress {
return { unlocked, runs: 0, bestSterne: 0, bestTier: null, bestPunkte: 0, ghost: null };
}
export function freshProgress(): Progress {
const lessons: Record<string, LessonProgress> = {};
for (const lesson of LESSONS) {
lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID);
}
return {
version: 1,
lessons,
keyStats: {},
perlen: 0,
aquarium: [],
streak: { days: 0, lastPlayed: null },
settings: { speech: true, sound: true, keyboardHint: "auto" },
};
}
/** Bring any stored value up to the current shape. Anything unrecognisable is thrown
* away rather than trusted - a half-valid Progress would crash the lesson map, and a
* fresh one merely means starting over. */
export function migrate(raw: unknown): Progress {
const fresh = freshProgress();
if (typeof raw !== "object" || raw === null) return fresh;
const stored = raw as Partial<Progress>;
if (stored.version !== 1) return fresh;
const lessons = { ...fresh.lessons };
if (typeof stored.lessons === "object" && stored.lessons !== null) {
for (const [id, value] of Object.entries(stored.lessons)) {
// Lessons that no longer exist in the curriculum are dropped silently.
if (!(id in lessons) || typeof value !== "object" || value === null) continue;
lessons[id] = { ...emptyLesson(false), ...value };
}
}
// The first lesson is unlocked by definition; a save that says otherwise is wrong.
lessons[FIRST_LESSON_ID] = { ...lessons[FIRST_LESSON_ID]!, unlocked: true };
return {
version: 1,
lessons,
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {},
perlen: typeof stored.perlen === "number" ? stored.perlen : 0,
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
streak:
typeof stored.streak === "object" && stored.streak !== null
? { days: stored.streak.days ?? 0, lastPlayed: stored.streak.lastPlayed ?? null }
: fresh.streak,
settings: { ...fresh.settings, ...(stored.settings ?? {}) },
};
}
/** 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
* one stray entry cannot put a broken image in the tank. */
function migrateAquarium(raw: readonly unknown[]): KreaturId[] {
const tiere: KreaturId[] = [];
for (const eintrag of raw) {
const id = kreaturAus(eintrag);
if (id && !tiere.includes(id)) tiere.push(id);
}
return tiere;
}
export function loadProgress(): Progress {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === null) return freshProgress();
return migrate(JSON.parse(raw));
} catch {
// Unparseable, unreadable, or no storage at all. Play on without memory.
return freshProgress();
}
}
export function saveProgress(progress: Progress): void {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch {
// Quota or a blocked store. Nothing to do and nothing worth telling a child.
}
}
/** Today as YYYY-MM-DD in local time - `toISOString` would roll over at the wrong hour
* and break the streak for anyone playing in the evening. */
export function today(now: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
function bumpStreak(streak: Progress["streak"], day: string): Progress["streak"] {
if (streak.lastPlayed === day) return streak;
const yesterday = new Date(`${day}T12:00:00`);
yesterday.setDate(yesterday.getDate() - 1);
const consecutive = streak.lastPlayed === today(yesterday);
// A missed day restarts at 1, never at 0 - playing today always counts for something.
return { days: consecutive ? streak.days + 1 : 1, lastPlayed: day };
}
/** Fold the per-key reaction times of a run into the stored averages. The EMA weight of
* 0.3 is slow enough that one distracted run does not rewrite what she knows. */
function foldKeyStats(stats: Record<string, KeyStat>, result: RunResult): Record<string, KeyStat> {
const next = { ...stats };
let previousAt: number | null = null;
for (const stroke of result.strokes) {
const key = stroke.expected.toLowerCase();
const current = next[key] ?? { ema: 0, attempts: 0, errors: 0 };
const gap = previousAt === null ? null : stroke.at - previousAt;
previousAt = stroke.at;
next[key] = {
// Reaction times over two seconds are a pause for thought or an interruption,
// not a measure of the key, so they are ignored rather than averaged in.
ema: gap !== null && gap < 2000 ? (current.ema === 0 ? gap : current.ema * 0.7 + gap * 0.3) : current.ema,
attempts: current.attempts + 1,
errors: current.errors + (stroke.correct ? 0 : 1),
};
}
return next;
}
/** Welt number to the creature it releases, read straight off the Welten table so it
* cannot drift from curriculum.ts. */
const WELT_BELOHNUNG = new Map(WELTEN.map((welt) => [welt.nummer, welt.belohnung]));
export interface RecordOutcome {
progress: Progress;
/** Set when this run unlocked the following lesson, for the celebration. */
unlockedLessonId: string | null;
/** Set when this run finished a Welt, for the creature that moved in. */
neuesTier: KreaturId | null;
bestseit: boolean;
}
/** Record a finished run: stars, animal, Perlen, key stats, streak, and the unlock.
*
* 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. */
export function recordRun(
progress: Progress,
lessonId: string,
result: RunResult,
day: string = today(),
): RecordOutcome {
const before = progress.lessons[lessonId] ?? emptyLesson(true);
const runs = before.runs + 1;
const improved = isBetter(result, { sterne: before.bestSterne, punkte: before.bestPunkte });
const lessons = { ...progress.lessons };
lessons[lessonId] = {
...before,
runs,
bestSterne: improved ? result.sterne : before.bestSterne,
bestTier: improved ? result.tier : before.bestTier,
bestPunkte: improved ? result.punkte : before.bestPunkte,
ghost: improved
? result.strokes.filter((s) => s.correct).map((s) => ({ key: s.key, at: s.at }))
: before.ghost,
};
const verdient = result.bestanden || runs >= FLEISS_VERSUCHE;
const folgend = nextLesson(lessonId);
let unlockedLessonId: string | null = null;
if (verdient && folgend && !lessons[folgend.id]?.unlocked) {
lessons[folgend.id] = { ...(lessons[folgend.id] ?? emptyLesson(false)), unlocked: true };
unlockedLessonId = folgend.id;
}
// Finishing the last lesson of a Welt releases that Welt's creature. Checked against
// the aquarium so it is only ever awarded once.
const aquarium = [...progress.aquarium];
let neuesTier: KreaturId | null = null;
if (unlockedLessonId && folgend) {
const beendet = LESSONS.find((l) => l.id === lessonId);
if (beendet && folgend.welt !== beendet.welt) {
const belohnung = WELT_BELOHNUNG.get(beendet.welt);
if (belohnung && !aquarium.includes(belohnung)) {
aquarium.push(belohnung);
neuesTier = belohnung;
}
}
}
return {
progress: {
...progress,
lessons,
aquarium,
keyStats: foldKeyStats(progress.keyStats, result),
perlen: progress.perlen + result.perlen,
streak: bumpStreak(progress.streak, day),
},
unlockedLessonId,
neuesTier,
bestseit: improved,
};
}
/** How many times a key must be typed before its stats mean anything. */
const GENUG_VERSUCHE = 3;
/** The key a lesson should drill hardest - the generator's focus key, or `null` for an
* even spread.
*
* `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
* line - which on Lektion 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;
* 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 {
const keys = activeKeys.filter((key) => key !== " ");
if (keys.length === 0) return null;
const geuebt = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= GENUG_VERSUCHE);
if (geuebt.length === 0) return null;
// 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);
if (unbekannt) return unbekannt;
let worst: string | null = null;
let worstScore = -Infinity;
for (const key of keys) {
const stat = progress.keyStats[key]!;
// Errors weigh heavily: a key she gets wrong matters more than one she is merely
// slow on, and 3000ms is well past the point where slow becomes a real hesitation.
const score = stat.ema + (stat.errors / stat.attempts) * 3000;
if (score > worstScore) {
worstScore = score;
worst = key;
}
}
return worst;
}
/** 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. */
export function besteTier(progress: Progress): TierId | null {
let best: TierId | null = null;
let bestPunkte = -1;
for (const lesson of Object.values(progress.lessons)) {
if (lesson.bestTier && lesson.bestPunkte > bestPunkte) {
best = lesson.bestTier;
bestPunkte = lesson.bestPunkte;
}
}
return best;
}
/** How well a key is known, 0..1 - the on-screen keyboard's opacity, so the hint fades
* away exactly where she no longer needs it. */
export function mastery(progress: Progress, key: string): number {
const stat = progress.keyStats[key.toLowerCase()];
if (!stat || stat.attempts < 5) return 0;
const accuracy = 1 - stat.errors / stat.attempts;
// 600ms is about where a six-year-old's key press stops being a search.
const speed = Math.max(0, Math.min(1, (1200 - stat.ema) / 600));
return Math.max(0, Math.min(1, accuracy * speed));
}

77
tippen/src/lib/speech.ts Normal file
View File

@@ -0,0 +1,77 @@
/** 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.
}
}

39
tippen/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,39 @@
/** Feature toggles and the one hue this app is built on.
*
* Same role as ../../web/src/lib/theme.ts: flip a switch here rather than hunting
* through components. The hue lives in styles/app.css because CSS is where it is used;
* it is repeated here only for the canvas, which cannot read a custom property. */
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
export const HUE = 175;
/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs. */
export const SHOW_GLASS_BLUR = true;
/** The decorative rising bubbles behind everything. */
export const SHOW_BUBBLES = true;
/** 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. */
export const SHOW_AQUARIUM_TIERE = true;
/** Fade screens in on entry. */
export const ANIMATE_VIEW_TRANSITIONS = true;
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
* as each one is mastered - the scaffold that removes itself, which is the whole point
* of teaching touch typing rather than hunt-and-peck. */
export const KEYBOARD_HINT_DEFAULT: "auto" | "on" | "off" = "auto";
/** Read the target letters and words aloud. She is six and still learning to read; a
* missing reading skill must never block the typing skill. */
export const SPEECH_DEFAULT = true;
export const SOUND_DEFAULT = true;
/** How many letters one Blasenplatzen or Quallenalarm round sends up - matched to the
* Tauchgang's length so a mode swap is not also a difficulty swap. Tauchgang line
* length lives on the lesson itself (`laengeFuer` in lib/curriculum.ts). */
export function blasenAnzahlFuer(welt: number): number {
return welt === 1 ? 50 : 100;
}

11
tippen/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/app.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

392
tippen/src/styles/app.css Normal file
View File

@@ -0,0 +1,392 @@
/* The Dolphin Beats design system, on its own hue.
../../web/src/styles/app.css is the original; the tokens, the glass recipe, the radius
scale and the entrance animations are deliberately identical so the two apps read as
siblings. What changes is the hue: the music player's sea is 210, "Mein Zimmer" is
300, Hörbücher are 55. This is 175 - a turquoise lagoon, clearly the same ocean and
clearly not the same room.
The pink --accent stays exactly as it is. It is the family's signature highlight. */
:root {
--ink: oklch(30% 0.04 175);
--paper: oklch(97% 0.01 175);
--accent: oklch(70% 0.16 340);
--accent-dim: oklch(78% 0.14 340);
--sea-deep: oklch(20% 0.045 175);
--shadow: oklch(15% 0.05 175 / 0.35);
/* 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. */
--richtig: oklch(80% 0.17 150);
--daneben: oklch(80% 0.13 75);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Nunito", system-ui, sans-serif;
overscroll-behavior: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
button {
font-family: inherit;
}
/* ------------------------------------------------------------------ stage -- */
.stage {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: linear-gradient(
180deg,
oklch(55% 0.07 175) 0%,
oklch(38% 0.06 175) 45%,
var(--sea-deep) 100%
);
display: flex;
flex-direction: column;
/* Its own stacking context, so the swimming pets (z-index -1, AquariumTiere.tsx) paint
above this gradient and below every screen - rather than below the page itself. */
isolation: isolate;
}
.bubble {
position: absolute;
bottom: -40px;
border-radius: 50%;
animation-name: bubbleRise;
animation-timing-function: linear;
animation-iteration-count: infinite;
pointer-events: none;
}
/* ------------------------------------------------------------- animations -- */
@keyframes bubbleRise {
0% { transform: translateY(0) scale(1); opacity: 0.55; }
100% { transform: translateY(-120vh) scale(1.3); opacity: 0; }
}
@keyframes dolphinBob {
0%, 100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-10px) rotate(2deg); }
}
@keyframes viewEnter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sheetEnter {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes backdropEnter {
from { opacity: 0; }
to { opacity: 1; }
}
.view-enter { animation: viewEnter 200ms ease-out; }
.sheet-enter { animation: sheetEnter 240ms ease-out; }
.backdrop-enter { animation: backdropEnter 150ms ease; }
/* A correct key. Short and bouncy - the reward has to land before the next keystroke. */
@keyframes correctPop {
0% { transform: scale(1); }
40% { transform: scale(1.25); }
100% { transform: scale(1); }
}
/* A wrong key. A small wobble, not a buzz: it says "not that one, try again", and
deliberately does not say "you failed". */
@keyframes wrongShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
@keyframes keyPulse {
0%, 100% { box-shadow: 0 0 0 0 oklch(97% 0.01 175 / 0.6); }
50% { box-shadow: 0 0 0 9px oklch(97% 0.01 175 / 0); }
}
@keyframes tierEnter {
0% { opacity: 0; transform: scale(0.4) rotate(-12deg); }
60% { opacity: 1; transform: scale(1.15) rotate(4deg); }
100% { opacity: 1; transform: scale(1) rotate(0); }
}
@keyframes sterneEnter {
0% { opacity: 0; transform: scale(0.2); }
100% { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
/* Keep the colour feedback, drop the movement. */
.bubble,
.view-enter,
.sheet-enter,
.backdrop-enter {
animation: none;
}
}
/* ----------------------------------------------------------------- glass -- */
.glass-panel {
background: linear-gradient(160deg, oklch(97% 0.01 175 / 0.14), oklch(97% 0.01 175 / 0.05));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid oklch(97% 0.01 175 / 0.16);
border-radius: 22px;
box-shadow:
0 8px 24px var(--shadow),
inset 0 1px 0 oklch(100% 0 0 / 0.14);
}
.stage[data-blur="off"] .glass-panel,
.stage[data-blur="off"] .card,
.stage[data-blur="off"] .kb-key {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.pill {
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 800;
padding: 9px 15px;
border-radius: 999px;
white-space: nowrap;
background: oklch(97% 0.01 175 / 0.16);
color: var(--paper);
}
.pill[data-active="true"] {
background: var(--paper);
color: oklch(28% 0.04 175);
box-shadow: 0 4px 14px var(--shadow);
}
.card {
backdrop-filter: blur(6px);
overflow: hidden;
cursor: pointer;
transition: transform 0.12s ease;
border: none;
padding: 0;
text-align: left;
font: inherit;
display: block;
width: 100%;
}
.card[data-selected="true"] {
outline: 5px solid var(--paper);
outline-offset: 3px;
transform: translateY(-3px);
}
/* A locked lesson is dimmed but never hidden - seeing what comes next is half the
reason to finish what is open. */
.card[data-locked="true"] {
cursor: default;
opacity: 0.45;
}
.sheet {
background: var(--paper);
border-radius: 24px;
box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5);
}
.overlay {
position: absolute;
inset: 0;
background: oklch(15% 0.03 175 / 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
z-index: 20;
}
.key-cap {
font: 800 12px ui-monospace, Menlo, monospace;
background: var(--ink);
color: #fff;
padding: 5px 12px;
border-radius: 8px;
min-width: 56px;
text-align: center;
}
/* ------------------------------------------------- die Bildschirmtastatur -- */
.kb {
display: flex;
flex-direction: column;
gap: 7px;
align-items: center;
transition: opacity 400ms ease;
}
.kb-row {
display: flex;
gap: 7px;
}
.kb-key {
width: var(--kb-size, 42px);
height: var(--kb-size, 42px);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: calc(var(--kb-size, 42px) * 0.42);
font-weight: 800;
color: var(--paper);
background: oklch(97% 0.01 175 / 0.1);
border: 1px solid oklch(97% 0.01 175 / 0.14);
backdrop-filter: blur(6px);
transition:
background 0.15s ease,
transform 0.1s ease;
}
/* A key that this lesson does not use yet fades back so the eye goes to the ones that
matter. It stays visible: the keyboard is a map, and a map with holes is confusing. */
.kb-key[data-active="false"] {
opacity: 0.3;
}
/* Each finger owns a hue (lib/fingers.ts), so "der grüne Finger" is something you can
say out loud to a six-year-old and have her understand it. */
/* Saturated on purpose. The finger colour is not decoration - it is how a six-year-old
is told which finger to use ("der grüne Finger"), so it has to survive being read at
arm's length against a dark blue-green background. The first pass was tinted at 50%
alpha and every key came out the same murky green. */
.kb-key[data-finger] {
background: linear-gradient(
160deg,
oklch(68% 0.19 var(--finger-hue) / 0.92),
oklch(52% 0.16 var(--finger-hue) / 0.8)
);
border-color: oklch(82% 0.13 var(--finger-hue) / 0.75);
color: oklch(22% 0.05 var(--finger-hue));
text-shadow: 0 1px 0 oklch(100% 0 0 / 0.25);
}
.kb-key[data-next="true"] {
background: var(--paper);
color: oklch(25% 0.05 175);
transform: translateY(-3px) scale(1.08);
animation: keyPulse 1.3s ease-out infinite;
}
.kb-key[data-home="true"]::after {
/* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
content: "";
position: absolute;
transform: translateY(12px);
width: 12px;
height: 2px;
border-radius: 2px;
background: currentColor;
opacity: 0.55;
}
.kb-key {
position: relative;
}
.kb-space {
width: calc(var(--kb-size, 42px) * 6);
}
/* ---------------------------------------------------------- die Zielzeile -- */
.ziel {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 12px 26px;
font-weight: 800;
letter-spacing: 0.02em;
/* 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
fallback for a caller that does not. */
max-width: min(900px, 88vw);
}
.ziel-chunk {
display: flex;
}
.ziel-zeichen {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0.78em;
color: oklch(97% 0.01 175 / 0.45);
transition: color 0.12s ease;
}
.ziel-zeichen[data-state="fertig"] {
color: var(--richtig);
}
.ziel-zeichen[data-state="aktuell"] {
color: oklch(25% 0.05 175);
background: var(--paper);
border-radius: 10px;
box-shadow: 0 6px 18px var(--shadow);
animation: correctPop 200ms ease-out;
}
.ziel-zeichen[data-state="aktuell"][data-daneben="true"] {
background: var(--daneben);
animation: wrongShake 260ms ease;
}
/* 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
"Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */
.ziel-zeichen[data-leer="true"] {
min-width: 0.9em;
}
/* The two Shift keys, drawn either side of the Leertaste. Wider than a letter key so
they read as the modifier they are, and dim until a capital actually needs one. */
.kb-shift {
width: calc(var(--kb-size, 42px) * 1.8);
background: oklch(97% 0.01 175 / 0.12);
font-size: calc(var(--kb-size, 42px) * 0.4);
}
/* A dome plus three trailing tentacles. Without them the Quallen read as plain circles,
and "Quallenalarm" stops being a picture of anything. Drawn in CSS rather than as an
emoji so the letter stays centred and legible inside the dome. */
.qualle::after {
content: "";
position: absolute;
bottom: -11px;
left: 26%;
right: 26%;
height: 16px;
opacity: 0.55;
background:
linear-gradient(currentColor, transparent) left / 3px 100% no-repeat,
linear-gradient(currentColor, transparent) center / 3px 100% no-repeat,
linear-gradient(currentColor, transparent) right / 3px 100% no-repeat;
}

20
tippen/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumTiere.tsx","./src/components/Bubbles.tsx","./src/components/HandHint.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ModePicker.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BlasenRun.tsx","./src/components/modes/FuetternRun.tsx","./src/components/modes/PerlenRun.tsx","./src/components/modes/QuallenRun.tsx","./src/components/modes/RennenRun.tsx","./src/components/modes/TauchgangRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/speech.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}

11
tippen/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Deliberately standalone: no proxy, no backend, no shared build with ../web. The game
// is pure client state (localStorage), so `vite dev` on its own is the whole runtime.
// Port 5174 so it can run next to the music player's 5173 without a clash.
export default defineConfig({
plugins: [react()],
server: { port: 5174 },
build: { outDir: "dist", emptyOutDir: true },
});