Typing lessons like duolingo & musicmouse cleanup
This commit is contained in:
18
tippen/package-lock.json
generated
18
tippen/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
422
tippen/src/data/curriculum.yaml
Normal file
422
tippen/src/data/curriculum.yaml
Normal 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."
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
44
tippen/src/lib/__tests__/lessonPath.test.ts
Normal file
44
tippen/src/lib/__tests__/lessonPath.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
45
tippen/src/lib/lessonPath.ts
Normal file
45
tippen/src/lib/lessonPath.ts
Normal 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;
|
||||
}
|
||||
12
tippen/src/lib/modeInfo.ts
Normal file
12
tippen/src/lib/modeInfo.ts
Normal 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" },
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user