Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player / smarthome / typing), replacing the old single room-toggle corner button with a vertical icon tab rail. Curriculum and progress now come from the backend (musicmouse/tippen/*) instead of a build-time YAML import and localStorage. Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question mark for locked albums/tracks with a hint on what unlocks them, and ResultSheet gets a new unlock-animation block alongside the existing lesson-unlock and aquarium-creature celebrations. CSS from the two apps is merged carefully: identical rules (bubble/card/ key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is, while rules that bake in each app's own hue are kept separate under a `tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so neither app's look bleeds into the other's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@@ -7,7 +7,17 @@ from pathlib import Path
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from musicmouse.tippen.curriculum import CurriculumError, load_curriculum
|
||||
from musicmouse.tippen.curriculum import (
|
||||
ELIGIBLE_MODES,
|
||||
CurriculumError,
|
||||
first_lesson_id,
|
||||
lesson_by_id,
|
||||
load_curriculum,
|
||||
next_lesson,
|
||||
)
|
||||
|
||||
#: The real, shipped curriculum - see python-backend/tippen-curriculum.yml.example.
|
||||
_EXAMPLE = Path(__file__).parent.parent / "tippen-curriculum.yml.example"
|
||||
|
||||
VALID: dict = {
|
||||
"worlds": [
|
||||
@@ -150,3 +160,168 @@ def test_unlocks_is_carried_through_unresolved(tmp_path: Path) -> None:
|
||||
curriculum = load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
assert curriculum.lessons[0].unlocks == "~/Music/Musik/Album/05Track.mp3"
|
||||
assert curriculum.lessons[1].unlocks is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the real curriculum
|
||||
#
|
||||
# Content-shape invariants the real, shipped curriculum must hold - ported from the
|
||||
# frontend's old curriculum.test.ts, which checked these against the same YAML back
|
||||
# when it was parsed client-side. The pedagogical content itself did not change in the
|
||||
# move to the backend; what moved is the parsing/deriving logic these tests actually
|
||||
# exercise (buildLessons's Python port, `_build_lessons`), so the coverage still earns
|
||||
# its keep here. A few of the original checks needed real-keyboard finger/hand mapping
|
||||
# (`fingers.ts`, never ported to Python, since nothing backend-side needs it) and are
|
||||
# not repeated.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def curriculum():
|
||||
return load_curriculum(_EXAMPLE)
|
||||
|
||||
|
||||
def test_has_unique_ids_and_consecutive_numbers(curriculum) -> None:
|
||||
ids = [lesson.id for lesson in curriculum.lessons]
|
||||
assert len(set(ids)) == len(ids)
|
||||
for i, lesson in enumerate(curriculum.lessons):
|
||||
assert lesson.number == i + 1
|
||||
|
||||
|
||||
def test_starts_on_the_two_keys_with_the_tactile_bumps(curriculum) -> None:
|
||||
assert curriculum.lessons[0].new_keys == ("f", "j")
|
||||
assert first_lesson_id(curriculum) == curriculum.lessons[0].id
|
||||
|
||||
|
||||
def test_eases_in_at_most_two_new_keys_per_lesson(curriculum) -> None:
|
||||
for lesson in curriculum.lessons:
|
||||
assert len(lesson.new_keys) <= 2, f"{lesson.number}: {lesson.title}"
|
||||
|
||||
|
||||
def test_makes_every_round_a_real_block_of_practice(curriculum) -> None:
|
||||
for lesson in curriculum.lessons:
|
||||
where = f"{lesson.number}: {lesson.title}"
|
||||
if lesson.kind == "sentences":
|
||||
assert lesson.chunks >= 10, where
|
||||
elif lesson.kind == "words":
|
||||
assert lesson.chunks >= 25, where
|
||||
else:
|
||||
assert lesson.chunks * lesson.chunk_size >= 60, where
|
||||
|
||||
|
||||
def test_has_more_sentences_to_draw_from_than_a_round_uses(curriculum) -> None:
|
||||
for lesson in curriculum.lessons:
|
||||
if lesson.kind != "sentences":
|
||||
continue
|
||||
assert len(lesson.words) >= lesson.chunks, f"{lesson.number}: {lesson.title}"
|
||||
|
||||
|
||||
def test_active_keys_grow_monotonically(curriculum) -> None:
|
||||
previous: set[str] = set()
|
||||
for lesson in curriculum.lessons:
|
||||
active = set(lesson.active_keys)
|
||||
assert previous <= active
|
||||
previous = active
|
||||
|
||||
|
||||
def test_every_new_key_enters_its_own_active_keys(curriculum) -> None:
|
||||
for lesson in curriculum.lessons:
|
||||
for key in lesson.new_keys:
|
||||
if key == "⇧": # Shift is not a character the generator can emit.
|
||||
continue
|
||||
assert key in lesson.active_keys, f"{lesson.number}: {lesson.title}"
|
||||
|
||||
|
||||
def test_covers_the_whole_alphabet_plus_umlauts_by_the_end(curriculum) -> None:
|
||||
final = set(curriculum.lessons[-1].active_keys)
|
||||
for key in "abcdefghijklmnopqrstuvwxyzäöü":
|
||||
assert key in final
|
||||
|
||||
|
||||
def test_plays_a_mode_that_fits_its_kind(curriculum) -> None:
|
||||
for lesson in curriculum.lessons:
|
||||
where = f"{lesson.number}: {lesson.title}"
|
||||
assert lesson.primary_mode in ELIGIBLE_MODES[lesson.kind], where
|
||||
for mode in lesson.bonus_modes:
|
||||
assert mode in ELIGIBLE_MODES[lesson.kind], where
|
||||
|
||||
|
||||
def test_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None:
|
||||
arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"]
|
||||
for i in range(1, len(arcade)):
|
||||
where = f"{arcade[i].number}: {arcade[i].title}"
|
||||
assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where
|
||||
|
||||
|
||||
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:
|
||||
for lesson in curriculum.lessons:
|
||||
expected = sorted(
|
||||
mode
|
||||
for mode in ELIGIBLE_MODES[lesson.kind]
|
||||
if mode in ("feed", "race") and mode != lesson.primary_mode
|
||||
)
|
||||
assert sorted(lesson.bonus_modes) == expected
|
||||
|
||||
|
||||
def test_mixes_games_instead_of_always_diving(curriculum) -> None:
|
||||
words = [lesson for lesson in curriculum.lessons if lesson.kind == "words"]
|
||||
sentences = [lesson for lesson in curriculum.lessons if lesson.kind == "sentences"]
|
||||
assert any(lesson.primary_mode == "feed" for lesson in words)
|
||||
assert any(lesson.primary_mode == "race" for lesson in words)
|
||||
assert any(lesson.primary_mode == "race" for lesson in sentences)
|
||||
|
||||
|
||||
def test_distinguishes_a_mixed_round_from_a_drill(curriculum) -> None:
|
||||
mixed = [lesson for lesson in curriculum.lessons if lesson.emphasis == "mixed"]
|
||||
assert len(mixed) > 0
|
||||
for lesson in mixed:
|
||||
assert lesson.new_keys == ()
|
||||
assert lesson.is_drill is False
|
||||
|
||||
|
||||
def test_every_isolated_round_has_something_new_to_drill(curriculum) -> None:
|
||||
for lesson in curriculum.lessons:
|
||||
if lesson.emphasis == "isolated":
|
||||
assert len(lesson.new_keys) > 0, f"{lesson.number}: {lesson.title}"
|
||||
|
||||
|
||||
def test_teaches_capitals_only_once_shift_exists(curriculum) -> None:
|
||||
for lesson in curriculum.lessons:
|
||||
has_uppercase = any(word != word.lower() for word in lesson.words)
|
||||
if has_uppercase:
|
||||
assert lesson.world >= 4, f"{lesson.number}: {lesson.title}"
|
||||
|
||||
|
||||
def test_follows_every_pair_of_new_keys_with_a_drill(curriculum) -> None:
|
||||
drills = [lesson for lesson in curriculum.lessons if lesson.is_drill]
|
||||
assert len(drills) >= 15
|
||||
for lesson in drills:
|
||||
assert lesson.new_keys == ()
|
||||
assert len(lesson.active_keys) > 0
|
||||
|
||||
|
||||
def test_is_a_real_duolingo_length_course(curriculum) -> None:
|
||||
assert len(curriculum.lessons) >= 80
|
||||
assert len(curriculum.worlds) == 5
|
||||
|
||||
|
||||
def test_every_world_has_at_least_three_lessons(curriculum) -> None:
|
||||
for world in curriculum.worlds:
|
||||
count = sum(1 for lesson in curriculum.lessons if lesson.world == world.number)
|
||||
assert count >= 3, world.title
|
||||
|
||||
|
||||
def test_every_world_has_its_own_creature(curriculum) -> None:
|
||||
rewards = [world.reward for world in curriculum.worlds]
|
||||
assert len(set(rewards)) == len(rewards)
|
||||
|
||||
|
||||
def test_navigation_chains_every_lesson_to_the_next(curriculum) -> None:
|
||||
lessons = curriculum.lessons
|
||||
for i in range(len(lessons) - 1):
|
||||
assert next_lesson(curriculum, lessons[i].id).id == lessons[i + 1].id
|
||||
assert next_lesson(curriculum, lessons[-1].id) is None
|
||||
assert next_lesson(curriculum, "gibt-es-nicht") is None
|
||||
|
||||
|
||||
def test_navigation_looks_lessons_up_by_id(curriculum) -> None:
|
||||
assert lesson_by_id(curriculum, first_lesson_id(curriculum)).number == 1
|
||||
assert lesson_by_id(curriculum, "gibt-es-nicht") is None
|
||||
|
||||
2
tippen/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
107
tippen/README.md
@@ -1,107 +0,0 @@
|
||||
# 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.
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 867 KiB |
@@ -1,19 +0,0 @@
|
||||
<!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>
|
||||
2349
tippen/package-lock.json
generated
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"yaml": "^2.6.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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 697 KiB |
@@ -1,22 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,57 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,422 +0,0 @@
|
||||
# The lesson plan, as data rather than code, so wording, word lists and pacing can be
|
||||
# tweaked here without touching curriculum.ts.
|
||||
#
|
||||
# Every lesson: title, subtitle (read aloud). Then either:
|
||||
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were
|
||||
# already active: the first time a key appears its lesson is "isolated" (heavy
|
||||
# weight, alone); the second (identical) appearance is "mixed" (lighter weight,
|
||||
# blended with everything learned so far). Two lessons with the same `keys` back to
|
||||
# back is exactly how you write "isolated, then mixed" - no separate flag needed.
|
||||
# - drill: true - a pure review round, no new content, whatever is active so far.
|
||||
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
|
||||
# default; `mode:` overrides it, see curriculum.ts for which modes fit which kind).
|
||||
#
|
||||
# Letter order follows German letter frequency, adapted to a home-row-first, mirrored
|
||||
# pace for a six-year-old - unchanged from the original course.
|
||||
|
||||
worlds:
|
||||
- number: 1
|
||||
title: "Die Grundstellung"
|
||||
emoji: "🏝️"
|
||||
reward: clownfish
|
||||
lessons:
|
||||
- title: "F und J"
|
||||
subtitle: "Die Zeigefinger - die Tasten mit den Punkten"
|
||||
keys: [f, j]
|
||||
- title: "F und J üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: [f, j]
|
||||
- title: "D und K"
|
||||
subtitle: "Die Mittelfinger"
|
||||
keys: [d, k]
|
||||
- title: "D und K üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: [d, k]
|
||||
- title: "Übung: F J D K"
|
||||
subtitle: "Die vier Tasten zusammen"
|
||||
drill: true
|
||||
- title: "S und L"
|
||||
subtitle: "Die Ringfinger"
|
||||
keys: [s, l]
|
||||
- title: "S und L üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: [s, l]
|
||||
- title: "Übung: sechs Tasten"
|
||||
subtitle: "Alles bisher zusammen"
|
||||
drill: true
|
||||
- title: "A und Ö"
|
||||
subtitle: "Die kleinen Finger"
|
||||
keys: [a, ö]
|
||||
- title: "A und Ö üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: [a, ö]
|
||||
- title: "Übung: die Grundstellung"
|
||||
subtitle: "Alle acht Finger"
|
||||
drill: true
|
||||
- title: "Erste kleine Wörter"
|
||||
subtitle: "Echte Wörter mit acht Tasten"
|
||||
kind: fragments
|
||||
words: [da, ja, das, dass, als, all, fall, falls, lass, saal, kalk, salsa, jass]
|
||||
- title: "Die Leertaste"
|
||||
subtitle: "Der Daumen kommt dazu"
|
||||
keys: [" "]
|
||||
- title: "Übung: Grundstellung mit Leertaste"
|
||||
subtitle: "Jetzt mit dem Daumen"
|
||||
drill: true
|
||||
- title: "Wörter mit Leertaste"
|
||||
subtitle: "Kleine Wörter, kleine Sätze"
|
||||
kind: fragments
|
||||
words: ["lass das", "das da", "da ja", "fall da", "kalk da", "saal da", "ja lass das", "als da"]
|
||||
|
||||
- number: 2
|
||||
title: "Nach oben"
|
||||
emoji: "🌊"
|
||||
reward: octopus
|
||||
lessons:
|
||||
- title: "Das E"
|
||||
subtitle: "Mittelfinger links nach oben"
|
||||
keys: [e]
|
||||
- title: "E üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [e]
|
||||
- title: "Das I"
|
||||
subtitle: "Mittelfinger rechts nach oben"
|
||||
keys: [i]
|
||||
- title: "I üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [i]
|
||||
- title: "Übung: E und I"
|
||||
subtitle: "Beide Mittelfinger nach oben"
|
||||
drill: true
|
||||
- title: "Das R"
|
||||
subtitle: "Zeigefinger links nach oben"
|
||||
keys: [r]
|
||||
- title: "R üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [r]
|
||||
- title: "Das U"
|
||||
subtitle: "Zeigefinger rechts nach oben"
|
||||
keys: [u]
|
||||
- title: "U üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [u]
|
||||
- title: "Übung: R und U"
|
||||
subtitle: "Beide Zeigefinger nach oben"
|
||||
drill: true
|
||||
- title: "Kleine Wörter: E I R U"
|
||||
subtitle: "Erste echte Wörter mit der oberen Reihe"
|
||||
kind: fragments
|
||||
words: [die, sie, elf, eis, esel, see, keks, fiel, lied, rad, reis, eier, rufe, feuer, sauer, lauf]
|
||||
- title: "Das T"
|
||||
subtitle: "Zeigefinger links weit nach oben"
|
||||
keys: [t]
|
||||
- title: "T üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [t]
|
||||
- title: "Das Z"
|
||||
subtitle: "Zeigefinger rechts weit nach oben"
|
||||
keys: [z]
|
||||
- title: "Z üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [z]
|
||||
- title: "Übung: T und Z"
|
||||
subtitle: "Weit nach oben greifen"
|
||||
drill: true
|
||||
- title: "Das O"
|
||||
subtitle: "Ringfinger rechts nach oben"
|
||||
keys: [o]
|
||||
- title: "O üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [o]
|
||||
- title: "Das W"
|
||||
subtitle: "Ringfinger links nach oben"
|
||||
keys: [w]
|
||||
- title: "W üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [w]
|
||||
- title: "Übung: O und W"
|
||||
subtitle: "Beide Ringfinger nach oben"
|
||||
drill: true
|
||||
- title: "Kleine Wörter: T Z O W"
|
||||
subtitle: "Noch mehr echte Wörter"
|
||||
kind: fragments
|
||||
mode: feed
|
||||
words: [tier, tafel, kette, leiter, zeit, salz, zelt, katze, rot, tor, los, foto, wo, wald, zwei, wolke]
|
||||
- title: "Das P"
|
||||
subtitle: "Kleiner Finger rechts nach oben"
|
||||
keys: [p]
|
||||
- title: "P üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [p]
|
||||
- title: "Das Q"
|
||||
subtitle: "Kleiner Finger links nach oben"
|
||||
keys: [q]
|
||||
- title: "Q üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [q]
|
||||
- title: "Das Ü"
|
||||
subtitle: "Kleiner Finger rechts, ganz außen"
|
||||
keys: [ü]
|
||||
- title: "Ü üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [ü]
|
||||
- title: "Übung: P Q Ü"
|
||||
subtitle: "Die kleinen Finger nach oben"
|
||||
drill: true
|
||||
- title: "Übung: die obere Reihe"
|
||||
subtitle: "Die ganze Reihe zusammen"
|
||||
kind: words
|
||||
mode: feed
|
||||
words: [wolke, zeit, pause, prüfe, qualle, torte, reiter, würfel]
|
||||
- title: "Übung: Welt 2 komplett"
|
||||
subtitle: "Alles aus der oberen Reihe"
|
||||
drill: true
|
||||
|
||||
- number: 3
|
||||
title: "Nach unten"
|
||||
emoji: "🪸"
|
||||
reward: seahorse
|
||||
lessons:
|
||||
- title: "Das N"
|
||||
subtitle: "Zeigefinger rechts nach unten"
|
||||
keys: [n]
|
||||
- title: "N üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [n]
|
||||
- title: "Das M"
|
||||
subtitle: "Zeigefinger rechts, neben dem N"
|
||||
keys: [m]
|
||||
- title: "M üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [m]
|
||||
- title: "Übung: N und M"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
drill: true
|
||||
- title: "Das G"
|
||||
subtitle: "Zeigefinger links, in der Mitte"
|
||||
keys: [g]
|
||||
- title: "G üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [g]
|
||||
- title: "Das H"
|
||||
subtitle: "Zeigefinger rechts, in der Mitte"
|
||||
keys: [h]
|
||||
- title: "H üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [h]
|
||||
- title: "Übung: G und H"
|
||||
subtitle: "Die Mitte der Grundreihe"
|
||||
drill: true
|
||||
- title: "Kleine Wörter: N M G H"
|
||||
subtitle: "Erste echte Wörter nach unten"
|
||||
kind: fragments
|
||||
words: [nase, nein, kind, wind, sonne, mama, mond, meer, maus, gut, gans, regen, hase, haus, hund, hupe]
|
||||
- title: "Das C"
|
||||
subtitle: "Mittelfinger links nach unten"
|
||||
keys: [c]
|
||||
- title: "C üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [c]
|
||||
- title: "Das V"
|
||||
subtitle: "Zeigefinger links nach unten"
|
||||
keys: [v]
|
||||
- title: "V üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [v]
|
||||
- title: "Übung: C und V"
|
||||
subtitle: "Nach unten greifen"
|
||||
drill: true
|
||||
- title: "Das B"
|
||||
subtitle: "Zeigefinger links, neben dem V"
|
||||
keys: [b]
|
||||
- title: "B üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [b]
|
||||
- title: "Das Y"
|
||||
subtitle: "Kleiner Finger links nach unten"
|
||||
keys: [y]
|
||||
- title: "Y üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [y]
|
||||
- title: "Übung: B und Y"
|
||||
subtitle: "Ganz unten links"
|
||||
drill: true
|
||||
- title: "Kleine Wörter: C V B Y"
|
||||
subtitle: "Noch mehr echte Wörter"
|
||||
kind: fragments
|
||||
words: [koch, milch, schule, chaos, vier, vase, voll, vater, baum, boot, bunt, brot, yoga, baby, typ, pony]
|
||||
- title: "Das X"
|
||||
subtitle: "Ringfinger links nach unten"
|
||||
keys: [x]
|
||||
- title: "X üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [x]
|
||||
- title: "Das Ä"
|
||||
subtitle: "Kleiner Finger rechts, ganz außen"
|
||||
keys: [ä]
|
||||
- title: "Ä üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [ä]
|
||||
- title: "Übung: X und Ä"
|
||||
subtitle: "Die letzten beiden Tasten"
|
||||
drill: true
|
||||
- title: "Übung: alle Buchstaben"
|
||||
subtitle: "Das ganze Alphabet"
|
||||
kind: words
|
||||
mode: race
|
||||
words: [delfin, wasser, xylofon, bäume, vogel, qualle, muschel, tauchen]
|
||||
- title: "Übung: Welt 3 komplett"
|
||||
subtitle: "Alles aus der unteren Reihe"
|
||||
drill: true
|
||||
|
||||
- number: 4
|
||||
title: "Große Buchstaben"
|
||||
emoji: "👑"
|
||||
reward: turtle
|
||||
lessons:
|
||||
- title: "Umschalttaste rechts, Teil 1"
|
||||
subtitle: "Große Buchstaben der linken Hand"
|
||||
keys: ["⇧"]
|
||||
kind: words
|
||||
words: [Delfin, Wal, Fisch, Baum]
|
||||
- title: "Umschalttaste rechts, Teil 2"
|
||||
subtitle: "Noch mehr große Buchstaben"
|
||||
kind: words
|
||||
words: [Garten, Ente, Vogel, Riff]
|
||||
- title: "Umschalttaste links, Teil 1"
|
||||
subtitle: "Große Buchstaben der rechten Hand"
|
||||
kind: words
|
||||
words: [Haus, Kind, Mond, Nase]
|
||||
- title: "Umschalttaste links, Teil 2"
|
||||
subtitle: "Noch mehr große Buchstaben"
|
||||
kind: words
|
||||
words: [Lampe, Onkel, Uhr, Puppe]
|
||||
- title: "Übung: Namen, Teil 1"
|
||||
subtitle: "Namen fangen groß an"
|
||||
kind: words
|
||||
words: [Anna, Lena, Paul, Mia, Emil, Jonas, Tom, Lisa]
|
||||
- title: "Übung: Namen, Teil 2"
|
||||
subtitle: "Noch mehr Namen"
|
||||
kind: words
|
||||
mode: feed
|
||||
words: [Ben, Nora, Finn, Ida, Max, Ella, Oskar, Greta]
|
||||
- title: "Übung: große und kleine"
|
||||
subtitle: "Beides gemischt"
|
||||
kind: words
|
||||
words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"]
|
||||
- title: "Übung: Welt 4 komplett"
|
||||
subtitle: "Groß und klein zusammen"
|
||||
kind: words
|
||||
drill: true
|
||||
words: [Delfin, Haus, Anna, Ben, "Das Meer", "Der Wal", Mond, Riff]
|
||||
|
||||
- number: 5
|
||||
title: "Ganze Sätze"
|
||||
emoji: "📖"
|
||||
reward: pearlmussel
|
||||
lessons:
|
||||
- title: "Der Punkt"
|
||||
subtitle: "Ringfinger rechts nach unten"
|
||||
keys: ["."]
|
||||
- title: "Punkt üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: ["."]
|
||||
- title: "Erste Sätze mit Punkt"
|
||||
subtitle: "Ein Satz, ein Punkt"
|
||||
kind: sentences
|
||||
words:
|
||||
- "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."
|
||||
- title: "Das Komma"
|
||||
subtitle: "Mittelfinger rechts nach unten"
|
||||
keys: [","]
|
||||
- title: "Komma üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [","]
|
||||
- title: "Sätze mit Komma"
|
||||
subtitle: "Zwei Gedanken, ein Satz"
|
||||
kind: sentences
|
||||
mode: race
|
||||
words:
|
||||
- "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."
|
||||
- title: "Der Bindestrich"
|
||||
subtitle: "Kleiner Finger rechts, ganz außen"
|
||||
keys: ["-"]
|
||||
- title: "Bindestrich üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: ["-"]
|
||||
- title: "Sätze mit Bindestrich"
|
||||
subtitle: "Zwei Wörter, ein Strich"
|
||||
kind: sentences
|
||||
words:
|
||||
- "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."
|
||||
- title: "Fragezeichen und Ausrufezeichen"
|
||||
subtitle: "Mit der Umschalttaste"
|
||||
keys: ["ß", "1"]
|
||||
- title: "Fragezeichen und Ausrufezeichen üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: ["ß", "1"]
|
||||
- title: "Fragen und Rufe"
|
||||
subtitle: "Wie klingt ein Satz?"
|
||||
kind: sentences
|
||||
words:
|
||||
- "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!"
|
||||
- title: "Übung: ganze Sätze"
|
||||
subtitle: "Alles zusammen"
|
||||
kind: sentences
|
||||
drill: true
|
||||
mode: race
|
||||
words:
|
||||
- "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."
|
||||
@@ -1,236 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, 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.number).toBe(i + 1));
|
||||
});
|
||||
|
||||
it("starts on the two keys with the tactile bumps", () => {
|
||||
expect(LESSONS[0]!.newKeys).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.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("teaches world 1 as mirrored finger pairs, one finger per hand", () => {
|
||||
const pairs = lessonsOfWorld(1).filter((lesson) => lesson.newKeys.length === 2);
|
||||
expect(pairs.length).toBe(4);
|
||||
for (const lesson of pairs) {
|
||||
const [left, right] = lesson.newKeys.map((key) => fingerOf(key)!);
|
||||
expect(left!.hand).toBe("left");
|
||||
expect(right!.hand).toBe("right");
|
||||
// Same finger on each hand - "die Zeigefinger", "die Mittelfinger", …
|
||||
expect(left!.id.replace("left-", "")).toBe(right!.id.replace("right-", ""));
|
||||
}
|
||||
});
|
||||
|
||||
it("has the whole home row active by the end of world 1", () => {
|
||||
const last = lessonsOfWorld(1).at(-1)!;
|
||||
for (const key of "asdfjklö") expect(last.activeKeys).toContain(key);
|
||||
expect(last.activeKeys).toContain(" ");
|
||||
});
|
||||
|
||||
it("makes every round a real block of practice, not a handful of keys", () => {
|
||||
// The first version ran 12 characters in world 1 - long enough to finish, too short
|
||||
// for a rhythm to form or for the speed score to mean anything.
|
||||
for (const lesson of LESSONS) {
|
||||
if (lesson.kind === "sentences") {
|
||||
expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(10);
|
||||
} else if (lesson.kind === "words") {
|
||||
expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(25);
|
||||
} else {
|
||||
expect(lesson.chunks * lesson.chunkSize, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(60);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("still starts gentler than it ends", () => {
|
||||
const length = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
|
||||
expect(length(lessonsOfWorld(3)[0]!)).toBeGreaterThan(length(lessonsOfWorld(1)[0]!));
|
||||
});
|
||||
|
||||
it("has more sentences to draw from than a round uses, so a round never has to repeat", () => {
|
||||
for (const lesson of LESSONS.filter((l) => l.kind === "sentences")) {
|
||||
expect(lesson.words.length, `${lesson.number}: ${lesson.title}`).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.newKeys) {
|
||||
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 word of lesson.words) {
|
||||
for (const char of word) {
|
||||
expect(
|
||||
active.has(keyForChar(char)),
|
||||
`Lektion ${lesson.number} (${lesson.title}): "${word}" braucht "${char}" (Taste ${keyForChar(char)})`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("only uses shifted characters once shift is taught", () => {
|
||||
const shiftFrom = LESSONS.find((lesson) => lesson.newKeys.includes("⇧"))!.number;
|
||||
for (const lesson of LESSONS) {
|
||||
for (const word of lesson.words) {
|
||||
for (const char of word) {
|
||||
if (!needsShift(char)) continue;
|
||||
expect(
|
||||
lesson.number,
|
||||
`Lektion ${lesson.number}: "${word}" braucht Umschalt für "${char}"`,
|
||||
).toBeGreaterThanOrEqual(shiftFrom);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("plays a mode that actually fits its kind", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(lesson.primaryMode);
|
||||
for (const mode of lesson.bonusModes) {
|
||||
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(mode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never plays the same arcade game twice in a row", () => {
|
||||
const arcade = LESSONS.filter((lesson) => lesson.kind === "letters");
|
||||
for (let i = 1; i < arcade.length; i++) {
|
||||
expect(arcade[i]!.primaryMode, `${arcade[i]!.number}: ${arcade[i]!.title}`).not.toBe(arcade[i - 1]!.primaryMode);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers every eligible mode this lesson isn't gated on, as a bonus", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
const expected = ELIGIBLE_MODES[lesson.kind].filter(
|
||||
(mode) => (mode === "feed" || mode === "race") && mode !== lesson.primaryMode,
|
||||
);
|
||||
expect([...lesson.bonusModes].sort()).toEqual([...expected].sort());
|
||||
}
|
||||
});
|
||||
|
||||
it("mixes the games instead of always diving - feed and race take a turn as the required mode too", () => {
|
||||
const words = LESSONS.filter((l) => l.kind === "words");
|
||||
const sentences = LESSONS.filter((l) => l.kind === "sentences");
|
||||
expect(words.some((l) => l.primaryMode === "feed")).toBe(true);
|
||||
expect(words.some((l) => l.primaryMode === "race")).toBe(true);
|
||||
expect(sentences.some((l) => l.primaryMode === "race")).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes a mixed round from a drill - both have no new keys, only one is a review", () => {
|
||||
const mixed = LESSONS.filter((lesson) => lesson.emphasis === "mixed");
|
||||
expect(mixed.length).toBeGreaterThan(0);
|
||||
for (const lesson of mixed) {
|
||||
expect(lesson.newKeys).toEqual([]);
|
||||
expect(lesson.isDrill).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every isolated round something new to drill", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
if (lesson.emphasis === "isolated") expect(lesson.newKeys.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("teaches capitals only once shift exists", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
const hasUppercase = lesson.words.some((word) => word !== word.toLowerCase());
|
||||
if (hasUppercase) expect(lesson.world).toBeGreaterThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every new key a lesson of its own after world 1", () => {
|
||||
// One key at a time is the pacing decision: world 1 pairs a finger across both
|
||||
// hands, everything after introduces exactly one key or none.
|
||||
for (const lesson of LESSONS) {
|
||||
if (lesson.world === 1) continue;
|
||||
expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("follows every pair of new keys with a drill", () => {
|
||||
const drills = LESSONS.filter((lesson) => lesson.isDrill);
|
||||
expect(drills.length).toBeGreaterThanOrEqual(15);
|
||||
// A drill never introduces anything, and always has something to practise.
|
||||
for (const lesson of drills) {
|
||||
expect(lesson.newKeys).toEqual([]);
|
||||
expect(lesson.activeKeys.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is long enough to be a real, Duolingo-length course", () => {
|
||||
expect(LESSONS.length).toBeGreaterThanOrEqual(80);
|
||||
expect(WORLDS.length).toBe(5);
|
||||
});
|
||||
|
||||
it("never leaves a world without a run of at least three lessons", () => {
|
||||
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Worlds", () => {
|
||||
it("has lessons in every world", () => {
|
||||
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gives every world its own aquarium creature", () => {
|
||||
const rewards = WORLDS.map((world) => world.reward);
|
||||
expect(new Set(rewards).size).toBe(rewards.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)?.number).toBe(1);
|
||||
expect(lessonById("gibt-es-nicht")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
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 {
|
||||
DILIGENCE_ATTEMPTS,
|
||||
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 perfect = () => runResult("asdfasdfasdfasdf");
|
||||
const bad = () => 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 unlocked = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked);
|
||||
expect(unlocked).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, perfect());
|
||||
expect(unlockedLessonId).toBe(L2);
|
||||
expect(progress.lessons[L2]!.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("does not unlock below two stars", () => {
|
||||
const result = bad();
|
||||
expect(result.stars).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 diligence rule", () => {
|
||||
let progress = freshProgress();
|
||||
for (let i = 0; i < DILIGENCE_ATTEMPTS - 1; i++) {
|
||||
progress = recordRun(progress, L1, bad()).progress;
|
||||
expect(progress.lessons[L2]!.unlocked).toBe(false);
|
||||
}
|
||||
const { progress: last, unlockedLessonId } = recordRun(progress, L1, bad());
|
||||
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, perfect()).progress;
|
||||
const best = progress.lessons[L1]!;
|
||||
progress = recordRun(progress, L1, bad()).progress;
|
||||
expect(progress.lessons[L1]!.bestStars).toBe(best.bestStars);
|
||||
expect(progress.lessons[L1]!.bestPoints).toBe(best.bestPoints);
|
||||
expect(progress.lessons[L1]!.bestAnimal).toBe(best.bestAnimal);
|
||||
});
|
||||
|
||||
it("counts every run, good or bad", () => {
|
||||
let progress = freshProgress();
|
||||
for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, bad()).progress;
|
||||
expect(progress.lessons[L1]!.runs).toBe(3);
|
||||
});
|
||||
|
||||
it("adds pearls on every run", () => {
|
||||
const first = recordRun(freshProgress(), L1, bad());
|
||||
expect(first.progress.pearls).toBeGreaterThan(0);
|
||||
const second = recordRun(first.progress, L1, bad());
|
||||
expect(second.progress.pearls).toBeGreaterThan(first.progress.pearls);
|
||||
});
|
||||
|
||||
it("releases a creature only when a world is finished, and only once", () => {
|
||||
let progress = freshProgress();
|
||||
// World 1 is lessons 1-3, so finishing lesson 3 is what crosses into world 2.
|
||||
const world1 = LESSONS.filter((lesson) => lesson.world === 1);
|
||||
let released: string[] = [];
|
||||
for (const lesson of world1) {
|
||||
const outcome = recordRun(progress, lesson.id, perfect());
|
||||
progress = outcome.progress;
|
||||
if (outcome.newCreature) released.push(outcome.newCreature);
|
||||
}
|
||||
expect(released).toHaveLength(1);
|
||||
expect(progress.aquarium).toEqual(released);
|
||||
|
||||
// Replaying the same lesson must not hand out a second copy.
|
||||
const again = recordRun(progress, world1.at(-1)!.id, perfect());
|
||||
expect(again.newCreature).toBeNull();
|
||||
expect(again.progress.aquarium).toEqual(released);
|
||||
});
|
||||
|
||||
it("records a ghost of the best run for the race", () => {
|
||||
const { progress } = recordRun(freshProgress(), L1, perfect());
|
||||
expect(progress.lessons[L1]!.ghost?.length).toBe(perfect().characters);
|
||||
});
|
||||
|
||||
it("builds the daily streak and restarts it after a gap", () => {
|
||||
let progress = recordRun(freshProgress(), L1, perfect(), "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, perfect(), "2026-09-11").progress;
|
||||
expect(progress.streak.days).toBe(1);
|
||||
|
||||
progress = recordRun(progress, L1, perfect(), "2026-09-12").progress;
|
||||
expect(progress.streak.days).toBe(2);
|
||||
|
||||
// A missed day restarts at 1, never at 0.
|
||||
progress = recordRun(progress, L1, perfect(), "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(2);
|
||||
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a valid save", () => {
|
||||
const stored = recordRun(freshProgress(), L1, perfect()).progress;
|
||||
const restored = migrate(JSON.parse(JSON.stringify(stored)));
|
||||
expect(restored.lessons[L2]!.unlocked).toBe(true);
|
||||
expect(restored.pearls).toBe(stored.pearls);
|
||||
});
|
||||
|
||||
it("drops lessons that no longer exist", () => {
|
||||
const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, oldLesson: {} } };
|
||||
expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["oldLesson"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves the emoji pets of old saves into today's aquarium", () => {
|
||||
const old = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] };
|
||||
expect(migrate(JSON.parse(JSON.stringify(old))).aquarium).toEqual(["clownfish", "octopus"]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("restarts the lesson path from an old version, but keeps the aquarium, pearls and streak", () => {
|
||||
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum,
|
||||
// so there is nothing sensible to remap them onto - she starts the path over, but
|
||||
// does not lose what she already earned.
|
||||
const old = {
|
||||
version: 1,
|
||||
lessons: { l09: { unlocked: true, runs: 3, bestStars: 3, bestAnimal: "orca", bestPoints: 999, ghost: null } },
|
||||
keyStats: { a: { ema: 400, attempts: 10, errors: 1 } },
|
||||
pearls: 42,
|
||||
aquarium: ["clownfish", "octopus"],
|
||||
streak: { days: 5, lastPlayed: "2024-01-01" },
|
||||
settings: { sound: false, keyboardHint: "off" },
|
||||
};
|
||||
const restored = migrate(JSON.parse(JSON.stringify(old)));
|
||||
expect(restored.version).toBe(2);
|
||||
// "l09" exists in the new curriculum too, just as a different lesson - its old
|
||||
// three-star, level-99 progress must not carry over onto whatever l09 means now.
|
||||
expect(restored.lessons["l09"]).toEqual({ unlocked: false, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null });
|
||||
expect(restored.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
|
||||
expect(Object.values(restored.lessons).filter((l) => l.unlocked)).toHaveLength(1);
|
||||
expect(restored.pearls).toBe(42);
|
||||
expect(restored.aquarium).toEqual(["clownfish", "octopus"]);
|
||||
expect(restored.keyStats["a"]?.attempts).toBe(10);
|
||||
expect(restored.streak).toEqual({ days: 5, lastPlayed: "2024-01-01" });
|
||||
expect(restored.settings).toEqual({ sound: false, keyboardHint: "off" });
|
||||
});
|
||||
});
|
||||
|
||||
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 lesson 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 space bar 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");
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
/** The lesson plan: content lives in data/curriculum.yaml, this file only derives and
|
||||
* validates it.
|
||||
*
|
||||
* The shape follows what every serious ten-finger course does - start on the home row
|
||||
* `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S, R, A, T, D, H,
|
||||
* U, L …), and drill everything learned so far each time. TIPP10's German course is the
|
||||
* same idea in 18 lessons.
|
||||
*
|
||||
* Where this deviates, it deviates for the age and for the Duolingo-style path this app
|
||||
* wants: every new key gets an **isolated** round (heavy weight, alone) before a
|
||||
* **mixed** round (lighter weight, blended with everything learned so far) - the same
|
||||
* new-key / mixed-key / real-content progression typing.com and typingstudy.com use,
|
||||
* just written as short rounds a six-year-old can climb one at a time. A drill lesson
|
||||
* follows every couple of keys, and periodically a **fragments** round turns the
|
||||
* newly-active keys into real short German words - the bridge between drilling letters
|
||||
* and typing whole words or sentences.
|
||||
*
|
||||
* `activeKeys` is cumulative on purpose: lesson 80 still drills `f`, or the first
|
||||
* lessons rot while the last ones are learned. */
|
||||
|
||||
import { parse } from "yaml";
|
||||
|
||||
import curriculumYaml from "../data/curriculum.yaml?raw";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import { HOME_ROW, SPACE_KEY } from "./fingers";
|
||||
|
||||
/** What a lesson's targets are made of. `fragments` is short real syllables/mini-words -
|
||||
* the bridge between drilling isolated letters and typing whole words or sentences. */
|
||||
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||
|
||||
/** Which game a lesson can be played as. `pearls` is gone - every other mode measures
|
||||
* something, and that one deliberately didn't. */
|
||||
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
world: number;
|
||||
number: number;
|
||||
title: string;
|
||||
/** What this lesson is about, in words a six-year-old hears read aloud. */
|
||||
subtitle: string;
|
||||
kind: LessonKind;
|
||||
/** The keys that became active *this* lesson - empty for a mixed round or a drill.
|
||||
* Used to size how gently a round starts (see World 1's mirrored pairs) and to check
|
||||
* the course never introduces more than it should. */
|
||||
newKeys: readonly string[];
|
||||
/** The keys this round is about, whether or not they are new - what the generator
|
||||
* over-represents. Equal to `newKeys` for an isolated round, the same keys again
|
||||
* (already active) for the mixed round that follows it, empty for a drill. */
|
||||
spotlightKeys: readonly string[];
|
||||
/** Isolated: newly-active keys, drilled alone. Mixed: the same keys, blended with
|
||||
* everything else. `null` for a drill or for anything that isn't a letters round. */
|
||||
emphasis: "isolated" | "mixed" | null;
|
||||
/** Everything typable in this lesson, cumulative. */
|
||||
activeKeys: readonly string[];
|
||||
/** The one mode that gates progress this round - passing a run in this mode is what
|
||||
* can unlock the next lesson. Mostly `dive` for non-letters kinds, but `feed`/`race`
|
||||
* take a periodic turn so the games stay mixed, not just `dive` end to end. */
|
||||
primaryMode: ModeId;
|
||||
/** Extra replays, offered on the result sheet once this lesson is passed. Never gate
|
||||
* anything - they exist so a favourite game can be played again. */
|
||||
bonusModes: readonly ModeId[];
|
||||
/** Real German words, fragments or sentences for the non-letters kinds. */
|
||||
words: readonly string[];
|
||||
/** True for a pure review round - no fresh content, just practice. Independent of
|
||||
* `newKeys`: a mixed round also has no new keys but is not a drill. */
|
||||
isDrill: boolean;
|
||||
/** How long one run is. Grows with the curriculum - see `lengthFor`. */
|
||||
chunks: number;
|
||||
chunkSize: number;
|
||||
}
|
||||
|
||||
export interface World {
|
||||
number: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
/** The creature that moves into the aquarium when this world is finished. */
|
||||
reward: CreatureId;
|
||||
}
|
||||
|
||||
/** A world's creature is a pet that stays - a drawing that swims behind every screen
|
||||
* from then on. An 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. */
|
||||
const CREATURE_IDS: readonly CreatureId[] = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"];
|
||||
|
||||
/** Which modes make sense for a kind. Letters rounds are single keys, so only the
|
||||
* arcade modes fit; fragments/words can be fed to a fish; only words and sentences are
|
||||
* long enough for a race. */
|
||||
export const ELIGIBLE_MODES: Record<LessonKind, readonly ModeId[]> = {
|
||||
letters: ["bubbles", "jellyfish"],
|
||||
fragments: ["dive", "feed"],
|
||||
words: ["dive", "feed", "race"],
|
||||
sentences: ["dive", "race"],
|
||||
};
|
||||
|
||||
/** Line length by world and kind - a full block of text per round. Letters rounds are
|
||||
* never rendered through `dive` any more (they are bubbles/jellyfish), but the length is
|
||||
* still computed for type uniformity and so the "still starts gentler than it ends"
|
||||
* shape is preserved if a letters round is ever asked for one. */
|
||||
function lengthFor(world: number, kind: LessonKind): { chunks: number; chunkSize: number } {
|
||||
if (kind === "fragments") return { chunks: 16, chunkSize: 4 }; // 64
|
||||
if (kind === "sentences") return { chunks: 10, chunkSize: 4 }; // ten whole sentences
|
||||
if (kind === "words") return { chunks: 25, chunkSize: 4 };
|
||||
// kind === "letters"
|
||||
if (world === 1) return { chunks: 24, chunkSize: 3 }; // 72 characters
|
||||
if (world === 2) return { chunks: 25, chunkSize: 4 }; // 100
|
||||
return { chunks: 30, chunkSize: 4 }; // 120, world 3 onward
|
||||
}
|
||||
|
||||
interface YamlLesson {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
kind?: LessonKind;
|
||||
keys?: readonly string[];
|
||||
drill?: boolean;
|
||||
mode?: ModeId;
|
||||
words?: readonly string[];
|
||||
}
|
||||
|
||||
interface YamlWorld {
|
||||
number: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
reward: CreatureId;
|
||||
lessons: readonly YamlLesson[];
|
||||
}
|
||||
|
||||
interface YamlRoot {
|
||||
worlds: readonly YamlWorld[];
|
||||
}
|
||||
|
||||
/** Turns a YAML typo into a clear startup error instead of a silently wrong lesson. */
|
||||
function validatePlan(worlds: readonly YamlWorld[]): void {
|
||||
if (worlds.length !== CREATURE_IDS.length) {
|
||||
throw new Error(`curriculum.yaml: expected ${CREATURE_IDS.length} worlds, found ${worlds.length}`);
|
||||
}
|
||||
const rewards = new Set<CreatureId>();
|
||||
for (const world of worlds) {
|
||||
if (rewards.has(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} reuses reward "${world.reward}"`);
|
||||
if (!CREATURE_IDS.includes(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} has an unknown reward "${world.reward}"`);
|
||||
rewards.add(world.reward);
|
||||
|
||||
world.lessons.forEach((entry, i) => {
|
||||
const where = `world ${world.number}, lesson ${i + 1} (${entry.title})`;
|
||||
const words = entry.words ?? [];
|
||||
const kind: LessonKind = entry.kind ?? "letters";
|
||||
if (words.length > 0 && entry.kind === undefined) {
|
||||
throw new Error(`${where}: has words but no explicit kind`);
|
||||
}
|
||||
if (kind === "letters" && words.length > 0) throw new Error(`${where}: kind "letters" cannot have words`);
|
||||
if (kind !== "letters" && words.length === 0) throw new Error(`${where}: kind "${kind}" needs a non-empty words list`);
|
||||
if (entry.drill && entry.keys?.length) throw new Error(`${where}: a drill cannot also introduce keys`);
|
||||
if ((entry.keys?.length ?? 0) > 2) throw new Error(`${where}: at most two keys per lesson`);
|
||||
if (entry.mode && !ELIGIBLE_MODES[kind].includes(entry.mode)) {
|
||||
throw new Error(`${where}: mode "${entry.mode}" does not fit kind "${kind}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function loadPlan(): readonly YamlWorld[] {
|
||||
const root = parse(curriculumYaml) as YamlRoot;
|
||||
validatePlan(root.worlds);
|
||||
return root.worlds;
|
||||
}
|
||||
|
||||
function buildLessons(worlds: readonly YamlWorld[]): Lesson[] {
|
||||
const lessons: Lesson[] = [];
|
||||
const active = new Set<string>();
|
||||
const seenBefore = new Set<string>();
|
||||
// Letters rounds alternate bubbles/jellyfish across the *whole* course, not per world
|
||||
// or per pair - a fragments/words lesson in between does not reset the count, so the
|
||||
// arcade game never repeats twice in a row even across a consolidation gap.
|
||||
let lastArcade: "bubbles" | "jellyfish" = "jellyfish"; // so lesson 1 opens on bubbles
|
||||
|
||||
for (const world of worlds) {
|
||||
for (const entry of world.lessons) {
|
||||
const keys = entry.keys ?? [];
|
||||
const newKeys = keys.filter((key) => !seenBefore.has(key));
|
||||
for (const key of keys) {
|
||||
seenBefore.add(key);
|
||||
active.add(key);
|
||||
}
|
||||
// The space-bar lesson is where the whole home row comes together, so it
|
||||
// activates every home key - belt-and-braces confirmation that the four
|
||||
// finger-pair lessons before it really did cover all eight.
|
||||
if (keys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
|
||||
|
||||
// Shift is not a character the generator can emit - the capitals in the word
|
||||
// list are what teaches it - so it never enters activeKeys.
|
||||
const activeKeys = [...active].filter((key) => key !== "⇧").sort();
|
||||
const words = entry.words ?? [];
|
||||
const kind: LessonKind = entry.kind ?? "letters";
|
||||
const isDrill = entry.drill === true;
|
||||
|
||||
const emphasis: Lesson["emphasis"] =
|
||||
kind !== "letters" || isDrill ? null : newKeys.length > 0 ? "isolated" : "mixed";
|
||||
|
||||
let primaryMode: ModeId;
|
||||
if (kind === "letters") {
|
||||
primaryMode = entry.mode ?? (lastArcade === "bubbles" ? "jellyfish" : "bubbles");
|
||||
lastArcade = primaryMode as "bubbles" | "jellyfish";
|
||||
} else {
|
||||
primaryMode = entry.mode ?? "dive";
|
||||
}
|
||||
// Bonus replays are only ever feed/race - dive is the plain default, not a treat
|
||||
// worth offering separately, and bubbles/jellyfish already alternate on their own.
|
||||
const bonusModes = ELIGIBLE_MODES[kind].filter(
|
||||
(mode) => (mode === "feed" || mode === "race") && mode !== primaryMode,
|
||||
);
|
||||
|
||||
lessons.push({
|
||||
id: `l${String(lessons.length + 1).padStart(2, "0")}`,
|
||||
world: world.number,
|
||||
number: lessons.length + 1,
|
||||
title: entry.title,
|
||||
subtitle: entry.subtitle,
|
||||
kind,
|
||||
newKeys,
|
||||
spotlightKeys: kind === "letters" && !isDrill ? keys : [],
|
||||
emphasis,
|
||||
activeKeys,
|
||||
primaryMode,
|
||||
bonusModes,
|
||||
words,
|
||||
isDrill,
|
||||
...lengthFor(world.number, kind),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lessons;
|
||||
}
|
||||
|
||||
const PLAN = loadPlan();
|
||||
|
||||
export const WORLDS: readonly World[] = PLAN.map((world) => ({
|
||||
number: world.number,
|
||||
title: world.title,
|
||||
emoji: world.emoji,
|
||||
reward: world.reward,
|
||||
}));
|
||||
|
||||
export const LESSONS: readonly Lesson[] = buildLessons(PLAN);
|
||||
|
||||
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 lessonsOfWorld(world: number): readonly Lesson[] {
|
||||
return LESSONS.filter((lesson) => lesson.world === world);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -1,335 +0,0 @@
|
||||
/** 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 { creatureFromRaw } from "./aquarium";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import { FIRST_LESSON_ID, LESSONS, WORLDS, nextLesson } from "./curriculum";
|
||||
import type { RunResult, AnimalId } 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 DILIGENCE_ATTEMPTS = 5;
|
||||
|
||||
export interface LessonProgress {
|
||||
unlocked: boolean;
|
||||
runs: number;
|
||||
bestStars: 0 | 1 | 2 | 3;
|
||||
bestAnimal: AnimalId | null;
|
||||
bestPoints: number;
|
||||
/** Best-run keystrokes, replayed as the opponent in race mode. */
|
||||
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 {
|
||||
sound: boolean;
|
||||
keyboardHint: "auto" | "on" | "off";
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
version: 2;
|
||||
lessons: Record<string, LessonProgress>;
|
||||
keyStats: Record<string, KeyStat>;
|
||||
pearls: number;
|
||||
/** Pets that have moved into the aquarium, in the order they arrived. */
|
||||
aquarium: CreatureId[];
|
||||
streak: { days: number; lastPlayed: string | null };
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
function emptyLesson(unlocked: boolean): LessonProgress {
|
||||
return { unlocked, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 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: 2,
|
||||
lessons,
|
||||
keyStats: {},
|
||||
pearls: 0,
|
||||
aquarium: [],
|
||||
streak: { days: 0, lastPlayed: null },
|
||||
settings: { sound: true, keyboardHint: "auto" },
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything that survives a curriculum rebuild - the pets, the currency, the per-key
|
||||
* stats and the streak - pulled defensively off whatever shape was stored, old or new. */
|
||||
function carryForward(stored: Record<string, unknown>, fresh: Progress): Omit<Progress, "version" | "lessons"> {
|
||||
return {
|
||||
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? (stored.keyStats as Progress["keyStats"]) : {},
|
||||
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
|
||||
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
|
||||
streak:
|
||||
typeof stored.streak === "object" && stored.streak !== null
|
||||
? {
|
||||
days: (stored.streak as Progress["streak"]).days ?? 0,
|
||||
lastPlayed: (stored.streak as Progress["streak"]).lastPlayed ?? null,
|
||||
}
|
||||
: fresh.streak,
|
||||
settings: { ...fresh.settings, ...((stored.settings as Partial<Settings>) ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 Record<string, unknown>;
|
||||
|
||||
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum -
|
||||
// "l09" used to be "Das E" and might be anything now - so there is nothing sensible to
|
||||
// remap. She keeps her aquarium, pearls, key stats and streak, and starts the (longer,
|
||||
// gentler) path over from the first lesson.
|
||||
if (stored.version !== 2) {
|
||||
return { version: 2, lessons: fresh.lessons, ...carryForward(stored, fresh) };
|
||||
}
|
||||
|
||||
const lessons = { ...fresh.lessons };
|
||||
if (typeof stored.lessons === "object" && stored.lessons !== null) {
|
||||
for (const [id, value] of Object.entries(stored.lessons as Record<string, unknown>)) {
|
||||
// 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: 2, lessons, ...carryForward(stored, fresh) };
|
||||
}
|
||||
|
||||
/** Saves from before the pets were drawings hold emoji here. Those map onto the creature
|
||||
* that fills the same world today; duplicates and anything unrecognisable are dropped,
|
||||
* so one stray entry cannot put a broken image in the tank. */
|
||||
function migrateAquarium(raw: readonly unknown[]): CreatureId[] {
|
||||
const creatures: CreatureId[] = [];
|
||||
for (const entry of raw) {
|
||||
const id = creatureFromRaw(entry);
|
||||
if (id && !creatures.includes(id)) creatures.push(id);
|
||||
}
|
||||
return creatures;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** World number to the creature it releases, read straight off the worlds table so it
|
||||
* cannot drift from curriculum.ts. */
|
||||
const WORLD_REWARD = new Map(WORLDS.map((world) => [world.number, world.reward]));
|
||||
|
||||
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 world, for the creature that moved in. */
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: boolean;
|
||||
}
|
||||
|
||||
/** Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
|
||||
*
|
||||
* The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
||||
* 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, { stars: before.bestStars, points: before.bestPoints });
|
||||
const lessons = { ...progress.lessons };
|
||||
lessons[lessonId] = {
|
||||
...before,
|
||||
runs,
|
||||
bestStars: improved ? result.stars : before.bestStars,
|
||||
bestAnimal: improved ? result.animal : before.bestAnimal,
|
||||
bestPoints: improved ? result.points : before.bestPoints,
|
||||
ghost: improved
|
||||
? result.strokes.filter((s) => s.correct).map((s) => ({ key: s.key, at: s.at }))
|
||||
: before.ghost,
|
||||
};
|
||||
|
||||
const earned = result.passed || runs >= DILIGENCE_ATTEMPTS;
|
||||
const next = nextLesson(lessonId);
|
||||
let unlockedLessonId: string | null = null;
|
||||
if (earned && next && !lessons[next.id]?.unlocked) {
|
||||
lessons[next.id] = { ...(lessons[next.id] ?? emptyLesson(false)), unlocked: true };
|
||||
unlockedLessonId = next.id;
|
||||
}
|
||||
|
||||
// Finishing the last lesson of a world releases that world's creature. Checked
|
||||
// against the aquarium so it is only ever awarded once.
|
||||
const aquarium = [...progress.aquarium];
|
||||
let newCreature: CreatureId | null = null;
|
||||
if (unlockedLessonId && next) {
|
||||
const finished = LESSONS.find((l) => l.id === lessonId);
|
||||
if (finished && next.world !== finished.world) {
|
||||
const reward = WORLD_REWARD.get(finished.world);
|
||||
if (reward && !aquarium.includes(reward)) {
|
||||
aquarium.push(reward);
|
||||
newCreature = reward;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
progress: {
|
||||
...progress,
|
||||
lessons,
|
||||
aquarium,
|
||||
keyStats: foldKeyStats(progress.keyStats, result),
|
||||
pearls: progress.pearls + result.pearls,
|
||||
streak: bumpStreak(progress.streak, day),
|
||||
},
|
||||
unlockedLessonId,
|
||||
newCreature,
|
||||
isNewBest: improved,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** How many times a key must be typed before its stats mean anything. */
|
||||
const ENOUGH_ATTEMPTS = 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 lesson 1 means typing `a` thirteen times out of twenty-four while
|
||||
* three other fingers go untrained. A lesson she has never played gets an even spread;
|
||||
* 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 practiced = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= ENOUGH_ATTEMPTS);
|
||||
if (practiced.length === 0) return null;
|
||||
|
||||
// Some keys practised and some not: the gap is the most useful thing to close.
|
||||
const unpracticed = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < ENOUGH_ATTEMPTS);
|
||||
if (unpracticed) return unpracticed;
|
||||
|
||||
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 overallBestAnimal(progress: Progress): AnimalId | null {
|
||||
let best: AnimalId | null = null;
|
||||
let bestPoints = -1;
|
||||
for (const lesson of Object.values(progress.lessons)) {
|
||||
if (lesson.bestAnimal && lesson.bestPoints > bestPoints) {
|
||||
best = lesson.bestAnimal;
|
||||
bestPoints = lesson.bestPoints;
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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>,
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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 +0,0 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumCreatures.tsx","./src/components/Bubbles.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BubblesRun.tsx","./src/components/modes/DiveRun.tsx","./src/components/modes/FeedRun.tsx","./src/components/modes/JellyfishRun.tsx","./src/components/modes/RaceRun.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/lessonPath.ts","./src/lib/modeInfo.ts","./src/lib/pop.ts","./src/lib/progress.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__/lessonPath.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}
|
||||
@@ -1,11 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@@ -22,6 +22,8 @@ import { PlayerBar } from "./components/PlayerBar";
|
||||
import { PlayView } from "./components/PlayView";
|
||||
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
|
||||
import { RoomView } from "./components/RoomView";
|
||||
import { TabRail } from "./components/TabRail";
|
||||
import { TippenApp } from "./components/TippenApp";
|
||||
import { useGridColumns } from "./hooks/useGridColumns";
|
||||
import { useLibrary } from "./hooks/useLibrary";
|
||||
import { usePlayerState } from "./hooks/usePlayerState";
|
||||
@@ -401,8 +403,8 @@ export function App() {
|
||||
}
|
||||
run(browseBackActions(ui));
|
||||
};
|
||||
const onToggleRoom = () =>
|
||||
setUi((previous) => ({ ...previous, page: previous.page === "room" ? "music" : "room" }));
|
||||
const onSelectPage = (page: UiState["page"]) =>
|
||||
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
|
||||
|
||||
return (
|
||||
<div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}>
|
||||
@@ -497,6 +499,10 @@ export function App() {
|
||||
|
||||
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
|
||||
|
||||
{ui.page === "typing" && (
|
||||
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
|
||||
)}
|
||||
|
||||
{openAlbum && state && (
|
||||
<AlbumModal
|
||||
album={openAlbum}
|
||||
@@ -522,15 +528,7 @@ export function App() {
|
||||
</CornerButton>
|
||||
)}
|
||||
|
||||
{haConfig && (
|
||||
<CornerButton
|
||||
side="right"
|
||||
onClick={onToggleRoom}
|
||||
label={ui.page === "room" ? "Musik" : "Mein Zimmer"}
|
||||
>
|
||||
{ui.page === "room" ? "🎵" : "💡"}
|
||||
</CornerButton>
|
||||
)}
|
||||
<TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />
|
||||
|
||||
{ui.showHelp && (
|
||||
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
||||
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
RemoteMapping,
|
||||
RemoteSlotInput,
|
||||
Settings,
|
||||
TippenCurriculum,
|
||||
TippenProgress,
|
||||
TippenRunInput,
|
||||
TippenRunResult,
|
||||
TippenSettings,
|
||||
TrackDetail,
|
||||
} from "./types";
|
||||
|
||||
@@ -53,6 +58,11 @@ const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>
|
||||
/** `null` means the IR remote isn't configured, not an error. */
|
||||
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
|
||||
|
||||
/** `null` means the typing game isn't configured, not an error - the tab still shows,
|
||||
* just with a "not set up" placeholder instead of a lesson map. */
|
||||
const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> =>
|
||||
fetchOrNullOn404<TippenCurriculum>("/tippen/curriculum");
|
||||
|
||||
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
|
||||
* right now" (the backend answers the latter with a 502) - the room page treats a
|
||||
* device with no state the same way either way, rather than crashing on a poll. */
|
||||
@@ -103,6 +113,13 @@ export const api = {
|
||||
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
|
||||
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
|
||||
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
|
||||
|
||||
tippenCurriculum: fetchTippenCurriculum,
|
||||
tippenProgress: () => request<TippenProgress>("/tippen/progress"),
|
||||
saveTippenSettings: (settings: TippenSettings) =>
|
||||
request<TippenSettings>("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
recordTippenRun: (body: TippenRunInput) =>
|
||||
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;
|
||||
|
||||
@@ -34,11 +34,24 @@ export interface TrackDetail {
|
||||
curve: TrackCurves | null;
|
||||
}
|
||||
|
||||
/** Which typing lesson unlocks a still-locked track - the browse view's "unlocks
|
||||
* when..." hint. Mirrors `UnlockHintOut`. */
|
||||
export interface UnlockHint {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
world_number: number;
|
||||
world_title: string;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
title: string;
|
||||
/** Seconds, read from the file's tags at scan time. */
|
||||
duration: number;
|
||||
analysis: TrackAnalysis | null;
|
||||
/** A typing-reward track not yet earned. Only ever `true` when the typing game is
|
||||
* configured on the backend. */
|
||||
locked: boolean;
|
||||
unlock_hint: UnlockHint | null;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
@@ -56,6 +69,8 @@ export interface Album {
|
||||
has_cover: boolean;
|
||||
duration: number;
|
||||
tracks: Track[];
|
||||
/** Every track is still locked - show a question mark instead of cover art. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
@@ -135,3 +150,131 @@ export interface RemoteSlotInput {
|
||||
export interface LircConfig {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- tippen
|
||||
//
|
||||
// The typing game. `null` from `GET /tippen/curriculum` means the whole feature isn't
|
||||
// configured, like `HaConfig` above - the tab still shows, just with nothing behind it.
|
||||
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
|
||||
|
||||
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||
export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
||||
|
||||
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
|
||||
* configured path matches nothing in the current library. */
|
||||
export interface TippenReward {
|
||||
resolved: boolean;
|
||||
album_id: string | null;
|
||||
has_cover: boolean;
|
||||
kind: "tracks" | "episode" | null;
|
||||
}
|
||||
|
||||
export interface TippenLesson {
|
||||
id: string;
|
||||
world: number;
|
||||
number: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
kind: TippenLessonKind;
|
||||
new_keys: string[];
|
||||
spotlight_keys: string[];
|
||||
emphasis: "isolated" | "mixed" | null;
|
||||
active_keys: string[];
|
||||
primary_mode: TippenModeId;
|
||||
bonus_modes: TippenModeId[];
|
||||
words: string[];
|
||||
is_drill: boolean;
|
||||
chunks: number;
|
||||
chunk_size: number;
|
||||
reward: TippenReward;
|
||||
}
|
||||
|
||||
export interface TippenWorld {
|
||||
number: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
reward: string;
|
||||
}
|
||||
|
||||
export interface TippenCurriculum {
|
||||
worlds: TippenWorld[];
|
||||
lessons: TippenLesson[];
|
||||
}
|
||||
|
||||
export interface TippenGhostStroke {
|
||||
key: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface TippenLessonProgress {
|
||||
unlocked: boolean;
|
||||
runs: number;
|
||||
best_stars: 0 | 1 | 2 | 3;
|
||||
best_animal: string | null;
|
||||
best_points: number;
|
||||
/** Derived server-side, not stored - two stars, or five attempts regardless. */
|
||||
earned: boolean;
|
||||
ghost: TippenGhostStroke[] | null;
|
||||
}
|
||||
|
||||
export interface TippenKeyStat {
|
||||
ema: number;
|
||||
attempts: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface TippenStreak {
|
||||
days: number;
|
||||
last_played: string | null;
|
||||
}
|
||||
|
||||
export interface TippenSettings {
|
||||
sound: boolean;
|
||||
keyboard_hint: "auto" | "on" | "off";
|
||||
}
|
||||
|
||||
export interface TippenProgress {
|
||||
lessons: Record<string, TippenLessonProgress>;
|
||||
key_stats: Record<string, TippenKeyStat>;
|
||||
pearls: number;
|
||||
aquarium: string[];
|
||||
streak: TippenStreak;
|
||||
settings: TippenSettings;
|
||||
}
|
||||
|
||||
export interface TippenStroke {
|
||||
key: string;
|
||||
expected: string;
|
||||
correct: boolean;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** A run the client already graded - see `lib/tippen/grading.ts`. Grading stays
|
||||
* client-side; the backend only owns progress bookkeeping. */
|
||||
export interface TippenRunInput {
|
||||
lesson_id: string;
|
||||
stars: 0 | 1 | 2 | 3;
|
||||
animal: string;
|
||||
points: number;
|
||||
passed: boolean;
|
||||
pearls: number;
|
||||
strokes: TippenStroke[];
|
||||
}
|
||||
|
||||
/** The literal track/episode this run's lesson names in its own `unlocks:` - what the
|
||||
* unlock animation shows, via the existing `/api/albums/{id}/cover`. */
|
||||
export interface TippenUnlockedReward {
|
||||
album_id: string;
|
||||
title: string;
|
||||
has_cover: boolean;
|
||||
kind: "album" | "book" | "podcast_episode";
|
||||
}
|
||||
|
||||
export interface TippenRunResult {
|
||||
progress: TippenProgress;
|
||||
unlocked_lesson_id: string | null;
|
||||
unlocked_lesson_title: string | null;
|
||||
new_creature: string | null;
|
||||
is_new_best: boolean;
|
||||
unlocked_reward: TippenUnlockedReward | null;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,13 @@ export function AlbumModal({
|
||||
>
|
||||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
||||
<div style={{ width: 150, flex: "none" }}>
|
||||
<Cover album={album} size={150} radius={book ? "10px 18px 18px 10px" : "16px"} label />
|
||||
<Cover
|
||||
album={album}
|
||||
size={150}
|
||||
radius={book ? "10px 18px 18px 10px" : "16px"}
|
||||
label
|
||||
locked={album.locked}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
@@ -103,13 +109,14 @@ export function AlbumModal({
|
||||
{album.figure && ` · 🧸 ${album.figure}`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPlay(0)}
|
||||
onClick={() => !album.locked && onPlay(0)}
|
||||
disabled={album.locked}
|
||||
style={{
|
||||
marginTop: 14,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
|
||||
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
padding: "12px 22px",
|
||||
@@ -117,18 +124,24 @@ export function AlbumModal({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
boxShadow: "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||||
boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 16,
|
||||
background: "#fff",
|
||||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||
}}
|
||||
/>
|
||||
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||||
{album.locked ? (
|
||||
"❓ Noch nicht freigeschaltet"
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 16,
|
||||
background: "#fff",
|
||||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||
}}
|
||||
/>
|
||||
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -158,17 +171,19 @@ export function AlbumModal({
|
||||
<button
|
||||
key={index}
|
||||
data-nav-index={index}
|
||||
onClick={() => onPlay(index)}
|
||||
onClick={() => !track.locked && onPlay(index)}
|
||||
disabled={track.locked}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
cursor: track.locked ? "default" : "pointer",
|
||||
border: "none",
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
opacity: track.locked ? 0.55 : 1,
|
||||
background: current
|
||||
? "oklch(70% 0.16 340 / .16)"
|
||||
: "oklch(30% 0.03 210 / .05)",
|
||||
@@ -190,27 +205,42 @@ export function AlbumModal({
|
||||
color: current ? "#fff" : "oklch(35% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
{track.locked ? "❓" : index + 1}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{track.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
font: "700 13px ui-monospace, Menlo, monospace",
|
||||
color: "oklch(45% 0.03 210 / .7)",
|
||||
}}
|
||||
>
|
||||
{clock(track.duration)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||||
}}
|
||||
>
|
||||
{track.locked ? "❓ Noch gesperrt" : track.title}
|
||||
</div>
|
||||
{track.locked && track.unlock_hint && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210 / .8)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
Freigeschaltet nach „{track.unlock_hint.lesson_title}" (Welt{" "}
|
||||
{track.unlock_hint.world_number})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!track.locked && (
|
||||
<div
|
||||
style={{
|
||||
font: "700 13px ui-monospace, Menlo, monospace",
|
||||
color: "oklch(45% 0.03 210 / .7)",
|
||||
}}
|
||||
>
|
||||
{clock(track.duration)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -425,6 +425,7 @@ export function BrowseView({
|
||||
// Fixed regardless of how many lines the title actually needs, so
|
||||
// every card - and thus every row of covers - is the same height.
|
||||
const titleLines = showArtist ? 2 : 3;
|
||||
const hint = album.locked ? (album.tracks[0]?.unlock_hint ?? null) : null;
|
||||
return (
|
||||
<div
|
||||
key={album.id}
|
||||
@@ -432,6 +433,7 @@ export function BrowseView({
|
||||
data-nav-index={navIndex}
|
||||
data-selected={selected === navIndex}
|
||||
data-current={album.id === currentAlbumId}
|
||||
data-locked={album.locked}
|
||||
style={{
|
||||
background: cardBackground(album),
|
||||
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
|
||||
@@ -439,26 +441,34 @@ export function BrowseView({
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onOpenAlbum(album, navIndex)}
|
||||
aria-label={podcast ? `${album.title} abspielen` : `${album.title}: Titel wählen`}
|
||||
onClick={() => !album.locked && onOpenAlbum(album, navIndex)}
|
||||
disabled={album.locked}
|
||||
aria-label={
|
||||
album.locked
|
||||
? `${album.title}: noch nicht freigeschaltet`
|
||||
: podcast
|
||||
? `${album.title} abspielen`
|
||||
: `${album.title}: Titel wählen`
|
||||
}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Cover album={album} size={180} radius="0" label />
|
||||
<Cover album={album} size={180} radius="0" label locked={album.locked} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPlayAlbum(album, navIndex)}
|
||||
aria-label={`${album.title} abspielen`}
|
||||
onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
|
||||
disabled={album.locked}
|
||||
aria-label={album.locked ? `${album.title}: noch nicht freigeschaltet` : `${album.title} abspielen`}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
background: "none",
|
||||
@@ -466,47 +476,77 @@ export function BrowseView({
|
||||
paddingRight: isBook(album) ? 22 : 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
height: `${titleLines * 1.2}em`,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: titleLines,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
{showArtist && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(30% 0.03 210)",
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{album.artist}
|
||||
</div>
|
||||
{album.locked ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
❓ Geheimnis
|
||||
</div>
|
||||
{hint && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(40% 0.03 210)",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
Freigeschaltet nach „{hint.lesson_title}" (Welt {hint.world_number})
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
height: `${titleLines * 1.2}em`,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: titleLines,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
{showArtist && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(30% 0.03 210)",
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{album.artist}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
|
||||
{album.figure && " · 🧸"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
|
||||
{album.figure && " · 🧸"}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,9 +26,12 @@ interface Props {
|
||||
style?: CSSProperties;
|
||||
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
||||
label?: boolean;
|
||||
/** A reward-gated album/track not yet earned - shows a question mark instead of the
|
||||
* real art or title, regardless of `has_cover`. */
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
|
||||
export function Cover({ album, size, fit = "width", radius, className, style, label, locked }: Props) {
|
||||
const book = isBook(album);
|
||||
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
|
||||
const box: CSSProperties =
|
||||
@@ -49,7 +52,7 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{!album.has_cover && label && (
|
||||
{locked ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -57,35 +60,54 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: book ? "10% 14% 10% 20%" : "10%",
|
||||
textAlign: "center",
|
||||
fontSize: Math.max(11, Math.round(size / 11)),
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
color: "oklch(99% 0 0 / .92)",
|
||||
fontSize: Math.max(22, Math.round(size / 2.2)),
|
||||
color: "oklch(99% 0 0 / .85)",
|
||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
❓
|
||||
</div>
|
||||
)}
|
||||
{album.has_cover && (
|
||||
<img
|
||||
src={coverUrl(album.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
// Books keep a sliver of spine showing on the right, so the shelf metaphor
|
||||
// survives contact with real square artwork.
|
||||
clipPath: book ? "inset(0 5% 0 0)" : undefined,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!album.has_cover && label && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: book ? "10% 14% 10% 20%" : "10%",
|
||||
textAlign: "center",
|
||||
fontSize: Math.max(11, Math.round(size / 11)),
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
color: "oklch(99% 0 0 / .92)",
|
||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
)}
|
||||
{album.has_cover && (
|
||||
<img
|
||||
src={coverUrl(album.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
// Books keep a sliver of spine showing on the right, so the shelf
|
||||
// metaphor survives contact with real square artwork.
|
||||
clipPath: book ? "inset(0 5% 0 0)" : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
65
web/src/components/TabRail.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/** The tab rail: icon-only, vertically centered on the right edge - audio player and
|
||||
* typing always shown, the smarthome tab only when Home Assistant is configured, same
|
||||
* gate the old single toggle button used. Replaces that button now that there are
|
||||
* three destinations instead of two. */
|
||||
|
||||
import { PAGE_ICON, PAGE_LABEL } from "../lib/theme";
|
||||
import type { UiState } from "../lib/keyboard";
|
||||
|
||||
interface Props {
|
||||
page: UiState["page"];
|
||||
onSelect: (page: UiState["page"]) => void;
|
||||
showRoom: boolean;
|
||||
}
|
||||
|
||||
const PAGES: readonly UiState["page"][] = ["music", "typing", "room"];
|
||||
|
||||
export function TabRail({ page, onSelect, showRoom }: Props) {
|
||||
const pages = PAGES.filter((candidate) => candidate !== "room" || showRoom);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
right: 24,
|
||||
transform: "translateY(-50%)",
|
||||
zIndex: 3,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{pages.map((candidate) => {
|
||||
const active = candidate === page;
|
||||
return (
|
||||
<button
|
||||
key={candidate}
|
||||
onClick={() => onSelect(candidate)}
|
||||
aria-label={PAGE_LABEL[candidate]}
|
||||
aria-current={active}
|
||||
title={PAGE_LABEL[candidate]}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
background: active ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
|
||||
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
color: active ? "#fff" : "var(--ink)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s ease, color 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{PAGE_ICON[candidate]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,54 @@
|
||||
/** The whole app: one state object, one keydown listener for navigation, four screens.
|
||||
/** The typing game, as a tab of the music player rather than its own app.
|
||||
*
|
||||
* 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.
|
||||
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
|
||||
* keydown listener for navigation, four screens) - with two changes: curriculum and
|
||||
* progress are now fetched from the backend instead of a build-time YAML import and
|
||||
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
|
||||
* `onExit` is the new base case for "back" - Escape/the map's own navigation peel one
|
||||
* layer at a time, same as before, but the aquarium screen is no longer the floor: one
|
||||
* more Escape leaves the tab entirely, back to the music player.
|
||||
*
|
||||
* 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. */
|
||||
* The rule that still matters most: **while a run is going, every key belongs to the
|
||||
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
|
||||
* run's own listener lives in hooks/useTippenRun.ts. The music player's own global
|
||||
* keydown listener (lib/keyboard.ts) is kept out of this entirely: it bails out
|
||||
* immediately whenever the typing tab is active, so the two never fight over a key. */
|
||||
|
||||
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 { ResultSheet } from "./components/ResultSheet";
|
||||
import { Stage } from "./components/Stage";
|
||||
import { BubblesRun } from "./components/modes/BubblesRun";
|
||||
import { FeedRun } from "./components/modes/FeedRun";
|
||||
import { JellyfishRun } from "./components/modes/JellyfishRun";
|
||||
import { RaceRun } from "./components/modes/RaceRun";
|
||||
import { DiveRun } from "./components/modes/DiveRun";
|
||||
import type { CreatureId } from "./lib/aquarium";
|
||||
import { LESSONS, lessonById, nextLesson } from "./lib/curriculum";
|
||||
import type { Lesson, ModeId } from "./lib/curriculum";
|
||||
import { lineFor, lineText, letterStream, mulberry32 } from "./lib/generator";
|
||||
import type { RunResult } from "./lib/grading";
|
||||
import { playFanfare, playPop } from "./lib/pop";
|
||||
import { overallBestAnimal, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress";
|
||||
import type { Progress } from "./lib/progress";
|
||||
import { bubbleCountFor } from "./lib/theme";
|
||||
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
|
||||
import { useTippenProgress } from "../hooks/useTippenProgress";
|
||||
import { Aquarium } from "./tippen/Aquarium";
|
||||
import { AppHeader } from "./tippen/AppHeader";
|
||||
import { HelpOverlay } from "./tippen/HelpOverlay";
|
||||
import { LessonMap } from "./tippen/LessonMap";
|
||||
import { ResultSheet } from "./tippen/ResultSheet";
|
||||
import { Stage } from "./tippen/Stage";
|
||||
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
||||
import { DiveRun } from "./tippen/modes/DiveRun";
|
||||
import { FeedRun } from "./tippen/modes/FeedRun";
|
||||
import { JellyfishRun } from "./tippen/modes/JellyfishRun";
|
||||
import { RaceRun } from "./tippen/modes/RaceRun";
|
||||
import type { CreatureId } from "../lib/tippen/aquarium";
|
||||
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
||||
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
||||
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
||||
import type { RunResult } from "../lib/tippen/grading";
|
||||
import { playFanfare, playPop } from "../lib/tippen/pop";
|
||||
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
|
||||
import type { UnlockedReward } from "../lib/tippen/progress";
|
||||
import { bubbleCountFor } from "../lib/tippen/theme";
|
||||
|
||||
type Screen = "aquarium" | "map" | "run";
|
||||
|
||||
/** What a mode is handed to draw. Tagged rather than optional-fielded so the render
|
||||
* below narrows on `kind` instead of guessing from which keys are present.
|
||||
*
|
||||
* Only two shapes, for five 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. */
|
||||
/** What a mode is handed to draw - see the original App.tsx for why there are only two
|
||||
* shapes for five modes. */
|
||||
type RunTarget =
|
||||
| { kind: "letters"; letters: readonly string[] }
|
||||
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
||||
|
||||
/** Modes that drill one key at a time rather than a line. */
|
||||
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
|
||||
|
||||
/** How much of a letters round the spotlighted key(s) should take. An isolated round -
|
||||
* the key's very first lesson - drills it hard; the mixed round right after blends it
|
||||
* back in with everything else, which is the whole point of "isolated then mixed". */
|
||||
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
|
||||
|
||||
interface Outcome {
|
||||
@@ -57,10 +56,18 @@ interface Outcome {
|
||||
unlockedTitle: string | null;
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: boolean;
|
||||
unlockedReward: UnlockedReward | null;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [progress, setProgress] = useState<Progress>(loadProgress);
|
||||
interface Props {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function TippenApp({ onExit }: Props) {
|
||||
const { curriculum, loading: curriculumLoading } = useTippenCurriculum();
|
||||
const configured = curriculum !== null;
|
||||
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
|
||||
|
||||
const [screen, setScreen] = useState<Screen>("aquarium");
|
||||
const [lessonId, setLessonId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<ModeId>("dive");
|
||||
@@ -70,20 +77,19 @@ export function App() {
|
||||
/** Bumped to generate a fresh line - a new seed for the same lesson. */
|
||||
const [round, setRound] = useState(0);
|
||||
|
||||
useEffect(() => saveProgress(progress), [progress]);
|
||||
|
||||
const lesson = lessonId === null ? null : lessonById(lessonId);
|
||||
const lesson = lessonId !== null && curriculum ? lessonById(curriculum, lessonId) : null;
|
||||
|
||||
/** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
|
||||
const nextUp = useMemo(() => {
|
||||
const unlocked = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked);
|
||||
if (!curriculum || !progress) return null;
|
||||
const unlocked = curriculum.lessons.filter((l) => progress.lessons[l.id]?.unlocked);
|
||||
return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null;
|
||||
}, [progress]);
|
||||
}, [curriculum, 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 run = useMemo((): RunTarget | null => {
|
||||
if (!lesson) return null;
|
||||
if (!lesson || !progress) return null;
|
||||
const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0);
|
||||
const rng = mulberry32(seed);
|
||||
const focusKey = focusKeyFor(progress, lesson.activeKeys);
|
||||
@@ -105,15 +111,9 @@ export function App() {
|
||||
),
|
||||
};
|
||||
}
|
||||
const chunks = lineFor(lesson, rng, {
|
||||
chunks: lesson.chunks,
|
||||
chunkSize: lesson.chunkSize,
|
||||
focusKey,
|
||||
});
|
||||
const chunks = lineFor(lesson, rng, { chunks: lesson.chunks, chunkSize: lesson.chunkSize, focusKey });
|
||||
return { kind: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
|
||||
// `progress` is deliberately not a dependency: the focus key is read once when the
|
||||
// line is built, and re-reading it after every keystroke would rebuild the line
|
||||
// underneath her fingers.
|
||||
// `progress` is deliberately not a dependency - see the original App.tsx.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lesson, mode, round]);
|
||||
|
||||
@@ -127,22 +127,24 @@ export function App() {
|
||||
|
||||
const onFinished = useCallback(
|
||||
(result: RunResult) => {
|
||||
if (!lessonId) return;
|
||||
const recorded = recordRun(progress, lessonId, result);
|
||||
setProgress(recorded.progress);
|
||||
setOutcome({
|
||||
result,
|
||||
unlockedTitle: recorded.unlockedLessonId
|
||||
? (lessonById(recorded.unlockedLessonId)?.title ?? null)
|
||||
: null,
|
||||
newCreature: recorded.newCreature,
|
||||
isNewBest: recorded.isNewBest,
|
||||
if (!lessonId || !progress) return;
|
||||
void recordRun(lessonId, result).then((recorded) => {
|
||||
setOutcome({
|
||||
result,
|
||||
unlockedTitle: recorded.unlockedLessonTitle,
|
||||
newCreature: recorded.newCreature,
|
||||
isNewBest: recorded.isNewBest,
|
||||
unlockedReward: recorded.unlockedReward,
|
||||
});
|
||||
if (
|
||||
progress.settings.sound &&
|
||||
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward)
|
||||
) {
|
||||
playFanfare();
|
||||
}
|
||||
});
|
||||
if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
|
||||
playFanfare();
|
||||
}
|
||||
},
|
||||
[lessonId, progress],
|
||||
[lessonId, progress, recordRun],
|
||||
);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
@@ -150,10 +152,9 @@ export function App() {
|
||||
setRound((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
/** A bonus replay in a mode this lesson didn't gate progress on - feed or race,
|
||||
* offered on the result sheet once the lesson is passed. Never touches the unlock:
|
||||
* `onFinished` still runs underneath, so a great bonus run can only improve the best
|
||||
* score, not change what is unlocked. */
|
||||
/** A bonus replay in a mode this lesson didn't gate progress on - never touches the
|
||||
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
|
||||
* the best score, not change what is unlocked. */
|
||||
const playBonus = useCallback((bonusMode: ModeId) => {
|
||||
setMode(bonusMode);
|
||||
setOutcome(null);
|
||||
@@ -161,22 +162,23 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
const continueAfterResult = useCallback(() => {
|
||||
const next = lessonId ? nextLesson(lessonId) : null;
|
||||
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
|
||||
setOutcome(null);
|
||||
if (next && progress.lessons[next.id]?.unlocked) start(next);
|
||||
if (next && progress?.lessons[next.id]?.unlocked) start(next);
|
||||
else setScreen("map");
|
||||
}, [lessonId, progress, start]);
|
||||
}, [lessonId, curriculum, progress, start]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (outcome) return setOutcome(null);
|
||||
if (screen === "run") return setScreen("map");
|
||||
if (screen === "map") return setScreen("aquarium");
|
||||
}, [outcome, screen]);
|
||||
if (screen === "aquarium") return onExit();
|
||||
}, [outcome, screen, onExit]);
|
||||
|
||||
// --- navigation keys -----------------------------------------------------
|
||||
|
||||
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start });
|
||||
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start };
|
||||
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum });
|
||||
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum };
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -206,25 +208,26 @@ export function App() {
|
||||
|
||||
// Everything below is navigation, and must not fire while typing.
|
||||
if (current.screen === "run") return;
|
||||
if (!current.curriculum) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (current.screen === "aquarium") {
|
||||
if (current.nextUp) current.start(current.nextUp);
|
||||
} else {
|
||||
const lesson = LESSONS[current.selected];
|
||||
const lesson = current.curriculum.lessons[current.selected];
|
||||
if (lesson) current.start(lesson);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.screen !== "map") return;
|
||||
// The map is a vertical path now, so "next" is down rather than to the right.
|
||||
// The map is a vertical path, so "next" is down rather than to the right.
|
||||
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
||||
if (step !== 0) {
|
||||
event.preventDefault();
|
||||
playPop(340);
|
||||
setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + step)));
|
||||
setSelected((index) => Math.min(current.curriculum!.lessons.length - 1, Math.max(0, index + step)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -236,34 +239,44 @@ export function App() {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (screen === "run" && !outcome) return;
|
||||
if (!progress) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } }));
|
||||
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
|
||||
if (key === "h") {
|
||||
setProgress((p) => ({
|
||||
...p,
|
||||
settings: {
|
||||
...p.settings,
|
||||
keyboardHint: p.settings.keyboardHint === "off" ? "auto" : "off",
|
||||
},
|
||||
}));
|
||||
void saveSettings({
|
||||
...progress.settings,
|
||||
keyboardHint: progress.settings.keyboardHint === "off" ? "auto" : "off",
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [screen, outcome]);
|
||||
}, [screen, outcome, progress, saveSettings]);
|
||||
|
||||
// --- render --------------------------------------------------------------
|
||||
|
||||
const next = lessonId ? nextLesson(lessonId) : null;
|
||||
if (curriculumLoading || (configured && progressLoading) || !progress) {
|
||||
return (
|
||||
<Stage creatures={[]} dimmed={false}>
|
||||
<AppHeader />
|
||||
<LoadingScreen text="Einen Moment …" />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 shared = {
|
||||
activeKeys: lesson?.activeKeys ?? [],
|
||||
progress,
|
||||
paused: outcome !== null,
|
||||
onFinished,
|
||||
};
|
||||
if (!curriculum) {
|
||||
return (
|
||||
<Stage creatures={[]} dimmed={false}>
|
||||
<AppHeader />
|
||||
<LoadingScreen text="Das Tippen-Spiel ist noch nicht eingerichtet." />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
|
||||
|
||||
// Every mode takes the same bundle; only the drawing differs.
|
||||
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
|
||||
const letterProps = (letters: readonly string[]) => ({ letters, ...shared });
|
||||
const textProps = (r: Extract<RunTarget, { kind: "text" }>) => ({
|
||||
chunks: r.chunks,
|
||||
@@ -287,6 +300,7 @@ export function App() {
|
||||
|
||||
{screen === "aquarium" && (
|
||||
<Aquarium
|
||||
worlds={curriculum.worlds}
|
||||
progress={progress}
|
||||
nextLesson={nextUp}
|
||||
onContinue={() => nextUp && start(nextUp)}
|
||||
@@ -295,7 +309,7 @@ export function App() {
|
||||
)}
|
||||
|
||||
{screen === "map" && (
|
||||
<LessonMap progress={progress} selected={selected} onPick={start} />
|
||||
<LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
|
||||
)}
|
||||
|
||||
{screen === "run" && lesson && run && (
|
||||
@@ -321,15 +335,14 @@ export function App() {
|
||||
result={outcome.result}
|
||||
unlockedTitle={outcome.unlockedTitle}
|
||||
newCreature={outcome.newCreature}
|
||||
unlockedReward={outcome.unlockedReward}
|
||||
isNewBest={outcome.isNewBest}
|
||||
bestEver={overallBestAnimal(progress)}
|
||||
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
|
||||
onPlayBonus={playBonus}
|
||||
onRetry={retry}
|
||||
onContinue={continueAfterResult}
|
||||
continueLabel={
|
||||
next && progress.lessons[next.id]?.unlocked ? `${next.title} ▶` : "Zur Karte"
|
||||
}
|
||||
continueLabel={next && progress.lessons[next.id]?.unlocked ? `${next.title} ▶` : "Zur Karte"}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -337,3 +350,23 @@ export function App() {
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingScreen({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--paper)",
|
||||
fontSize: 18,
|
||||
fontWeight: 800,
|
||||
textAlign: "center",
|
||||
padding: "0 32px",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,14 @@
|
||||
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to
|
||||
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
|
||||
|
||||
import { creatureById } from "../lib/aquarium";
|
||||
import type { Lesson } from "../lib/curriculum";
|
||||
import { WORLDS } from "../lib/curriculum";
|
||||
import { animalById } from "../lib/grading";
|
||||
import { overallBestAnimal } from "../lib/progress";
|
||||
import type { Progress } from "../lib/progress";
|
||||
import { creatureById } from "../../lib/tippen/aquarium";
|
||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||
import { animalById } from "../../lib/tippen/grading";
|
||||
import { overallBestAnimal } from "../../lib/tippen/progress";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
|
||||
interface Props {
|
||||
worlds: readonly World[];
|
||||
progress: Progress;
|
||||
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
|
||||
nextLesson: Lesson | null;
|
||||
@@ -24,7 +24,7 @@ interface Props {
|
||||
onOpenMap: () => void;
|
||||
}
|
||||
|
||||
export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props) {
|
||||
export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) {
|
||||
const bestAnimal = overallBestAnimal(progress);
|
||||
|
||||
return (
|
||||
@@ -42,7 +42,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="glass-panel"
|
||||
className="tp-glass-panel"
|
||||
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
|
||||
@@ -60,7 +60,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props)
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{WORLDS.map((world) => {
|
||||
{worlds.map((world) => {
|
||||
const creature = creatureById(world.reward);
|
||||
const owned = progress.aquarium.includes(creature.id);
|
||||
return (
|
||||
@@ -118,7 +118,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props)
|
||||
▶ {nextLesson.title}
|
||||
</button>
|
||||
)}
|
||||
<button className="pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
|
||||
<button className="tp-pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
|
||||
🗺️ Alle Lektionen
|
||||
</button>
|
||||
</div>
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../lib/aquarium";
|
||||
import type { CreatureId, Swimmer } from "../lib/aquarium";
|
||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
||||
|
||||
interface Props {
|
||||
creatures: readonly CreatureId[];
|
||||
@@ -16,8 +16,8 @@ const KEYS: readonly [string, string][] = [
|
||||
|
||||
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 className="tp-overlay backdrop-enter" onClick={onClose}>
|
||||
<div className="tp-sheet tp-sheet-enter" style={{ padding: "26px 32px", width: "min(460px, 100%)" }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 900, color: "var(--ink)", marginBottom: 16 }}>
|
||||
⌨️ Zaubertasten
|
||||
</div>
|
||||
@@ -10,9 +10,9 @@
|
||||
* 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";
|
||||
import { KEYBOARD_ROWS, fingerOf, keyForChar, keysInRow, needsShift, shiftHandFor } from "../../lib/tippen/fingers";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
import { mastery } from "../../lib/tippen/progress";
|
||||
|
||||
interface Props {
|
||||
/** Keys this lesson uses - everything else is dimmed. */
|
||||
@@ -39,9 +39,9 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
|
||||
const shiftHand = shift && nextKey !== null ? shiftHandFor(nextKey) : null;
|
||||
|
||||
return (
|
||||
<div className="kb" style={{ ["--kb-size" as string]: `${size}px` }} aria-hidden="true">
|
||||
<div className="tp-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 }}>
|
||||
<div className="tp-kb-row" key={row} style={{ marginLeft: ROW_INDENT[row] ?? 0 }}>
|
||||
{keysInRow(row).map((key) => {
|
||||
const finger = fingerOf(key);
|
||||
const isActive = active.has(key);
|
||||
@@ -52,7 +52,7 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="kb-key"
|
||||
className="tp-kb-key"
|
||||
data-active={isActive}
|
||||
data-next={key === next}
|
||||
data-finger={finger?.id}
|
||||
@@ -71,20 +71,20 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="kb-row" style={{ marginTop: 2, alignItems: "center", gap: 10 }}>
|
||||
<div className="tp-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 world 4 exists to teach. */}
|
||||
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "left"}>
|
||||
<div className="tp-kb-key tp-kb-shift" data-active={shift} data-next={shift && shiftHand === "left"}>
|
||||
⇧
|
||||
</div>
|
||||
<div
|
||||
className="kb-key kb-space"
|
||||
className="tp-kb-key tp-kb-space"
|
||||
data-active={spaceActive}
|
||||
data-next={next === " "}
|
||||
data-finger="thumb"
|
||||
style={{ ["--finger-hue" as string]: 220 }}
|
||||
/>
|
||||
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "right"}>
|
||||
<div className="tp-kb-key tp-kb-shift" data-active={shift} data-next={shift && shiftHand === "right"}>
|
||||
⇧
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,14 +5,15 @@
|
||||
* best animal, star count and a badge for which game it plays, so the map doubles as
|
||||
* both a path forward and a trophy cabinet. */
|
||||
|
||||
import { LESSONS, WORLDS } from "../lib/curriculum";
|
||||
import type { Lesson } from "../lib/curriculum";
|
||||
import { animalById } from "../lib/grading";
|
||||
import { NODE_SPACING, pathD, pointFor } from "../lib/lessonPath";
|
||||
import { MODE_INFO } from "../lib/modeInfo";
|
||||
import type { Progress } from "../lib/progress";
|
||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||
import { animalById } from "../../lib/tippen/grading";
|
||||
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
|
||||
interface Props {
|
||||
worlds: readonly World[];
|
||||
lessons: readonly Lesson[];
|
||||
progress: Progress;
|
||||
/** Which card the keyboard selection is on. */
|
||||
selected: number;
|
||||
@@ -31,23 +32,23 @@ const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> =
|
||||
sentences: "Sätze",
|
||||
};
|
||||
|
||||
export function LessonMap({ progress, selected, onPick }: Props) {
|
||||
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
|
||||
>
|
||||
<div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{WORLDS.map((world) => {
|
||||
const lessons = LESSONS.filter((lesson) => lesson.world === world.number);
|
||||
const done = lessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
|
||||
const height = lessons.length * NODE_SPACING;
|
||||
const points = lessons.map((_, i) => pointFor(i));
|
||||
{worlds.map((world) => {
|
||||
const worldLessons = lessons.filter((lesson) => lesson.world === world.number);
|
||||
const done = worldLessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
|
||||
const height = worldLessons.length * NODE_SPACING;
|
||||
const points = worldLessons.map((_, i) => pointFor(i));
|
||||
|
||||
return (
|
||||
<section key={world.number}>
|
||||
<div
|
||||
className="glass-panel"
|
||||
className="tp-glass-panel"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
@@ -64,7 +65,7 @@ export function LessonMap({ progress, selected, onPick }: Props) {
|
||||
Welt {world.number} — {world.title}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
|
||||
{done}/{lessons.length}
|
||||
{done}/{worldLessons.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -78,18 +79,18 @@ export function LessonMap({ progress, selected, onPick }: Props) {
|
||||
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
|
||||
{lessons.map((lesson, i) => {
|
||||
{worldLessons.map((lesson, i) => {
|
||||
const entry = progress.lessons[lesson.id];
|
||||
const locked = !entry?.unlocked;
|
||||
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
|
||||
const index = LESSONS.indexOf(lesson);
|
||||
const index = lessons.indexOf(lesson);
|
||||
const point = points[i]!;
|
||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
className="card glass-panel"
|
||||
className="card tp-glass-panel"
|
||||
data-selected={index === selected}
|
||||
data-locked={locked}
|
||||
disabled={locked}
|
||||
@@ -7,13 +7,15 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { creatureById } from "../lib/aquarium";
|
||||
import type { CreatureId } from "../lib/aquarium";
|
||||
import type { ModeId } from "../lib/curriculum";
|
||||
import type { RunResult } from "../lib/grading";
|
||||
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
|
||||
import type { AnimalId } from "../lib/grading";
|
||||
import { MODE_INFO } from "../lib/modeInfo";
|
||||
import { coverUrl } from "../../api/client";
|
||||
import { creatureById } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||
import type { ModeId } from "../../lib/tippen/curriculum";
|
||||
import type { RunResult } from "../../lib/tippen/grading";
|
||||
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../../lib/tippen/grading";
|
||||
import type { AnimalId } from "../../lib/tippen/grading";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||
|
||||
interface Props {
|
||||
result: RunResult;
|
||||
@@ -21,6 +23,9 @@ interface Props {
|
||||
unlockedTitle: string | null;
|
||||
/** Set when this run released a creature into the aquarium. */
|
||||
newCreature: CreatureId | null;
|
||||
/** Set when this run's lesson named an `unlocks:` target that just became reachable -
|
||||
* the literal track/episode it names, not "one of several". */
|
||||
unlockedReward: UnlockedReward | null;
|
||||
isNewBest: boolean;
|
||||
/** The fastest animal earned on any lesson so far - decides how much of the ladder
|
||||
* may be revealed. */
|
||||
@@ -39,6 +44,7 @@ export function ResultSheet({
|
||||
result,
|
||||
unlockedTitle,
|
||||
newCreature,
|
||||
unlockedReward,
|
||||
isNewBest,
|
||||
bestEver,
|
||||
bonusModes,
|
||||
@@ -56,9 +62,9 @@ export function ResultSheet({
|
||||
useEffect(() => retryButton.current?.focus(), []);
|
||||
|
||||
return (
|
||||
<div className="overlay backdrop-enter">
|
||||
<div className="tp-overlay backdrop-enter">
|
||||
<div
|
||||
className="sheet sheet-enter"
|
||||
className="tp-sheet tp-sheet-enter"
|
||||
style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }}
|
||||
>
|
||||
<div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}>
|
||||
@@ -143,6 +149,36 @@ export function ResultSheet({
|
||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
||||
</div>
|
||||
)}
|
||||
{unlockedReward && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
color: "var(--ink)",
|
||||
fontWeight: 900,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{unlockedReward.hasCover ? (
|
||||
<img
|
||||
src={coverUrl(unlockedReward.albumId)}
|
||||
alt=""
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
objectFit: "cover",
|
||||
borderRadius: 8,
|
||||
animation: "tierEnter 520ms ease-out",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: 40, animation: "tierEnter 520ms ease-out" }}>🎁</span>
|
||||
)}
|
||||
🎁 Neu zum Anhören: {unlockedReward.title}
|
||||
</div>
|
||||
)}
|
||||
{!result.passed && !unlockedTitle && (
|
||||
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
|
||||
Mit {Math.round(STAR_THRESHOLDS.two * 100)}% Treffern geht es weiter. Fast!
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CreatureId } from "../lib/aquarium";
|
||||
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
||||
import { AquariumCreatures } from "./AquariumCreatures";
|
||||
import { Bubbles } from "./Bubbles";
|
||||
|
||||
@@ -21,7 +21,7 @@ interface Props {
|
||||
|
||||
export function Stage({ children, creatures, dimmed }: Props) {
|
||||
return (
|
||||
<div className="stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
|
||||
<div className="tp-stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
|
||||
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||
{SHOW_BUBBLES && <Bubbles />}
|
||||
{children}
|
||||
@@ -5,7 +5,7 @@
|
||||
* not. The gap is a real space to type from the very first lesson, even before the
|
||||
* space-bar lesson formally teaches the thumb. */
|
||||
|
||||
import { chunkOffsets } from "../lib/generator";
|
||||
import { chunkOffsets } from "../../lib/tippen/generator";
|
||||
|
||||
interface Props {
|
||||
chunks: readonly string[];
|
||||
@@ -43,11 +43,11 @@ export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
|
||||
const size = fontSize ?? layout.size;
|
||||
|
||||
return (
|
||||
<div className="target" style={{ fontSize: size, maxWidth: `min(${layout.width}px, 90vw)` }}>
|
||||
<div className="tp-target" style={{ fontSize: size, maxWidth: `min(${layout.width}px, 90vw)` }}>
|
||||
{chunks.map((chunk, chunkIndex) => {
|
||||
const start = offsets[chunkIndex] ?? 0;
|
||||
return (
|
||||
<div className="target-chunk" key={chunkIndex}>
|
||||
<div className="tp-target-chunk" key={chunkIndex}>
|
||||
{[...chunk].map((char, i) => {
|
||||
const at = start + i;
|
||||
const state = at < index ? "done" : at === index ? "current" : "open";
|
||||
@@ -58,7 +58,7 @@ export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="target-char"
|
||||
className="tp-target-char"
|
||||
data-state={state}
|
||||
data-blank={isBlank}
|
||||
data-wrong={state === "current" && wrong}
|
||||
@@ -71,7 +71,7 @@ export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
|
||||
needs to be typeable and to show the cursor. Before that it is a gap. */}
|
||||
{spaceActive && chunkIndex < chunks.length - 1 && (
|
||||
<span
|
||||
className="target-char"
|
||||
className="tp-target-char"
|
||||
data-blank="true"
|
||||
data-state={
|
||||
start + chunk.length < index
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
import { 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 { useRun } from "../../hooks/useRun";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||
import { fingerOf } from "../../../lib/tippen/fingers";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
@@ -3,10 +3,10 @@
|
||||
* and race periodically take a turn as the required mode instead - any of them can
|
||||
* unlock the next lesson, since `recordRun` doesn't care which mode produced the run. */
|
||||
|
||||
import { currentChar } from "../../lib/engine";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
import type { Progress } from "../../lib/progress";
|
||||
import { useRun } from "../../hooks/useRun";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
import { Target } from "../Target";
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
|
||||
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 { useRun } from "../../hooks/useRun";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||
import { chunkOffsets } from "../../../lib/tippen/generator";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
@@ -11,12 +11,12 @@
|
||||
|
||||
import { 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 { useRun } from "../../hooks/useRun";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import { fingerOf } from "../../../lib/tippen/fingers";
|
||||
import { mulberry32 } from "../../../lib/tippen/generator";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
@@ -96,7 +96,7 @@ export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="jellyfish"
|
||||
className="tp-jellyfish"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${spot.left}%`,
|
||||
@@ -16,10 +16,10 @@
|
||||
|
||||
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 { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
import { useRun } from "../../../hooks/useTippenRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
import { Target } from "../Target";
|
||||
|
||||
34
web/src/hooks/useTippenCurriculum.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** The typing game's lesson plan, fetched once - see `lib/tippen/curriculum.ts` for the
|
||||
* derived Lesson/World shape everything else in the typing game expects. `null` after
|
||||
* loading means the backend has no `general.tippen` section configured, same as
|
||||
* `useHomeAssistant`'s `config` for the room page. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Curriculum } from "../lib/tippen/curriculum";
|
||||
import { fromApi } from "../lib/tippen/curriculum";
|
||||
|
||||
export interface TippenCurriculumState {
|
||||
curriculum: Curriculum | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useTippenCurriculum(): TippenCurriculumState {
|
||||
const [curriculum, setCurriculum] = useState<Curriculum | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void api.tippenCurriculum().then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setCurriculum(loaded ? fromApi(loaded) : null);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { curriculum, loading };
|
||||
}
|
||||
57
web/src/hooks/useTippenProgress.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** The typing game's progress: fetched once and updated from each run-recording
|
||||
* response, since the backend is the only place it lives now - there is no local save.
|
||||
* `enabled` mirrors `useHomeAssistant`'s `config` argument: pass whether the curriculum
|
||||
* resolved non-null, so this hook stays a no-op until there is something to fetch. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { RunResult } from "../lib/tippen/grading";
|
||||
import type { Progress, RunOutcome, Settings } from "../lib/tippen/progress";
|
||||
import { progressFromApi, runOutcomeFromApi, toRunInput } from "../lib/tippen/progress";
|
||||
|
||||
export interface TippenProgressState {
|
||||
progress: Progress | null;
|
||||
loading: boolean;
|
||||
recordRun: (lessonId: string, result: RunResult) => Promise<RunOutcome>;
|
||||
saveSettings: (settings: Settings) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTippenProgress(enabled: boolean): TippenProgressState {
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
void api.tippenProgress().then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setProgress(progressFromApi(loaded));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const recordRun = useCallback(async (lessonId: string, result: RunResult) => {
|
||||
const response = await api.recordTippenRun(toRunInput(lessonId, result));
|
||||
const outcome = runOutcomeFromApi(response);
|
||||
setProgress(outcome.progress);
|
||||
return outcome;
|
||||
}, []);
|
||||
|
||||
const saveSettings = useCallback(async (settings: Settings) => {
|
||||
const saved = await api.saveTippenSettings({
|
||||
sound: settings.sound,
|
||||
keyboard_hint: settings.keyboardHint,
|
||||
});
|
||||
setProgress((previous) =>
|
||||
previous
|
||||
? { ...previous, settings: { sound: saved.sound, keyboardHint: saved.keyboard_hint } }
|
||||
: previous,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { progress, loading, recordRun, saveSettings };
|
||||
}
|
||||
@@ -11,10 +11,10 @@
|
||||
|
||||
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 { playWrong, playDone, playCorrect } from "../lib/pop";
|
||||
import { isTypingKey, press, startRun } from "../lib/tippen/engine";
|
||||
import type { RunEvent, RunState } from "../lib/tippen/engine";
|
||||
import type { RunResult } from "../lib/tippen/grading";
|
||||
import { playWrong, playDone, playCorrect } from "../lib/tippen/pop";
|
||||
|
||||
interface Options {
|
||||
/** The text to type. Changing it restarts the run. */
|
||||
@@ -28,7 +28,8 @@ function album(over: Partial<Album> = {}): Album {
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
tracks: [{ title: "Lied", duration: 60, analysis: null }],
|
||||
locked: false,
|
||||
tracks: [{ title: "Lied", duration: 60, analysis: null, locked: false, unlock_hint: null }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ function album(id: string, over: Partial<Album> = {}): Album {
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
locked: false,
|
||||
tracks: [
|
||||
{ title: `Lied ${id}`, duration: 60, analysis: null },
|
||||
{ title: "Zweites Lied", duration: 60, analysis: null },
|
||||
{ title: `Lied ${id}`, duration: 60, analysis: null, locked: false, unlock_hint: null },
|
||||
{ title: "Zweites Lied", duration: 60, analysis: null, locked: false, unlock_hint: null },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ function album(id: string, over: Partial<Album> = {}): Album {
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
locked: false,
|
||||
tracks: [],
|
||||
...over,
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ export interface UiState {
|
||||
* that row", the way it means "which flat position" everywhere else. */
|
||||
shelfRow: number;
|
||||
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
|
||||
* so toggling to the room and back leaves the music side exactly as it was. */
|
||||
page: "music" | "room";
|
||||
* so switching pages and back leaves the music side exactly as it was. */
|
||||
page: "music" | "room" | "typing";
|
||||
view: "browse" | "play";
|
||||
openAlbumId: string | null;
|
||||
/** Which track is highlighted in the open album's track list. Only meaningful while
|
||||
@@ -232,6 +232,12 @@ export function handleKey(
|
||||
): Action[] {
|
||||
const { key } = event;
|
||||
|
||||
// The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its
|
||||
// own complete keyboard handling, including its own Escape/F1 bindings. It must own
|
||||
// every key while active - even Shift+media and Space below, which would otherwise
|
||||
// hijack a key a lesson happens to be drilling (see TippenApp.tsx's own doc comment).
|
||||
if (state.page === "typing") return [];
|
||||
|
||||
if (event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
||||
const media = SHIFT_MEDIA[key.toUpperCase()];
|
||||
if (media) return media;
|
||||
|
||||
@@ -114,6 +114,9 @@ export function songMatches(query: BrowseQuery): SongHit[] {
|
||||
const hits: SongHit[] = [];
|
||||
for (const album of pool(query.albums, query.group)) {
|
||||
album.tracks.forEach((track, index) => {
|
||||
// A locked track has no real title to search by - it shows as a question mark
|
||||
// wherever it appears, so it has nothing useful to match here either.
|
||||
if (track.locked) return;
|
||||
if (!words.length || matchesWords(normalize(track.title), words)) {
|
||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { Group } from "./search";
|
||||
import type { UiState } from "./keyboard";
|
||||
|
||||
// ---------------------------------------------------------------- toggles --
|
||||
|
||||
@@ -74,6 +75,21 @@ export const GROUP_LABEL: Record<Group, string> = {
|
||||
podcasts: "Podcasts",
|
||||
};
|
||||
|
||||
/** Icon/label for the vertical tab rail - one entry per `UiState["page"]`. The
|
||||
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
|
||||
* other two always show. */
|
||||
export const PAGE_ICON: Record<UiState["page"], string> = {
|
||||
music: "🎵",
|
||||
room: "💡",
|
||||
typing: "⌨️",
|
||||
};
|
||||
|
||||
export const PAGE_LABEL: Record<UiState["page"], string> = {
|
||||
music: "Musik",
|
||||
room: "Mein Zimmer",
|
||||
typing: "Tippen",
|
||||
};
|
||||
|
||||
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
|
||||
* with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
|
||||
export function glassTint(hue: number): CSSProperties {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium";
|
||||
import type { Tank, Swimmer } from "../aquarium";
|
||||
import { WORLDS } from "../curriculum";
|
||||
import { mulberry32 } from "../generator";
|
||||
|
||||
const TANK: Tank = { width: 1280, height: 800 };
|
||||
@@ -15,11 +14,6 @@ function swim(s: Swimmer, seconds: number, tank = TANK, rng = mulberry32(7)): Sw
|
||||
}
|
||||
|
||||
describe("Creatures", () => {
|
||||
it("gives every pet exactly one world", () => {
|
||||
const rewards = WORLDS.map((world) => world.reward);
|
||||
expect([...rewards].sort()).toEqual(CREATURES.map((c) => c.id).sort());
|
||||
});
|
||||
|
||||
it("looks pets up by id", () => {
|
||||
for (const creature of CREATURES) expect(creatureById(creature.id)).toBe(creature);
|
||||
});
|
||||
93
web/src/lib/tippen/__tests__/curriculum.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** What's left client-side of the old curriculum.test.ts, now that the lesson plan's
|
||||
* content lives in the backend (see musicmouse/tippen/curriculum.py and its own tests
|
||||
* against the real curriculum file) - just `fromApi`'s wire-to-app-shape conversion and
|
||||
* the navigation helpers. */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { TippenCurriculum as ApiCurriculum, TippenLesson as ApiLesson } from "../../../api/types";
|
||||
import { firstLessonId, fromApi, lessonById, nextLesson } from "../curriculum";
|
||||
|
||||
function apiLesson(over: Partial<ApiLesson> = {}): ApiLesson {
|
||||
return {
|
||||
id: "l01",
|
||||
world: 1,
|
||||
number: 1,
|
||||
title: "F und J",
|
||||
subtitle: "Die Zeigefinger",
|
||||
kind: "letters",
|
||||
new_keys: ["f", "j"],
|
||||
spotlight_keys: ["f", "j"],
|
||||
emphasis: "isolated",
|
||||
active_keys: ["f", "j"],
|
||||
primary_mode: "bubbles",
|
||||
bonus_modes: [],
|
||||
words: [],
|
||||
is_drill: false,
|
||||
chunks: 24,
|
||||
chunk_size: 3,
|
||||
reward: { resolved: false, album_id: null, has_cover: false, kind: null },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const API: ApiCurriculum = {
|
||||
worlds: [{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" }],
|
||||
lessons: [apiLesson({ id: "l01", number: 1 }), apiLesson({ id: "l02", number: 2, new_keys: [] })],
|
||||
};
|
||||
|
||||
describe("fromApi", () => {
|
||||
it("converts snake_case wire fields to the app's own camelCase shape", () => {
|
||||
const curriculum = fromApi(API);
|
||||
expect(curriculum.worlds).toEqual([
|
||||
{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
|
||||
]);
|
||||
const [lesson] = curriculum.lessons;
|
||||
expect(lesson).toMatchObject({
|
||||
id: "l01",
|
||||
newKeys: ["f", "j"],
|
||||
spotlightKeys: ["f", "j"],
|
||||
activeKeys: ["f", "j"],
|
||||
primaryMode: "bubbles",
|
||||
bonusModes: [],
|
||||
isDrill: false,
|
||||
chunkSize: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries a resolved reward through unchanged", () => {
|
||||
const api: ApiCurriculum = {
|
||||
worlds: API.worlds,
|
||||
lessons: [
|
||||
apiLesson({
|
||||
reward: { resolved: true, album_id: "abc123", has_cover: true, kind: "tracks" },
|
||||
}),
|
||||
],
|
||||
};
|
||||
expect(fromApi(api).lessons[0]!.reward).toEqual({
|
||||
resolved: true,
|
||||
albumId: "abc123",
|
||||
hasCover: true,
|
||||
kind: "tracks",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation", () => {
|
||||
const curriculum = fromApi(API);
|
||||
|
||||
it("chains every lesson to the next and stops at the end", () => {
|
||||
expect(nextLesson(curriculum, "l01")?.id).toBe("l02");
|
||||
expect(nextLesson(curriculum, "l02")).toBeNull();
|
||||
expect(nextLesson(curriculum, "nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("looks lessons up by id", () => {
|
||||
expect(lessonById(curriculum, "l01")?.number).toBe(1);
|
||||
expect(lessonById(curriculum, "nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("names the first lesson", () => {
|
||||
expect(firstLessonId(curriculum)).toBe("l01");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LESSONS } from "../curriculum";
|
||||
import type { Lesson } from "../curriculum";
|
||||
import {
|
||||
chunkOffsets,
|
||||
drillChunks,
|
||||
@@ -13,6 +13,44 @@ import {
|
||||
|
||||
const keys = ["a", "s", "d", "f"];
|
||||
|
||||
function lesson(over: Partial<Lesson> = {}): Lesson {
|
||||
return {
|
||||
id: "l01",
|
||||
world: 1,
|
||||
number: 1,
|
||||
title: "Test",
|
||||
subtitle: "",
|
||||
kind: "letters",
|
||||
newKeys: [],
|
||||
spotlightKeys: [],
|
||||
emphasis: null,
|
||||
activeKeys: keys,
|
||||
primaryMode: "dive",
|
||||
bonusModes: [],
|
||||
words: [],
|
||||
isDrill: false,
|
||||
chunks: 10,
|
||||
chunkSize: 4,
|
||||
reward: { resolved: false, albumId: null, hasCover: false, kind: null },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
// A small spread of shapes - a plain letters lesson, one with real words, and one
|
||||
// whose active-key set differs from the others - standing in for the real curriculum's
|
||||
// variety without depending on its (now backend-fetched) content.
|
||||
const FIXTURE_LESSONS: readonly Lesson[] = [
|
||||
lesson({ id: "l01", number: 1, activeKeys: ["a", "s"] }),
|
||||
lesson({ id: "l02", number: 2, activeKeys: ["a", "s", "d", "f"] }),
|
||||
lesson({
|
||||
id: "l03",
|
||||
number: 3,
|
||||
kind: "words",
|
||||
activeKeys: [..."asdfjklö"],
|
||||
words: ["das", "sass", "fass"],
|
||||
}),
|
||||
];
|
||||
|
||||
describe("mulberry32", () => {
|
||||
it("is deterministic for a seed and different across seeds", () => {
|
||||
expect(drillChunks(keys, mulberry32(7))).toEqual(drillChunks(keys, mulberry32(7)));
|
||||
@@ -31,9 +69,9 @@ describe("mulberry32", () => {
|
||||
|
||||
describe("drillChunks", () => {
|
||||
it("only ever emits active keys", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
const chunks = drillChunks(lesson.activeKeys, mulberry32(lesson.number), { chunks: 20 });
|
||||
const active = new Set(lesson.activeKeys);
|
||||
for (const fixture of FIXTURE_LESSONS) {
|
||||
const chunks = drillChunks(fixture.activeKeys, mulberry32(fixture.number), { chunks: 20 });
|
||||
const active = new Set(fixture.activeKeys);
|
||||
for (const char of chunks.join("")) expect(active.has(char)).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -144,22 +182,22 @@ describe("wordChunks", () => {
|
||||
|
||||
describe("lineFor", () => {
|
||||
it("prefers real words once a lesson has them", () => {
|
||||
const lesson = LESSONS.find((l) => l.words.length > 0)!;
|
||||
const chunks = lineFor(lesson, mulberry32(1));
|
||||
for (const chunk of chunks) expect(lesson.words).toContain(chunk);
|
||||
const withWords = FIXTURE_LESSONS.find((l) => l.words.length > 0)!;
|
||||
const chunks = lineFor(withWords, mulberry32(1));
|
||||
for (const chunk of chunks) expect(withWords.words).toContain(chunk);
|
||||
});
|
||||
|
||||
it("falls back to letters for the home-row lessons", () => {
|
||||
const lesson = LESSONS[0]!;
|
||||
const chunks = lineFor(lesson, mulberry32(1));
|
||||
it("falls back to letters for a lesson with no words", () => {
|
||||
const noWords = FIXTURE_LESSONS[0]!;
|
||||
const chunks = lineFor(noWords, mulberry32(1));
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
for (const char of chunks.join("")) expect(lesson.activeKeys).toContain(char);
|
||||
for (const char of chunks.join("")) expect(noWords.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.words).not.toContain(chunk);
|
||||
const withWords = FIXTURE_LESSONS.find((l) => l.words.length > 0)!;
|
||||
const chunks = lineFor(withWords, mulberry32(1), { preferWords: false, chunks: 4 });
|
||||
for (const chunk of chunks) expect(withWords.words).not.toContain(chunk);
|
||||
});
|
||||
});
|
||||
|
||||
67
web/src/lib/tippen/__tests__/progress.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** What's left client-side of the old progress.ts test suite, now that
|
||||
* `recordRun`/`freshProgress`/`migrate` live in the backend (see
|
||||
* musicmouse/tippen/progress.py and its own tests) - just the pure derivations that
|
||||
* still run here: which key to drill next, and how worn-in a key looks on the
|
||||
* on-screen keyboard. */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { focusKeyFor, mastery } from "../progress";
|
||||
import type { Progress } from "../progress";
|
||||
|
||||
function basicProgress(over: Partial<Progress> = {}): Progress {
|
||||
return {
|
||||
lessons: {},
|
||||
keyStats: {},
|
||||
pearls: 0,
|
||||
aquarium: [],
|
||||
streak: { days: 0, lastPlayed: null },
|
||||
settings: { sound: true, keyboardHint: "auto" },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
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 lesson 1.
|
||||
expect(focusKeyFor(basicProgress(), ["a", "s", "d", "f"])).toBeNull();
|
||||
});
|
||||
|
||||
it("picks an unpractised key before a merely slow one", () => {
|
||||
const progress = basicProgress({ 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 = basicProgress({
|
||||
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 space bar and copes with an empty lesson", () => {
|
||||
expect(focusKeyFor(basicProgress(), [" "])).toBeNull();
|
||||
expect(focusKeyFor(basicProgress(), [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mastery", () => {
|
||||
it("is zero until a key has been seen enough times", () => {
|
||||
const progress = basicProgress({ 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) =>
|
||||
basicProgress({ 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);
|
||||
});
|
||||
});
|
||||
109
web/src/lib/tippen/curriculum.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/** The lesson plan, fetched from the backend rather than parsed from a YAML file at
|
||||
* build time - see `musicmouse/tippen/curriculum.py` for how it is loaded and
|
||||
* validated, and `musicmouse/tippen/rewards.py` for how a lesson's reward resolves
|
||||
* against the library. `fromApi` is the only place the wire's snake_case shape
|
||||
* (`api/types.ts`) meets this module's own camelCase one, which every other tippen
|
||||
* module (ported near-unchanged from the old standalone app) still expects. */
|
||||
|
||||
import type {
|
||||
TippenCurriculum as ApiCurriculum,
|
||||
TippenLesson as ApiLesson,
|
||||
TippenReward as ApiReward,
|
||||
} from "../../api/types";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
|
||||
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
||||
|
||||
/** What this lesson unlocks in the music library, if anything - see `rewards.py`.
|
||||
* `resolved: false` means the curriculum names a path that matches nothing right now. */
|
||||
export interface MediaReward {
|
||||
resolved: boolean;
|
||||
albumId: string | null;
|
||||
hasCover: boolean;
|
||||
kind: "tracks" | "episode" | null;
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
world: number;
|
||||
number: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
kind: LessonKind;
|
||||
newKeys: readonly string[];
|
||||
spotlightKeys: readonly string[];
|
||||
emphasis: "isolated" | "mixed" | null;
|
||||
activeKeys: readonly string[];
|
||||
primaryMode: ModeId;
|
||||
bonusModes: readonly ModeId[];
|
||||
words: readonly string[];
|
||||
isDrill: boolean;
|
||||
chunks: number;
|
||||
chunkSize: number;
|
||||
reward: MediaReward;
|
||||
}
|
||||
|
||||
export interface World {
|
||||
number: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
reward: CreatureId;
|
||||
}
|
||||
|
||||
export interface Curriculum {
|
||||
worlds: readonly World[];
|
||||
lessons: readonly Lesson[];
|
||||
}
|
||||
|
||||
function toReward(reward: ApiReward): MediaReward {
|
||||
return { resolved: reward.resolved, albumId: reward.album_id, hasCover: reward.has_cover, kind: reward.kind };
|
||||
}
|
||||
|
||||
function toLesson(lesson: ApiLesson): Lesson {
|
||||
return {
|
||||
id: lesson.id,
|
||||
world: lesson.world,
|
||||
number: lesson.number,
|
||||
title: lesson.title,
|
||||
subtitle: lesson.subtitle,
|
||||
kind: lesson.kind,
|
||||
newKeys: lesson.new_keys,
|
||||
spotlightKeys: lesson.spotlight_keys,
|
||||
emphasis: lesson.emphasis,
|
||||
activeKeys: lesson.active_keys,
|
||||
primaryMode: lesson.primary_mode,
|
||||
bonusModes: lesson.bonus_modes,
|
||||
words: lesson.words,
|
||||
isDrill: lesson.is_drill,
|
||||
chunks: lesson.chunks,
|
||||
chunkSize: lesson.chunk_size,
|
||||
reward: toReward(lesson.reward),
|
||||
};
|
||||
}
|
||||
|
||||
export function fromApi(curriculum: ApiCurriculum): Curriculum {
|
||||
return {
|
||||
worlds: curriculum.worlds.map((world) => ({
|
||||
number: world.number,
|
||||
title: world.title,
|
||||
emoji: world.emoji,
|
||||
reward: world.reward as CreatureId,
|
||||
})),
|
||||
lessons: curriculum.lessons.map(toLesson),
|
||||
};
|
||||
}
|
||||
|
||||
export function lessonById(curriculum: Curriculum, id: string): Lesson | null {
|
||||
return curriculum.lessons.find((lesson) => lesson.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function nextLesson(curriculum: Curriculum, id: string): Lesson | null {
|
||||
const index = curriculum.lessons.findIndex((lesson) => lesson.id === id);
|
||||
if (index < 0) return null;
|
||||
return curriculum.lessons[index + 1] ?? null;
|
||||
}
|
||||
|
||||
export function firstLessonId(curriculum: Curriculum): string | null {
|
||||
return curriculum.lessons[0]?.id ?? null;
|
||||
}
|
||||
185
web/src/lib/tippen/progress.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/** What stays client-side now that the backend owns `recordRun`/`freshProgress` (see
|
||||
* `musicmouse/tippen/progress.py`): the pure, per-keystroke derivations - which key to
|
||||
* drill next, how worn-in a key looks on the on-screen keyboard, and the fastest animal
|
||||
* earned anywhere, for the aquarium's headline stat. Progress itself is fetched once
|
||||
* per session and updated from each run-recording response; there is no local save. */
|
||||
|
||||
import type {
|
||||
TippenProgress as ApiProgress,
|
||||
TippenRunInput,
|
||||
TippenRunResult as ApiRunResult,
|
||||
TippenStroke,
|
||||
} from "../../api/types";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import type { AnimalId, RunResult } from "./grading";
|
||||
|
||||
export interface LessonProgress {
|
||||
unlocked: boolean;
|
||||
runs: number;
|
||||
bestStars: 0 | 1 | 2 | 3;
|
||||
bestAnimal: AnimalId | null;
|
||||
bestPoints: number;
|
||||
/** Best-run keystrokes, replayed as the opponent in race mode. */
|
||||
ghost: { key: string; at: number }[] | null;
|
||||
}
|
||||
|
||||
export interface KeyStat {
|
||||
ema: number;
|
||||
attempts: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
sound: boolean;
|
||||
keyboardHint: "auto" | "on" | "off";
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
lessons: Record<string, LessonProgress>;
|
||||
keyStats: Record<string, KeyStat>;
|
||||
pearls: number;
|
||||
/** Pets that have moved into the aquarium, in the order they arrived. */
|
||||
aquarium: CreatureId[];
|
||||
streak: { days: number; lastPlayed: string | null };
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export function progressFromApi(progress: ApiProgress): Progress {
|
||||
const lessons: Record<string, LessonProgress> = {};
|
||||
for (const [id, entry] of Object.entries(progress.lessons)) {
|
||||
lessons[id] = {
|
||||
unlocked: entry.unlocked,
|
||||
runs: entry.runs,
|
||||
bestStars: entry.best_stars,
|
||||
bestAnimal: entry.best_animal as AnimalId | null,
|
||||
bestPoints: entry.best_points,
|
||||
ghost: entry.ghost,
|
||||
};
|
||||
}
|
||||
const keyStats: Record<string, KeyStat> = {};
|
||||
for (const [key, stat] of Object.entries(progress.key_stats)) {
|
||||
keyStats[key] = { ema: stat.ema, attempts: stat.attempts, errors: stat.errors };
|
||||
}
|
||||
return {
|
||||
lessons,
|
||||
keyStats,
|
||||
pearls: progress.pearls,
|
||||
aquarium: progress.aquarium as CreatureId[],
|
||||
streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played },
|
||||
settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint },
|
||||
};
|
||||
}
|
||||
|
||||
export function toRunInput(lessonId: string, result: RunResult): TippenRunInput {
|
||||
const strokes: TippenStroke[] = result.strokes.map((stroke) => ({
|
||||
key: stroke.key,
|
||||
expected: stroke.expected,
|
||||
correct: stroke.correct,
|
||||
at: stroke.at,
|
||||
}));
|
||||
return {
|
||||
lesson_id: lessonId,
|
||||
stars: result.stars,
|
||||
animal: result.animal,
|
||||
points: result.points,
|
||||
passed: result.passed,
|
||||
pearls: result.pearls,
|
||||
strokes,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UnlockedReward {
|
||||
albumId: string;
|
||||
title: string;
|
||||
hasCover: boolean;
|
||||
kind: "album" | "book" | "podcast_episode";
|
||||
}
|
||||
|
||||
export interface RunOutcome {
|
||||
progress: Progress;
|
||||
unlockedLessonId: string | null;
|
||||
unlockedLessonTitle: string | null;
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: boolean;
|
||||
unlockedReward: UnlockedReward | null;
|
||||
}
|
||||
|
||||
export function runOutcomeFromApi(result: ApiRunResult): RunOutcome {
|
||||
return {
|
||||
progress: progressFromApi(result.progress),
|
||||
unlockedLessonId: result.unlocked_lesson_id,
|
||||
unlockedLessonTitle: result.unlocked_lesson_title,
|
||||
newCreature: result.new_creature as CreatureId | null,
|
||||
isNewBest: result.is_new_best,
|
||||
unlockedReward: result.unlocked_reward
|
||||
? {
|
||||
albumId: result.unlocked_reward.album_id,
|
||||
title: result.unlocked_reward.title,
|
||||
hasCover: result.unlocked_reward.has_cover,
|
||||
kind: result.unlocked_reward.kind,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** How many times a key must be typed before its stats mean anything. */
|
||||
const ENOUGH_ATTEMPTS = 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 lesson 1 means typing `a` thirteen times out of twenty-four while
|
||||
* three other fingers go untrained. A lesson she has never played gets an even spread;
|
||||
* 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 practiced = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= ENOUGH_ATTEMPTS);
|
||||
if (practiced.length === 0) return null;
|
||||
|
||||
// Some keys practised and some not: the gap is the most useful thing to close.
|
||||
const unpracticed = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < ENOUGH_ATTEMPTS);
|
||||
if (unpracticed) return unpracticed;
|
||||
|
||||
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 overallBestAnimal(progress: Progress): AnimalId | null {
|
||||
let best: AnimalId | null = null;
|
||||
let bestPoints = -1;
|
||||
for (const lesson of Object.values(progress.lessons)) {
|
||||
if (lesson.bestAnimal && lesson.bestPoints > bestPoints) {
|
||||
best = lesson.bestAnimal;
|
||||
bestPoints = lesson.bestPoints;
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles/app.css";
|
||||
import "./styles/room.css";
|
||||
import "./styles/tippen.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -1,50 +1,34 @@
|
||||
/* 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. */
|
||||
/* The typing game's own corner of the Dolphin Beats design system - ported from the
|
||||
standalone tippen app's app.css (see its own header: "the tokens, the glass recipe,
|
||||
the radius scale and the entrance animations are deliberately identical [to
|
||||
app.css], so the two apps read as siblings. What changes is the hue: the music
|
||||
player's sea is 210, this is 175 - a turquoise lagoon, clearly the same ocean and
|
||||
clearly not the same room.").
|
||||
|
||||
:root {
|
||||
Classes here are prefixed `tp-` and colours scoped under `.tp-stage` rather than
|
||||
redefined at `:root`, because app.css already claims the *same* custom property
|
||||
names (--ink, --paper, --accent, --shadow, ...) for the music player's own blue hue -
|
||||
redefining them globally would have silently reskinned one app or the other. Anything
|
||||
below with no hue baked into it - .bubble, .card, .key-cap, .view-enter,
|
||||
.backdrop-enter, and the dolphinBob/bubbleRise/viewEnter/backdropEnter keyframes - is
|
||||
identical between the two apps and reused directly from app.css instead of being
|
||||
duplicated here. */
|
||||
|
||||
.tp-stage {
|
||||
--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. */
|
||||
is amber and gentle, never an alarm. */
|
||||
--correct: oklch(80% 0.17 150);
|
||||
--wrong: 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;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -54,51 +38,22 @@ button {
|
||||
);
|
||||
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. */
|
||||
/* Its own stacking context, so the swimming pets (AquariumCreatures.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;
|
||||
/* Locked lessons stay visible, just dimmed - seeing what comes next is half the reason
|
||||
to finish what is open. Extends the shared .card rule from app.css; harmless on the
|
||||
music player's own cards, which never set this attribute. */
|
||||
.card[data-locked="true"] {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- 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. */
|
||||
/* None of these five collide with app.css's own keyframes (bubbleRise, dolphinBob,
|
||||
dolphinSwim, viewEnter, sheetEnter, backdropEnter), so they keep their plain names -
|
||||
only classes that would otherwise collide are `tp-`-prefixed. */
|
||||
@keyframes correctPop {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.25); }
|
||||
@@ -129,19 +84,23 @@ button {
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Its own duration (240ms vs app.css's 200ms for the shared .sheet-enter) - kept as
|
||||
its own class for exactly that reason, but reuses app.css's identical `sheetEnter`
|
||||
keyframe body. */
|
||||
.tp-sheet-enter { animation: sheetEnter 240ms ease-out; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Keep the colour feedback, drop the movement. */
|
||||
/* Keep the colour feedback, drop the movement - covers the shared .bubble/.view-enter/
|
||||
.backdrop-enter classes too, which app.css does not otherwise handle. */
|
||||
.bubble,
|
||||
.view-enter,
|
||||
.sheet-enter,
|
||||
.tp-sheet-enter,
|
||||
.backdrop-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- glass -- */
|
||||
|
||||
.glass-panel {
|
||||
.tp-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);
|
||||
@@ -152,14 +111,14 @@ button {
|
||||
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 {
|
||||
.tp-stage[data-blur="off"] .tp-glass-panel,
|
||||
.tp-stage[data-blur="off"] .card,
|
||||
.tp-stage[data-blur="off"] .tp-kb-key {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.pill {
|
||||
.tp-pill {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
@@ -171,45 +130,19 @@ button {
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.pill[data-active="true"] {
|
||||
.tp-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 {
|
||||
.tp-sheet {
|
||||
background: var(--paper);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
.tp-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: oklch(15% 0.03 175 / 0.55);
|
||||
@@ -220,19 +153,9 @@ button {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- the on-screen keyboard -- */
|
||||
|
||||
.kb {
|
||||
.tp-kb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
@@ -240,12 +163,13 @@ button {
|
||||
transition: opacity 400ms ease;
|
||||
}
|
||||
|
||||
.kb-row {
|
||||
.tp-kb-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.kb-key {
|
||||
.tp-kb-key {
|
||||
position: relative;
|
||||
width: var(--kb-size, 42px);
|
||||
height: var(--kb-size, 42px);
|
||||
border-radius: 10px;
|
||||
@@ -265,17 +189,14 @@ button {
|
||||
|
||||
/* 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"] {
|
||||
.tp-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] {
|
||||
/* Each finger owns a hue (lib/tippen/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 - it has to survive being read at arm's length against a dark background. */
|
||||
.tp-kb-key[data-finger] {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
oklch(68% 0.19 var(--finger-hue) / 0.92),
|
||||
@@ -286,14 +207,14 @@ button {
|
||||
text-shadow: 0 1px 0 oklch(100% 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.kb-key[data-next="true"] {
|
||||
.tp-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 {
|
||||
.tp-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;
|
||||
@@ -305,17 +226,21 @@ button {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.kb-key {
|
||||
position: relative;
|
||||
.tp-kb-space {
|
||||
width: calc(var(--kb-size, 42px) * 6);
|
||||
}
|
||||
|
||||
.kb-space {
|
||||
width: calc(var(--kb-size, 42px) * 6);
|
||||
/* The two Shift keys, drawn either side of the space bar. Wider than a letter key so
|
||||
they read as the modifier they are, and dim until a capital actually needs one. */
|
||||
.tp-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);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- the target line -- */
|
||||
|
||||
.target {
|
||||
.tp-target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
@@ -329,11 +254,11 @@ button {
|
||||
max-width: min(900px, 88vw);
|
||||
}
|
||||
|
||||
.target-chunk {
|
||||
.tp-target-chunk {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.target-char {
|
||||
.tp-target-char {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -346,11 +271,11 @@ button {
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.target-char[data-state="done"] {
|
||||
.tp-target-char[data-state="done"] {
|
||||
color: var(--correct);
|
||||
}
|
||||
|
||||
.target-char[data-state="current"] {
|
||||
.tp-target-char[data-state="current"] {
|
||||
color: oklch(25% 0.05 175);
|
||||
background: var(--paper);
|
||||
border-radius: 10px;
|
||||
@@ -358,7 +283,7 @@ button {
|
||||
animation: correctPop 200ms ease-out;
|
||||
}
|
||||
|
||||
.target-char[data-state="current"][data-wrong="true"] {
|
||||
.tp-target-char[data-state="current"][data-wrong="true"] {
|
||||
background: var(--wrong);
|
||||
animation: wrongShake 260ms ease;
|
||||
}
|
||||
@@ -366,22 +291,14 @@ button {
|
||||
/* 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. */
|
||||
.target-char[data-blank="true"] {
|
||||
.tp-target-char[data-blank="true"] {
|
||||
min-width: 0.9em;
|
||||
}
|
||||
|
||||
/* The two Shift keys, drawn either side of the space bar. Wider than a letter key so
|
||||
they read as the modifier they are, and dim until a capital actually needs one. */
|
||||
.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 jellyfish read as plain
|
||||
circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather
|
||||
than as an emoji so the letter stays centred and legible inside the dome. */
|
||||
.jellyfish::after {
|
||||
.tp-jellyfish::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -11px;
|
||||