First implementation of 10 finger typing
2
tippen/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
107
tippen/README.md
Normal 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.
|
||||||
BIN
tippen/art/aquarium/clownfisch.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
tippen/art/aquarium/krake.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
tippen/art/aquarium/perlmuschel.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
tippen/art/aquarium/schildkroete.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
tippen/art/aquarium/seepferdchen.png
Normal file
|
After Width: | Height: | Size: 867 KiB |
19
tippen/index.html
Normal 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
27
tippen/package.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
tippen/public/aquarium/clownfisch.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
tippen/public/aquarium/krake.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
tippen/public/aquarium/perlmuschel.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
tippen/public/aquarium/schildkroete.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
tippen/public/aquarium/seepferdchen.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
tippen/public/dolphin-mascot.png
Normal file
|
After Width: | Height: | Size: 697 KiB |
BIN
tippen/public/fonts/nunito-latin-ext.woff2
Normal file
BIN
tippen/public/fonts/nunito-latin.woff2
Normal file
22
tippen/public/fonts/nunito.css
Normal 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
|
After Width: | Height: | Size: 18 KiB |
57
tippen/scripts/aquarium-bild.sh
Executable 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
tippen/src/components/AppHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
tippen/src/components/Aquarium.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
tippen/src/components/AquariumTiere.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
tippen/src/components/Bubbles.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
tippen/src/components/HandHint.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
tippen/src/components/HelpOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
tippen/src/components/Keyboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
tippen/src/components/LessonMap.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
tippen/src/components/ModePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
tippen/src/components/ResultSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
tippen/src/components/Stage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
tippen/src/components/Target.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
tippen/src/components/modes/BlasenRun.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
tippen/src/components/modes/FuetternRun.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
tippen/src/components/modes/PerlenRun.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
tippen/src/components/modes/QuallenRun.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
tippen/src/components/modes/RennenRun.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
tippen/src/components/modes/TauchgangRun.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
tippen/src/hooks/useRun.ts
Normal 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 };
|
||||||
|
}
|
||||||
98
tippen/src/lib/__tests__/aquarium.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
199
tippen/src/lib/__tests__/curriculum.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
tippen/src/lib/__tests__/engine.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
tippen/src/lib/__tests__/fingers.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
198
tippen/src/lib/__tests__/generator.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
184
tippen/src/lib/__tests__/grading.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
238
tippen/src/lib/__tests__/progress.test.ts
Normal 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
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
308
tippen/src/lib/curriculum.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||
1
tippen/tsconfig.tsbuildinfo
Normal 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
@@ -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 },
|
||||||
|
});
|
||||||