Typing lessons like duolingo & musicmouse cleanup

This commit is contained in:
2026-09-12 18:58:02 +02:00
parent 498243af46
commit a5210fead2
50 changed files with 3074 additions and 710 deletions

View File

@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
@@ -2328,6 +2329,21 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}

View File

@@ -11,7 +11,8 @@
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/react": "^19.2.0",

View File

@@ -15,12 +15,10 @@ import { Aquarium } from "./components/Aquarium";
import { AppHeader } from "./components/AppHeader";
import { HelpOverlay } from "./components/HelpOverlay";
import { LessonMap } from "./components/LessonMap";
import { ModePicker } from "./components/ModePicker";
import { ResultSheet } from "./components/ResultSheet";
import { Stage } from "./components/Stage";
import { BubblesRun } from "./components/modes/BubblesRun";
import { FeedRun } from "./components/modes/FeedRun";
import { PearlsRun } from "./components/modes/PearlsRun";
import { JellyfishRun } from "./components/modes/JellyfishRun";
import { RaceRun } from "./components/modes/RaceRun";
import { DiveRun } from "./components/modes/DiveRun";
@@ -39,7 +37,7 @@ 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 six modes: the arcade modes want a stream of single letters, and
* 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. */
type RunTarget =
@@ -49,6 +47,11 @@ type RunTarget =
/** 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 {
result: RunResult;
unlockedTitle: string | null;
@@ -89,9 +92,17 @@ export function App() {
const spaceActive = true;
if (LETTER_ONLY_MODES.includes(mode)) {
const share = lesson.emphasis ? SHARE_FOR_EMPHASIS[lesson.emphasis] : undefined;
return {
kind: "letters",
letters: letterStream(lesson.activeKeys, rng, bubbleCountFor(lesson.world), focusKey, lesson.newKeys),
letters: letterStream(
lesson.activeKeys,
rng,
bubbleCountFor(lesson.world),
focusKey,
lesson.spotlightKeys,
share,
),
};
}
const chunks = lineFor(lesson, rng, {
@@ -106,16 +117,13 @@ export function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lesson, mode, round]);
const start = useCallback(
(lesson: Lesson) => {
setLessonId(lesson.id);
setMode(lesson.modes.includes(mode) ? mode : "dive");
setOutcome(null);
setRound((r) => r + 1);
setScreen("run");
},
[mode],
);
const start = useCallback((lesson: Lesson) => {
setLessonId(lesson.id);
setMode(lesson.primaryMode);
setOutcome(null);
setRound((r) => r + 1);
setScreen("run");
}, []);
const onFinished = useCallback(
(result: RunResult) => {
@@ -142,6 +150,16 @@ 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. */
const playBonus = useCallback((bonusMode: ModeId) => {
setMode(bonusMode);
setOutcome(null);
setRound((r) => r + 1);
}, []);
const continueAfterResult = useCallback(() => {
const next = lessonId ? nextLesson(lessonId) : null;
setOutcome(null);
@@ -201,8 +219,8 @@ export function App() {
}
if (current.screen !== "map") return;
const step =
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
// The map is a vertical path now, 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);
@@ -290,33 +308,23 @@ export function App() {
)
) : mode === "feed" ? (
<FeedRun {...textProps(run)} />
) : mode === "pearls" ? (
<PearlsRun {...textProps(run)} />
) : mode === "race" ? (
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
) : (
<DiveRun {...textProps(run)} />
)}
<div style={{ padding: "0 32px 18px", flex: "none" }}>
<ModePicker
modes={lesson.modes}
active={mode}
onPick={(chosen) => {
setMode(chosen);
setRound((r) => r + 1);
}}
/>
</div>
</>
)}
{outcome && (
{outcome && lesson && (
<ResultSheet
result={outcome.result}
unlockedTitle={outcome.unlockedTitle}
newCreature={outcome.newCreature}
isNewBest={outcome.isNewBest}
bestEver={overallBestAnimal(progress)}
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
onPlayBonus={playBonus}
onRetry={retry}
onContinue={continueAfterResult}
continueLabel={

View File

@@ -1,13 +1,15 @@
/** The map: four worlds, each a row of lesson cards.
/** The map: a single winding path, one world section at a time.
*
* Locked lessons are dimmed rather than hidden - seeing that there is a world called
* "Große Buchstaben" waiting is half the reason to finish the one that is open. Each
* card carries its own best animal and star count, so the map doubles as the trophy
* cabinet. */
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
* waiting is half the reason to finish the world that is open. Each node carries its own
* 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";
interface Props {
@@ -17,88 +19,152 @@ interface Props {
onPick: (lesson: Lesson) => void;
}
const NODE_SIZE = 88;
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
const PATH_HALF_WIDTH = 160;
/** What a consolidation node's key-label line says when it has no keys of its own to
* show - a child-friendly word rather than the raw `LessonKind`. */
const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = {
fragments: "Wörter",
words: "Wörter",
sentences: "Sätze",
};
export function LessonMap({ progress, selected, onPick }: Props) {
return (
<div
className="view-enter"
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 32px", minHeight: 0 }}
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
>
<div style={{ maxWidth: 1180, margin: "0 auto", display: "flex", flexDirection: "column", gap: 22 }}>
{WORLDS.map((world) => (
<div key={world.number} className="glass-panel" style={{ padding: "16px 20px 20px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 22 }}>{world.emoji}</span>
<span style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>
Welt {world.number} {world.title}
</span>
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>
{LESSONS.filter((l) => l.world === world.number && (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length}
/{LESSONS.filter((l) => l.world === world.number).length}
</span>
</div>
<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));
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{LESSONS.filter((lesson) => lesson.world === world.number).map((lesson) => {
const entry = progress.lessons[lesson.id];
const locked = !entry?.unlocked;
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
const index = LESSONS.indexOf(lesson);
return (
<section key={world.number}>
<div
className="glass-panel"
style={{
position: "sticky",
top: 0,
zIndex: 2,
padding: "10px 18px",
display: "flex",
alignItems: "center",
gap: 10,
marginBottom: 8,
}}
>
<span style={{ fontSize: 22 }}>{world.emoji}</span>
<span style={{ fontSize: 17, fontWeight: 900, color: "var(--paper)" }}>
Welt {world.number} {world.title}
</span>
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
{done}/{lessons.length}
</span>
</div>
return (
<button
key={lesson.id}
className="card glass-panel"
data-selected={index === selected}
data-locked={locked}
disabled={locked}
onClick={() => onPick(lesson)}
style={{ width: 150, padding: 12, flex: "none" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 12, fontWeight: 900, color: "var(--paper)", opacity: 0.7 }}>
{lesson.number}
</span>
<span style={{ fontSize: 26 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
</div>
<div style={{ position: "relative", height, margin: "0 auto" }}>
<svg
style={{ position: "absolute", left: "50%", top: 0, transform: "translateX(-50%)", overflow: "visible" }}
width={PATH_HALF_WIDTH * 2}
height={height}
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
>
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
</svg>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--paper)", marginTop: 6 }}>
{lesson.title}
</div>
{lessons.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 point = points[i]!;
const modeInfo = MODE_INFO[lesson.primaryMode];
{/* The keys themselves, big: for a pre-reader this is the real label
and the title is decoration. A drill has no new keys, so it says
so with a symbol instead of showing an empty line. */}
<div
return (
<button
key={lesson.id}
className="card glass-panel"
data-selected={index === selected}
data-locked={locked}
disabled={locked}
onClick={() => onPick(lesson)}
title={lesson.title}
style={{
fontSize: 15,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.75,
letterSpacing: lesson.isDrill ? "normal" : "0.14em",
marginTop: 3,
minHeight: 20,
position: "absolute",
left: `calc(50% + ${point.x}px)`,
top: point.y,
transform: "translate(-50%, -50%)",
width: NODE_SIZE,
height: NODE_SIZE,
borderRadius: "50%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 0,
textAlign: "center",
}}
>
{lesson.isDrill
? "🔁 Übung"
: lesson.newKeys
.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase()))
.join(" ")}
</div>
<span
aria-hidden
style={{
position: "absolute",
top: -4,
right: -4,
fontSize: 15,
filter: locked ? "grayscale(1)" : "none",
opacity: locked ? 0.4 : 0.9,
}}
title={modeInfo.name}
>
{modeInfo.emoji}
</span>
<div style={{ marginTop: 8, fontSize: 13, letterSpacing: "0.08em" }}>
{[1, 2, 3].map((star) => (
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
</span>
))}
</div>
</button>
);
})}
</div>
</div>
))}
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
{/* The keys themselves: for a pre-reader this is the real label, the
title is decoration. A drill has no new keys, so it says so with
a symbol instead of showing an empty line. */}
<span
style={{
fontSize: 11,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.8,
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
lineHeight: 1.1,
}}
>
{lesson.isDrill
? "🔁"
: lesson.newKeys.length > 0
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
: lesson.kind === "letters"
? "üben"
: CONSOLIDATION_LABEL[lesson.kind]}
</span>
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
{[1, 2, 3].map((star) => (
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
</span>
))}
</span>
</button>
);
})}
</div>
</section>
);
})}
</div>
</div>
);

View File

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

View File

@@ -9,9 +9,11 @@ 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";
interface Props {
result: RunResult;
@@ -23,6 +25,10 @@ interface Props {
/** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */
bestEver: AnimalId | null;
/** Extra games this lesson didn't gate progress on - feed or race, offered only once
* the lesson is passed. Playing one never changes the unlock, only the best score. */
bonusModes: readonly ModeId[];
onPlayBonus: (mode: ModeId) => void;
onRetry: () => void;
onContinue: () => void;
/** Null at the end of the curriculum. */
@@ -35,6 +41,8 @@ export function ResultSheet({
newCreature,
isNewBest,
bestEver,
bonusModes,
onPlayBonus,
onRetry,
onContinue,
continueLabel,
@@ -159,6 +167,33 @@ export function ResultSheet({
</button>
)}
</div>
{/* Extra games this lesson didn't need to pass - a treat, not a requirement, so
they only appear once the lesson is already behind her. `.pill` is styled for
the dark stage background, not this light sheet, so these get their own
(smaller, quieter) version of the sheet's own button look instead. */}
{bonusModes.length > 0 && (
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center", flexWrap: "wrap" }}>
{bonusModes.map((mode) => (
<button
key={mode}
onClick={() => onPlayBonus(mode)}
style={{
border: "none",
borderRadius: 999,
padding: "8px 16px",
fontSize: 14,
fontWeight: 800,
cursor: "pointer",
background: "oklch(90% 0.02 175)",
color: "var(--ink)",
}}
>
{MODE_INFO[mode].emoji} {MODE_INFO[mode].name}
</button>
))}
</div>
)}
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
/** Dive mode - the core drill, and the run that counts for the unlock.
*
* A line of chunks with a moving cursor above the keyboard. Everything else in the
* game is a variation on this; this is the one that is measured. */
/** Dive mode - the plain drill: a line of chunks with a moving cursor above the
* keyboard. Most fragments/words/sentences lessons play this by default, though feed
* 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";

View File

@@ -1,155 +0,0 @@
/** Pearls mode - the mode with no clock.
*
* Every other mode measures something. This one deliberately does not show a timer, a
* progress bar, a streak or a speed: an oyster opens, a word is inside it, and each
* correct letter is a pearl on the string. A mistake costs a pearl, and that is the
* only pressure there is.
*
* It exists because a six-year-old having a bad afternoon needs somewhere to go that is
* still the same lesson and still counts, but cannot be failed at quickly. Every lesson
* offers it, always. The run is still graded the same way underneath - a calm round and
* a frantic one land in the same `grade()` - but nothing on screen is urging her on. */
import { 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 { Keyboard } from "../Keyboard";
interface Props {
chunks: readonly string[];
text: string;
spaceActive: boolean;
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
export function PearlsRun({
chunks,
text,
spaceActive,
activeKeys,
progress,
paused,
onFinished,
}: Props) {
const [pearls, setPearls] = useState(0);
const onEvent = (event: RunEvent) => {
// A pearl per correct letter, one lost per mistake - but never below zero. Watching
// the string shrink past empty is the kind of punishment this mode exists to avoid.
if (event.type === "correct") setPearls((n) => n + 1);
else if (event.type === "wrong" && event.firstAt) setPearls((n) => Math.max(0, n - 1));
};
const { state, wrong } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
onEvent,
});
const next = currentChar(state);
const offsets = chunkOffsets(chunks, spaceActive);
// Which word the oyster is holding right now.
const currentChunkIndex = offsets.findIndex((start, i) => {
const end = start + (chunks[i]?.length ?? 0);
return state.index <= end;
});
const word = chunks[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? "";
const wordStart = offsets[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? 0;
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: "0 32px",
minHeight: 0,
}}
>
{/* The pearl string. It only ever grows, one bead per letter. */}
<div
style={{
display: "flex",
gap: 5,
flexWrap: "wrap",
justifyContent: "center",
maxWidth: "70%",
minHeight: 26,
alignItems: "center",
}}
aria-label={`${pearls} Perlen`}
>
{Array.from({ length: pearls }, (_, i) => (
<span
key={i}
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: "radial-gradient(circle at 32% 30%, oklch(99% 0.01 175), oklch(80% 0.04 300))",
boxShadow: "0 2px 7px var(--shadow)",
animation: i === pearls - 1 ? "correctPop 240ms ease-out" : undefined,
}}
/>
))}
</div>
{/* The oyster, holding one word at a time. */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
padding: "26px 42px",
borderRadius: 26,
background: "linear-gradient(160deg, oklch(97% 0.01 175 / .2), oklch(97% 0.01 175 / .07))",
border: "1px solid oklch(97% 0.01 175 / .22)",
animation: wrong ? "wrongShake 260ms ease" : undefined,
}}
>
<div style={{ fontSize: 34 }}>🦪</div>
<div style={{ display: "flex", fontSize: 46, fontWeight: 800 }}>
{[...word].map((char, i) => {
const at = wordStart + i;
const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
return (
<span
key={i}
className="target-char"
data-state={charState}
data-blank={char === " "}
data-wrong={charState === "current" && wrong}
>
{char === " " ? (charState === "current" ? "␣" : "") : char}
</span>
);
})}
</div>
</div>
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={38}
/>
</div>
);
}

View File

@@ -0,0 +1,422 @@
# 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."

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => {
@@ -125,15 +125,52 @@ describe("LESSONS", () => {
}
});
it("only offers word modes where words exist", () => {
it("plays a mode that actually fits its kind", () => {
for (const lesson of LESSONS) {
const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race");
expect(wordModes).toBe(lesson.words.length > 0);
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("always offers the pressure-free pearl-diving mode", () => {
for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls");
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", () => {
@@ -154,7 +191,7 @@ describe("LESSONS", () => {
it("follows every pair of new keys with a drill", () => {
const drills = LESSONS.filter((lesson) => lesson.isDrill);
expect(drills.length).toBeGreaterThanOrEqual(12);
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([]);
@@ -162,8 +199,8 @@ describe("LESSONS", () => {
}
});
it("is long enough to be a real course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(40);
it("is long enough to be a real, Duolingo-length course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(80);
expect(WORLDS.length).toBe(5);
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { PATH_AMPLITUDE, pathD, pointFor, xOffsetFor } from "../lessonPath";
describe("xOffsetFor", () => {
it("starts centred", () => {
expect(xOffsetFor(0)).toBeCloseTo(0);
});
it("never strays further than the amplitude", () => {
for (let i = 0; i < 40; i++) {
expect(Math.abs(xOffsetFor(i))).toBeLessThanOrEqual(PATH_AMPLITUDE + 1e-9);
}
});
it("is deterministic", () => {
expect(xOffsetFor(5)).toBe(xOffsetFor(5));
});
});
describe("pointFor", () => {
it("places nodes strictly further down as the index grows", () => {
let previousY = -Infinity;
for (let i = 0; i < 10; i++) {
const point = pointFor(i);
expect(point.y).toBeGreaterThan(previousY);
previousY = point.y;
}
});
});
describe("pathD", () => {
it("draws nothing for fewer than two points", () => {
expect(pathD([])).toBe("");
expect(pathD([{ x: 0, y: 0 }])).toBe("");
});
it("starts at the first point and mentions every point", () => {
const points = [pointFor(0), pointFor(1), pointFor(2)];
const d = pathD(points);
expect(d.startsWith(`M ${points[0]!.x} ${points[0]!.y}`)).toBe(true);
for (const point of points) expect(d).toContain(String(point.x));
});
});

View File

@@ -150,7 +150,7 @@ describe("migrate", () => {
it("returns a fresh profile for anything unusable", () => {
for (const raw of [null, undefined, 42, "nope", {}, { version: 99 }, []]) {
const progress = migrate(raw);
expect(progress.version).toBe(1);
expect(progress.version).toBe(2);
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
}
});
@@ -177,6 +177,33 @@ describe("migrate", () => {
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", () => {

View File

@@ -1,41 +1,36 @@
/** The lesson plan: 48 lessons in 5 worlds.
/** 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.
* 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 the deviation is *pace*. A
* six-year-old gets:
* 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.
*
* - **one new key per lesson** from world 2 onward (world 1 pairs the same finger on
* both hands, which is one motion, not two);
* - **a drill lesson after every pair of new keys** - no new keys at all, just the
* ones she has. Consolidation is where typing actually becomes automatic, and a
* course that only ever moves forward never gives it room;
* - **short rounds**, growing from twelve characters in world 1 to whole sentences in
* world 5.
*
* That is 48 short lessons rather than 17 big ones. The curriculum is the same; the
* steps between are small enough to climb.
*
* `activeKeys` is cumulative on purpose: lesson 30 still drills `f`, or the first
* `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";
export type ModeId =
| "dive"
| "bubbles"
| "jellyfish"
| "feed"
| "race"
| "pearls";
/** 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";
/** What a lesson's targets are made of. Decides which modes make sense: jellyfish mode
* cannot show a sentence, and feed mode cannot put one on a fish. */
export type LessonKind = "letters" | "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;
@@ -44,16 +39,31 @@ export interface Lesson {
title: string;
/** What this lesson is about, in words a six-year-old hears read aloud. */
subtitle: string;
/** The keys introduced here - what the generator weights toward. Empty for a drill. */
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[];
/** Which game modes this lesson offers, in carousel order. */
modes: readonly ModeId[];
/** Real German words (or sentences) for the word modes. */
/** 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[];
kind: LessonKind;
/** True for a consolidation lesson - no new keys, just practice. */
/** 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;
@@ -70,233 +80,168 @@ export interface World {
/** 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. The
* pets grow with the worlds: a small fish first, the pearl mussel - the pearls' own
* home - last. */
export const WORLDS: readonly World[] = [
{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
{ number: 2, title: "Nach oben", emoji: "🌊", reward: "octopus" },
{ number: 3, title: "Nach unten", emoji: "🪸", reward: "seahorse" },
{ number: 4, title: "Große Buchstaben", emoji: "👑", reward: "turtle" },
{ number: 5, title: "Ganze Sätze", emoji: "📖", reward: "pearlmussel" },
];
* emoji. lib/aquarium.ts has why the two are kept in different visual languages. */
const CREATURE_IDS: readonly CreatureId[] = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"];
/** Letters only - no words can be spelled yet. */
const LETTER_MODES: readonly ModeId[] = ["dive", "bubbles", "jellyfish", "pearls"];
/** Real words exist: the word modes and the race join in. */
const WORD_MODES: readonly ModeId[] = ["dive", "feed", "bubbles", "race", "jellyfish", "pearls"];
/** Sentences do not fit on a bubble or a fish. */
const SENTENCE_MODES: readonly ModeId[] = ["dive", "race", "pearls"];
/** 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 - a full block of text per round, at least five lines of it.
*
* Deliberately five times what the first version used. Twelve characters was long
* enough to *finish*, which is what the first day needs, but far too short to build
* anything: a round that ends before the hands settle measures reaction time rather
* than typing, and the score bounces around so much that getting better is invisible.
* Sixty-plus characters is long enough for a rhythm to appear and for the characters
* per minute to mean something.
*
* World 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
/** 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 (world === 1) return kind === "words" ? { chunks: 25, chunkSize: 3 } : { chunks: 24, chunkSize: 3 }; // 72 characters
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
if (world === 3) return { chunks: 30, chunkSize: 4 }; // 120
if (world === 4) return { chunks: 25, chunkSize: 4 }; // 25 words
return { chunks: 10, chunkSize: 4 }; // world 5: ten whole sentences
return { chunks: 30, chunkSize: 4 }; // 120, world 3 onward
}
interface PlanEntry {
world: number;
interface YamlLesson {
title: string;
subtitle: string;
/** Empty marks a drill - consolidation, no new keys. */
newKeys: readonly string[];
words?: readonly string[];
kind?: LessonKind;
keys?: readonly string[];
drill?: boolean;
mode?: ModeId;
words?: readonly string[];
}
/** The plan. Everything else is derived from this, so a curriculum change is one edit. */
const PLAN: readonly PlanEntry[] = [
// ---------------------------------------------------------------- World 1 --
// Two keys per lesson, always the same finger on each hand - one motion, mirrored.
// F and J first because they carry the tactile bumps: they are the two keys a child
// can find without looking, and every other key is taught as an offset from them.
{ world: 1, title: "F und J", subtitle: "Die Zeigefinger - die Tasten mit den Punkten", newKeys: ["f", "j"] },
{ world: 1, title: "D und K", subtitle: "Die Mittelfinger", newKeys: ["d", "k"] },
{ world: 1, title: "Übung: F J D K", subtitle: "Die vier Tasten zusammen", newKeys: [] },
{ world: 1, title: "S und L", subtitle: "Die Ringfinger", newKeys: ["s", "l"] },
{ world: 1, title: "Übung: sechs Tasten", subtitle: "Alles bisher zusammen", newKeys: [] },
{ world: 1, title: "A und Ö", subtitle: "Die kleinen Finger", newKeys: ["a", "ö"] },
{ world: 1, title: "Übung: die Grundstellung", subtitle: "Alle acht Finger", newKeys: [],
words: [
"da", "das", "dass", "ja", "als", "all", "fall", "falls", "lass",
"aal", "aas", "as", "ass", "fass", "saal", "kalk", "salsa", "jass",
"asa", "sas", "sad", "sal", "dal", "fad", "fal", "jak", "jas", "kal",
"kas", "lak", "las", "lad", "dasa", "sala", "kala", "jala", "fasa",
"daka", "kasa", "salla", "dalla", "jassa", "fassa", "kalla", "falla",
"salak", "dalas", "jasal", "kalas",
"lö", "döl", "söl", "jöl", "köl", "löl",
] },
{ world: 1, title: "Die Leertaste", subtitle: "Der Daumen kommt dazu", newKeys: [SPACE_KEY] },
interface YamlWorld {
number: number;
title: string;
emoji: string;
reward: CreatureId;
lessons: readonly YamlLesson[];
}
// ---------------------------------------------------------------- World 2 --
// One key per lesson from here on, an Übung after every two.
{ world: 2, title: "Das E", subtitle: "Mittelfinger links nach oben", newKeys: ["e"],
words: ["elf", "alle", "esel", "see", "keks", "fell"] },
{ world: 2, title: "Das I", subtitle: "Mittelfinger rechts nach oben", newKeys: ["i"],
words: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
{ world: 2, title: "Übung: E und I", subtitle: "Die neuen Tasten festigen", newKeys: [],
words: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
{ world: 2, title: "Das R", subtitle: "Zeigefinger links nach oben", newKeys: ["r"],
words: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
{ world: 2, title: "Das U", subtitle: "Zeigefinger rechts nach oben", newKeys: ["u"],
words: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
{ world: 2, title: "Übung: R und U", subtitle: "Die Zeigefinger nach oben", newKeys: [],
words: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
{ world: 2, title: "Das T", subtitle: "Zeigefinger links weit nach oben", newKeys: ["t"],
words: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
{ world: 2, title: "Das Z", subtitle: "Zeigefinger rechts weit nach oben", newKeys: ["z"],
words: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
{ world: 2, title: "Übung: T und Z", subtitle: "Weit nach oben greifen", newKeys: [],
words: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
{ world: 2, title: "Das O", subtitle: "Ringfinger rechts nach oben", newKeys: ["o"],
words: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
{ world: 2, title: "Das W", subtitle: "Ringfinger links nach oben", newKeys: ["w"],
words: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
{ world: 2, title: "Übung: W und O", subtitle: "Die Ringfinger nach oben", newKeys: [],
words: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
{ world: 2, title: "Das P", subtitle: "Kleiner Finger rechts nach oben", newKeys: ["p"],
words: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
{ world: 2, title: "Das Q", subtitle: "Kleiner Finger links nach oben", newKeys: ["q"],
words: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
{ world: 2, title: "Das Ü", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ü"],
words: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] },
{ world: 2, title: "Übung: die obere Reihe", subtitle: "Die ganze Reihe zusammen", newKeys: [],
words: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
interface YamlRoot {
worlds: readonly YamlWorld[];
}
// ---------------------------------------------------------------- World 3 --
{ world: 3, title: "Das N", subtitle: "Zeigefinger rechts nach unten", newKeys: ["n"],
words: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] },
{ world: 3, title: "Das M", subtitle: "Zeigefinger rechts, neben dem N", newKeys: ["m"],
words: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] },
{ world: 3, title: "Übung: N und M", subtitle: "Die neuen Tasten festigen", newKeys: [],
words: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] },
{ world: 3, title: "Das G", subtitle: "Zeigefinger links, in der Mitte", newKeys: ["g"],
words: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] },
{ world: 3, title: "Das H", subtitle: "Zeigefinger rechts, in der Mitte", newKeys: ["h"],
words: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
{ world: 3, title: "Übung: G und H", subtitle: "Die Mitte der Grundreihe", newKeys: [],
words: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
{ world: 3, title: "Das C", subtitle: "Mittelfinger links nach unten", newKeys: ["c"],
words: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
{ world: 3, title: "Das V", subtitle: "Zeigefinger links nach unten", newKeys: ["v"],
words: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
{ world: 3, title: "Übung: C und V", subtitle: "Nach unten greifen", newKeys: [],
words: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
{ world: 3, title: "Das B", subtitle: "Zeigefinger links, neben dem V", newKeys: ["b"],
words: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
{ world: 3, title: "Das Y", subtitle: "Kleiner Finger links nach unten", newKeys: ["y"],
words: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
{ world: 3, title: "Übung: B und Y", subtitle: "Ganz unten links", newKeys: [],
words: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
{ world: 3, title: "Das X", subtitle: "Ringfinger links nach unten", newKeys: ["x"],
words: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
{ world: 3, title: "Das Ä", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ä"],
words: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] },
{ world: 3, title: "Übung: alle Buchstaben", subtitle: "Das ganze Alphabet", newKeys: [],
words: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
/** 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 4 --
{ world: 4, title: "Umschalttaste rechts", subtitle: "Große Buchstaben der linken Hand", newKeys: ["⇧"],
words: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff",
"Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] },
{ world: 4, title: "Umschalttaste links", subtitle: "Große Buchstaben der rechten Hand", newKeys: [],
words: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe",
"Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] },
{ world: 4, title: "Übung: Namen", subtitle: "Namen fangen groß an", newKeys: [],
words: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa",
"Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] },
{ world: 4, title: "Übung: große und kleine", subtitle: "Beides gemischt", newKeys: [],
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"] },
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}"`);
}
});
}
}
// ---------------------------------------------------------------- World 5 --
{ world: 5, title: "Der Punkt", subtitle: "Ringfinger rechts nach unten", newKeys: ["."], 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.",
] },
{ world: 5, title: "Das Komma", subtitle: "Mittelfinger rechts nach unten", newKeys: [","], kind: "sentences",
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.",
] },
{ world: 5, title: "Der Bindestrich", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["-"], 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.",
] },
{ world: 5, title: "Fragezeichen und Ausrufezeichen", subtitle: "Mit der Umschalttaste", newKeys: ["ß", "1"], 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!",
] },
{ world: 5, title: "Übung: ganze Sätze", subtitle: "Alles zusammen", newKeys: [], kind: "sentences",
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.",
] },
];
function loadPlan(): readonly YamlWorld[] {
const root = parse(curriculumYaml) as YamlRoot;
validatePlan(root.worlds);
return root.worlds;
}
function buildLessons(): Lesson[] {
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
PLAN.forEach((entry, i) => {
for (const key of entry.newKeys) active.add(key);
// The space-bar lesson is where the whole home row comes together, so it
// activates every home key - a belt-and-braces guarantee that the four finger-pair
// lessons before it really did cover all eight.
if (entry.newKeys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
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. The question-mark lesson
// reaches its marks with Shift too, so ß and 1 are active as *keys* even though the
// characters that appear are ? and !.
const activeKeys = [...active].filter((key) => key !== "⇧").sort();
const words = entry.words ?? [];
const kind: LessonKind = entry.kind ?? (words.length > 0 ? "words" : "letters");
// 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;
lessons.push({
id: `l${String(i + 1).padStart(2, "0")}`,
world: entry.world,
number: i + 1,
title: entry.title,
subtitle: entry.subtitle,
newKeys: entry.newKeys,
activeKeys,
modes: kind === "sentences" ? SENTENCE_MODES : kind === "words" ? WORD_MODES : LETTER_MODES,
words,
kind,
isDrill: entry.newKeys.length === 0,
...lengthFor(entry.world, kind),
});
});
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;
}
export const LESSONS: readonly Lesson[] = buildLessons();
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]));

View File

@@ -38,29 +38,33 @@ export interface LineOptions {
chunkSize?: number;
/** The key to over-represent, if any. */
focusKey?: string | null;
/** The keys this lesson introduces. They take the majority of the line; everything
/** The keys this lesson spotlights. They take the majority of the line; everything
* learned earlier keeps appearing as review. */
newKeys?: readonly string[];
/** How much of the line the spotlighted keys should take, in place of the default
* (see `NEW_KEY_SHARE`). An isolated round wants this high; a mixed round - the same
* keys again, but blended with everything else - wants it lower. */
newKeyShare?: number;
}
/** The most of a line one key may ever occupy. Above this it stops being practice and
* starts being a stutter - and on a small key set it starves the other fingers. */
const MAX_FOCUS_SHARE = 0.3;
/** How much of a line the lesson's *new* keys should take. The rest is review of
* everything learned so far, which is what stops the early lessons rotting while the
* late ones are learned.
/** How much of a line the lesson's spotlighted keys should take by default. The rest is
* review of everything learned so far, which is what stops the early lessons rotting
* while the late ones are learned.
*
* Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and
* spent half the line on the left hand it had already taught - which is not what a
* lesson called "the right hand" should drill. */
const NEW_KEY_SHARE = 0.6;
/** Copies of each new key needed to reach `NEW_KEY_SHARE` of the pool, clamped so a
* lesson with one new key and many old ones does not bury the review entirely. */
function newKeyCopies(newCount: number, oldCount: number): number {
/** Copies of each spotlighted key needed to reach `share` of the pool, clamped so a
* lesson with one spotlighted key and many old ones does not bury the review entirely. */
function newKeyCopies(newCount: number, oldCount: number, share: number): number {
if (newCount === 0 || oldCount === 0) return 1;
const exact = (NEW_KEY_SHARE * oldCount) / (newCount * (1 - NEW_KEY_SHARE));
const exact = (share * oldCount) / (newCount * (1 - share));
return Math.max(1, Math.min(6, Math.round(exact)));
}
@@ -81,6 +85,7 @@ function weighted(
activeKeys: readonly string[],
focusKey: string | null | undefined,
newKeys: readonly string[] = [],
newKeyShare: number = NEW_KEY_SHARE,
): string[] {
const letters = activeKeys.filter((key) => key !== " ");
if (letters.length === 0) return [];
@@ -91,7 +96,7 @@ function weighted(
const pool = [...letters];
if (newActive.length > 0 && oldActive.length > 0) {
const copies = newKeyCopies(newActive.length, oldActive.length);
const copies = newKeyCopies(newActive.length, oldActive.length, newKeyShare);
for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
}
@@ -137,8 +142,8 @@ export function drillChunks(
rng: Rng,
options: LineOptions = {},
): string[] {
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [] } = options;
const pool = weighted(activeKeys, focusKey, newKeys);
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [], newKeyShare } = options;
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return [];
const letters = bagDraw(pool, chunks * chunkSize, rng);
@@ -225,8 +230,9 @@ export function letterStream(
count: number,
focusKey?: string | null,
newKeys: readonly string[] = [],
newKeyShare?: number,
): string[] {
const pool = weighted(activeKeys, focusKey, newKeys);
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return [];
return bagDraw(pool, count, rng);
}

View File

@@ -0,0 +1,45 @@
/** The geometry of the zigzag lesson path - kept pure and out of the component so it can
* be tested the way `aquarium.ts`'s swim physics are. */
/** How many nodes make one full left-to-right-to-left swing. */
export const PATH_PERIOD = 6;
/** The furthest a node strays from the centre line, in px. */
export const PATH_AMPLITUDE = 110;
/** Vertical spacing between two nodes, in px. */
export const NODE_SPACING = 108;
/** Horizontal offset for the nth node of a world's path, centred on 0. A sine wave
* rather than a zigzag of straight segments, so the path reads as one smooth ribbon
* instead of a jagged staircase. */
export function xOffsetFor(indexInWorld: number): number {
return Math.sin((indexInWorld / PATH_PERIOD) * 2 * Math.PI) * PATH_AMPLITUDE;
}
export interface PathPoint {
x: number;
y: number;
}
/** The centre of the nth node, for both its own placement and the connector line. */
export function pointFor(indexInWorld: number): PathPoint {
return { x: xOffsetFor(indexInWorld), y: indexInWorld * NODE_SPACING + NODE_SPACING / 2 };
}
/** An SVG path string threading every node centre with a smooth curve - a straight
* polyline through a sine wave looks faceted; a vertical Bezier through each segment
* does not. Empty/one-point paths draw nothing, which is fine: a one-lesson world needs
* no connector. */
export function pathD(points: readonly PathPoint[]): string {
if (points.length < 2) return "";
const [first, ...rest] = points;
let d = `M ${first!.x} ${first!.y}`;
for (let i = 0; i < rest.length; i++) {
const from = points[i]!;
const to = rest[i]!;
const midY = (from.y + to.y) / 2;
d += ` C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${to.y}`;
}
return d;
}

View File

@@ -0,0 +1,12 @@
/** Labels and emoji for each game, shared by the result sheet's bonus buttons and the
* lesson map's mode badge. */
import type { ModeId } from "./curriculum";
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
dive: { emoji: "🤿", name: "Tauchgang" },
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
feed: { emoji: "🐟", name: "Fütterungszeit" },
race: { emoji: "🐬", name: "Delfinrennen" },
};

View File

@@ -46,7 +46,7 @@ export interface Settings {
}
export interface Progress {
version: 1;
version: 2;
lessons: Record<string, LessonProgress>;
keyStats: Record<string, KeyStat>;
pearls: number;
@@ -66,7 +66,7 @@ export function freshProgress(): Progress {
lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID);
}
return {
version: 1,
version: 2,
lessons,
keyStats: {},
pearls: 0,
@@ -76,18 +76,43 @@ export function freshProgress(): Progress {
};
}
/** 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 Partial<Progress>;
if (stored.version !== 1) 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)) {
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 };
@@ -96,18 +121,7 @@ export function migrate(raw: unknown): Progress {
// The first lesson is unlocked by definition; a save that says otherwise is wrong.
lessons[FIRST_LESSON_ID] = { ...lessons[FIRST_LESSON_ID]!, unlocked: true };
return {
version: 1,
lessons,
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {},
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.days ?? 0, lastPlayed: stored.streak.lastPlayed ?? null }
: fresh.streak,
settings: { ...fresh.settings, ...(stored.settings ?? {}) },
};
return { version: 2, lessons, ...carryForward(stored, fresh) };
}
/** Saves from before the pets were drawings hold emoji here. Those map onto the creature

View File

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