Anglicize tippen's codebase and finish pending UI/curriculum cleanup
- Rename all German identifiers, types, mode ids, file names, CSS classes and data-attributes to English throughout tippen/src; only user-facing text (lesson titles, word lists, labels, spoken praise) stays German. - Add a word/nonsense-word list to the "Übung: die Grundstellung" home-row lesson in the curriculum. - Remove the unused HandHint component and speech.ts, and carry forward the in-progress App.tsx/component/generator/progress edits from other sessions. - Refresh the regenerated music-library cache index. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -18,79 +18,80 @@ import { LessonMap } from "./components/LessonMap";
|
||||
import { ModePicker } from "./components/ModePicker";
|
||||
import { ResultSheet } from "./components/ResultSheet";
|
||||
import { Stage } from "./components/Stage";
|
||||
import { BlasenRun } from "./components/modes/BlasenRun";
|
||||
import { FuetternRun } from "./components/modes/FuetternRun";
|
||||
import { PerlenRun } from "./components/modes/PerlenRun";
|
||||
import { QuallenRun } from "./components/modes/QuallenRun";
|
||||
import { RennenRun } from "./components/modes/RennenRun";
|
||||
import { TauchgangRun } from "./components/modes/TauchgangRun";
|
||||
import type { KreaturId } from "./lib/aquarium";
|
||||
import { 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";
|
||||
import type { CreatureId } from "./lib/aquarium";
|
||||
import { LESSONS, lessonById, nextLesson } from "./lib/curriculum";
|
||||
import type { Lesson, ModeId } from "./lib/curriculum";
|
||||
import { lineFor, lineText, letterStream, mulberry32 } from "./lib/generator";
|
||||
import type { RunResult } from "./lib/grading";
|
||||
import { playFanfare, playPop } from "./lib/pop";
|
||||
import { besteTier, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress";
|
||||
import { overallBestAnimal, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress";
|
||||
import type { Progress } from "./lib/progress";
|
||||
import { say, stop as stopSpeech } from "./lib/speech";
|
||||
import { blasenAnzahlFuer } from "./lib/theme";
|
||||
import { bubbleCountFor } from "./lib/theme";
|
||||
|
||||
type Screen = "aquarium" | "karte" | "lauf";
|
||||
type Screen = "aquarium" | "map" | "run";
|
||||
|
||||
/** What a mode is handed to draw. Tagged rather than optional-fielded so the render
|
||||
* below narrows on `art` instead of guessing from which keys are present.
|
||||
* 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
|
||||
* everything else wants a line of chunks. That is the whole reason the modes are cheap
|
||||
* to add - they are presentations of one of two targets, all driven by the same engine. */
|
||||
type Lauf =
|
||||
| { art: "buchstaben"; letters: readonly string[] }
|
||||
| { art: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
||||
type RunTarget =
|
||||
| { kind: "letters"; letters: readonly string[] }
|
||||
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
||||
|
||||
/** Modes that drill one key at a time rather than a line. */
|
||||
const BUCHSTABEN_MODI: readonly ModeId[] = ["blasen", "quallen"];
|
||||
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
|
||||
|
||||
interface Ergebnis {
|
||||
interface Outcome {
|
||||
result: RunResult;
|
||||
unlockedTitel: string | null;
|
||||
neuesTier: KreaturId | null;
|
||||
bestseit: boolean;
|
||||
unlockedTitle: string | null;
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: boolean;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [progress, setProgress] = useState<Progress>(loadProgress);
|
||||
const [screen, setScreen] = useState<Screen>("aquarium");
|
||||
const [lessonId, setLessonId] = useState<string | null>(null);
|
||||
const [modus, setModus] = useState<ModeId>("tauchgang");
|
||||
const [ergebnis, setErgebnis] = useState<Ergebnis | null>(null);
|
||||
const [mode, setMode] = useState<ModeId>("dive");
|
||||
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [selected, setSelected] = useState(0);
|
||||
/** Bumped to generate a fresh line - a new seed for the same lesson. */
|
||||
const [runde, setRunde] = useState(0);
|
||||
const [round, setRound] = useState(0);
|
||||
|
||||
useEffect(() => saveProgress(progress), [progress]);
|
||||
|
||||
const lesson = lessonId === null ? null : lessonById(lessonId);
|
||||
|
||||
/** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
|
||||
const weiter = useMemo(() => {
|
||||
const offen = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked);
|
||||
return offen.find((l) => (progress.lessons[l.id]?.bestSterne ?? 0) < 2) ?? offen.at(-1) ?? null;
|
||||
const nextUp = useMemo(() => {
|
||||
const unlocked = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked);
|
||||
return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null;
|
||||
}, [progress]);
|
||||
|
||||
/** The line for this run. Reproducible from the lesson, the mode and the round
|
||||
* counter, so a re-render never reshuffles the text mid-run. */
|
||||
const lauf = useMemo((): Lauf | null => {
|
||||
const run = useMemo((): RunTarget | null => {
|
||||
if (!lesson) return null;
|
||||
const seed = lesson.nummer * 1000 + runde * 7 + (modus === "blasen" ? 3 : 0);
|
||||
const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0);
|
||||
const rng = mulberry32(seed);
|
||||
const focusKey = focusKeyFor(progress, lesson.activeKeys);
|
||||
const spaceActive = lesson.activeKeys.includes(" ");
|
||||
// Always a real space, even before the space-bar lesson formally teaches the thumb:
|
||||
// a gap she can see but not type is confusing, not gentle. See generator.ts.
|
||||
const spaceActive = true;
|
||||
|
||||
if (BUCHSTABEN_MODI.includes(modus)) {
|
||||
if (LETTER_ONLY_MODES.includes(mode)) {
|
||||
return {
|
||||
art: "buchstaben",
|
||||
letters: letterStream(lesson.activeKeys, rng, blasenAnzahlFuer(lesson.welt), focusKey, lesson.neueKeys),
|
||||
kind: "letters",
|
||||
letters: letterStream(lesson.activeKeys, rng, bubbleCountFor(lesson.world), focusKey, lesson.newKeys),
|
||||
};
|
||||
}
|
||||
const chunks = lineFor(lesson, rng, {
|
||||
@@ -98,68 +99,66 @@ export function App() {
|
||||
chunkSize: lesson.chunkSize,
|
||||
focusKey,
|
||||
});
|
||||
return { art: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
|
||||
return { kind: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
|
||||
// `progress` is deliberately not a dependency: the focus key is read once when the
|
||||
// line is built, and re-reading it after every keystroke would rebuild the line
|
||||
// underneath her fingers.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lesson, modus, runde]);
|
||||
}, [lesson, mode, round]);
|
||||
|
||||
const starte = useCallback(
|
||||
(ziel: Lesson) => {
|
||||
setLessonId(ziel.id);
|
||||
setModus(ziel.modi.includes(modus) ? modus : "tauchgang");
|
||||
setErgebnis(null);
|
||||
setRunde((r) => r + 1);
|
||||
setScreen("lauf");
|
||||
say(ziel.titel, progress.settings.speech);
|
||||
const start = useCallback(
|
||||
(lesson: Lesson) => {
|
||||
setLessonId(lesson.id);
|
||||
setMode(lesson.modes.includes(mode) ? mode : "dive");
|
||||
setOutcome(null);
|
||||
setRound((r) => r + 1);
|
||||
setScreen("run");
|
||||
},
|
||||
[modus, progress.settings.speech],
|
||||
[mode],
|
||||
);
|
||||
|
||||
const onFinished = useCallback(
|
||||
(result: RunResult) => {
|
||||
if (!lessonId) return;
|
||||
const outcome = recordRun(progress, lessonId, result);
|
||||
setProgress(outcome.progress);
|
||||
setErgebnis({
|
||||
const recorded = recordRun(progress, lessonId, result);
|
||||
setProgress(recorded.progress);
|
||||
setOutcome({
|
||||
result,
|
||||
unlockedTitel: outcome.unlockedLessonId
|
||||
? (lessonById(outcome.unlockedLessonId)?.titel ?? null)
|
||||
unlockedTitle: recorded.unlockedLessonId
|
||||
? (lessonById(recorded.unlockedLessonId)?.title ?? null)
|
||||
: null,
|
||||
neuesTier: outcome.neuesTier,
|
||||
bestseit: outcome.bestseit,
|
||||
newCreature: recorded.newCreature,
|
||||
isNewBest: recorded.isNewBest,
|
||||
});
|
||||
if (progress.settings.sound && (outcome.unlockedLessonId || outcome.neuesTier)) {
|
||||
if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
|
||||
playFanfare();
|
||||
}
|
||||
},
|
||||
[lessonId, progress],
|
||||
);
|
||||
|
||||
const nochmal = useCallback(() => {
|
||||
setErgebnis(null);
|
||||
setRunde((r) => r + 1);
|
||||
const retry = useCallback(() => {
|
||||
setOutcome(null);
|
||||
setRound((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
const weiterNachErgebnis = useCallback(() => {
|
||||
const folgend = lessonId ? nextLesson(lessonId) : null;
|
||||
setErgebnis(null);
|
||||
if (folgend && progress.lessons[folgend.id]?.unlocked) starte(folgend);
|
||||
else setScreen("karte");
|
||||
}, [lessonId, progress, starte]);
|
||||
const continueAfterResult = useCallback(() => {
|
||||
const next = lessonId ? nextLesson(lessonId) : null;
|
||||
setOutcome(null);
|
||||
if (next && progress.lessons[next.id]?.unlocked) start(next);
|
||||
else setScreen("map");
|
||||
}, [lessonId, progress, start]);
|
||||
|
||||
const zurueck = useCallback(() => {
|
||||
stopSpeech();
|
||||
if (ergebnis) return setErgebnis(null);
|
||||
if (screen === "lauf") return setScreen("karte");
|
||||
if (screen === "karte") return setScreen("aquarium");
|
||||
}, [ergebnis, screen]);
|
||||
const goBack = useCallback(() => {
|
||||
if (outcome) return setOutcome(null);
|
||||
if (screen === "run") return setScreen("map");
|
||||
if (screen === "map") return setScreen("aquarium");
|
||||
}, [outcome, screen]);
|
||||
|
||||
// --- navigation keys -----------------------------------------------------
|
||||
|
||||
const latest = useRef({ screen, ergebnis, selected, zurueck, nochmal, weiter, starte });
|
||||
latest.current = { screen, ergebnis, selected, zurueck, nochmal, weiter, starte };
|
||||
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start });
|
||||
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start };
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -173,41 +172,41 @@ export function App() {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setShowHelp(false);
|
||||
current.zurueck();
|
||||
current.goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter repeats a finished run; the result sheet's own button has focus, so this
|
||||
// is only a fallback for when focus has been lost.
|
||||
if (current.ergebnis) {
|
||||
if (current.outcome) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
current.nochmal();
|
||||
current.retry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything below is navigation, and must not fire while typing.
|
||||
if (current.screen === "lauf") return;
|
||||
if (current.screen === "run") return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (current.screen === "aquarium") {
|
||||
if (current.weiter) current.starte(current.weiter);
|
||||
if (current.nextUp) current.start(current.nextUp);
|
||||
} else {
|
||||
const lesson = LESSONS[current.selected];
|
||||
if (lesson) current.starte(lesson);
|
||||
if (lesson) current.start(lesson);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.screen !== "karte") return;
|
||||
const schritt =
|
||||
if (current.screen !== "map") return;
|
||||
const step =
|
||||
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
||||
if (schritt !== 0) {
|
||||
if (step !== 0) {
|
||||
event.preventDefault();
|
||||
playPop(340);
|
||||
setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + schritt)));
|
||||
setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + step)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,10 +217,9 @@ export function App() {
|
||||
// Settings keys live outside a run, where they cannot collide with the alphabet.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (screen === "lauf" && !ergebnis) return;
|
||||
if (screen === "run" && !outcome) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } }));
|
||||
if (key === "s") setProgress((p) => ({ ...p, settings: { ...p.settings, speech: !p.settings.speech } }));
|
||||
if (key === "h") {
|
||||
setProgress((p) => ({
|
||||
...p,
|
||||
@@ -234,36 +232,36 @@ export function App() {
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [screen, ergebnis]);
|
||||
}, [screen, outcome]);
|
||||
|
||||
// --- render --------------------------------------------------------------
|
||||
|
||||
const folgend = lessonId ? nextLesson(lessonId) : null;
|
||||
const next = lessonId ? nextLesson(lessonId) : null;
|
||||
|
||||
// Every mode takes the same bundle; only the drawing differs. Built here so adding a
|
||||
// seventh mode is one line in the switch below rather than eight repeated props.
|
||||
const gemeinsam = {
|
||||
const shared = {
|
||||
activeKeys: lesson?.activeKeys ?? [],
|
||||
progress,
|
||||
paused: ergebnis !== null,
|
||||
paused: outcome !== null,
|
||||
onFinished,
|
||||
};
|
||||
const buchstabenProps = (letters: readonly string[]) => ({ letters, ...gemeinsam });
|
||||
const textProps = (l: Extract<Lauf, { art: "text" }>) => ({
|
||||
chunks: l.chunks,
|
||||
text: l.text,
|
||||
spaceActive: l.spaceActive,
|
||||
...gemeinsam,
|
||||
const letterProps = (letters: readonly string[]) => ({ letters, ...shared });
|
||||
const textProps = (r: Extract<RunTarget, { kind: "text" }>) => ({
|
||||
chunks: r.chunks,
|
||||
text: r.text,
|
||||
spaceActive: r.spaceActive,
|
||||
...shared,
|
||||
});
|
||||
|
||||
return (
|
||||
<Stage tiere={progress.aquarium} gedaempft={screen === "lauf"}>
|
||||
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
|
||||
<AppHeader
|
||||
title={screen === "lauf" && lesson ? lesson.titel : "Delfin Tippen"}
|
||||
compact={screen === "lauf"}
|
||||
title={screen === "run" && lesson ? lesson.title : "Delfin Tippen"}
|
||||
compact={screen === "run"}
|
||||
status={
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
||||
<span>🦪 {progress.perlen}</span>
|
||||
<span>🦪 {progress.pearls}</span>
|
||||
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
||||
</div>
|
||||
}
|
||||
@@ -272,57 +270,57 @@ export function App() {
|
||||
{screen === "aquarium" && (
|
||||
<Aquarium
|
||||
progress={progress}
|
||||
weiter={weiter}
|
||||
onWeiter={() => weiter && starte(weiter)}
|
||||
onKarte={() => setScreen("karte")}
|
||||
nextLesson={nextUp}
|
||||
onContinue={() => nextUp && start(nextUp)}
|
||||
onOpenMap={() => setScreen("map")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === "karte" && (
|
||||
<LessonMap progress={progress} selected={selected} onPick={starte} />
|
||||
{screen === "map" && (
|
||||
<LessonMap progress={progress} selected={selected} onPick={start} />
|
||||
)}
|
||||
|
||||
{screen === "lauf" && lesson && lauf && (
|
||||
{screen === "run" && lesson && run && (
|
||||
<>
|
||||
{lauf.art === "buchstaben" ? (
|
||||
modus === "quallen" ? (
|
||||
<QuallenRun {...buchstabenProps(lauf.letters)} />
|
||||
{run.kind === "letters" ? (
|
||||
mode === "jellyfish" ? (
|
||||
<JellyfishRun {...letterProps(run.letters)} />
|
||||
) : (
|
||||
<BlasenRun {...buchstabenProps(lauf.letters)} />
|
||||
<BubblesRun {...letterProps(run.letters)} />
|
||||
)
|
||||
) : modus === "fuettern" ? (
|
||||
<FuetternRun {...textProps(lauf)} />
|
||||
) : modus === "perlen" ? (
|
||||
<PerlenRun {...textProps(lauf)} />
|
||||
) : modus === "rennen" ? (
|
||||
<RennenRun {...textProps(lauf)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
|
||||
) : mode === "feed" ? (
|
||||
<FeedRun {...textProps(run)} />
|
||||
) : mode === "pearls" ? (
|
||||
<PearlsRun {...textProps(run)} />
|
||||
) : mode === "race" ? (
|
||||
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
|
||||
) : (
|
||||
<TauchgangRun {...textProps(lauf)} />
|
||||
<DiveRun {...textProps(run)} />
|
||||
)}
|
||||
<div style={{ padding: "0 32px 18px", flex: "none" }}>
|
||||
<ModePicker
|
||||
modi={lesson.modi}
|
||||
aktiv={modus}
|
||||
onPick={(gewaehlt) => {
|
||||
setModus(gewaehlt);
|
||||
setRunde((r) => r + 1);
|
||||
modes={lesson.modes}
|
||||
active={mode}
|
||||
onPick={(chosen) => {
|
||||
setMode(chosen);
|
||||
setRound((r) => r + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ergebnis && (
|
||||
{outcome && (
|
||||
<ResultSheet
|
||||
result={ergebnis.result}
|
||||
unlockedTitel={ergebnis.unlockedTitel}
|
||||
neuesTier={ergebnis.neuesTier}
|
||||
bestseit={ergebnis.bestseit}
|
||||
bestEver={besteTier(progress)}
|
||||
onNochmal={nochmal}
|
||||
onWeiter={weiterNachErgebnis}
|
||||
weiterLabel={
|
||||
folgend && progress.lessons[folgend.id]?.unlocked ? `${folgend.titel} ▶` : "Zur Karte"
|
||||
result={outcome.result}
|
||||
unlockedTitle={outcome.unlockedTitle}
|
||||
newCreature={outcome.newCreature}
|
||||
isNewBest={outcome.isNewBest}
|
||||
bestEver={overallBestAnimal(progress)}
|
||||
onRetry={retry}
|
||||
onContinue={continueAfterResult}
|
||||
continueLabel={
|
||||
next && progress.lessons[next.id]?.unlocked ? `${next.title} ▶` : "Zur Karte"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
/** Shown on the right - Perlen, streak, a back hint. */
|
||||
/** Shown on the right - pearls, streak, a back hint. */
|
||||
status?: ReactNode;
|
||||
/** Smaller header while a lesson is running, so the target line gets the room. */
|
||||
compact?: boolean;
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
/** The home screen: what she has collected, before she is asked to do anything.
|
||||
*
|
||||
* Deliberately the first thing on opening the app. The reward for finishing a Welt is a
|
||||
* Deliberately the first thing on opening the app. The reward for finishing a world is a
|
||||
* pet that moves in for good - it swims behind every screen from then on (see
|
||||
* AquariumTiere.tsx) - and a reward you can see before you start is worth more than one
|
||||
* you are told about afterwards.
|
||||
* AquariumCreatures.tsx) - and a reward you can see before you start is worth more than
|
||||
* one you are told about afterwards.
|
||||
*
|
||||
* The panel shows every pet there is to earn: the ones at home in colour, the rest as
|
||||
* pale outlines. The same idea as the tier ladder - the next thing has to be visible to
|
||||
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to
|
||||
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
|
||||
|
||||
import { kreaturById } from "../lib/aquarium";
|
||||
import { creatureById } from "../lib/aquarium";
|
||||
import type { Lesson } from "../lib/curriculum";
|
||||
import { WELTEN } from "../lib/curriculum";
|
||||
import { tierById } from "../lib/grading";
|
||||
import { besteTier } from "../lib/progress";
|
||||
import { WORLDS } from "../lib/curriculum";
|
||||
import { animalById } from "../lib/grading";
|
||||
import { overallBestAnimal } from "../lib/progress";
|
||||
import type { Progress } from "../lib/progress";
|
||||
|
||||
interface Props {
|
||||
progress: Progress;
|
||||
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
|
||||
weiter: Lesson | null;
|
||||
onWeiter: () => void;
|
||||
onKarte: () => void;
|
||||
nextLesson: Lesson | null;
|
||||
onContinue: () => void;
|
||||
onOpenMap: () => void;
|
||||
}
|
||||
|
||||
export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
|
||||
const bestesTier = besteTier(progress);
|
||||
export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props) {
|
||||
const bestAnimal = overallBestAnimal(progress);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -60,23 +60,23 @@ export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{WELTEN.map((welt) => {
|
||||
const kreatur = kreaturById(welt.belohnung);
|
||||
const daheim = progress.aquarium.includes(kreatur.id);
|
||||
{WORLDS.map((world) => {
|
||||
const creature = creatureById(world.reward);
|
||||
const owned = progress.aquarium.includes(creature.id);
|
||||
return (
|
||||
<img
|
||||
key={kreatur.id}
|
||||
src={kreatur.bild}
|
||||
alt={daheim ? kreatur.name : `Noch nicht da - Welt ${welt.nummer}`}
|
||||
title={daheim ? kreatur.name : `Welt ${welt.nummer}`}
|
||||
key={creature.id}
|
||||
src={creature.image}
|
||||
alt={owned ? creature.name : `Noch nicht da - Welt ${world.number}`}
|
||||
title={owned ? creature.name : `Welt ${world.number}`}
|
||||
style={{
|
||||
width: 62,
|
||||
height: 62,
|
||||
objectFit: "contain",
|
||||
// Not yet earned: a pale outline of the shape, no colours given away.
|
||||
filter: daheim ? "none" : "brightness(0) invert(1)",
|
||||
opacity: daheim ? 1 : 0.2,
|
||||
animation: daheim ? `dolphinBob ${3 + welt.nummer * 0.4}s ease-in-out infinite` : undefined,
|
||||
filter: owned ? "none" : "brightness(0) invert(1)",
|
||||
opacity: owned ? 1 : 0.2,
|
||||
animation: owned ? `dolphinBob ${3 + world.number * 0.4}s ease-in-out infinite` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -90,19 +90,19 @@ export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
|
||||
<Stat label="Perlen" value={`🦪 ${progress.perlen}`} />
|
||||
<Stat label="Perlen" value={`🦪 ${progress.pearls}`} />
|
||||
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
|
||||
<Stat
|
||||
label="Schnellstes Tier"
|
||||
value={bestesTier ? `${tierById(bestesTier).emoji} ${tierById(bestesTier).name}` : "—"}
|
||||
value={bestAnimal ? `${animalById(bestAnimal).emoji} ${animalById(bestAnimal).name}` : "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
{weiter && (
|
||||
{nextLesson && (
|
||||
<button
|
||||
onClick={onWeiter}
|
||||
onClick={onContinue}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
@@ -115,10 +115,10 @@ export function Aquarium({ progress, weiter, onWeiter, onKarte }: Props) {
|
||||
boxShadow: "0 8px 24px var(--shadow)",
|
||||
}}
|
||||
>
|
||||
▶ {weiter.titel}
|
||||
▶ {nextLesson.title}
|
||||
</button>
|
||||
)}
|
||||
<button className="pill" onClick={onKarte} style={{ fontSize: 16, padding: "15px 26px" }}>
|
||||
<button className="pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
|
||||
🗺️ Alle Lektionen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,73 +14,73 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { kreaturById, neuerSchwimmer, pose, schwimme } from "../lib/aquarium";
|
||||
import type { KreaturId, Schwimmer } from "../lib/aquarium";
|
||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../lib/aquarium";
|
||||
import type { CreatureId, Swimmer } from "../lib/aquarium";
|
||||
|
||||
interface Props {
|
||||
tiere: readonly KreaturId[];
|
||||
creatures: readonly CreatureId[];
|
||||
/** 0..1, eased - the pets fade back while she types. */
|
||||
deckkraft: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/** A tab in the background resumes with a gap of minutes. Swimming that in one step would
|
||||
* teleport every pet, so a frame never counts for more than this. */
|
||||
const MAX_SCHRITT_S = 0.1;
|
||||
const MAX_STEP_S = 0.1;
|
||||
|
||||
export function AquariumTiere({ tiere, deckkraft }: Props) {
|
||||
const becken = useRef<HTMLDivElement>(null);
|
||||
const bilder = useRef(new Map<KreaturId, HTMLImageElement>());
|
||||
const schwimmer = useRef(new Map<KreaturId, Schwimmer>());
|
||||
const aktuell = useRef(tiere);
|
||||
aktuell.current = tiere;
|
||||
export function AquariumCreatures({ creatures, opacity }: Props) {
|
||||
const tank = useRef<HTMLDivElement>(null);
|
||||
const images = useRef(new Map<CreatureId, HTMLImageElement>());
|
||||
const swimmers = useRef(new Map<CreatureId, Swimmer>());
|
||||
const current = useRef(creatures);
|
||||
current.current = creatures;
|
||||
/** The pets present at first render - everyone after them is a newcomer. */
|
||||
const daheim = useRef<ReadonlySet<KreaturId>>(new Set(tiere));
|
||||
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
|
||||
|
||||
useEffect(() => {
|
||||
// Reduced motion: every pet is still placed and shown, it just holds still.
|
||||
const ruhig = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
let frame = 0;
|
||||
let zuletzt = performance.now();
|
||||
let lastTime = performance.now();
|
||||
|
||||
const schritt = (jetzt: number) => {
|
||||
const dt = Math.min(MAX_SCHRITT_S, Math.max(0, (jetzt - zuletzt) / 1000));
|
||||
zuletzt = jetzt;
|
||||
const el = becken.current;
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(MAX_STEP_S, Math.max(0, (now - lastTime) / 1000));
|
||||
lastTime = now;
|
||||
const el = tank.current;
|
||||
if (el) {
|
||||
const groesse = { breite: el.clientWidth, hoehe: el.clientHeight };
|
||||
for (const id of aktuell.current) {
|
||||
const bild = bilder.current.get(id);
|
||||
if (!bild) continue;
|
||||
const kreatur = kreaturById(id);
|
||||
const hoehe = kreatur.groesse * groesse.hoehe;
|
||||
const rand = hoehe / 2;
|
||||
const tempo = kreatur.tempo * groesse.breite;
|
||||
const size = { width: el.clientWidth, height: el.clientHeight };
|
||||
for (const id of current.current) {
|
||||
const image = images.current.get(id);
|
||||
if (!image) continue;
|
||||
const creature = creatureById(id);
|
||||
const height = creature.size * size.height;
|
||||
const margin = height / 2;
|
||||
const speed = creature.speed * size.width;
|
||||
|
||||
const vorher = schwimmer.current.get(id);
|
||||
const s = vorher
|
||||
? ruhig
|
||||
? vorher
|
||||
: schwimme(vorher, dt, groesse, rand, tempo, Math.random)
|
||||
: neuerSchwimmer(groesse, rand, Math.random, !ruhig && !daheim.current.has(id));
|
||||
schwimmer.current.set(id, s);
|
||||
const previous = swimmers.current.get(id);
|
||||
const s = previous
|
||||
? reducedMotion
|
||||
? previous
|
||||
: stepSwimmer(previous, dt, size, margin, speed, Math.random)
|
||||
: createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id));
|
||||
swimmers.current.set(id, s);
|
||||
|
||||
const p = pose(s, kreatur, tempo);
|
||||
bild.style.height = `${hoehe}px`;
|
||||
bild.style.transform =
|
||||
const p = pose(s, creature, speed);
|
||||
image.style.height = `${height}px`;
|
||||
image.style.transform =
|
||||
`translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
|
||||
`rotate(${p.drehung}deg) scaleX(${p.spiegel})`;
|
||||
`rotate(${p.rotation}deg) scaleX(${p.mirror})`;
|
||||
}
|
||||
}
|
||||
frame = requestAnimationFrame(schritt);
|
||||
frame = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(schritt);
|
||||
frame = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={becken}
|
||||
ref={tank}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -88,18 +88,18 @@ export function AquariumTiere({ tiere, deckkraft }: Props) {
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
zIndex: -1,
|
||||
opacity: deckkraft,
|
||||
opacity,
|
||||
transition: "opacity 700ms ease",
|
||||
}}
|
||||
>
|
||||
{tiere.map((id) => (
|
||||
{creatures.map((id) => (
|
||||
<img
|
||||
key={id}
|
||||
ref={(bild) => {
|
||||
if (bild) bilder.current.set(id, bild);
|
||||
else bilder.current.delete(id);
|
||||
ref={(image) => {
|
||||
if (image) images.current.set(id, image);
|
||||
else images.current.delete(id);
|
||||
}}
|
||||
src={kreaturById(id).bild}
|
||||
src={creatureById(id).image}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
@@ -1,54 +0,0 @@
|
||||
/** Which finger types the key that is wanted right now, said in words.
|
||||
*
|
||||
* The keyboard shows *where*; this shows *which finger*, because at six the failure mode
|
||||
* is not finding the key, it is finding it with the wrong finger and building the habit
|
||||
* that the whole exercise exists to prevent.
|
||||
*
|
||||
* When the target is a capital it also names the Shift hand - the opposite one, which is
|
||||
* the single rule that separates touch typing from a pinky cramp. */
|
||||
|
||||
import { fingerOf, needsShift, shiftHandFor } from "../lib/fingers";
|
||||
|
||||
interface Props {
|
||||
nextKey: string | null;
|
||||
}
|
||||
|
||||
export function HandHint({ nextKey }: Props) {
|
||||
if (!nextKey) return null;
|
||||
|
||||
const finger = fingerOf(nextKey);
|
||||
if (!finger) return null;
|
||||
|
||||
const shiftHand = needsShift(nextKey) ? shiftHandFor(nextKey) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
color: "var(--paper)",
|
||||
fontWeight: 800,
|
||||
fontSize: 17,
|
||||
opacity: 0.92,
|
||||
minHeight: 30,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 999,
|
||||
flex: "none",
|
||||
background: `oklch(72% 0.15 ${finger.hue})`,
|
||||
boxShadow: `0 0 12px oklch(72% 0.15 ${finger.hue} / .7)`,
|
||||
}}
|
||||
/>
|
||||
<span>{finger.label}</span>
|
||||
{shiftHand && (
|
||||
<span style={{ opacity: 0.85 }}>+ Umschalttaste {shiftHand}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,12 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TASTEN: readonly [string, string][] = [
|
||||
const KEYS: readonly [string, string][] = [
|
||||
["⏎", "Lektion starten · Runde wiederholen"],
|
||||
["Esc", "Eine Ebene zurück"],
|
||||
["← →", "Lektion auswählen"],
|
||||
["H", "Tastatur-Hilfe ein- und ausblenden"],
|
||||
["M", "Ton an und aus"],
|
||||
["S", "Sprache an und aus"],
|
||||
["F1", "Diese Übersicht"],
|
||||
];
|
||||
|
||||
@@ -23,10 +22,10 @@ export function HelpOverlay({ onClose }: Props) {
|
||||
⌨️ Zaubertasten
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{TASTEN.map(([taste, was]) => (
|
||||
<div key={taste} style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<span className="key-cap">{taste}</span>
|
||||
<span style={{ fontWeight: 700, color: "var(--ink)" }}>{was}</span>
|
||||
{KEYS.map(([key, description]) => (
|
||||
<div key={key} style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<span className="key-cap">{key}</span>
|
||||
<span style={{ fontWeight: 700, color: "var(--ink)" }}>{description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
}
|
||||
|
||||
/** Rows are offset the way a real keyboard staggers them. */
|
||||
const ROW_INDENT: Record<string, number> = { oben: 0, grund: 14, unten: 30 };
|
||||
const ROW_INDENT: Record<string, number> = { top: 0, home: 14, bottom: 30 };
|
||||
|
||||
export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Props) {
|
||||
if (mode === "off") return null;
|
||||
@@ -44,16 +44,16 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
|
||||
<div className="kb-row" key={row} style={{ marginLeft: ROW_INDENT[row] ?? 0 }}>
|
||||
{keysInRow(row).map((key) => {
|
||||
const finger = fingerOf(key);
|
||||
const istAktiv = active.has(key);
|
||||
const isActive = active.has(key);
|
||||
// A key she has mastered fades out; one she is still learning stays bright.
|
||||
// Only ever applied to active keys - dimming an unlearned key would hide the
|
||||
// very thing she needs to find.
|
||||
const gelernt = mode === "auto" && istAktiv ? mastery(progress, key) : 0;
|
||||
const masteryLevel = mode === "auto" && isActive ? mastery(progress, key) : 0;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="kb-key"
|
||||
data-active={istAktiv}
|
||||
data-active={isActive}
|
||||
data-next={key === next}
|
||||
data-finger={finger?.id}
|
||||
data-home={key === "f" || key === "j"}
|
||||
@@ -61,7 +61,7 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
|
||||
["--finger-hue" as string]: finger?.hue ?? 0,
|
||||
// Never below 0.25: the keyboard stops helping, it does not vanish
|
||||
// mid-lesson and leave her staring at a blank strip.
|
||||
opacity: istAktiv ? Math.max(0.25, 1 - gelernt * 0.75) : undefined,
|
||||
opacity: isActive ? Math.max(0.25, 1 - masteryLevel * 0.75) : undefined,
|
||||
}}
|
||||
>
|
||||
{key.toUpperCase()}
|
||||
@@ -73,18 +73,18 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
|
||||
|
||||
<div className="kb-row" style={{ marginTop: 2, alignItems: "center", gap: 10 }}>
|
||||
{/* Both Shifts are drawn, and the one to use lights up - the opposite hand from
|
||||
the letter, which is the rule Welt 4 exists to teach. */}
|
||||
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "links"}>
|
||||
the letter, which is the rule world 4 exists to teach. */}
|
||||
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "left"}>
|
||||
⇧
|
||||
</div>
|
||||
<div
|
||||
className="kb-key kb-space"
|
||||
data-active={spaceActive}
|
||||
data-next={next === " "}
|
||||
data-finger="daumen"
|
||||
data-finger="thumb"
|
||||
style={{ ["--finger-hue" as string]: 220 }}
|
||||
/>
|
||||
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "rechts"}>
|
||||
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "right"}>
|
||||
⇧
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/** The Karte: four Welten, each a row of lesson cards.
|
||||
/** The map: four worlds, each a row of lesson cards.
|
||||
*
|
||||
* Locked lessons are dimmed rather than hidden - seeing that there is a Welt called
|
||||
* 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. */
|
||||
|
||||
import { LESSONS, WELTEN } from "../lib/curriculum";
|
||||
import { LESSONS, WORLDS } from "../lib/curriculum";
|
||||
import type { Lesson } from "../lib/curriculum";
|
||||
import { tierById } from "../lib/grading";
|
||||
import { animalById } from "../lib/grading";
|
||||
import type { Progress } from "../lib/progress";
|
||||
|
||||
interface Props {
|
||||
@@ -24,24 +24,24 @@ export function LessonMap({ progress, selected, onPick }: Props) {
|
||||
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 32px", minHeight: 0 }}
|
||||
>
|
||||
<div style={{ maxWidth: 1180, margin: "0 auto", display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
{WELTEN.map((welt) => (
|
||||
<div key={welt.nummer} className="glass-panel" style={{ padding: "16px 20px 20px" }}>
|
||||
{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 }}>{welt.emoji}</span>
|
||||
<span style={{ fontSize: 22 }}>{world.emoji}</span>
|
||||
<span style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>
|
||||
Welt {welt.nummer} — {welt.titel}
|
||||
Welt {world.number} — {world.title}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>
|
||||
{LESSONS.filter((l) => l.welt === welt.nummer && (progress.lessons[l.id]?.bestSterne ?? 0) >= 2).length}
|
||||
/{LESSONS.filter((l) => l.welt === welt.nummer).length}
|
||||
{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={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
{LESSONS.filter((lesson) => lesson.welt === welt.nummer).map((lesson) => {
|
||||
const fortschritt = progress.lessons[lesson.id];
|
||||
const locked = !fortschritt?.unlocked;
|
||||
const tier = fortschritt?.bestTier ? tierById(fortschritt.bestTier) : null;
|
||||
{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 (
|
||||
@@ -56,17 +56,17 @@ export function LessonMap({ progress, selected, onPick }: Props) {
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 900, color: "var(--paper)", opacity: 0.7 }}>
|
||||
{lesson.nummer}
|
||||
{lesson.number}
|
||||
</span>
|
||||
<span style={{ fontSize: 26 }}>{locked ? "🔒" : (tier?.emoji ?? "·")}</span>
|
||||
<span style={{ fontSize: 26 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--paper)", marginTop: 6 }}>
|
||||
{lesson.titel}
|
||||
{lesson.title}
|
||||
</div>
|
||||
|
||||
{/* The keys themselves, big: for a pre-reader this is the real label
|
||||
and the title is decoration. An Übung has no new keys, so it says
|
||||
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
|
||||
style={{
|
||||
@@ -74,21 +74,21 @@ export function LessonMap({ progress, selected, onPick }: Props) {
|
||||
fontWeight: 800,
|
||||
color: "var(--paper)",
|
||||
opacity: 0.75,
|
||||
letterSpacing: lesson.istUebung ? "normal" : "0.14em",
|
||||
letterSpacing: lesson.isDrill ? "normal" : "0.14em",
|
||||
marginTop: 3,
|
||||
minHeight: 20,
|
||||
}}
|
||||
>
|
||||
{lesson.istUebung
|
||||
{lesson.isDrill
|
||||
? "🔁 Übung"
|
||||
: lesson.neueKeys
|
||||
: lesson.newKeys
|
||||
.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase()))
|
||||
.join(" ")}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8, fontSize: 13, letterSpacing: "0.08em" }}>
|
||||
{[1, 2, 3].map((stern) => (
|
||||
<span key={stern} style={{ opacity: (fortschritt?.bestSterne ?? 0) >= stern ? 1 : 0.22 }}>
|
||||
{[1, 2, 3].map((star) => (
|
||||
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
||||
⭐
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
/** Which game a lesson is played as. The Tauchgang is the measured one; the rest are
|
||||
/** 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 {
|
||||
modi: readonly ModeId[];
|
||||
aktiv: ModeId;
|
||||
onPick: (modus: ModeId) => void;
|
||||
modes: readonly ModeId[];
|
||||
active: ModeId;
|
||||
onPick: (mode: ModeId) => void;
|
||||
}
|
||||
|
||||
export const MODUS_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
||||
tauchgang: { emoji: "🤿", name: "Tauchgang" },
|
||||
blasen: { emoji: "🫧", name: "Blasenplatzen" },
|
||||
quallen: { emoji: "🦑", name: "Quallenalarm" },
|
||||
fuettern: { emoji: "🐟", name: "Fütterungszeit" },
|
||||
rennen: { emoji: "🐬", name: "Delfinrennen" },
|
||||
perlen: { emoji: "🦪", name: "Perlentaucher" },
|
||||
export 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({ modi, aktiv, onPick }: Props) {
|
||||
export function ModePicker({ modes, active, onPick }: Props) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
{modi.map((modus) => {
|
||||
const info = MODUS_INFO[modus];
|
||||
{modes.map((mode) => {
|
||||
const info = MODE_INFO[mode];
|
||||
return (
|
||||
<button
|
||||
key={modus}
|
||||
key={mode}
|
||||
className="pill"
|
||||
data-active={modus === aktiv}
|
||||
onClick={() => onPick(modus)}
|
||||
data-active={mode === active}
|
||||
onClick={() => onPick(mode)}
|
||||
>
|
||||
{info.emoji} {info.name}
|
||||
</button>
|
||||
|
||||
@@ -2,50 +2,50 @@
|
||||
*
|
||||
* The rule this screen exists to enforce: there is no losing screen. A bad run shows
|
||||
* fewer stars and a slower animal, and the primary button still says "Nochmal" with the
|
||||
* focus already on it. Nothing here ever says "failed", nothing is red, and the Perlen
|
||||
* always go up - see `perlenFor` in lib/grading.ts. */
|
||||
* focus already on it. Nothing here ever says "failed", nothing is red, and the pearls
|
||||
* always go up - see `pearlsFor` in lib/grading.ts. */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { kreaturById } from "../lib/aquarium";
|
||||
import type { KreaturId } from "../lib/aquarium";
|
||||
import { creatureById } from "../lib/aquarium";
|
||||
import type { CreatureId } from "../lib/aquarium";
|
||||
import type { RunResult } from "../lib/grading";
|
||||
import { STERNE_SCHWELLEN, sichtbareTiers, tierById, tierIndex, tierProgress } from "../lib/grading";
|
||||
import type { TierId } from "../lib/grading";
|
||||
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
|
||||
import type { AnimalId } from "../lib/grading";
|
||||
|
||||
interface Props {
|
||||
result: RunResult;
|
||||
/** Set when this run opened the next lesson. */
|
||||
unlockedTitel: string | null;
|
||||
unlockedTitle: string | null;
|
||||
/** Set when this run released a creature into the aquarium. */
|
||||
neuesTier: KreaturId | null;
|
||||
bestseit: boolean;
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: boolean;
|
||||
/** The fastest animal earned on any lesson so far - decides how much of the ladder
|
||||
* may be revealed. */
|
||||
bestEver: TierId | null;
|
||||
onNochmal: () => void;
|
||||
onWeiter: () => void;
|
||||
bestEver: AnimalId | null;
|
||||
onRetry: () => void;
|
||||
onContinue: () => void;
|
||||
/** Null at the end of the curriculum. */
|
||||
weiterLabel: string | null;
|
||||
continueLabel: string | null;
|
||||
}
|
||||
|
||||
export function ResultSheet({
|
||||
result,
|
||||
unlockedTitel,
|
||||
neuesTier,
|
||||
bestseit,
|
||||
unlockedTitle,
|
||||
newCreature,
|
||||
isNewBest,
|
||||
bestEver,
|
||||
onNochmal,
|
||||
onWeiter,
|
||||
weiterLabel,
|
||||
onRetry,
|
||||
onContinue,
|
||||
continueLabel,
|
||||
}: Props) {
|
||||
const tier = tierById(result.tier);
|
||||
const leiter = sichtbareTiers(result.tier, bestEver);
|
||||
const erreicht = tierIndex(result.tier);
|
||||
const nochmal = useRef<HTMLButtonElement>(null);
|
||||
const animal = animalById(result.animal);
|
||||
const ladder = visibleAnimals(result.animal, bestEver);
|
||||
const reached = animalIndex(result.animal);
|
||||
const retryButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Enter repeats the run. Fewest keystrokes between "that was fun" and "again".
|
||||
useEffect(() => nochmal.current?.focus(), []);
|
||||
useEffect(() => retryButton.current?.focus(), []);
|
||||
|
||||
return (
|
||||
<div className="overlay backdrop-enter">
|
||||
@@ -54,21 +54,21 @@ export function ResultSheet({
|
||||
style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }}
|
||||
>
|
||||
<div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}>
|
||||
{tier.emoji}
|
||||
{animal.emoji}
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 900, color: "var(--ink)", marginTop: 6 }}>
|
||||
{tier.name}
|
||||
{animal.name}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 8, margin: "14px 0 4px" }}>
|
||||
{[1, 2, 3].map((stern) => (
|
||||
{[1, 2, 3].map((star) => (
|
||||
<span
|
||||
key={stern}
|
||||
key={star}
|
||||
style={{
|
||||
fontSize: 40,
|
||||
animation: `sterneEnter 320ms ease-out ${140 + stern * 130}ms both`,
|
||||
filter: result.sterne >= stern ? "none" : "grayscale(1)",
|
||||
opacity: result.sterne >= stern ? 1 : 0.25,
|
||||
animation: `sterneEnter 320ms ease-out ${140 + star * 130}ms both`,
|
||||
filter: result.stars >= star ? "none" : "grayscale(1)",
|
||||
opacity: result.stars >= star ? 1 : 0.25,
|
||||
}}
|
||||
>
|
||||
⭐
|
||||
@@ -77,9 +77,9 @@ export function ResultSheet({
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
|
||||
<Stat label="Richtig" value={`${Math.round(result.genauigkeit * 100)}%`} />
|
||||
<Stat label="Zeichen/Min" value={String(Math.round(result.tempo))} />
|
||||
<Stat label="Perlen" value={`+${result.perlen}`} />
|
||||
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
|
||||
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
|
||||
<Stat label="Perlen" value={`+${result.pearls}`} />
|
||||
</div>
|
||||
|
||||
{/* How close the next animal is. A near miss is the strongest reason to press
|
||||
@@ -95,7 +95,7 @@ export function ResultSheet({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${tierProgress(result.punkte) * 100}%`,
|
||||
width: `${animalProgress(result.points) * 100}%`,
|
||||
height: "100%",
|
||||
background: "var(--accent)",
|
||||
transition: "width 700ms ease-out",
|
||||
@@ -103,19 +103,19 @@ export function ResultSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TierLeiter tiers={leiter.tiers} erreicht={erreicht} mehrVerborgen={leiter.mehrVerborgen} />
|
||||
<AnimalLadder animals={ladder.animals} reached={reached} moreHidden={ladder.moreHidden} />
|
||||
|
||||
{bestseit && (
|
||||
{isNewBest && (
|
||||
<div style={{ color: "var(--accent)", fontWeight: 900, marginTop: 8 }}>
|
||||
🏅 Neuer Bestwert!
|
||||
</div>
|
||||
)}
|
||||
{unlockedTitel && (
|
||||
{unlockedTitle && (
|
||||
<div style={{ color: "var(--ink)", fontWeight: 900, marginTop: 8 }}>
|
||||
🔓 Neu: {unlockedTitel}
|
||||
🔓 Neu: {unlockedTitle}
|
||||
</div>
|
||||
)}
|
||||
{neuesTier && (
|
||||
{newCreature && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -128,34 +128,34 @@ export function ResultSheet({
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={kreaturById(neuesTier).bild}
|
||||
src={creatureById(newCreature).image}
|
||||
alt=""
|
||||
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
|
||||
/>
|
||||
{kreaturById(neuesTier).artikel} {kreaturById(neuesTier).name} ist ins Aquarium gezogen!
|
||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
||||
</div>
|
||||
)}
|
||||
{!result.bestanden && !unlockedTitel && (
|
||||
{!result.passed && !unlockedTitle && (
|
||||
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
|
||||
Mit {Math.round(STERNE_SCHWELLEN.zwei * 100)}% Treffern geht es weiter. Fast!
|
||||
Mit {Math.round(STAR_THRESHOLDS.two * 100)}% Treffern geht es weiter. Fast!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 22, justifyContent: "center" }}>
|
||||
<button
|
||||
ref={nochmal}
|
||||
onClick={onNochmal}
|
||||
ref={retryButton}
|
||||
onClick={onRetry}
|
||||
style={{
|
||||
...knopf,
|
||||
...buttonStyle,
|
||||
background: "var(--accent)",
|
||||
color: "var(--paper)",
|
||||
}}
|
||||
>
|
||||
Nochmal ⏎
|
||||
</button>
|
||||
{weiterLabel && (
|
||||
<button onClick={onWeiter} style={{ ...knopf, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
|
||||
{weiterLabel}
|
||||
{continueLabel && (
|
||||
<button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
|
||||
{continueLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -170,17 +170,17 @@ export function ResultSheet({
|
||||
* rungs below are what shows how far she has come. The rungs above are greyed out but
|
||||
* still legible, because the next animal has to be visible to be worth aiming at.
|
||||
*
|
||||
* Above the Delfin the ladder stops: those animals are not shown at all until they are
|
||||
* Above the dolphin the ladder stops: those animals are not shown at all until they are
|
||||
* reached. A single "?" says the ladder continues without saying what is on it, which
|
||||
* keeps the top end a surprise rather than a distant, discouraging number. */
|
||||
function TierLeiter({
|
||||
tiers,
|
||||
erreicht,
|
||||
mehrVerborgen,
|
||||
function AnimalLadder({
|
||||
animals,
|
||||
reached,
|
||||
moreHidden,
|
||||
}: {
|
||||
tiers: readonly { id: string; name: string; emoji: string }[];
|
||||
erreicht: number;
|
||||
mehrVerborgen: boolean;
|
||||
animals: readonly { id: string; name: string; emoji: string }[];
|
||||
reached: number;
|
||||
moreHidden: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -195,29 +195,29 @@ function TierLeiter({
|
||||
}}
|
||||
aria-label="Tier-Leiter"
|
||||
>
|
||||
{tiers.map((eintrag, i) => {
|
||||
const geschafft = i <= erreicht;
|
||||
const aktuell = i === erreicht;
|
||||
{animals.map((entry, i) => {
|
||||
const achieved = i <= reached;
|
||||
const isCurrent = i === reached;
|
||||
return (
|
||||
<div
|
||||
key={eintrag.id}
|
||||
title={eintrag.name}
|
||||
key={entry.id}
|
||||
title={entry.name}
|
||||
style={{
|
||||
fontSize: aktuell ? 40 : 26,
|
||||
fontSize: isCurrent ? 40 : 26,
|
||||
lineHeight: 1,
|
||||
padding: aktuell ? "0 5px" : 0,
|
||||
filter: geschafft ? "none" : "grayscale(1)",
|
||||
padding: isCurrent ? "0 5px" : 0,
|
||||
filter: achieved ? "none" : "grayscale(1)",
|
||||
// Bright enough to read as "this is next", dim enough to read as "not yet".
|
||||
opacity: geschafft ? 1 : 0.42,
|
||||
transform: aktuell ? "translateY(-3px)" : undefined,
|
||||
opacity: achieved ? 1 : 0.42,
|
||||
transform: isCurrent ? "translateY(-3px)" : undefined,
|
||||
transition: "all 200ms ease",
|
||||
}}
|
||||
>
|
||||
{eintrag.emoji}
|
||||
{entry.emoji}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mehrVerborgen && (
|
||||
{moreHidden && (
|
||||
<div
|
||||
title="Da geht noch was!"
|
||||
style={{ fontSize: 24, opacity: 0.4, marginLeft: 4, filter: "grayscale(1)" }}
|
||||
@@ -229,7 +229,7 @@ function TierLeiter({
|
||||
);
|
||||
}
|
||||
|
||||
const knopf: React.CSSProperties = {
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
padding: "13px 26px",
|
||||
|
||||
@@ -6,23 +6,23 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { KreaturId } from "../lib/aquarium";
|
||||
import { SHOW_AQUARIUM_TIERE, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||
import { AquariumTiere } from "./AquariumTiere";
|
||||
import type { CreatureId } from "../lib/aquarium";
|
||||
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||
import { AquariumCreatures } from "./AquariumCreatures";
|
||||
import { Bubbles } from "./Bubbles";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** The pets that have moved in so far. */
|
||||
tiere: readonly KreaturId[];
|
||||
creatures: readonly CreatureId[];
|
||||
/** While she is typing the pets fade back: still there, not competing with the line. */
|
||||
gedaempft: boolean;
|
||||
dimmed: boolean;
|
||||
}
|
||||
|
||||
export function Stage({ children, tiere, gedaempft }: Props) {
|
||||
export function Stage({ children, creatures, dimmed }: Props) {
|
||||
return (
|
||||
<div className="stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
|
||||
{SHOW_AQUARIUM_TIERE && <AquariumTiere tiere={tiere} deckkraft={gedaempft ? 0.25 : 1} />}
|
||||
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||
{SHOW_BUBBLES && <Bubbles />}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
*
|
||||
* Chunks matter more than they look: twenty-four letters in an unbroken row is
|
||||
* something a six-year-old loses her place in, four letters with a gap after them is
|
||||
* not. In Welt 1 and 2 the gaps are purely visual - the Leertaste has not been taught,
|
||||
* so `spaceActive` is false and the chunks join without one. */
|
||||
* not. The gap is a real space to type from the very first lesson, even before the
|
||||
* space-bar lesson formally teaches the thumb. */
|
||||
|
||||
import { chunkOffsets } from "../lib/generator";
|
||||
|
||||
@@ -13,58 +13,57 @@ interface Props {
|
||||
/** Cursor position in the joined text. */
|
||||
index: number;
|
||||
/** True while a wrong key is still being corrected at the cursor. */
|
||||
daneben: boolean;
|
||||
wrong: boolean;
|
||||
/** Overrides the size picked from the text's own length. */
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
/** Type size and column width for a block of `laenge` characters.
|
||||
/** Type size and column width for a block of `length` characters.
|
||||
*
|
||||
* A round is at least five lines of text, and the whole block has to sit on one screen
|
||||
* above the keyboard without the stage ever scrolling - a line that scrolls out of view
|
||||
* mid-run is worse than no line at all.
|
||||
*
|
||||
* The two move together on purpose. Short blocks (Welt 1) get the biggest letters *and*
|
||||
* The two move together on purpose. Short blocks (world 1) get the biggest letters *and*
|
||||
* the narrowest column, so they still wrap into five lines instead of three long ones -
|
||||
* and a narrow column is easier for a beginner to keep her place in anyway. Long blocks
|
||||
* (Welt 5's sentences) get smaller type and a wider column so ten sentences still fit. */
|
||||
function satzspiegel(laenge: number): { groesse: number; breite: number } {
|
||||
if (laenge <= 40) return { groesse: 44, breite: 560 };
|
||||
if (laenge <= 80) return { groesse: 38, breite: 600 };
|
||||
if (laenge <= 130) return { groesse: 32, breite: 760 };
|
||||
if (laenge <= 220) return { groesse: 26, breite: 880 };
|
||||
return { groesse: 22, breite: 960 };
|
||||
* (world 5's sentences) get smaller type and a wider column so ten sentences still fit. */
|
||||
function layoutFor(length: number): { size: number; width: number } {
|
||||
if (length <= 40) return { size: 44, width: 560 };
|
||||
if (length <= 80) return { size: 38, width: 600 };
|
||||
if (length <= 130) return { size: 32, width: 760 };
|
||||
if (length <= 220) return { size: 26, width: 880 };
|
||||
return { size: 22, width: 960 };
|
||||
}
|
||||
|
||||
export function Target({ chunks, spaceActive, index, daneben, fontSize }: Props) {
|
||||
export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
|
||||
const offsets = chunkOffsets(chunks, spaceActive);
|
||||
const laenge = chunks.join("").length + (spaceActive ? chunks.length - 1 : 0);
|
||||
const spiegel = satzspiegel(laenge);
|
||||
const groesse = fontSize ?? spiegel.groesse;
|
||||
const length = chunks.join("").length + (spaceActive ? chunks.length - 1 : 0);
|
||||
const layout = layoutFor(length);
|
||||
const size = fontSize ?? layout.size;
|
||||
|
||||
return (
|
||||
<div className="ziel" style={{ fontSize: groesse, maxWidth: `min(${spiegel.breite}px, 90vw)` }}>
|
||||
<div className="target" style={{ fontSize: size, maxWidth: `min(${layout.width}px, 90vw)` }}>
|
||||
{chunks.map((chunk, chunkIndex) => {
|
||||
const start = offsets[chunkIndex] ?? 0;
|
||||
return (
|
||||
<div className="ziel-chunk" key={chunkIndex}>
|
||||
<div className="target-chunk" key={chunkIndex}>
|
||||
{[...chunk].map((char, i) => {
|
||||
const at = start + i;
|
||||
const state = at < index ? "fertig" : at === index ? "aktuell" : "offen";
|
||||
// Welt 5's targets are whole sentences, so a chunk can contain spaces of
|
||||
const state = at < index ? "done" : at === index ? "current" : "open";
|
||||
// World 5's targets are whole sentences, so a chunk can contain spaces of
|
||||
// its own. They need the same visible body as the ones between chunks, or
|
||||
// the cursor lands on nothing and looks lost.
|
||||
const leer = char === " ";
|
||||
const isBlank = char === " ";
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="ziel-zeichen"
|
||||
className="target-char"
|
||||
data-state={state}
|
||||
data-leer={leer}
|
||||
data-daneben={state === "aktuell" && daneben}
|
||||
style={{ padding: state === "aktuell" ? "0 6px" : undefined }}
|
||||
data-blank={isBlank}
|
||||
data-wrong={state === "current" && wrong}
|
||||
>
|
||||
{leer ? (state === "aktuell" ? "␣" : "") : char}
|
||||
{isBlank ? (state === "current" ? "␣" : "") : char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -72,16 +71,16 @@ export function Target({ chunks, spaceActive, index, daneben, fontSize }: Props)
|
||||
needs to be typeable and to show the cursor. Before that it is a gap. */}
|
||||
{spaceActive && chunkIndex < chunks.length - 1 && (
|
||||
<span
|
||||
className="ziel-zeichen"
|
||||
data-leer="true"
|
||||
className="target-char"
|
||||
data-blank="true"
|
||||
data-state={
|
||||
start + chunk.length < index
|
||||
? "fertig"
|
||||
? "done"
|
||||
: start + chunk.length === index
|
||||
? "aktuell"
|
||||
: "offen"
|
||||
? "current"
|
||||
: "open"
|
||||
}
|
||||
data-daneben={start + chunk.length === index && daneben}
|
||||
data-wrong={start + chunk.length === index && wrong}
|
||||
>
|
||||
{start + chunk.length === index ? "␣" : ""}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Blasenplatzen - the first arcade mode, and the proof that the engine is
|
||||
/** Bubbles mode - the first arcade mode, and the proof that the engine is
|
||||
* mode-agnostic: same `useRun`, same grading, same unlock. Only the drawing differs.
|
||||
*
|
||||
* Each letter of the line is a bubble. The bubble at the cursor is the one nearest the
|
||||
@@ -10,16 +10,14 @@
|
||||
* ever escapes and nothing is ever lost. At six, "you were too slow" is the fastest way
|
||||
* to end a session. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { currentChar } from "../../lib/engine";
|
||||
import type { RunEvent } from "../../lib/engine";
|
||||
import { fingerOf } from "../../lib/fingers";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
import type { Progress } from "../../lib/progress";
|
||||
import { say, spellKey } from "../../lib/speech";
|
||||
import { useRun } from "../../hooks/useRun";
|
||||
import { HandHint } from "../HandHint";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
@@ -32,21 +30,21 @@ interface Props {
|
||||
|
||||
/** How many bubbles are in the water at once. More than five and the column of letters
|
||||
* reads as a wall of text; fewer and there is nothing to look forward to. */
|
||||
const SICHTBAR = 5;
|
||||
const VISIBLE_COUNT = 5;
|
||||
|
||||
/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
|
||||
const LANES = [50, 28, 68, 38, 60, 46];
|
||||
|
||||
export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
||||
export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
||||
const text = letters.join("");
|
||||
const [geplatzt, setGeplatzt] = useState<number | null>(null);
|
||||
const [popped, setPopped] = useState<number | null>(null);
|
||||
|
||||
const onEvent = (event: RunEvent) => {
|
||||
// Remember which bubble just popped so it can play its burst before disappearing.
|
||||
if (event.type === "correct") setGeplatzt(event.index);
|
||||
if (event.type === "correct") setPopped(event.index);
|
||||
};
|
||||
|
||||
const { state, daneben } = useRun({
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
@@ -56,12 +54,6 @@ export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }:
|
||||
|
||||
const next = currentChar(state);
|
||||
|
||||
useEffect(() => {
|
||||
if (next) say(spellKey(next), progress.settings.speech);
|
||||
// Every bubble is spoken here, unlike the Tauchgang: one letter at a time is slow
|
||||
// enough that the voice keeps up, and this is the mode a pre-reader plays first.
|
||||
}, [next, progress.settings.speech]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
@@ -88,11 +80,11 @@ export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }:
|
||||
}}
|
||||
/>
|
||||
{[...text].map((letter, i) => {
|
||||
const abstand = i - state.index;
|
||||
if (abstand < 0 || abstand >= SICHTBAR) return null;
|
||||
const distance = i - state.index;
|
||||
if (distance < 0 || distance >= VISIBLE_COUNT) return null;
|
||||
const finger = fingerOf(letter);
|
||||
const aktuell = abstand === 0;
|
||||
const groesse = aktuell ? 104 : 68;
|
||||
const isCurrent = distance === 0;
|
||||
const size = isCurrent ? 104 : 68;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -102,28 +94,28 @@ export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }:
|
||||
left: `${LANES[i % LANES.length]}%`,
|
||||
// Distance from the cursor is distance from the surface. The transition
|
||||
// is what makes popping one visibly lift the rest.
|
||||
top: `${6 + abstand * 19}%`,
|
||||
top: `${6 + distance * 19}%`,
|
||||
transform: "translate(-50%, 0)",
|
||||
width: groesse,
|
||||
height: groesse,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: groesse * 0.42,
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 900,
|
||||
color: aktuell ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
background: aktuell
|
||||
color: isCurrent ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
background: isCurrent
|
||||
? "var(--paper)"
|
||||
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`,
|
||||
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${aktuell ? 0.9 : 0.35})`,
|
||||
boxShadow: aktuell ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
|
||||
opacity: 1 - abstand * 0.13,
|
||||
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${isCurrent ? 0.9 : 0.35})`,
|
||||
boxShadow: isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
|
||||
opacity: 1 - distance * 0.13,
|
||||
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
|
||||
animation:
|
||||
geplatzt === i
|
||||
popped === i
|
||||
? "correctPop 200ms ease-out"
|
||||
: aktuell && daneben
|
||||
: isCurrent && wrong
|
||||
? "wrongShake 260ms ease"
|
||||
: undefined,
|
||||
}}
|
||||
@@ -134,7 +126,6 @@ export function BlasenRun({ letters, activeKeys, progress, paused, onFinished }:
|
||||
})}
|
||||
</div>
|
||||
|
||||
<HandHint nextKey={next} />
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
@@ -1,17 +1,12 @@
|
||||
/** Tauchgang - the core drill, and the run that counts for the unlock.
|
||||
/** Dive mode - the core drill, and the run that counts for the unlock.
|
||||
*
|
||||
* A line of chunks with a moving cursor, the finger hint above it and the keyboard
|
||||
* below. Everything else in the game is a variation on this; this is the one that is
|
||||
* measured. */
|
||||
|
||||
import { useEffect } from "react";
|
||||
* 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. */
|
||||
|
||||
import { currentChar } from "../../lib/engine";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
import type { Progress } from "../../lib/progress";
|
||||
import { say, spellKey } from "../../lib/speech";
|
||||
import { useRun } from "../../hooks/useRun";
|
||||
import { HandHint } from "../HandHint";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
import { Target } from "../Target";
|
||||
|
||||
@@ -25,7 +20,7 @@ interface Props {
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
export function TauchgangRun({
|
||||
export function DiveRun({
|
||||
chunks,
|
||||
text,
|
||||
spaceActive,
|
||||
@@ -34,7 +29,7 @@ export function TauchgangRun({
|
||||
paused,
|
||||
onFinished,
|
||||
}: Props) {
|
||||
const { state, daneben } = useRun({
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
@@ -43,16 +38,6 @@ export function TauchgangRun({
|
||||
|
||||
const next = currentChar(state);
|
||||
|
||||
// Say the first key of a line, and any key she gets stuck on. Not every key: a voice
|
||||
// talking over every keystroke is noise, and it would lag behind a good streak.
|
||||
useEffect(() => {
|
||||
if (state.index === 0 && next) say(spellKey(next), progress.settings.speech);
|
||||
}, [state.index, next, progress.settings.speech]);
|
||||
|
||||
useEffect(() => {
|
||||
if (daneben && next) say(spellKey(next), progress.settings.speech);
|
||||
}, [daneben, next, progress.settings.speech]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
@@ -67,9 +52,8 @@ export function TauchgangRun({
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<HandHint nextKey={next} />
|
||||
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} daneben={daneben} />
|
||||
<Fortschritt done={state.index} total={text.length} />
|
||||
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} wrong={wrong} />
|
||||
<ProgressBar done={state.index} total={text.length} />
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
@@ -82,7 +66,7 @@ export function TauchgangRun({
|
||||
|
||||
/** How far through the line she is - a bar, not a number, because "18 von 24" is a
|
||||
* reading task and a filling bar is not. */
|
||||
function Fortschritt({ done, total }: { done: number; total: number }) {
|
||||
function ProgressBar({ done, total }: { done: number; total: number }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Fütterungszeit - words instead of letters.
|
||||
/** Feed mode - words instead of letters.
|
||||
*
|
||||
* Fish swim past carrying short German words; typing one feeds it to the Delfin. This
|
||||
* Fish swim past carrying short German words; typing one feeds it to the dolphin. This
|
||||
* is the first mode where the unit is a whole word rather than a key, which is the step
|
||||
* from "I can find the letters" to "I can write something", and it is the reason the
|
||||
* word lists in curriculum.ts are real German words a six-year-old knows rather than
|
||||
* pronounceable nonsense.
|
||||
*
|
||||
* The clock is soft, like Blasenplatzen: the fish queue up and wait. Nothing swims off
|
||||
* The clock is soft, like bubbles mode: the fish queue up and wait. Nothing swims off
|
||||
* unfed. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -16,9 +16,7 @@ import type { RunEvent } from "../../lib/engine";
|
||||
import { chunkOffsets } from "../../lib/generator";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
import type { Progress } from "../../lib/progress";
|
||||
import { say } from "../../lib/speech";
|
||||
import { useRun } from "../../hooks/useRun";
|
||||
import { HandHint } from "../HandHint";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
@@ -31,9 +29,9 @@ interface Props {
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
const FISCHE = ["🐟", "🐠", "🐡", "🦐", "🦀"];
|
||||
const FISH = ["🐟", "🐠", "🐡", "🦐", "🦀"];
|
||||
|
||||
export function FuetternRun({
|
||||
export function FeedRun({
|
||||
chunks,
|
||||
text,
|
||||
spaceActive,
|
||||
@@ -42,15 +40,15 @@ export function FuetternRun({
|
||||
paused,
|
||||
onFinished,
|
||||
}: Props) {
|
||||
const [gefuettert, setGefuettert] = useState(0);
|
||||
const [fed, setFed] = useState(0);
|
||||
|
||||
const { state, daneben } = useRun({
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
onFinished,
|
||||
onEvent: (event: RunEvent) => {
|
||||
if (event.type === "correct") setGefuettert((n) => n);
|
||||
if (event.type === "correct") setFed((n) => n);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,19 +56,14 @@ export function FuetternRun({
|
||||
const offsets = chunkOffsets(chunks, spaceActive);
|
||||
|
||||
/** Which word is being typed. A word counts as current until its last letter is done. */
|
||||
const aktuell = Math.max(
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
offsets.findIndex((start, i) => state.index <= start + (chunks[i]?.length ?? 0)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setGefuettert(aktuell);
|
||||
}, [aktuell]);
|
||||
|
||||
useEffect(() => {
|
||||
const wort = chunks[aktuell];
|
||||
if (wort) say(wort, progress.settings.speech);
|
||||
}, [aktuell, chunks, progress.settings.speech]);
|
||||
setFed(currentIndex);
|
||||
}, [currentIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,7 +79,7 @@ export function FuetternRun({
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0, overflow: "hidden" }}>
|
||||
{/* The Delfin waits on the left; the fish come to it. */}
|
||||
{/* The dolphin waits on the left; the fish come to it. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -100,69 +93,69 @@ export function FuetternRun({
|
||||
🐬
|
||||
</div>
|
||||
|
||||
{chunks.map((wort, i) => {
|
||||
const abstand = i - aktuell;
|
||||
{chunks.map((word, i) => {
|
||||
const distance = i - currentIndex;
|
||||
// Three fish in the water. A fourth at this spacing runs off the right edge,
|
||||
// and a queue you cannot see the end of is not a queue.
|
||||
if (abstand < 0 || abstand > 2) return null;
|
||||
if (distance < 0 || distance > 2) return null;
|
||||
const start = offsets[i] ?? 0;
|
||||
const istZiel = abstand === 0;
|
||||
const isTarget = distance === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
// Distance from the Delfin is distance in the queue. Feeding one lets
|
||||
// Distance from the dolphin is distance in the queue. Feeding one lets
|
||||
// the rest swim in, via the transition rather than a rAF loop.
|
||||
left: `${15 + abstand * 26}%`,
|
||||
left: `${15 + distance * 26}%`,
|
||||
top: `${30 + (i % 3) * 16}%`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: istZiel ? "10px 18px" : "7px 13px",
|
||||
padding: isTarget ? "10px 18px" : "7px 13px",
|
||||
borderRadius: 999,
|
||||
background: istZiel ? "var(--paper)" : "oklch(97% 0.01 175 / .13)",
|
||||
border: `2px solid oklch(97% 0.01 175 / ${istZiel ? 0.85 : 0.2})`,
|
||||
boxShadow: istZiel ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)",
|
||||
background: isTarget ? "var(--paper)" : "oklch(97% 0.01 175 / .13)",
|
||||
border: `2px solid oklch(97% 0.01 175 / ${isTarget ? 0.85 : 0.2})`,
|
||||
boxShadow: isTarget ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)",
|
||||
maxWidth: "34%",
|
||||
opacity: 1 - abstand * 0.22,
|
||||
opacity: 1 - distance * 0.22,
|
||||
transition: "left 420ms cubic-bezier(.2,.7,.3,1), opacity 300ms ease, padding 200ms ease",
|
||||
animation: istZiel && daneben ? "wrongShake 260ms ease" : undefined,
|
||||
zIndex: istZiel ? 3 : 1,
|
||||
animation: isTarget && wrong ? "wrongShake 260ms ease" : undefined,
|
||||
zIndex: isTarget ? 3 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: istZiel ? 34 : 24 }}>{FISCHE[i % FISCHE.length]}</span>
|
||||
<span style={{ fontSize: isTarget ? 34 : 24 }}>{FISH[i % FISH.length]}</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: istZiel ? 34 : 22,
|
||||
fontSize: isTarget ? 34 : 22,
|
||||
fontWeight: 800,
|
||||
color: istZiel ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
}}
|
||||
>
|
||||
{istZiel
|
||||
? [...wort].map((char, j) => {
|
||||
{isTarget
|
||||
? [...word].map((char, j) => {
|
||||
const at = start + j;
|
||||
const zustand = at < state.index ? "fertig" : at === state.index ? "aktuell" : "offen";
|
||||
const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
|
||||
return (
|
||||
<span
|
||||
key={j}
|
||||
style={{
|
||||
color:
|
||||
zustand === "fertig"
|
||||
charState === "done"
|
||||
? "oklch(58% 0.15 150)"
|
||||
: zustand === "aktuell"
|
||||
: charState === "current"
|
||||
? "var(--accent)"
|
||||
: "oklch(45% 0.03 175)",
|
||||
textDecoration: zustand === "aktuell" ? "underline" : undefined,
|
||||
textDecoration: charState === "current" ? "underline" : undefined,
|
||||
}}
|
||||
>
|
||||
{char === " " ? " " : char}
|
||||
{char === " " ? " " : char}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
: wort}
|
||||
: word}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -170,9 +163,8 @@ export function FuetternRun({
|
||||
</div>
|
||||
|
||||
<div style={{ color: "var(--paper)", fontWeight: 800, opacity: 0.8 }}>
|
||||
{gefuettert} von {chunks.length} gefüttert
|
||||
{fed} von {chunks.length} gefüttert
|
||||
</div>
|
||||
<HandHint nextKey={next} />
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
@@ -1,24 +1,22 @@
|
||||
/** Quallenalarm - pure key location, nothing else.
|
||||
/** Jellyfish mode - pure key location, nothing else.
|
||||
*
|
||||
* Six jellyfish drift in the water, each showing a letter. One of them glows: that is
|
||||
* the one to zap. There is no line to read and no word to spell, so the only thing
|
||||
* being exercised is "where does this letter live" - which is exactly the skill the
|
||||
* Tauchgang hides behind reading.
|
||||
* being exercised is "where does this letter live" - which is exactly the skill dive
|
||||
* mode hides behind reading.
|
||||
*
|
||||
* The decoys matter. Showing only the target turns this into the bubble mode; showing
|
||||
* five wrong letters next to it means she has to find *her* letter before she can type
|
||||
* it, which is the searching step that eventually goes away. */
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { currentChar } from "../../lib/engine";
|
||||
import { fingerOf } from "../../lib/fingers";
|
||||
import { mulberry32 } from "../../lib/generator";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
import type { Progress } from "../../lib/progress";
|
||||
import { say, spellKey } from "../../lib/speech";
|
||||
import { useRun } from "../../hooks/useRun";
|
||||
import { HandHint } from "../HandHint";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
@@ -30,18 +28,18 @@ interface Props {
|
||||
}
|
||||
|
||||
/** How many jellyfish are in the water at once, target included. */
|
||||
const QUALLEN = 6;
|
||||
const JELLYFISH_COUNT = 6;
|
||||
|
||||
interface Qualle {
|
||||
interface Jellyfish {
|
||||
left: number;
|
||||
top: number;
|
||||
drift: number;
|
||||
groesse: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
||||
export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
||||
const text = letters.join("");
|
||||
const { state, daneben } = useRun({
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
@@ -52,32 +50,28 @@ export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }
|
||||
|
||||
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
|
||||
// would make the searching step impossible rather than merely hard.
|
||||
const plaetze = useMemo<Qualle[]>(() => {
|
||||
const spots = useMemo<Jellyfish[]>(() => {
|
||||
const rng = mulberry32(text.length * 31 + 7);
|
||||
return Array.from({ length: QUALLEN }, () => ({
|
||||
return Array.from({ length: JELLYFISH_COUNT }, () => ({
|
||||
left: 10 + rng() * 76,
|
||||
top: 6 + rng() * 66,
|
||||
drift: 3 + rng() * 3,
|
||||
groesse: 74 + rng() * 26,
|
||||
size: 74 + rng() * 26,
|
||||
}));
|
||||
}, [text]);
|
||||
|
||||
/** The decoys shown alongside the target: other active keys, never the target itself,
|
||||
* and stable for as long as the target is. */
|
||||
const koeder = useMemo(() => {
|
||||
const decoys = useMemo(() => {
|
||||
if (!next) return [];
|
||||
const rng = mulberry32(state.index * 101 + 13);
|
||||
const andere = activeKeys.filter((key) => key !== next && key !== " ");
|
||||
const gemischt = [...andere].sort(() => rng() - 0.5);
|
||||
return gemischt.slice(0, QUALLEN - 1);
|
||||
const others = activeKeys.filter((key) => key !== next && key !== " ");
|
||||
const shuffled = [...others].sort(() => rng() - 0.5);
|
||||
return shuffled.slice(0, JELLYFISH_COUNT - 1);
|
||||
}, [next, state.index, activeKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (next) say(spellKey(next), progress.settings.speech);
|
||||
}, [next, progress.settings.speech]);
|
||||
|
||||
// Which jellyfish carries the target. Moves around so it is not always the same one.
|
||||
const zielPlatz = next ? state.index % QUALLEN : -1;
|
||||
const targetSlot = next ? state.index % JELLYFISH_COUNT : -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -93,44 +87,44 @@ export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
|
||||
{plaetze.map((platz, i) => {
|
||||
const istZiel = i === zielPlatz;
|
||||
const buchstabe = istZiel ? next : koeder[i > zielPlatz ? i - 1 : i];
|
||||
if (!buchstabe) return null;
|
||||
const finger = fingerOf(buchstabe);
|
||||
{spots.map((spot, i) => {
|
||||
const isTarget = i === targetSlot;
|
||||
const letter = isTarget ? next : decoys[i > targetSlot ? i - 1 : i];
|
||||
if (!letter) return null;
|
||||
const finger = fingerOf(letter);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="qualle"
|
||||
className="jellyfish"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${platz.left}%`,
|
||||
top: `${platz.top}%`,
|
||||
width: platz.groesse,
|
||||
height: platz.groesse,
|
||||
left: `${spot.left}%`,
|
||||
top: `${spot.top}%`,
|
||||
width: spot.size,
|
||||
height: spot.size,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: platz.groesse * (istZiel ? 0.4 : 0.3),
|
||||
fontSize: spot.size * (isTarget ? 0.4 : 0.3),
|
||||
fontWeight: 900,
|
||||
borderRadius: "50% 50% 42% 42%",
|
||||
color: istZiel ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
background: istZiel
|
||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
background: isTarget
|
||||
? "var(--paper)"
|
||||
: `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`,
|
||||
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${istZiel ? 0.9 : 0.3})`,
|
||||
boxShadow: istZiel
|
||||
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${isTarget ? 0.9 : 0.3})`,
|
||||
boxShadow: isTarget
|
||||
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
|
||||
: "0 4px 14px var(--shadow)",
|
||||
opacity: istZiel ? 1 : 0.55,
|
||||
transform: istZiel ? "scale(1.12)" : "scale(1)",
|
||||
animation: `dolphinBob ${platz.drift}s ease-in-out infinite`,
|
||||
opacity: isTarget ? 1 : 0.55,
|
||||
transform: isTarget ? "scale(1.12)" : "scale(1)",
|
||||
animation: `dolphinBob ${spot.drift}s ease-in-out infinite`,
|
||||
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
|
||||
}}
|
||||
>
|
||||
{buchstabe === " " ? "␣" : buchstabe}
|
||||
{istZiel && daneben && (
|
||||
{letter === " " ? "␣" : letter}
|
||||
{isTarget && wrong && (
|
||||
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
|
||||
)}
|
||||
</div>
|
||||
@@ -138,7 +132,6 @@ export function QuallenRun({ letters, activeKeys, progress, paused, onFinished }
|
||||
})}
|
||||
</div>
|
||||
|
||||
<HandHint nextKey={next} />
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Perlentaucher - the mode with no clock.
|
||||
/** 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
|
||||
@@ -10,16 +10,14 @@
|
||||
* offers it, always. The run is still graded the same way underneath - a calm round and
|
||||
* a frantic one land in the same `grade()` - but nothing on screen is urging her on. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { currentChar } from "../../lib/engine";
|
||||
import type { RunEvent } from "../../lib/engine";
|
||||
import { chunkOffsets } from "../../lib/generator";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
import type { Progress } from "../../lib/progress";
|
||||
import { say } from "../../lib/speech";
|
||||
import { useRun } from "../../hooks/useRun";
|
||||
import { HandHint } from "../HandHint";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
@@ -32,7 +30,7 @@ interface Props {
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
export function PerlenRun({
|
||||
export function PearlsRun({
|
||||
chunks,
|
||||
text,
|
||||
spaceActive,
|
||||
@@ -41,16 +39,16 @@ export function PerlenRun({
|
||||
paused,
|
||||
onFinished,
|
||||
}: Props) {
|
||||
const [perlen, setPerlen] = useState(0);
|
||||
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") setPerlen((n) => n + 1);
|
||||
else if (event.type === "wrong" && event.firstAt) setPerlen((n) => Math.max(0, n - 1));
|
||||
if (event.type === "correct") setPearls((n) => n + 1);
|
||||
else if (event.type === "wrong" && event.firstAt) setPearls((n) => Math.max(0, n - 1));
|
||||
};
|
||||
|
||||
const { state, daneben } = useRun({
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
@@ -62,16 +60,12 @@ export function PerlenRun({
|
||||
const offsets = chunkOffsets(chunks, spaceActive);
|
||||
|
||||
// Which word the oyster is holding right now.
|
||||
const aktuellerChunk = offsets.findIndex((start, i) => {
|
||||
const ende = start + (chunks[i]?.length ?? 0);
|
||||
return state.index <= ende;
|
||||
const currentChunkIndex = offsets.findIndex((start, i) => {
|
||||
const end = start + (chunks[i]?.length ?? 0);
|
||||
return state.index <= end;
|
||||
});
|
||||
const wort = chunks[aktuellerChunk === -1 ? chunks.length - 1 : aktuellerChunk] ?? "";
|
||||
const wortStart = offsets[aktuellerChunk === -1 ? chunks.length - 1 : aktuellerChunk] ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (wort) say(wort, progress.settings.speech);
|
||||
}, [wort, progress.settings.speech]);
|
||||
const word = chunks[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? "";
|
||||
const wordStart = offsets[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -98,9 +92,9 @@ export function PerlenRun({
|
||||
minHeight: 26,
|
||||
alignItems: "center",
|
||||
}}
|
||||
aria-label={`${perlen} Perlen`}
|
||||
aria-label={`${pearls} Perlen`}
|
||||
>
|
||||
{Array.from({ length: perlen }, (_, i) => (
|
||||
{Array.from({ length: pearls }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
@@ -109,7 +103,7 @@ export function PerlenRun({
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle at 32% 30%, oklch(99% 0.01 175), oklch(80% 0.04 300))",
|
||||
boxShadow: "0 2px 7px var(--shadow)",
|
||||
animation: i === perlen - 1 ? "correctPop 240ms ease-out" : undefined,
|
||||
animation: i === pearls - 1 ? "correctPop 240ms ease-out" : undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -126,31 +120,29 @@ export function PerlenRun({
|
||||
borderRadius: 26,
|
||||
background: "linear-gradient(160deg, oklch(97% 0.01 175 / .2), oklch(97% 0.01 175 / .07))",
|
||||
border: "1px solid oklch(97% 0.01 175 / .22)",
|
||||
animation: daneben ? "wrongShake 260ms ease" : undefined,
|
||||
animation: wrong ? "wrongShake 260ms ease" : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 34 }}>🦪</div>
|
||||
<div style={{ display: "flex", fontSize: 46, fontWeight: 800 }}>
|
||||
{[...wort].map((char, i) => {
|
||||
const at = wortStart + i;
|
||||
const state_ = at < state.index ? "fertig" : at === state.index ? "aktuell" : "offen";
|
||||
{[...word].map((char, i) => {
|
||||
const at = wordStart + i;
|
||||
const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="ziel-zeichen"
|
||||
data-state={state_}
|
||||
data-leer={char === " "}
|
||||
data-daneben={state_ === "aktuell" && daneben}
|
||||
style={{ padding: state_ === "aktuell" ? "0 6px" : undefined }}
|
||||
className="target-char"
|
||||
data-state={charState}
|
||||
data-blank={char === " "}
|
||||
data-wrong={charState === "current" && wrong}
|
||||
>
|
||||
{char === " " ? (state_ === "aktuell" ? "␣" : "") : char}
|
||||
{char === " " ? (charState === "current" ? "␣" : "") : char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HandHint nextKey={next} />
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Delfinrennen - a race against her own best run.
|
||||
/** Race mode - a race against her own best run.
|
||||
*
|
||||
* The opponent is the ghost of the fastest run she has ever had on this lesson: the
|
||||
* keystroke timings are stored in `progress.lessons[id].ghost` and replayed against the
|
||||
@@ -6,7 +6,7 @@
|
||||
* opponent is by definition exactly as good as she was, so the race is always close, and
|
||||
* winning means she actually improved.
|
||||
*
|
||||
* Before a ghost exists there is a Krabbe swimming at a gentle fixed pace. It is beatable
|
||||
* Before a ghost exists there is a crab swimming at a gentle fixed pace. It is beatable
|
||||
* on the first try on purpose.
|
||||
*
|
||||
* The ghost is moved by writing to the DOM node directly from a requestAnimationFrame
|
||||
@@ -20,7 +20,6 @@ import { currentChar } from "../../lib/engine";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
import type { Progress } from "../../lib/progress";
|
||||
import { useRun } from "../../hooks/useRun";
|
||||
import { HandHint } from "../HandHint";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
import { Target } from "../Target";
|
||||
|
||||
@@ -36,11 +35,11 @@ interface Props {
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
/** The pace of the stand-in opponent, in Zeichen pro Minute. Slow enough that a careful
|
||||
* first-timer beats it - roughly a Schildkröte. */
|
||||
const KRABBEN_TEMPO = 30;
|
||||
/** The pace of the stand-in opponent, in characters per minute. Slow enough that a
|
||||
* careful first-timer beats it - roughly a turtle's pace. */
|
||||
const CRAB_PACE = 30;
|
||||
|
||||
export function RennenRun({
|
||||
export function RaceRun({
|
||||
chunks,
|
||||
text,
|
||||
spaceActive,
|
||||
@@ -50,7 +49,7 @@ export function RennenRun({
|
||||
paused,
|
||||
onFinished,
|
||||
}: Props) {
|
||||
const { state, daneben } = useRun({
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
@@ -58,12 +57,12 @@ export function RennenRun({
|
||||
});
|
||||
|
||||
const next = currentChar(state);
|
||||
const bahn = useRef<HTMLDivElement>(null);
|
||||
const gegner = useRef<HTMLDivElement>(null);
|
||||
const track = useRef<HTMLDivElement>(null);
|
||||
const opponent = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Everything the loop reads lives in a ref, so it is started once and never restarted.
|
||||
const latest = useRef({ ghost, startedAt: state.startedAt, laenge: text.length, fertig: state.finishedAt !== null });
|
||||
latest.current = { ghost, startedAt: state.startedAt, laenge: text.length, fertig: state.finishedAt !== null };
|
||||
const latest = useRef({ ghost, startedAt: state.startedAt, length: text.length, finished: state.finishedAt !== null });
|
||||
latest.current = { ghost, startedAt: state.startedAt, length: text.length, finished: state.finishedAt !== null };
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
@@ -71,7 +70,7 @@ export function RennenRun({
|
||||
const tick = () => {
|
||||
frame = requestAnimationFrame(tick);
|
||||
const current = latest.current;
|
||||
const node = gegner.current;
|
||||
const node = opponent.current;
|
||||
if (!node) return;
|
||||
|
||||
// The race starts on her first keystroke, not when the screen opens - the same
|
||||
@@ -81,27 +80,27 @@ export function RennenRun({
|
||||
return;
|
||||
}
|
||||
|
||||
const vergangen = performance.now() - current.startedAt;
|
||||
let anteil: number;
|
||||
const elapsed = performance.now() - current.startedAt;
|
||||
let fraction: number;
|
||||
|
||||
if (current.ghost && current.ghost.length > 1) {
|
||||
const start = current.ghost[0]!.at;
|
||||
// How many of the ghost's keystrokes have come due by now.
|
||||
let getippt = 0;
|
||||
while (getippt < current.ghost.length && current.ghost[getippt]!.at - start <= vergangen) getippt++;
|
||||
anteil = getippt / current.laenge;
|
||||
let typed = 0;
|
||||
while (typed < current.ghost.length && current.ghost[typed]!.at - start <= elapsed) typed++;
|
||||
fraction = typed / current.length;
|
||||
} else {
|
||||
anteil = vergangen / 60000 / (1 / KRABBEN_TEMPO) / current.laenge;
|
||||
fraction = elapsed / 60000 / (1 / CRAB_PACE) / current.length;
|
||||
}
|
||||
|
||||
node.style.left = `${Math.min(1, Math.max(0, anteil)) * 100}%`;
|
||||
node.style.left = `${Math.min(1, Math.max(0, fraction)) * 100}%`;
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
const meinAnteil = text.length === 0 ? 0 : state.index / text.length;
|
||||
const myFraction = text.length === 0 ? 0 : state.index / text.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -117,12 +116,12 @@ export function RennenRun({
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div ref={bahn} style={{ width: "min(860px, 92%)", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Bahn label="Du" farbe="var(--paper)">
|
||||
<div ref={track} style={{ width: "min(860px, 92%)", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Lane label="Du" color="var(--paper)">
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${meinAnteil * 100}%`,
|
||||
left: `${myFraction * 100}%`,
|
||||
transform: "translateX(-50%)",
|
||||
fontSize: 34,
|
||||
transition: "left 140ms ease-out",
|
||||
@@ -130,17 +129,16 @@ export function RennenRun({
|
||||
>
|
||||
🐬
|
||||
</div>
|
||||
</Bahn>
|
||||
</Lane>
|
||||
|
||||
<Bahn label={ghost ? "Dein Rekord" : "Die Krabbe"} farbe="oklch(97% 0.01 175 / .55)">
|
||||
<div ref={gegner} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}>
|
||||
<Lane label={ghost ? "Dein Rekord" : "Die Krabbe"} color="oklch(97% 0.01 175 / .55)">
|
||||
<div ref={opponent} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}>
|
||||
{ghost ? "👻" : "🦀"}
|
||||
</div>
|
||||
</Bahn>
|
||||
</Lane>
|
||||
</div>
|
||||
|
||||
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} daneben={daneben} fontSize={34} />
|
||||
<HandHint nextKey={next} />
|
||||
<Target chunks={chunks} spaceActive={spaceActive} index={state.index} wrong={wrong} fontSize={34} />
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
@@ -153,10 +151,10 @@ export function RennenRun({
|
||||
}
|
||||
|
||||
/** One lane, with the finish line at the right. */
|
||||
function Bahn({ label, farbe, children }: { label: string; farbe: string; children: React.ReactNode }) {
|
||||
function Lane({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ width: 96, flex: "none", fontSize: 13, fontWeight: 800, color: farbe, textAlign: "right" }}>
|
||||
<div style={{ width: 96, flex: "none", fontSize: 13, fontWeight: 800, color, textAlign: "right" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
@@ -14,7 +14,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { isTypingKey, press, startRun } from "../lib/engine";
|
||||
import type { RunEvent, RunState } from "../lib/engine";
|
||||
import type { RunResult } from "../lib/grading";
|
||||
import { playDaneben, playFertig, playRichtig } from "../lib/pop";
|
||||
import { playWrong, playDone, playCorrect } from "../lib/pop";
|
||||
|
||||
interface Options {
|
||||
/** The text to type. Changing it restarts the run. */
|
||||
@@ -31,13 +31,13 @@ interface Options {
|
||||
export interface RunHandle {
|
||||
state: RunState;
|
||||
/** True while the cursor is sitting on a key that was just missed. */
|
||||
daneben: boolean;
|
||||
wrong: boolean;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
export function useRun({ target, sound, onFinished, onEvent, paused = false }: Options): RunHandle {
|
||||
const [state, setState] = useState<RunState>(() => startRun(target));
|
||||
const [daneben, setDaneben] = useState(false);
|
||||
const [wrong, setWrong] = useState(false);
|
||||
|
||||
// Everything the listener needs, kept current without reinstalling it.
|
||||
const latest = useRef({ state, sound, onFinished, onEvent, paused });
|
||||
@@ -45,7 +45,7 @@ export function useRun({ target, sound, onFinished, onEvent, paused = false }: O
|
||||
|
||||
const restart = useCallback(() => {
|
||||
setState(startRun(target));
|
||||
setDaneben(false);
|
||||
setWrong(false);
|
||||
}, [target]);
|
||||
|
||||
// A new target is a new run - the modes swap the line rather than remounting.
|
||||
@@ -69,13 +69,13 @@ export function useRun({ target, sound, onFinished, onEvent, paused = false }: O
|
||||
setState(next);
|
||||
for (const runEvent of events) {
|
||||
if (runEvent.type === "correct") {
|
||||
setDaneben(false);
|
||||
if (current.sound) playRichtig(runEvent.streak);
|
||||
setWrong(false);
|
||||
if (current.sound) playCorrect(runEvent.streak);
|
||||
} else if (runEvent.type === "wrong") {
|
||||
setDaneben(true);
|
||||
if (current.sound) playDaneben();
|
||||
setWrong(true);
|
||||
if (current.sound) playWrong();
|
||||
} else {
|
||||
if (current.sound) playFertig();
|
||||
if (current.sound) playDone();
|
||||
current.onFinished(runEvent.result);
|
||||
}
|
||||
current.onEvent?.(runEvent);
|
||||
@@ -86,5 +86,5 @@ export function useRun({ target, sound, onFinished, onEvent, paused = false }: O
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
return { state, daneben, restart };
|
||||
return { state, wrong, restart };
|
||||
}
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { KREATUREN, kreaturAus, kreaturById, neuerSchwimmer, pose, schwimme } from "../aquarium";
|
||||
import type { Becken, Schwimmer } from "../aquarium";
|
||||
import { WELTEN } from "../curriculum";
|
||||
import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium";
|
||||
import type { Tank, Swimmer } from "../aquarium";
|
||||
import { WORLDS } from "../curriculum";
|
||||
import { mulberry32 } from "../generator";
|
||||
|
||||
const BECKEN: Becken = { breite: 1280, hoehe: 800 };
|
||||
const RAND = 60;
|
||||
const TEMPO = 55;
|
||||
const TANK: Tank = { width: 1280, height: 800 };
|
||||
const MARGIN = 60;
|
||||
const SPEED = 55;
|
||||
const DT = 1 / 60;
|
||||
|
||||
function swim(s: Schwimmer, sekunden: number, becken = BECKEN, rng = mulberry32(7)): Schwimmer {
|
||||
for (let t = 0; t < sekunden; t += DT) s = schwimme(s, DT, becken, RAND, TEMPO, rng);
|
||||
function swim(s: Swimmer, seconds: number, tank = TANK, rng = mulberry32(7)): Swimmer {
|
||||
for (let t = 0; t < seconds; t += DT) s = stepSwimmer(s, DT, tank, MARGIN, SPEED, rng);
|
||||
return s;
|
||||
}
|
||||
|
||||
describe("Kreaturen", () => {
|
||||
it("gives every pet exactly one Welt", () => {
|
||||
const belohnungen = WELTEN.map((welt) => welt.belohnung);
|
||||
expect([...belohnungen].sort()).toEqual(KREATUREN.map((k) => k.id).sort());
|
||||
describe("Creatures", () => {
|
||||
it("gives every pet exactly one world", () => {
|
||||
const rewards = WORLDS.map((world) => world.reward);
|
||||
expect([...rewards].sort()).toEqual(CREATURES.map((c) => c.id).sort());
|
||||
});
|
||||
|
||||
it("looks pets up by id", () => {
|
||||
for (const kreatur of KREATUREN) expect(kreaturById(kreatur.id)).toBe(kreatur);
|
||||
for (const creature of CREATURES) expect(creatureById(creature.id)).toBe(creature);
|
||||
});
|
||||
|
||||
it("reads today's ids and the emoji old saves stored, and nothing else", () => {
|
||||
expect(kreaturAus("krake")).toBe("krake");
|
||||
expect(kreaturAus("🐠")).toBe("clownfisch");
|
||||
expect(kreaturAus("🧜")).toBe("perlmuschel");
|
||||
expect(kreaturAus("🦈")).toBeNull();
|
||||
expect(kreaturAus(42)).toBeNull();
|
||||
expect(creatureFromRaw("octopus")).toBe("octopus");
|
||||
expect(creatureFromRaw("🐠")).toBe("clownfish");
|
||||
expect(creatureFromRaw("🧜")).toBe("pearlmussel");
|
||||
expect(creatureFromRaw("🦈")).toBeNull();
|
||||
expect(creatureFromRaw(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("schwimme", () => {
|
||||
describe("stepSwimmer", () => {
|
||||
it("never lets a pet's centre leave the tank", () => {
|
||||
const rng = mulberry32(3);
|
||||
let s = neuerSchwimmer(BECKEN, RAND, rng);
|
||||
let s = createSwimmer(TANK, MARGIN, rng);
|
||||
// Ten minutes of swimming, checked every frame.
|
||||
for (let t = 0; t < 600; t += DT) {
|
||||
s = schwimme(s, DT, BECKEN, RAND, TEMPO, rng);
|
||||
s = stepSwimmer(s, DT, TANK, MARGIN, SPEED, rng);
|
||||
expect(s.x).toBeGreaterThanOrEqual(0);
|
||||
expect(s.x).toBeLessThanOrEqual(BECKEN.breite);
|
||||
expect(s.x).toBeLessThanOrEqual(TANK.width);
|
||||
expect(s.y).toBeGreaterThanOrEqual(0);
|
||||
expect(s.y).toBeLessThanOrEqual(BECKEN.hoehe);
|
||||
expect(s.y).toBeLessThanOrEqual(TANK.height);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps moving rather than settling", () => {
|
||||
const rng = mulberry32(11);
|
||||
const start = neuerSchwimmer(BECKEN, RAND, rng);
|
||||
const spaeter = swim(start, 60, BECKEN, rng);
|
||||
expect(Math.hypot(spaeter.x - start.x, spaeter.y - start.y)).toBeGreaterThan(0);
|
||||
expect(spaeter.zielX !== start.zielX || spaeter.zielY !== start.zielY).toBe(true);
|
||||
const start = createSwimmer(TANK, MARGIN, rng);
|
||||
const later = swim(start, 60, TANK, rng);
|
||||
expect(Math.hypot(later.x - start.x, later.y - start.y)).toBeGreaterThan(0);
|
||||
expect(later.targetX !== start.targetX || later.targetY !== start.targetY).toBe(true);
|
||||
});
|
||||
|
||||
it("turns to face the way it swims", () => {
|
||||
const base: Schwimmer = { x: 640, y: 400, vx: 0, vy: 0, zielX: 100, zielY: 400, blick: 1, zeit: 0 };
|
||||
const links = swim(base, 3);
|
||||
expect(links.vx).toBeLessThan(0);
|
||||
expect(links.blick).toBeLessThan(-0.9);
|
||||
const base: Swimmer = { x: 640, y: 400, vx: 0, vy: 0, targetX: 100, targetY: 400, facing: 1, age: 0 };
|
||||
const left = swim(base, 3);
|
||||
expect(left.vx).toBeLessThan(0);
|
||||
expect(left.facing).toBeLessThan(-0.9);
|
||||
|
||||
const rechts = swim({ ...base, zielX: 1180, blick: -1 }, 3);
|
||||
expect(rechts.vx).toBeGreaterThan(0);
|
||||
expect(rechts.blick).toBeGreaterThan(0.9);
|
||||
const right = swim({ ...base, targetX: 1180, facing: -1 }, 3);
|
||||
expect(right.vx).toBeGreaterThan(0);
|
||||
expect(right.facing).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("brings a newly earned pet in from outside the tank", () => {
|
||||
const rng = mulberry32(5);
|
||||
const neu = neuerSchwimmer(BECKEN, RAND, rng, true);
|
||||
expect(neu.x < 0 || neu.x > BECKEN.breite).toBe(true);
|
||||
const drinnen = swim(neu, 40, BECKEN, rng);
|
||||
expect(drinnen.x).toBeGreaterThan(0);
|
||||
expect(drinnen.x).toBeLessThan(BECKEN.breite);
|
||||
const fresh = createSwimmer(TANK, MARGIN, rng, true);
|
||||
expect(fresh.x < 0 || fresh.x > TANK.width).toBe(true);
|
||||
const inside = swim(fresh, 40, TANK, rng);
|
||||
expect(inside.x).toBeGreaterThan(0);
|
||||
expect(inside.x).toBeLessThan(TANK.width);
|
||||
});
|
||||
|
||||
it("finds a new target when the window shrinks under the old one", () => {
|
||||
const s: Schwimmer = { x: 200, y: 200, vx: 0, vy: 0, zielX: 1200, zielY: 700, blick: 1, zeit: 0 };
|
||||
const klein = { breite: 500, hoehe: 400 };
|
||||
const next = schwimme(s, DT, klein, RAND, TEMPO, mulberry32(1));
|
||||
expect(next.zielX).toBeLessThanOrEqual(klein.breite - RAND);
|
||||
expect(next.zielY).toBeLessThanOrEqual(klein.hoehe - RAND);
|
||||
const s: Swimmer = { x: 200, y: 200, vx: 0, vy: 0, targetX: 1200, targetY: 700, facing: 1, age: 0 };
|
||||
const small = { width: 500, height: 400 };
|
||||
const next = stepSwimmer(s, DT, small, MARGIN, SPEED, mulberry32(1));
|
||||
expect(next.targetX).toBeLessThanOrEqual(small.width - MARGIN);
|
||||
expect(next.targetY).toBeLessThanOrEqual(small.height - MARGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pose", () => {
|
||||
const s: Schwimmer = { x: 10, y: 20, vx: -30, vy: 0, zielX: 0, zielY: 0, blick: -1, zeit: 0 };
|
||||
const s: Swimmer = { x: 10, y: 20, vx: -30, vy: 0, targetX: 0, targetY: 0, facing: -1, age: 0 };
|
||||
|
||||
it("mirrors side-view pets to look where they swim", () => {
|
||||
expect(pose(s, kreaturById("clownfisch"), TEMPO).spiegel).toBe(-1);
|
||||
expect(pose(s, creatureById("clownfish"), SPEED).mirror).toBe(-1);
|
||||
});
|
||||
|
||||
it("never mirrors a front-view pet", () => {
|
||||
expect(pose(s, kreaturById("krake"), TEMPO).spiegel).toBe(1);
|
||||
expect(pose(s, kreaturById("perlmuschel"), TEMPO).spiegel).toBe(1);
|
||||
expect(pose(s, creatureById("octopus"), SPEED).mirror).toBe(1);
|
||||
expect(pose(s, creatureById("pearlmussel"), SPEED).mirror).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FIRST_LESSON_ID, LESSONS, WELTEN, lessonById, lessonsOfWelt, nextLesson } from "../curriculum";
|
||||
import { FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
|
||||
import { fingerOf, keyForChar, needsShift } from "../fingers";
|
||||
|
||||
describe("LESSONS", () => {
|
||||
it("has unique ids and consecutive numbers", () => {
|
||||
const ids = LESSONS.map((lesson) => lesson.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
LESSONS.forEach((lesson, i) => expect(lesson.nummer).toBe(i + 1));
|
||||
LESSONS.forEach((lesson, i) => expect(lesson.number).toBe(i + 1));
|
||||
});
|
||||
|
||||
it("starts on the two keys with the tactile bumps", () => {
|
||||
expect(LESSONS[0]!.neueKeys).toEqual(["f", "j"]);
|
||||
expect(LESSONS[0]!.newKeys).toEqual(["f", "j"]);
|
||||
expect(FIRST_LESSON_ID).toBe(LESSONS[0]!.id);
|
||||
});
|
||||
|
||||
@@ -19,50 +19,50 @@ describe("LESSONS", () => {
|
||||
// The first draft opened with all four left-hand keys at once, which is a steep
|
||||
// first five minutes for a six-year-old. Two at a time, always.
|
||||
for (const lesson of LESSONS) {
|
||||
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
|
||||
expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("teaches Welt 1 as mirrored finger pairs, one finger per hand", () => {
|
||||
const paare = lessonsOfWelt(1).filter((lesson) => lesson.neueKeys.length === 2);
|
||||
expect(paare.length).toBe(4);
|
||||
for (const lesson of paare) {
|
||||
const [links, rechts] = lesson.neueKeys.map((key) => fingerOf(key)!);
|
||||
expect(links!.hand).toBe("links");
|
||||
expect(rechts!.hand).toBe("rechts");
|
||||
it("teaches world 1 as mirrored finger pairs, one finger per hand", () => {
|
||||
const pairs = lessonsOfWorld(1).filter((lesson) => lesson.newKeys.length === 2);
|
||||
expect(pairs.length).toBe(4);
|
||||
for (const lesson of pairs) {
|
||||
const [left, right] = lesson.newKeys.map((key) => fingerOf(key)!);
|
||||
expect(left!.hand).toBe("left");
|
||||
expect(right!.hand).toBe("right");
|
||||
// Same finger on each hand - "die Zeigefinger", "die Mittelfinger", …
|
||||
expect(links!.id.replace("links-", "")).toBe(rechts!.id.replace("rechts-", ""));
|
||||
expect(left!.id.replace("left-", "")).toBe(right!.id.replace("right-", ""));
|
||||
}
|
||||
});
|
||||
|
||||
it("has the whole Grundstellung active by the end of Welt 1", () => {
|
||||
const letzte = lessonsOfWelt(1).at(-1)!;
|
||||
for (const key of "asdfjklö") expect(letzte.activeKeys).toContain(key);
|
||||
expect(letzte.activeKeys).toContain(" ");
|
||||
it("has the whole home row active by the end of world 1", () => {
|
||||
const last = lessonsOfWorld(1).at(-1)!;
|
||||
for (const key of "asdfjklö") expect(last.activeKeys).toContain(key);
|
||||
expect(last.activeKeys).toContain(" ");
|
||||
});
|
||||
|
||||
it("makes every round a real block of practice, not a handful of keys", () => {
|
||||
// The first version ran 12 characters in Welt 1 - long enough to finish, too short
|
||||
// The first version ran 12 characters in world 1 - long enough to finish, too short
|
||||
// for a rhythm to form or for the speed score to mean anything.
|
||||
for (const lesson of LESSONS) {
|
||||
if (lesson.art === "saetze") {
|
||||
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(10);
|
||||
} else if (lesson.art === "woerter") {
|
||||
expect(lesson.chunks, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(25);
|
||||
if (lesson.kind === "sentences") {
|
||||
expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(10);
|
||||
} else if (lesson.kind === "words") {
|
||||
expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(25);
|
||||
} else {
|
||||
expect(lesson.chunks * lesson.chunkSize, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(60);
|
||||
expect(lesson.chunks * lesson.chunkSize, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(60);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("still starts gentler than it ends", () => {
|
||||
const laenge = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
|
||||
expect(laenge(lessonsOfWelt(3)[0]!)).toBeGreaterThan(laenge(lessonsOfWelt(1)[0]!));
|
||||
const length = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
|
||||
expect(length(lessonsOfWorld(3)[0]!)).toBeGreaterThan(length(lessonsOfWorld(1)[0]!));
|
||||
});
|
||||
|
||||
it("has more sentences to draw from than a round uses, so a round never has to repeat", () => {
|
||||
for (const lesson of LESSONS.filter((l) => l.art === "saetze")) {
|
||||
expect(lesson.woerter.length, `${lesson.nummer}: ${lesson.titel}`).toBeGreaterThanOrEqual(lesson.chunks);
|
||||
for (const lesson of LESSONS.filter((l) => l.kind === "sentences")) {
|
||||
expect(lesson.words.length, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(lesson.chunks);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("LESSONS", () => {
|
||||
|
||||
it("introduces every new key into its own activeKeys", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
for (const key of lesson.neueKeys) {
|
||||
for (const key of lesson.newKeys) {
|
||||
if (key === "⇧") continue; // Shift is not a character the generator can emit.
|
||||
expect(lesson.activeKeys).toContain(key);
|
||||
}
|
||||
@@ -99,27 +99,27 @@ describe("LESSONS", () => {
|
||||
// so a lesson can type them as soon as it has the ß or a key.
|
||||
for (const lesson of LESSONS) {
|
||||
const active = new Set(lesson.activeKeys);
|
||||
for (const wort of lesson.woerter) {
|
||||
for (const char of wort) {
|
||||
for (const word of lesson.words) {
|
||||
for (const char of word) {
|
||||
expect(
|
||||
active.has(keyForChar(char)),
|
||||
`Lektion ${lesson.nummer} (${lesson.titel}): "${wort}" braucht "${char}" (Taste ${keyForChar(char)})`,
|
||||
`Lektion ${lesson.number} (${lesson.title}): "${word}" braucht "${char}" (Taste ${keyForChar(char)})`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("only uses shifted characters once the Umschalttaste is taught", () => {
|
||||
const shiftAb = LESSONS.find((lesson) => lesson.neueKeys.includes("⇧"))!.nummer;
|
||||
it("only uses shifted characters once shift is taught", () => {
|
||||
const shiftFrom = LESSONS.find((lesson) => lesson.newKeys.includes("⇧"))!.number;
|
||||
for (const lesson of LESSONS) {
|
||||
for (const wort of lesson.woerter) {
|
||||
for (const char of wort) {
|
||||
for (const word of lesson.words) {
|
||||
for (const char of word) {
|
||||
if (!needsShift(char)) continue;
|
||||
expect(
|
||||
lesson.nummer,
|
||||
`Lektion ${lesson.nummer}: "${wort}" braucht Umschalt für "${char}"`,
|
||||
).toBeGreaterThanOrEqual(shiftAb);
|
||||
lesson.number,
|
||||
`Lektion ${lesson.number}: "${word}" braucht Umschalt für "${char}"`,
|
||||
).toBeGreaterThanOrEqual(shiftFrom);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,59 +127,59 @@ describe("LESSONS", () => {
|
||||
|
||||
it("only offers word modes where words exist", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
const wortModi = lesson.modi.some((modus) => modus === "fuettern" || modus === "rennen");
|
||||
expect(wortModi).toBe(lesson.woerter.length > 0);
|
||||
const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race");
|
||||
expect(wordModes).toBe(lesson.words.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("always offers the pressure-free Perlentaucher", () => {
|
||||
for (const lesson of LESSONS) expect(lesson.modi).toContain("perlen");
|
||||
it("always offers the pressure-free pearl-diving mode", () => {
|
||||
for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls");
|
||||
});
|
||||
|
||||
it("teaches capitals only once the Umschalttaste exists", () => {
|
||||
it("teaches capitals only once shift exists", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
const hatGross = lesson.woerter.some((wort) => wort !== wort.toLowerCase());
|
||||
if (hatGross) expect(lesson.welt).toBeGreaterThanOrEqual(4);
|
||||
const hasUppercase = lesson.words.some((word) => word !== word.toLowerCase());
|
||||
if (hasUppercase) expect(lesson.world).toBeGreaterThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every new key a lesson of its own after Welt 1", () => {
|
||||
// One key at a time is the pacing decision: Welt 1 pairs a finger across both
|
||||
it("gives every new key a lesson of its own after world 1", () => {
|
||||
// One key at a time is the pacing decision: world 1 pairs a finger across both
|
||||
// hands, everything after introduces exactly one key or none.
|
||||
for (const lesson of LESSONS) {
|
||||
if (lesson.welt === 1) continue;
|
||||
expect(lesson.neueKeys.length, `${lesson.nummer}: ${lesson.titel}`).toBeLessThanOrEqual(2);
|
||||
if (lesson.world === 1) continue;
|
||||
expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("follows every pair of new keys with an Übung", () => {
|
||||
const uebungen = LESSONS.filter((lesson) => lesson.istUebung);
|
||||
expect(uebungen.length).toBeGreaterThanOrEqual(12);
|
||||
// An Übung never introduces anything, and always has something to practise.
|
||||
for (const lesson of uebungen) {
|
||||
expect(lesson.neueKeys).toEqual([]);
|
||||
it("follows every pair of new keys with a drill", () => {
|
||||
const drills = LESSONS.filter((lesson) => lesson.isDrill);
|
||||
expect(drills.length).toBeGreaterThanOrEqual(12);
|
||||
// A drill never introduces anything, and always has something to practise.
|
||||
for (const lesson of drills) {
|
||||
expect(lesson.newKeys).toEqual([]);
|
||||
expect(lesson.activeKeys.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is long enough to be a real course", () => {
|
||||
expect(LESSONS.length).toBeGreaterThanOrEqual(40);
|
||||
expect(WELTEN.length).toBe(5);
|
||||
expect(WORLDS.length).toBe(5);
|
||||
});
|
||||
|
||||
it("never leaves a Welt without a run of at least three lessons", () => {
|
||||
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThanOrEqual(3);
|
||||
it("never leaves a world without a run of at least three lessons", () => {
|
||||
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Welten", () => {
|
||||
it("has lessons in every Welt", () => {
|
||||
for (const welt of WELTEN) expect(lessonsOfWelt(welt.nummer).length).toBeGreaterThan(0);
|
||||
describe("Worlds", () => {
|
||||
it("has lessons in every world", () => {
|
||||
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gives every Welt its own aquarium creature", () => {
|
||||
const belohnungen = WELTEN.map((welt) => welt.belohnung);
|
||||
expect(new Set(belohnungen).size).toBe(belohnungen.length);
|
||||
it("gives every world its own aquarium creature", () => {
|
||||
const rewards = WORLDS.map((world) => world.reward);
|
||||
expect(new Set(rewards).size).toBe(rewards.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ describe("navigation", () => {
|
||||
});
|
||||
|
||||
it("looks lessons up by id", () => {
|
||||
expect(lessonById(FIRST_LESSON_ID)?.nummer).toBe(1);
|
||||
expect(lessonById(FIRST_LESSON_ID)?.number).toBe(1);
|
||||
expect(lessonById("gibt-es-nicht")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("press", () => {
|
||||
expect(done.finishedAt).toBe(1000);
|
||||
const finished = events.find((event) => event.type === "finished");
|
||||
expect(finished).toBeDefined();
|
||||
expect(finished?.type === "finished" && finished.result.zeichen).toBe(2);
|
||||
expect(finished?.type === "finished" && finished.result.characters).toBe(2);
|
||||
});
|
||||
|
||||
it("does nothing once the run is over", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FINGERS,
|
||||
GRUNDSTELLUNG,
|
||||
HOME_ROW,
|
||||
KEYBOARD_ROWS,
|
||||
fingerOf,
|
||||
handOf,
|
||||
@@ -23,9 +23,9 @@ describe("fingerOf", () => {
|
||||
for (const key of ALPHABET) {
|
||||
// "ß".toUpperCase() is "SS" - two characters, and not a key. `event.key` never
|
||||
// reports that, so the single-character case is the one that has to hold.
|
||||
const gross = key.toUpperCase();
|
||||
if ([...gross].length !== 1) continue;
|
||||
expect(fingerOf(gross)?.id, gross).toBe(fingerOf(key)?.id);
|
||||
const upper = key.toUpperCase();
|
||||
if ([...upper].length !== 1) continue;
|
||||
expect(fingerOf(upper)?.id, upper).toBe(fingerOf(key)?.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,44 +33,44 @@ describe("fingerOf", () => {
|
||||
for (const key of ["Enter", "F1", "€", ""]) expect(fingerOf(key)).toBeNull();
|
||||
});
|
||||
|
||||
it("assigns the Grundstellung to the eight home fingers, left to right", () => {
|
||||
it("assigns the home row to the eight home fingers, left to right", () => {
|
||||
const expected = [
|
||||
"links-klein",
|
||||
"links-ring",
|
||||
"links-mitte",
|
||||
"links-zeige",
|
||||
"rechts-zeige",
|
||||
"rechts-mitte",
|
||||
"rechts-ring",
|
||||
"rechts-klein",
|
||||
"left-pinky",
|
||||
"left-ring",
|
||||
"left-middle",
|
||||
"left-index",
|
||||
"right-index",
|
||||
"right-middle",
|
||||
"right-ring",
|
||||
"right-pinky",
|
||||
];
|
||||
GRUNDSTELLUNG.forEach((key, i) => expect(fingerOf(key)?.id).toBe(expected[i]));
|
||||
HOME_ROW.forEach((key, i) => expect(fingerOf(key)?.id).toBe(expected[i]));
|
||||
});
|
||||
|
||||
it("gives each finger the home key it actually rests on", () => {
|
||||
for (const key of GRUNDSTELLUNG) expect(homeKeyOf(key)).toBe(key);
|
||||
for (const key of HOME_ROW) expect(homeKeyOf(key)).toBe(key);
|
||||
expect(homeKeyOf(" ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("sends the two index fingers to their stretch keys", () => {
|
||||
for (const key of "rtfgvb") expect(fingerOf(key)?.id).toBe("links-zeige");
|
||||
for (const key of "zuhjnm") expect(fingerOf(key)?.id).toBe("rechts-zeige");
|
||||
for (const key of "rtfgvb") expect(fingerOf(key)?.id).toBe("left-index");
|
||||
for (const key of "zuhjnm") expect(fingerOf(key)?.id).toBe("right-index");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hands", () => {
|
||||
it("splits the letters into two disjoint, non-empty sets", () => {
|
||||
const links = [...ALPHABET].filter((key) => handOf(key) === "links");
|
||||
const rechts = [...ALPHABET].filter((key) => handOf(key) === "rechts");
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
expect(rechts.length).toBeGreaterThan(0);
|
||||
expect(links.length + rechts.length).toBe(ALPHABET.length);
|
||||
expect(links.some((key) => rechts.includes(key))).toBe(false);
|
||||
const left = [...ALPHABET].filter((key) => handOf(key) === "left");
|
||||
const right = [...ALPHABET].filter((key) => handOf(key) === "right");
|
||||
expect(left.length).toBeGreaterThan(0);
|
||||
expect(right.length).toBeGreaterThan(0);
|
||||
expect(left.length + right.length).toBe(ALPHABET.length);
|
||||
expect(left.some((key) => right.includes(key))).toBe(false);
|
||||
});
|
||||
|
||||
it("shifts with the opposite hand", () => {
|
||||
expect(shiftHandFor("a")).toBe("rechts");
|
||||
expect(shiftHandFor("l")).toBe("links");
|
||||
expect(shiftHandFor("a")).toBe("right");
|
||||
expect(shiftHandFor("l")).toBe("left");
|
||||
expect(shiftHandFor("Enter")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -80,8 +80,8 @@ describe("rows", () => {
|
||||
for (const key of ALPHABET) expect(rowOf(key), key).not.toBeNull();
|
||||
});
|
||||
|
||||
it("has the Grundstellung in the Grundreihe", () => {
|
||||
for (const key of GRUNDSTELLUNG) expect(rowOf(key)).toBe("grund");
|
||||
it("has the home row in the home row", () => {
|
||||
for (const key of HOME_ROW) expect(rowOf(key)).toBe("home");
|
||||
});
|
||||
|
||||
it("draws three rows, each with keys", () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("mulberry32", () => {
|
||||
describe("drillChunks", () => {
|
||||
it("only ever emits active keys", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
const chunks = drillChunks(lesson.activeKeys, mulberry32(lesson.nummer), { chunks: 20 });
|
||||
const chunks = drillChunks(lesson.activeKeys, mulberry32(lesson.number), { chunks: 20 });
|
||||
const active = new Set(lesson.activeKeys);
|
||||
for (const char of chunks.join("")) expect(active.has(char)).toBe(true);
|
||||
}
|
||||
@@ -45,22 +45,22 @@ describe("drillChunks", () => {
|
||||
});
|
||||
|
||||
it("over-represents the focus key once there are enough keys to spare", () => {
|
||||
const viele = [..."asdfjklöei"];
|
||||
const plain = drillChunks(viele, mulberry32(42), { chunks: 200 }).join("");
|
||||
const focused = drillChunks(viele, mulberry32(42), { chunks: 200, focusKey: "e" }).join("");
|
||||
const many = [..."asdfjklöei"];
|
||||
const plain = drillChunks(many, mulberry32(42), { chunks: 200 }).join("");
|
||||
const focused = drillChunks(many, mulberry32(42), { chunks: 200, focusKey: "e" }).join("");
|
||||
const count = (text: string) => [...text].filter((char) => char === "e").length;
|
||||
expect(count(focused)).toBeGreaterThan(count(plain));
|
||||
});
|
||||
|
||||
it("never lets one key take over a line", () => {
|
||||
// The failure this guards against: on a fresh profile every key looks equally
|
||||
// unpractised, and an unchecked focus weight drilled `a` for half of Lektion 1
|
||||
// unpractised, and an unchecked focus weight drilled `a` for half of lesson 1
|
||||
// while three other fingers went untrained.
|
||||
for (const set of [[..."asdf"], [..."asdfjklö"], [..."asdfjklöei"]]) {
|
||||
for (const focusKey of set) {
|
||||
const text = drillChunks(set, mulberry32(3), { chunks: 200, focusKey }).join("");
|
||||
const anteil = [...text].filter((char) => char === focusKey).length / text.length;
|
||||
expect(anteil, `${focusKey} in ${set.join("")}`).toBeLessThanOrEqual(0.35);
|
||||
const share = [...text].filter((char) => char === focusKey).length / text.length;
|
||||
expect(share, `${focusKey} in ${set.join("")}`).toBeLessThanOrEqual(0.35);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -68,8 +68,8 @@ describe("drillChunks", () => {
|
||||
it("spreads a small key set evenly - every finger gets a turn", () => {
|
||||
const text = drillChunks(keys, mulberry32(11), { chunks: 200, focusKey: "a" }).join("");
|
||||
for (const key of keys) {
|
||||
const anteil = [...text].filter((char) => char === key).length / text.length;
|
||||
expect(anteil, key).toBeGreaterThan(0.15);
|
||||
const share = [...text].filter((char) => char === key).length / text.length;
|
||||
expect(share, key).toBeGreaterThan(0.15);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -80,8 +80,8 @@ describe("drillChunks", () => {
|
||||
for (let seed = 0; seed < 60; seed++) {
|
||||
const text = drillChunks(keys, mulberry32(seed), { chunks: 6, chunkSize: 4 }).join("");
|
||||
for (const key of keys) {
|
||||
const wie_oft = [...text].filter((char) => char === key).length;
|
||||
expect(wie_oft, `"${key}" in "${text}" (seed ${seed})`).toBeGreaterThanOrEqual(4);
|
||||
const count = [...text].filter((char) => char === key).length;
|
||||
expect(count, `"${key}" in "${text}" (seed ${seed})`).toBeGreaterThanOrEqual(4);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -114,9 +114,9 @@ describe("wordChunks", () => {
|
||||
});
|
||||
|
||||
it("draws only from the given list", () => {
|
||||
const woerter = ["die", "ei", "elf"];
|
||||
const chunks = wordChunks(woerter, mulberry32(2), { chunks: 10 })!;
|
||||
for (const chunk of chunks) expect(woerter).toContain(chunk);
|
||||
const words = ["die", "ei", "elf"];
|
||||
const chunks = wordChunks(words, mulberry32(2), { chunks: 10 })!;
|
||||
for (const chunk of chunks) expect(words).toContain(chunk);
|
||||
});
|
||||
|
||||
it("survives a single-word list", () => {
|
||||
@@ -125,18 +125,18 @@ describe("wordChunks", () => {
|
||||
|
||||
it("uses every word once before repeating any, across a long round", () => {
|
||||
// A real ten-sentence round from a four-sentence list showed one sentence four times.
|
||||
const woerter = ["a1", "b2", "c3", "d4", "e5", "f6", "g7", "h8"];
|
||||
const words = ["a1", "b2", "c3", "d4", "e5", "f6", "g7", "h8"];
|
||||
for (let seed = 0; seed < 40; seed++) {
|
||||
const runde = wordChunks(woerter, mulberry32(seed), { chunks: 8 })!;
|
||||
expect(new Set(runde).size, `seed ${seed}: ${runde.join(" ")}`).toBe(8);
|
||||
const round = wordChunks(words, mulberry32(seed), { chunks: 8 })!;
|
||||
expect(new Set(round).size, `seed ${seed}: ${round.join(" ")}`).toBe(8);
|
||||
}
|
||||
});
|
||||
|
||||
it("never puts the same word twice in a row, even across a bag refill", () => {
|
||||
for (let seed = 0; seed < 60; seed++) {
|
||||
const runde = wordChunks(["eins", "zwei", "drei"], mulberry32(seed), { chunks: 30 })!;
|
||||
for (let i = 1; i < runde.length; i++) {
|
||||
expect(runde[i], `seed ${seed} bei ${i}: ${runde.join(" ")}`).not.toBe(runde[i - 1]);
|
||||
const round = wordChunks(["eins", "zwei", "drei"], mulberry32(seed), { chunks: 30 })!;
|
||||
for (let i = 1; i < round.length; i++) {
|
||||
expect(round[i], `seed ${seed} at ${i}: ${round.join(" ")}`).not.toBe(round[i - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -144,12 +144,12 @@ describe("wordChunks", () => {
|
||||
|
||||
describe("lineFor", () => {
|
||||
it("prefers real words once a lesson has them", () => {
|
||||
const lesson = LESSONS.find((l) => l.woerter.length > 0)!;
|
||||
const lesson = LESSONS.find((l) => l.words.length > 0)!;
|
||||
const chunks = lineFor(lesson, mulberry32(1));
|
||||
for (const chunk of chunks) expect(lesson.woerter).toContain(chunk);
|
||||
for (const chunk of chunks) expect(lesson.words).toContain(chunk);
|
||||
});
|
||||
|
||||
it("falls back to letters for the Grundstellung lessons", () => {
|
||||
it("falls back to letters for the home-row lessons", () => {
|
||||
const lesson = LESSONS[0]!;
|
||||
const chunks = lineFor(lesson, mulberry32(1));
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
@@ -159,12 +159,12 @@ describe("lineFor", () => {
|
||||
it("can be asked for letters even in a word lesson", () => {
|
||||
const lesson = LESSONS.at(-1)!;
|
||||
const chunks = lineFor(lesson, mulberry32(1), { preferWords: false, chunks: 4 });
|
||||
for (const chunk of chunks) expect(lesson.woerter).not.toContain(chunk);
|
||||
for (const chunk of chunks) expect(lesson.words).not.toContain(chunk);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineText and chunkOffsets", () => {
|
||||
it("joins with spaces only once the Leertaste is taught", () => {
|
||||
it("joins with spaces only once the space bar is taught", () => {
|
||||
expect(lineText(["as", "df"], true)).toBe("as df");
|
||||
expect(lineText(["as", "df"], false)).toBe("asdf");
|
||||
});
|
||||
|
||||
@@ -3,24 +3,24 @@ import { describe, expect, it } from "vitest";
|
||||
import { press, startRun } from "../engine";
|
||||
import type { RunState } from "../engine";
|
||||
import {
|
||||
TIERS,
|
||||
UEBERRASCHUNG_AB,
|
||||
ANIMALS,
|
||||
SURPRISE_FROM,
|
||||
grade,
|
||||
isBetter,
|
||||
isPassed,
|
||||
sichtbareTiers,
|
||||
sterneFor,
|
||||
tierFor,
|
||||
tierIndex,
|
||||
tierProgress,
|
||||
visibleAnimals,
|
||||
starsFor,
|
||||
animalFor,
|
||||
animalIndex,
|
||||
animalProgress,
|
||||
} from "../grading";
|
||||
|
||||
/** A run of `target` where `wrong` positions get one wrong key first, paced so the whole
|
||||
* run takes exactly `dauerMs`. */
|
||||
function run(target: string, wrongAt: number[] = [], dauerMs = 60000): RunState {
|
||||
* run takes exactly `durationMs`. */
|
||||
function run(target: string, wrongAt: number[] = [], durationMs = 60000): RunState {
|
||||
let state = startRun(target);
|
||||
const steps = target.length + wrongAt.length;
|
||||
const tick = dauerMs / Math.max(1, steps - 1);
|
||||
const tick = durationMs / Math.max(1, steps - 1);
|
||||
let t = 0;
|
||||
for (let i = 0; i < target.length; i++) {
|
||||
if (wrongAt.includes(i)) {
|
||||
@@ -34,53 +34,53 @@ function run(target: string, wrongAt: number[] = [], dauerMs = 60000): RunState
|
||||
}
|
||||
|
||||
describe("grade", () => {
|
||||
it("measures Zeichen pro Minute over exactly one minute", () => {
|
||||
it("measures characters per minute over exactly one minute", () => {
|
||||
const result = grade(run("a".repeat(60)));
|
||||
expect(result.zeichen).toBe(60);
|
||||
expect(result.tempo).toBeCloseTo(60, 0);
|
||||
expect(result.genauigkeit).toBe(1);
|
||||
expect(result.characters).toBe(60);
|
||||
expect(result.speed).toBeCloseTo(60, 0);
|
||||
expect(result.accuracy).toBe(1);
|
||||
});
|
||||
|
||||
it("weights accuracy cubically", () => {
|
||||
// 90 correct, 10 wrong -> 90% accuracy, so 0.9^3 = 0.729 of the raw speed survives.
|
||||
const result = grade(run("a".repeat(90), Array.from({ length: 10 }, (_, i) => i)));
|
||||
expect(result.genauigkeit).toBeCloseTo(0.9, 2);
|
||||
expect(result.punkte / result.tempo).toBeCloseTo(0.729, 3);
|
||||
expect(result.accuracy).toBeCloseTo(0.9, 2);
|
||||
expect(result.points / result.speed).toBeCloseTo(0.729, 3);
|
||||
});
|
||||
|
||||
it("never produces a negative score, however bad the run", () => {
|
||||
const result = grade(run("asdf", [0, 1, 2, 3]));
|
||||
expect(result.punkte).toBeGreaterThanOrEqual(0);
|
||||
expect(result.points).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("does not divide by zero on an instant run", () => {
|
||||
let state = startRun("a");
|
||||
[state] = press(state, "a", 1000);
|
||||
const result = grade(state);
|
||||
expect(Number.isFinite(result.tempo)).toBe(true);
|
||||
expect(result.tempo).toBeLessThanOrEqual(60);
|
||||
expect(Number.isFinite(result.speed)).toBe(true);
|
||||
expect(result.speed).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("counts a hammered wrong key once", () => {
|
||||
let state = startRun("as");
|
||||
for (const key of ["x", "x", "x", "a", "s"]) [state] = press(state, key, 0);
|
||||
expect(grade(state).fehler).toBe(1);
|
||||
expect(grade(state).errors).toBe(1);
|
||||
});
|
||||
|
||||
it("awards Perlen even for a bad run", () => {
|
||||
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).perlen).toBeGreaterThan(0);
|
||||
it("awards pearls even for a bad run", () => {
|
||||
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sterne", () => {
|
||||
describe("stars", () => {
|
||||
it("uses the documented accuracy thresholds", () => {
|
||||
expect(sterneFor(1)).toBe(3);
|
||||
expect(sterneFor(0.97)).toBe(3);
|
||||
expect(sterneFor(0.969)).toBe(2);
|
||||
expect(sterneFor(0.93)).toBe(2);
|
||||
expect(sterneFor(0.929)).toBe(1);
|
||||
expect(sterneFor(0.85)).toBe(1);
|
||||
expect(sterneFor(0.849)).toBe(0);
|
||||
expect(starsFor(1)).toBe(3);
|
||||
expect(starsFor(0.97)).toBe(3);
|
||||
expect(starsFor(0.969)).toBe(2);
|
||||
expect(starsFor(0.93)).toBe(2);
|
||||
expect(starsFor(0.929)).toBe(1);
|
||||
expect(starsFor(0.85)).toBe(1);
|
||||
expect(starsFor(0.849)).toBe(0);
|
||||
});
|
||||
|
||||
it("gates the unlock at two stars and ignores speed entirely", () => {
|
||||
@@ -89,96 +89,96 @@ describe("sterne", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tierFor", () => {
|
||||
describe("animalFor", () => {
|
||||
it("returns the right animal at every boundary", () => {
|
||||
for (const tier of TIERS) {
|
||||
expect(tierFor(tier.ab).id).toBe(tier.id);
|
||||
for (const animal of ANIMALS) {
|
||||
expect(animalFor(animal.from).id).toBe(animal.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps below the slowest and above the fastest", () => {
|
||||
expect(tierFor(0).id).toBe("schnecke");
|
||||
expect(tierFor(-5).id).toBe("schnecke");
|
||||
expect(tierFor(9999).id).toBe("schwertwal");
|
||||
expect(animalFor(0).id).toBe("snail");
|
||||
expect(animalFor(-5).id).toBe("snail");
|
||||
expect(animalFor(9999).id).toBe("orca");
|
||||
});
|
||||
|
||||
it("is monotone - more Punkte never means a slower animal", () => {
|
||||
it("is monotone - more points never means a slower animal", () => {
|
||||
let seen = 0;
|
||||
for (let punkte = 0; punkte < 200; punkte += 1) {
|
||||
const index = TIERS.findIndex((tier) => tier.id === tierFor(punkte).id);
|
||||
for (let points = 0; points < 200; points += 1) {
|
||||
const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id);
|
||||
expect(index).toBeGreaterThanOrEqual(seen);
|
||||
seen = index;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports progress toward the next animal", () => {
|
||||
expect(tierProgress(15)).toBeCloseTo(0, 5);
|
||||
expect(tierProgress(20)).toBeCloseTo(0.5, 5);
|
||||
expect(tierProgress(9999)).toBe(1);
|
||||
expect(animalProgress(15)).toBeCloseTo(0, 5);
|
||||
expect(animalProgress(20)).toBeCloseTo(0.5, 5);
|
||||
expect(animalProgress(9999)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TIERS", () => {
|
||||
it("gives every tier a distinct name and emoji", () => {
|
||||
expect(new Set(TIERS.map((tier) => tier.emoji)).size).toBe(TIERS.length);
|
||||
expect(new Set(TIERS.map((tier) => tier.name)).size).toBe(TIERS.length);
|
||||
expect(new Set(TIERS.map((tier) => tier.id)).size).toBe(TIERS.length);
|
||||
describe("ANIMALS", () => {
|
||||
it("gives every animal a distinct name and emoji", () => {
|
||||
expect(new Set(ANIMALS.map((animal) => animal.emoji)).size).toBe(ANIMALS.length);
|
||||
expect(new Set(ANIMALS.map((animal) => animal.name)).size).toBe(ANIMALS.length);
|
||||
expect(new Set(ANIMALS.map((animal) => animal.id)).size).toBe(ANIMALS.length);
|
||||
});
|
||||
|
||||
it("rises in speed with no gaps", () => {
|
||||
for (let i = 1; i < TIERS.length; i++) {
|
||||
expect(TIERS[i]!.ab).toBeGreaterThan(TIERS[i - 1]!.ab);
|
||||
for (let i = 1; i < ANIMALS.length; i++) {
|
||||
expect(ANIMALS[i]!.from).toBeGreaterThan(ANIMALS[i - 1]!.from);
|
||||
}
|
||||
expect(TIERS[0]!.ab).toBe(0);
|
||||
expect(ANIMALS[0]!.from).toBe(0);
|
||||
});
|
||||
|
||||
it("does not use the rating star as an animal", () => {
|
||||
for (const tier of TIERS) expect(tier.emoji).not.toBe("\u2b50");
|
||||
for (const animal of ANIMALS) expect(animal.emoji).not.toBe("⭐");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sichtbareTiers", () => {
|
||||
it("always shows the ladder up to the Delfin, however slow the run", () => {
|
||||
const { tiers } = sichtbareTiers("schnecke", null);
|
||||
expect(tiers.at(-1)!.id).toBe(UEBERRASCHUNG_AB);
|
||||
expect(tiers.map((tier) => tier.id)).toContain("schnecke");
|
||||
describe("visibleAnimals", () => {
|
||||
it("always shows the ladder up to the dolphin, however slow the run", () => {
|
||||
const { animals } = visibleAnimals("snail", null);
|
||||
expect(animals.at(-1)!.id).toBe(SURPRISE_FROM);
|
||||
expect(animals.map((animal) => animal.id)).toContain("snail");
|
||||
});
|
||||
|
||||
it("keeps the animals above the Delfin hidden until they are reached", () => {
|
||||
const { tiers, mehrVerborgen } = sichtbareTiers("qualle", "delfin");
|
||||
expect(tiers.some((tier) => tier.id === "hai")).toBe(false);
|
||||
expect(tiers.some((tier) => tier.id === "schwertwal")).toBe(false);
|
||||
expect(mehrVerborgen).toBe(true);
|
||||
it("keeps the animals above the dolphin hidden until they are reached", () => {
|
||||
const { animals, moreHidden } = visibleAnimals("jellyfish", "dolphin");
|
||||
expect(animals.some((animal) => animal.id === "shark")).toBe(false);
|
||||
expect(animals.some((animal) => animal.id === "orca")).toBe(false);
|
||||
expect(moreHidden).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals a surprise animal once this run earns it", () => {
|
||||
const { tiers } = sichtbareTiers("hai", null);
|
||||
expect(tiers.at(-1)!.id).toBe("hai");
|
||||
const { animals } = visibleAnimals("shark", null);
|
||||
expect(animals.at(-1)!.id).toBe("shark");
|
||||
});
|
||||
|
||||
it("keeps a surprise animal revealed on later, slower runs", () => {
|
||||
const { tiers, mehrVerborgen } = sichtbareTiers("krabbe", "hai");
|
||||
expect(tiers.at(-1)!.id).toBe("hai");
|
||||
expect(mehrVerborgen).toBe(true);
|
||||
const { animals, moreHidden } = visibleAnimals("crab", "shark");
|
||||
expect(animals.at(-1)!.id).toBe("shark");
|
||||
expect(moreHidden).toBe(true);
|
||||
});
|
||||
|
||||
it("stops promising more once the ladder is complete", () => {
|
||||
expect(sichtbareTiers("schwertwal", "schwertwal").mehrVerborgen).toBe(false);
|
||||
expect(visibleAnimals("orca", "orca").moreHidden).toBe(false);
|
||||
});
|
||||
|
||||
it("has something to keep secret in the first place", () => {
|
||||
expect(tierIndex(UEBERRASCHUNG_AB)).toBeLessThan(TIERS.length - 1);
|
||||
expect(animalIndex(SURPRISE_FROM)).toBeLessThan(ANIMALS.length - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBetter", () => {
|
||||
it("prefers more stars over more Punkte", () => {
|
||||
expect(isBetter({ sterne: 3, punkte: 10 } as never, { sterne: 2, punkte: 500 })).toBe(true);
|
||||
expect(isBetter({ sterne: 2, punkte: 500 } as never, { sterne: 3, punkte: 10 })).toBe(false);
|
||||
it("prefers more stars over more points", () => {
|
||||
expect(isBetter({ stars: 3, points: 10 } as never, { stars: 2, points: 500 })).toBe(true);
|
||||
expect(isBetter({ stars: 2, points: 500 } as never, { stars: 3, points: 10 })).toBe(false);
|
||||
});
|
||||
|
||||
it("breaks a tie on Punkte", () => {
|
||||
expect(isBetter({ sterne: 2, punkte: 50 } as never, { sterne: 2, punkte: 49 })).toBe(true);
|
||||
expect(isBetter({ sterne: 2, punkte: 49 } as never, { sterne: 2, punkte: 50 })).toBe(false);
|
||||
it("breaks a tie on points", () => {
|
||||
expect(isBetter({ stars: 2, points: 50 } as never, { stars: 2, points: 49 })).toBe(true);
|
||||
expect(isBetter({ stars: 2, points: 49 } as never, { stars: 2, points: 50 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { press, startRun } from "../engine";
|
||||
import { grade } from "../grading";
|
||||
import type { RunResult } from "../grading";
|
||||
import {
|
||||
FLEISS_VERSUCHE,
|
||||
DILIGENCE_ATTEMPTS,
|
||||
focusKeyFor,
|
||||
freshProgress,
|
||||
mastery,
|
||||
@@ -33,15 +33,15 @@ function runResult(target: string, wrongAt: number[] = [], tickMs = 1000): RunRe
|
||||
return grade(state);
|
||||
}
|
||||
|
||||
const perfekt = () => runResult("asdfasdfasdfasdf");
|
||||
const schlecht = () => runResult("asdfasdfasdfasdf", [0, 1, 2, 3, 4, 5]);
|
||||
const perfect = () => runResult("asdfasdfasdfasdf");
|
||||
const bad = () => runResult("asdfasdfasdfasdf", [0, 1, 2, 3, 4, 5]);
|
||||
|
||||
describe("freshProgress", () => {
|
||||
it("unlocks the first lesson and nothing else", () => {
|
||||
const progress = freshProgress();
|
||||
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
|
||||
const offen = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked);
|
||||
expect(offen).toHaveLength(1);
|
||||
const unlocked = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked);
|
||||
expect(unlocked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("knows every lesson in the curriculum", () => {
|
||||
@@ -52,89 +52,89 @@ describe("freshProgress", () => {
|
||||
|
||||
describe("recordRun", () => {
|
||||
it("unlocks the next lesson on two stars", () => {
|
||||
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, perfekt());
|
||||
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, perfect());
|
||||
expect(unlockedLessonId).toBe(L2);
|
||||
expect(progress.lessons[L2]!.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("does not unlock below two stars", () => {
|
||||
const result = schlecht();
|
||||
expect(result.sterne).toBeLessThan(2);
|
||||
const result = bad();
|
||||
expect(result.stars).toBeLessThan(2);
|
||||
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, result);
|
||||
expect(unlockedLessonId).toBeNull();
|
||||
expect(progress.lessons[L2]!.unlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("unlocks after enough tries however bad the score - the Fleiß rule", () => {
|
||||
it("unlocks after enough tries however bad the score - the diligence rule", () => {
|
||||
let progress = freshProgress();
|
||||
for (let i = 0; i < FLEISS_VERSUCHE - 1; i++) {
|
||||
progress = recordRun(progress, L1, schlecht()).progress;
|
||||
for (let i = 0; i < DILIGENCE_ATTEMPTS - 1; i++) {
|
||||
progress = recordRun(progress, L1, bad()).progress;
|
||||
expect(progress.lessons[L2]!.unlocked).toBe(false);
|
||||
}
|
||||
const { progress: last, unlockedLessonId } = recordRun(progress, L1, schlecht());
|
||||
const { progress: last, unlockedLessonId } = recordRun(progress, L1, bad());
|
||||
expect(unlockedLessonId).toBe(L2);
|
||||
expect(last.lessons[L2]!.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("never lets the best animal or star count go down", () => {
|
||||
let progress = recordRun(freshProgress(), L1, perfekt()).progress;
|
||||
let progress = recordRun(freshProgress(), L1, perfect()).progress;
|
||||
const best = progress.lessons[L1]!;
|
||||
progress = recordRun(progress, L1, schlecht()).progress;
|
||||
expect(progress.lessons[L1]!.bestSterne).toBe(best.bestSterne);
|
||||
expect(progress.lessons[L1]!.bestPunkte).toBe(best.bestPunkte);
|
||||
expect(progress.lessons[L1]!.bestTier).toBe(best.bestTier);
|
||||
progress = recordRun(progress, L1, bad()).progress;
|
||||
expect(progress.lessons[L1]!.bestStars).toBe(best.bestStars);
|
||||
expect(progress.lessons[L1]!.bestPoints).toBe(best.bestPoints);
|
||||
expect(progress.lessons[L1]!.bestAnimal).toBe(best.bestAnimal);
|
||||
});
|
||||
|
||||
it("counts every run, good or bad", () => {
|
||||
let progress = freshProgress();
|
||||
for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, schlecht()).progress;
|
||||
for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, bad()).progress;
|
||||
expect(progress.lessons[L1]!.runs).toBe(3);
|
||||
});
|
||||
|
||||
it("adds Perlen on every run", () => {
|
||||
const first = recordRun(freshProgress(), L1, schlecht());
|
||||
expect(first.progress.perlen).toBeGreaterThan(0);
|
||||
const second = recordRun(first.progress, L1, schlecht());
|
||||
expect(second.progress.perlen).toBeGreaterThan(first.progress.perlen);
|
||||
it("adds pearls on every run", () => {
|
||||
const first = recordRun(freshProgress(), L1, bad());
|
||||
expect(first.progress.pearls).toBeGreaterThan(0);
|
||||
const second = recordRun(first.progress, L1, bad());
|
||||
expect(second.progress.pearls).toBeGreaterThan(first.progress.pearls);
|
||||
});
|
||||
|
||||
it("releases a creature only when a Welt is finished, and only once", () => {
|
||||
it("releases a creature only when a world is finished, and only once", () => {
|
||||
let progress = freshProgress();
|
||||
// Welt 1 is lessons 1-3, so finishing lesson 3 is what crosses into Welt 2.
|
||||
const welt1 = LESSONS.filter((lesson) => lesson.welt === 1);
|
||||
// World 1 is lessons 1-3, so finishing lesson 3 is what crosses into world 2.
|
||||
const world1 = LESSONS.filter((lesson) => lesson.world === 1);
|
||||
let released: string[] = [];
|
||||
for (const lesson of welt1) {
|
||||
const outcome = recordRun(progress, lesson.id, perfekt());
|
||||
for (const lesson of world1) {
|
||||
const outcome = recordRun(progress, lesson.id, perfect());
|
||||
progress = outcome.progress;
|
||||
if (outcome.neuesTier) released.push(outcome.neuesTier);
|
||||
if (outcome.newCreature) released.push(outcome.newCreature);
|
||||
}
|
||||
expect(released).toHaveLength(1);
|
||||
expect(progress.aquarium).toEqual(released);
|
||||
|
||||
// Replaying the same lesson must not hand out a second copy.
|
||||
const again = recordRun(progress, welt1.at(-1)!.id, perfekt());
|
||||
expect(again.neuesTier).toBeNull();
|
||||
const again = recordRun(progress, world1.at(-1)!.id, perfect());
|
||||
expect(again.newCreature).toBeNull();
|
||||
expect(again.progress.aquarium).toEqual(released);
|
||||
});
|
||||
|
||||
it("records a ghost of the best run for the race", () => {
|
||||
const { progress } = recordRun(freshProgress(), L1, perfekt());
|
||||
expect(progress.lessons[L1]!.ghost?.length).toBe(perfekt().zeichen);
|
||||
const { progress } = recordRun(freshProgress(), L1, perfect());
|
||||
expect(progress.lessons[L1]!.ghost?.length).toBe(perfect().characters);
|
||||
});
|
||||
|
||||
it("builds the daily streak and restarts it after a gap", () => {
|
||||
let progress = recordRun(freshProgress(), L1, perfekt(), "2026-09-11").progress;
|
||||
let progress = recordRun(freshProgress(), L1, perfect(), "2026-09-11").progress;
|
||||
expect(progress.streak).toEqual({ days: 1, lastPlayed: "2026-09-11" });
|
||||
|
||||
// A second run the same day does not double-count.
|
||||
progress = recordRun(progress, L1, perfekt(), "2026-09-11").progress;
|
||||
progress = recordRun(progress, L1, perfect(), "2026-09-11").progress;
|
||||
expect(progress.streak.days).toBe(1);
|
||||
|
||||
progress = recordRun(progress, L1, perfekt(), "2026-09-12").progress;
|
||||
progress = recordRun(progress, L1, perfect(), "2026-09-12").progress;
|
||||
expect(progress.streak.days).toBe(2);
|
||||
|
||||
// A missed day restarts at 1, never at 0.
|
||||
progress = recordRun(progress, L1, perfekt(), "2026-09-20").progress;
|
||||
progress = recordRun(progress, L1, perfect(), "2026-09-20").progress;
|
||||
expect(progress.streak.days).toBe(1);
|
||||
});
|
||||
|
||||
@@ -156,20 +156,20 @@ describe("migrate", () => {
|
||||
});
|
||||
|
||||
it("keeps a valid save", () => {
|
||||
const stored = recordRun(freshProgress(), L1, perfekt()).progress;
|
||||
const stored = recordRun(freshProgress(), L1, perfect()).progress;
|
||||
const restored = migrate(JSON.parse(JSON.stringify(stored)));
|
||||
expect(restored.lessons[L2]!.unlocked).toBe(true);
|
||||
expect(restored.perlen).toBe(stored.perlen);
|
||||
expect(restored.pearls).toBe(stored.pearls);
|
||||
});
|
||||
|
||||
it("drops lessons that no longer exist", () => {
|
||||
const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, alteLektion: {} } };
|
||||
expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["alteLektion"]).toBeUndefined();
|
||||
const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, oldLesson: {} } };
|
||||
expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["oldLesson"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves the emoji pets of old saves into today's aquarium", () => {
|
||||
const alt = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] };
|
||||
expect(migrate(JSON.parse(JSON.stringify(alt))).aquarium).toEqual(["clownfisch", "krake"]);
|
||||
const old = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] };
|
||||
expect(migrate(JSON.parse(JSON.stringify(old))).aquarium).toEqual(["clownfish", "octopus"]);
|
||||
});
|
||||
|
||||
it("re-unlocks the first lesson even if the save says otherwise", () => {
|
||||
@@ -182,7 +182,7 @@ describe("migrate", () => {
|
||||
describe("focusKeyFor", () => {
|
||||
it("has no focus key on a lesson that has never been played", () => {
|
||||
// Otherwise "pick an unpractised key" picks whichever sorts first and drills it half
|
||||
// the line, starving the other three fingers on Lektion 1.
|
||||
// the line, starving the other three fingers on lesson 1.
|
||||
expect(focusKeyFor(freshProgress(), ["a", "s", "d", "f"])).toBeNull();
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("focusKeyFor", () => {
|
||||
expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
|
||||
});
|
||||
|
||||
it("ignores the Leertaste and copes with an empty lesson", () => {
|
||||
it("ignores the space bar and copes with an empty lesson", () => {
|
||||
expect(focusKeyFor(freshProgress(), [" "])).toBeNull();
|
||||
expect(focusKeyFor(freshProgress(), [])).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,81 +1,81 @@
|
||||
/** The aquarium's pets, and how they swim.
|
||||
*
|
||||
* A pet moves in when a Welt is finished and then stays - not in a list on the home
|
||||
* A pet moves in when a world is finished and then stays - not in a list on the home
|
||||
* screen, but swimming around behind every screen of the game. A reward that is always
|
||||
* in view, drifting past while she types, is the strongest version of "the reward
|
||||
* persists", and it costs nothing to look at.
|
||||
*
|
||||
* Pets are illustrations, speed trophies are emoji. The ladder in grading.ts changes
|
||||
* with every run; a pet arrives once and never leaves. Keeping the two in different
|
||||
* visual languages is what lets a Schildkröte be both a speed trophy (🐢) and a pet
|
||||
* (the drawing) without a six-year-old having to work out which is which.
|
||||
* visual languages is what lets a turtle be both a speed trophy (🐢) and a pet (the
|
||||
* drawing) without a six-year-old having to work out which is which.
|
||||
*
|
||||
* The swimming lives here rather than in the component because it is the part worth
|
||||
* testing: `schwimme` is a pure step - a swimmer and a time slice in, a swimmer out - so
|
||||
* "never leaves the tank" and "looks where it is going" are checkable without a DOM or a
|
||||
* clock. The component only calls it once per frame and writes the transform. */
|
||||
* testing: `stepSwimmer` is a pure step - a swimmer and a time slice in, a swimmer out -
|
||||
* so "never leaves the tank" and "looks where it is going" are checkable without a DOM
|
||||
* or a clock. The component only calls it once per frame and writes the transform. */
|
||||
|
||||
export type KreaturId = "clownfisch" | "krake" | "seepferdchen" | "schildkroete" | "perlmuschel";
|
||||
export type CreatureId = "clownfish" | "octopus" | "seahorse" | "turtle" | "pearlmussel";
|
||||
|
||||
export interface Kreatur {
|
||||
id: KreaturId;
|
||||
export interface Creature {
|
||||
id: CreatureId;
|
||||
name: string;
|
||||
/** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */
|
||||
artikel: "Der" | "Die" | "Das";
|
||||
article: "Der" | "Die" | "Das";
|
||||
/** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */
|
||||
bild: string;
|
||||
image: string;
|
||||
/** Height as a fraction of the stage height - so a pet is the same size relative to
|
||||
* the sea on a small laptop and on a big screen. */
|
||||
groesse: number;
|
||||
size: number;
|
||||
/** Cruising speed as a fraction of the stage width per second. Slow on purpose: these
|
||||
* are in the background of a typing drill, and anything darting reads as an event. */
|
||||
tempo: number;
|
||||
speed: number;
|
||||
/** Which way the drawing faces. Side-view creatures are mirrored to look where they
|
||||
* swim; front-view ones never are - a krake flipping on every turn looks broken. */
|
||||
blick: "seite" | "vorne";
|
||||
* swim; front-view ones never are - an octopus flipping on every turn looks broken. */
|
||||
facing: "side" | "front";
|
||||
}
|
||||
|
||||
export const KREATUREN: readonly Kreatur[] = [
|
||||
{ id: "clownfisch", name: "Clownfisch", artikel: "Der", bild: "/aquarium/clownfisch.webp", groesse: 0.13, tempo: 0.045, blick: "seite" },
|
||||
{ id: "krake", name: "Krake", artikel: "Die", bild: "/aquarium/krake.webp", groesse: 0.17, tempo: 0.025, blick: "vorne" },
|
||||
{ id: "seepferdchen", name: "Seepferdchen", artikel: "Das", bild: "/aquarium/seepferdchen.webp", groesse: 0.19, tempo: 0.02, blick: "seite" },
|
||||
{ id: "schildkroete", name: "Schildkröte", artikel: "Die", bild: "/aquarium/schildkroete.webp", groesse: 0.17, tempo: 0.032, blick: "seite" },
|
||||
{ id: "perlmuschel", name: "Perlmuschel", artikel: "Die", bild: "/aquarium/perlmuschel.webp", groesse: 0.12, tempo: 0.016, blick: "vorne" },
|
||||
export const CREATURES: readonly Creature[] = [
|
||||
{ id: "clownfish", name: "Clownfisch", article: "Der", image: "/aquarium/clownfisch.webp", size: 0.13, speed: 0.045, facing: "side" },
|
||||
{ id: "octopus", name: "Krake", article: "Die", image: "/aquarium/krake.webp", size: 0.17, speed: 0.025, facing: "front" },
|
||||
{ id: "seahorse", name: "Seepferdchen", article: "Das", image: "/aquarium/seepferdchen.webp", size: 0.19, speed: 0.02, facing: "side" },
|
||||
{ id: "turtle", name: "Schildkröte", article: "Die", image: "/aquarium/schildkroete.webp", size: 0.17, speed: 0.032, facing: "side" },
|
||||
{ id: "pearlmussel", name: "Perlmuschel", article: "Die", image: "/aquarium/perlmuschel.webp", size: 0.12, speed: 0.016, facing: "front" },
|
||||
];
|
||||
|
||||
const KREATUR_BY_ID = new Map(KREATUREN.map((kreatur) => [kreatur.id, kreatur]));
|
||||
const CREATURE_BY_ID = new Map(CREATURES.map((creature) => [creature.id, creature]));
|
||||
|
||||
export function kreaturById(id: KreaturId): Kreatur {
|
||||
// Every KreaturId is in KREATUREN, so this cannot miss; the fallback only satisfies
|
||||
export function creatureById(id: CreatureId): Creature {
|
||||
// Every CreatureId is in CREATURES, so this cannot miss; the fallback only satisfies
|
||||
// `noUncheckedIndexedAccess`.
|
||||
return KREATUR_BY_ID.get(id) ?? KREATUREN[0]!;
|
||||
return CREATURE_BY_ID.get(id) ?? CREATURES[0]!;
|
||||
}
|
||||
|
||||
/** Saves from before the pets were drawings stored the Welt's emoji. Each Welt kept its
|
||||
* slot, so the old emoji map one-to-one onto the creature that now fills it. */
|
||||
const ALTE_EMOJI: Readonly<Record<string, KreaturId>> = {
|
||||
"🐠": "clownfisch",
|
||||
"🐙": "krake",
|
||||
"🦑": "seepferdchen",
|
||||
"🐳": "schildkroete",
|
||||
"🧜": "perlmuschel",
|
||||
/** Saves from before the pets were drawings stored the world's emoji. Each world kept
|
||||
* its slot, so the old emoji map one-to-one onto the creature that now fills it. */
|
||||
const LEGACY_EMOJI: Readonly<Record<string, CreatureId>> = {
|
||||
"🐠": "clownfish",
|
||||
"🐙": "octopus",
|
||||
"🦑": "seahorse",
|
||||
"🐳": "turtle",
|
||||
"🧜": "pearlmussel",
|
||||
};
|
||||
|
||||
/** A stored aquarium entry as a creature, or `null` for anything unrecognisable. */
|
||||
export function kreaturAus(raw: unknown): KreaturId | null {
|
||||
export function creatureFromRaw(raw: unknown): CreatureId | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
if (KREATUR_BY_ID.has(raw as KreaturId)) return raw as KreaturId;
|
||||
return ALTE_EMOJI[raw] ?? null;
|
||||
if (CREATURE_BY_ID.has(raw as CreatureId)) return raw as CreatureId;
|
||||
return LEGACY_EMOJI[raw] ?? null;
|
||||
}
|
||||
|
||||
// --- swimming ---------------------------------------------------------------
|
||||
|
||||
export interface Becken {
|
||||
breite: number;
|
||||
hoehe: number;
|
||||
export interface Tank {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Schwimmer {
|
||||
export interface Swimmer {
|
||||
/** Centre, in px. */
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -83,126 +83,126 @@ export interface Schwimmer {
|
||||
vx: number;
|
||||
vy: number;
|
||||
/** Where it is currently drifting towards. */
|
||||
zielX: number;
|
||||
zielY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
/** -1 looking left … 1 looking right. Eased rather than switched, so a turn is a
|
||||
* visible flip through the middle instead of a jump. */
|
||||
blick: number;
|
||||
facing: number;
|
||||
/** Seconds swum, for the bob. Started at a random offset so pets do not bob in step. */
|
||||
zeit: number;
|
||||
age: number;
|
||||
}
|
||||
|
||||
/** How long the velocity takes to swing round to a new heading. Over a second, so every
|
||||
* change of course is a lazy curve and nothing ever jerks. */
|
||||
const LENK_TAU = 1.4;
|
||||
const STEER_TAU = 1.4;
|
||||
/** How long a turn-around takes. */
|
||||
const WENDE_TAU = 0.25;
|
||||
const TURN_TAU = 0.25;
|
||||
/** One gentle bob per this many seconds. */
|
||||
const WIPP_PERIODE = 3.6;
|
||||
const BOB_PERIOD = 3.6;
|
||||
|
||||
function zufallsZiel(becken: Becken, rand: number, rng: () => number): { x: number; y: number } {
|
||||
function randomTarget(tank: Tank, margin: number, rng: () => number): { x: number; y: number } {
|
||||
// A tank smaller than the creature (a collapsed window) still needs a valid target:
|
||||
// the middle.
|
||||
const spanne = (laenge: number) => Math.max(0, laenge - 2 * rand);
|
||||
const span = (length: number) => Math.max(0, length - 2 * margin);
|
||||
return {
|
||||
x: rand + rng() * spanne(becken.breite),
|
||||
y: rand + rng() * spanne(becken.hoehe),
|
||||
x: margin + rng() * span(tank.width),
|
||||
y: margin + rng() * span(tank.height),
|
||||
};
|
||||
}
|
||||
|
||||
/** A new swimmer. `vonAussen` starts it just past a side edge, so a pet that has only
|
||||
/** A new swimmer. `fromOutside` starts it just past a side edge, so a pet that has only
|
||||
* just been earned visibly swims in rather than popping into existence mid-screen. */
|
||||
export function neuerSchwimmer(
|
||||
becken: Becken,
|
||||
rand: number,
|
||||
export function createSwimmer(
|
||||
tank: Tank,
|
||||
margin: number,
|
||||
rng: () => number,
|
||||
vonAussen = false,
|
||||
): Schwimmer {
|
||||
const ziel = zufallsZiel(becken, rand, rng);
|
||||
const start = vonAussen
|
||||
? { x: rng() < 0.5 ? -rand : becken.breite + rand, y: ziel.y }
|
||||
: zufallsZiel(becken, rand, rng);
|
||||
fromOutside = false,
|
||||
): Swimmer {
|
||||
const target = randomTarget(tank, margin, rng);
|
||||
const start = fromOutside
|
||||
? { x: rng() < 0.5 ? -margin : tank.width + margin, y: target.y }
|
||||
: randomTarget(tank, margin, rng);
|
||||
return {
|
||||
x: start.x,
|
||||
y: start.y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
zielX: ziel.x,
|
||||
zielY: ziel.y,
|
||||
blick: ziel.x >= start.x ? 1 : -1,
|
||||
zeit: rng() * WIPP_PERIODE,
|
||||
targetX: target.x,
|
||||
targetY: target.y,
|
||||
facing: target.x >= start.x ? 1 : -1,
|
||||
age: rng() * BOB_PERIOD,
|
||||
};
|
||||
}
|
||||
|
||||
/** One time slice of swimming: steer towards the target, pick a new one on arrival, and
|
||||
* turn to face the direction of travel.
|
||||
*
|
||||
* `rand` is half the creature's size - how far its centre stays from the edges - and
|
||||
* `tempo` its cruising speed in px/s. */
|
||||
export function schwimme(
|
||||
s: Schwimmer,
|
||||
* `margin` is half the creature's size - how far its centre stays from the edges - and
|
||||
* `speed` its cruising speed in px/s. */
|
||||
export function stepSwimmer(
|
||||
s: Swimmer,
|
||||
dt: number,
|
||||
becken: Becken,
|
||||
rand: number,
|
||||
tempo: number,
|
||||
tank: Tank,
|
||||
margin: number,
|
||||
speed: number,
|
||||
rng: () => number,
|
||||
): Schwimmer {
|
||||
let { zielX, zielY } = s;
|
||||
const imBecken = (x: number, y: number) =>
|
||||
x >= rand && x <= becken.breite - rand && y >= rand && y <= becken.hoehe - rand;
|
||||
): Swimmer {
|
||||
let { targetX, targetY } = s;
|
||||
const inTank = (x: number, y: number) =>
|
||||
x >= margin && x <= tank.width - margin && y >= margin && y <= tank.height - margin;
|
||||
|
||||
// Arrived, or the window shrank and the target is now outside it: drift somewhere new.
|
||||
const dx = zielX - s.x;
|
||||
const dy = zielY - s.y;
|
||||
if (Math.hypot(dx, dy) < Math.max(rand, 24) || !imBecken(zielX, zielY)) {
|
||||
const ziel = zufallsZiel(becken, rand, rng);
|
||||
zielX = ziel.x;
|
||||
zielY = ziel.y;
|
||||
const dx = targetX - s.x;
|
||||
const dy = targetY - s.y;
|
||||
if (Math.hypot(dx, dy) < Math.max(margin, 24) || !inTank(targetX, targetY)) {
|
||||
const target = randomTarget(tank, margin, rng);
|
||||
targetX = target.x;
|
||||
targetY = target.y;
|
||||
}
|
||||
|
||||
const richtungX = zielX - s.x;
|
||||
const richtungY = zielY - s.y;
|
||||
const abstand = Math.hypot(richtungX, richtungY) || 1;
|
||||
const directionX = targetX - s.x;
|
||||
const directionY = targetY - s.y;
|
||||
const distance = Math.hypot(directionX, directionY) || 1;
|
||||
// Vertical drift at half speed: fish cruise, they do not climb.
|
||||
const sollVx = (richtungX / abstand) * tempo;
|
||||
const sollVy = (richtungY / abstand) * tempo * 0.5;
|
||||
const targetVx = (directionX / distance) * speed;
|
||||
const targetVy = (directionY / distance) * speed * 0.5;
|
||||
|
||||
const lenk = 1 - Math.exp(-dt / LENK_TAU);
|
||||
const vx = s.vx + (sollVx - s.vx) * lenk;
|
||||
const vy = s.vy + (sollVy - s.vy) * lenk;
|
||||
const steer = 1 - Math.exp(-dt / STEER_TAU);
|
||||
const vx = s.vx + (targetVx - s.vx) * steer;
|
||||
const vy = s.vy + (targetVy - s.vy) * steer;
|
||||
|
||||
// Only turn round once it is really swimming that way - hovering on the spot must not
|
||||
// make it flicker left and right.
|
||||
const sollBlick = Math.abs(vx) > tempo * 0.2 ? Math.sign(vx) : Math.sign(s.blick) || 1;
|
||||
const blick = s.blick + (sollBlick - s.blick) * (1 - Math.exp(-dt / WENDE_TAU));
|
||||
const targetFacing = Math.abs(vx) > speed * 0.2 ? Math.sign(vx) : Math.sign(s.facing) || 1;
|
||||
const facing = s.facing + (targetFacing - s.facing) * (1 - Math.exp(-dt / TURN_TAU));
|
||||
|
||||
return {
|
||||
x: s.x + vx * dt,
|
||||
y: s.y + vy * dt,
|
||||
vx,
|
||||
vy,
|
||||
zielX,
|
||||
zielY,
|
||||
blick,
|
||||
zeit: s.zeit + dt,
|
||||
targetX,
|
||||
targetY,
|
||||
facing,
|
||||
age: s.age + dt,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the component draws for a swimmer: the bob and the tilt layered on top of the
|
||||
* position, and the mirroring for side-view drawings. */
|
||||
export function pose(
|
||||
s: Schwimmer,
|
||||
kreatur: Kreatur,
|
||||
tempo: number,
|
||||
): { x: number; y: number; spiegel: number; drehung: number } {
|
||||
const wippen = Math.sin((s.zeit / WIPP_PERIODE) * 2 * Math.PI);
|
||||
const seite = kreatur.blick === "seite";
|
||||
s: Swimmer,
|
||||
creature: Creature,
|
||||
speed: number,
|
||||
): { x: number; y: number; mirror: number; rotation: number } {
|
||||
const bob = Math.sin((s.age / BOB_PERIOD) * 2 * Math.PI);
|
||||
const isSide = creature.facing === "side";
|
||||
// Nose up when rising, down when sinking - a few degrees, in the direction it faces.
|
||||
const neigung = tempo > 0 ? Math.max(-1, Math.min(1, s.vy / tempo)) : 0;
|
||||
const tilt = speed > 0 ? Math.max(-1, Math.min(1, s.vy / speed)) : 0;
|
||||
return {
|
||||
x: s.x,
|
||||
y: s.y + wippen * 7,
|
||||
spiegel: seite ? s.blick : 1,
|
||||
drehung: seite ? neigung * 10 * Math.sign(s.blick || 1) : wippen * 3,
|
||||
y: s.y + bob * 7,
|
||||
mirror: isSide ? s.facing : 1,
|
||||
rotation: isSide ? tilt * 10 * Math.sign(s.facing || 1) : bob * 3,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,246 +1,256 @@
|
||||
/** The lesson plan: 48 Lektionen in 5 Welten.
|
||||
/** The lesson plan: 48 lessons in 5 worlds.
|
||||
*
|
||||
* The shape follows what every serious Zehnfinger-Kurs does - start on the
|
||||
* Grundstellung `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S,
|
||||
* 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:
|
||||
*
|
||||
* - **one new key per lesson** from Welt 2 onward (Welt 1 pairs the same finger on
|
||||
* - **one new key per lesson** from world 2 onward (world 1 pairs the same finger on
|
||||
* both hands, which is one motion, not two);
|
||||
* - **an Übung lesson after every pair of new keys** - no new keys at all, just the
|
||||
* - **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 Welt 1 to whole sentences in
|
||||
* Welt 5.
|
||||
* - **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: Lektion 30 still drills `f`, or the first
|
||||
* `activeKeys` is cumulative on purpose: lesson 30 still drills `f`, or the first
|
||||
* lessons rot while the last ones are learned. */
|
||||
|
||||
import type { KreaturId } from "./aquarium";
|
||||
import { GRUNDSTELLUNG, LEERTASTE } from "./fingers";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import { HOME_ROW, SPACE_KEY } from "./fingers";
|
||||
|
||||
export type ModeId =
|
||||
| "tauchgang"
|
||||
| "blasen"
|
||||
| "quallen"
|
||||
| "fuettern"
|
||||
| "rennen"
|
||||
| "perlen";
|
||||
| "dive"
|
||||
| "bubbles"
|
||||
| "jellyfish"
|
||||
| "feed"
|
||||
| "race"
|
||||
| "pearls";
|
||||
|
||||
/** What a lesson's targets are made of. Decides which modes make sense: Quallenalarm
|
||||
* cannot show a sentence, and Fütterungszeit cannot put one on a fish. */
|
||||
export type LessonArt = "buchstaben" | "woerter" | "saetze";
|
||||
/** 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";
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
welt: number;
|
||||
nummer: number;
|
||||
titel: string;
|
||||
world: number;
|
||||
number: number;
|
||||
title: string;
|
||||
/** What this lesson is about, in words a six-year-old hears read aloud. */
|
||||
untertitel: string;
|
||||
/** The keys introduced here - what the generator weights toward. Empty for an Übung. */
|
||||
neueKeys: readonly string[];
|
||||
subtitle: string;
|
||||
/** The keys introduced here - what the generator weights toward. Empty for a drill. */
|
||||
newKeys: readonly string[];
|
||||
/** Everything typable in this lesson, cumulative. */
|
||||
activeKeys: readonly string[];
|
||||
/** Which game modes this lesson offers, in carousel order. */
|
||||
modi: readonly ModeId[];
|
||||
modes: readonly ModeId[];
|
||||
/** Real German words (or sentences) for the word modes. */
|
||||
woerter: readonly string[];
|
||||
art: LessonArt;
|
||||
words: readonly string[];
|
||||
kind: LessonKind;
|
||||
/** True for a consolidation lesson - no new keys, just practice. */
|
||||
istUebung: boolean;
|
||||
/** How long one run is. Grows with the curriculum - see `laengeFuer`. */
|
||||
isDrill: boolean;
|
||||
/** How long one run is. Grows with the curriculum - see `lengthFor`. */
|
||||
chunks: number;
|
||||
chunkSize: number;
|
||||
}
|
||||
|
||||
export interface Welt {
|
||||
nummer: number;
|
||||
titel: string;
|
||||
export interface World {
|
||||
number: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
/** The creature that moves into the aquarium when this Welt is finished. */
|
||||
belohnung: KreaturId;
|
||||
/** The creature that moves into the aquarium when this world is finished. */
|
||||
reward: CreatureId;
|
||||
}
|
||||
|
||||
/** A Welt's creature is a pet that stays - a drawing that swims behind every screen from
|
||||
* then on. A tier animal in grading.ts is a speed trophy that changes, and is an emoji.
|
||||
* lib/aquarium.ts has why the two are kept in different visual languages. The pets grow
|
||||
* with the Welten: a small fish first, the pearl clam - the Perlen's own home - last. */
|
||||
export const WELTEN: readonly Welt[] = [
|
||||
{ nummer: 1, titel: "Die Grundstellung", emoji: "🏝️", belohnung: "clownfisch" },
|
||||
{ nummer: 2, titel: "Nach oben", emoji: "🌊", belohnung: "krake" },
|
||||
{ nummer: 3, titel: "Nach unten", emoji: "🪸", belohnung: "seepferdchen" },
|
||||
{ nummer: 4, titel: "Große Buchstaben", emoji: "👑", belohnung: "schildkroete" },
|
||||
{ nummer: 5, titel: "Ganze Sätze", emoji: "📖", belohnung: "perlmuschel" },
|
||||
/** 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" },
|
||||
];
|
||||
|
||||
/** Letters only - no words can be spelled yet. */
|
||||
const BUCHSTABEN_MODI: readonly ModeId[] = ["tauchgang", "blasen", "quallen", "perlen"];
|
||||
const LETTER_MODES: readonly ModeId[] = ["dive", "bubbles", "jellyfish", "pearls"];
|
||||
/** Real words exist: the word modes and the race join in. */
|
||||
const WORT_MODI: readonly ModeId[] = ["tauchgang", "fuettern", "blasen", "rennen", "quallen", "perlen"];
|
||||
const WORD_MODES: readonly ModeId[] = ["dive", "feed", "bubbles", "race", "jellyfish", "pearls"];
|
||||
/** Sentences do not fit on a bubble or a fish. */
|
||||
const SATZ_MODI: readonly ModeId[] = ["tauchgang", "rennen", "perlen"];
|
||||
const SENTENCE_MODES: readonly ModeId[] = ["dive", "race", "pearls"];
|
||||
|
||||
/** Line length by Welt - a full block of text per round, at least five lines of it.
|
||||
/** 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 Zeichen pro
|
||||
* Minute to mean something.
|
||||
* Sixty-plus characters is long enough for a rhythm to appear and for the characters
|
||||
* per minute to mean something.
|
||||
*
|
||||
* Welt 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
|
||||
function laengeFuer(welt: number): { chunks: number; chunkSize: number } {
|
||||
if (welt === 1) return { chunks: 24, chunkSize: 3 }; // 72 Zeichen
|
||||
if (welt === 2) return { chunks: 25, chunkSize: 4 }; // 100
|
||||
if (welt === 3) return { chunks: 30, chunkSize: 4 }; // 120
|
||||
if (welt === 4) return { chunks: 25, chunkSize: 4 }; // 25 Wörter
|
||||
return { chunks: 10, chunkSize: 4 }; // Welt 5: zehn ganze Sätze
|
||||
* World 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
|
||||
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 (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
|
||||
}
|
||||
|
||||
interface PlanEntry {
|
||||
welt: number;
|
||||
titel: string;
|
||||
untertitel: string;
|
||||
/** Empty marks an Übung - consolidation, no new keys. */
|
||||
neu: readonly string[];
|
||||
woerter?: readonly string[];
|
||||
art?: LessonArt;
|
||||
world: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** Empty marks a drill - consolidation, no new keys. */
|
||||
newKeys: readonly string[];
|
||||
words?: readonly string[];
|
||||
kind?: LessonKind;
|
||||
}
|
||||
|
||||
/** The plan. Everything else is derived from this, so a curriculum change is one edit. */
|
||||
const PLAN: readonly PlanEntry[] = [
|
||||
// ---------------------------------------------------------------- Welt 1 --
|
||||
// ---------------------------------------------------------------- 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.
|
||||
{ welt: 1, titel: "F und J", untertitel: "Die Zeigefinger - die Tasten mit den Punkten", neu: ["f", "j"] },
|
||||
{ welt: 1, titel: "D und K", untertitel: "Die Mittelfinger", neu: ["d", "k"] },
|
||||
{ welt: 1, titel: "Übung: F J D K", untertitel: "Die vier Tasten zusammen", neu: [] },
|
||||
{ welt: 1, titel: "S und L", untertitel: "Die Ringfinger", neu: ["s", "l"] },
|
||||
{ welt: 1, titel: "Übung: sechs Tasten", untertitel: "Alles bisher zusammen", neu: [] },
|
||||
{ welt: 1, titel: "A und Ö", untertitel: "Die kleinen Finger", neu: ["a", "ö"] },
|
||||
{ welt: 1, titel: "Übung: die Grundstellung", untertitel: "Alle acht Finger", neu: [] },
|
||||
{ welt: 1, titel: "Die Leertaste", untertitel: "Der Daumen kommt dazu", neu: [LEERTASTE] },
|
||||
{ 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] },
|
||||
|
||||
// ---------------------------------------------------------------- Welt 2 --
|
||||
// ---------------------------------------------------------------- World 2 --
|
||||
// One key per lesson from here on, an Übung after every two.
|
||||
{ welt: 2, titel: "Das E", untertitel: "Mittelfinger links nach oben", neu: ["e"],
|
||||
woerter: ["elf", "alle", "esel", "see", "keks", "fell"] },
|
||||
{ welt: 2, titel: "Das I", untertitel: "Mittelfinger rechts nach oben", neu: ["i"],
|
||||
woerter: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
|
||||
{ welt: 2, titel: "Übung: E und I", untertitel: "Die neuen Tasten festigen", neu: [],
|
||||
woerter: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
|
||||
{ welt: 2, titel: "Das R", untertitel: "Zeigefinger links nach oben", neu: ["r"],
|
||||
woerter: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
|
||||
{ welt: 2, titel: "Das U", untertitel: "Zeigefinger rechts nach oben", neu: ["u"],
|
||||
woerter: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
|
||||
{ welt: 2, titel: "Übung: R und U", untertitel: "Die Zeigefinger nach oben", neu: [],
|
||||
woerter: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
|
||||
{ welt: 2, titel: "Das T", untertitel: "Zeigefinger links weit nach oben", neu: ["t"],
|
||||
woerter: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
|
||||
{ welt: 2, titel: "Das Z", untertitel: "Zeigefinger rechts weit nach oben", neu: ["z"],
|
||||
woerter: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
|
||||
{ welt: 2, titel: "Übung: T und Z", untertitel: "Weit nach oben greifen", neu: [],
|
||||
woerter: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
|
||||
{ welt: 2, titel: "Das O", untertitel: "Ringfinger rechts nach oben", neu: ["o"],
|
||||
woerter: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
|
||||
{ welt: 2, titel: "Das W", untertitel: "Ringfinger links nach oben", neu: ["w"],
|
||||
woerter: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
|
||||
{ welt: 2, titel: "Übung: W und O", untertitel: "Die Ringfinger nach oben", neu: [],
|
||||
woerter: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
|
||||
{ welt: 2, titel: "Das P", untertitel: "Kleiner Finger rechts nach oben", neu: ["p"],
|
||||
woerter: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
|
||||
{ welt: 2, titel: "Das Q", untertitel: "Kleiner Finger links nach oben", neu: ["q"],
|
||||
woerter: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
|
||||
{ welt: 2, titel: "Das Ü", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["ü"],
|
||||
woerter: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] },
|
||||
{ welt: 2, titel: "Übung: die obere Reihe", untertitel: "Die ganze Reihe zusammen", neu: [],
|
||||
woerter: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
|
||||
{ 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"] },
|
||||
|
||||
// ---------------------------------------------------------------- Welt 3 --
|
||||
{ welt: 3, titel: "Das N", untertitel: "Zeigefinger rechts nach unten", neu: ["n"],
|
||||
woerter: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] },
|
||||
{ welt: 3, titel: "Das M", untertitel: "Zeigefinger rechts, neben dem N", neu: ["m"],
|
||||
woerter: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] },
|
||||
{ welt: 3, titel: "Übung: N und M", untertitel: "Die neuen Tasten festigen", neu: [],
|
||||
woerter: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] },
|
||||
{ welt: 3, titel: "Das G", untertitel: "Zeigefinger links, in der Mitte", neu: ["g"],
|
||||
woerter: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] },
|
||||
{ welt: 3, titel: "Das H", untertitel: "Zeigefinger rechts, in der Mitte", neu: ["h"],
|
||||
woerter: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
|
||||
{ welt: 3, titel: "Übung: G und H", untertitel: "Die Mitte der Grundreihe", neu: [],
|
||||
woerter: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
|
||||
{ welt: 3, titel: "Das C", untertitel: "Mittelfinger links nach unten", neu: ["c"],
|
||||
woerter: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
|
||||
{ welt: 3, titel: "Das V", untertitel: "Zeigefinger links nach unten", neu: ["v"],
|
||||
woerter: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
|
||||
{ welt: 3, titel: "Übung: C und V", untertitel: "Nach unten greifen", neu: [],
|
||||
woerter: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
|
||||
{ welt: 3, titel: "Das B", untertitel: "Zeigefinger links, neben dem V", neu: ["b"],
|
||||
woerter: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
|
||||
{ welt: 3, titel: "Das Y", untertitel: "Kleiner Finger links nach unten", neu: ["y"],
|
||||
woerter: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
|
||||
{ welt: 3, titel: "Übung: B und Y", untertitel: "Ganz unten links", neu: [],
|
||||
woerter: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
|
||||
{ welt: 3, titel: "Das X", untertitel: "Ringfinger links nach unten", neu: ["x"],
|
||||
woerter: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
|
||||
{ welt: 3, titel: "Das Ä", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["ä"],
|
||||
woerter: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] },
|
||||
{ welt: 3, titel: "Übung: alle Buchstaben", untertitel: "Das ganze Alphabet", neu: [],
|
||||
woerter: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
|
||||
// ---------------------------------------------------------------- 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"] },
|
||||
|
||||
// ---------------------------------------------------------------- Welt 4 --
|
||||
{ welt: 4, titel: "Umschalttaste rechts", untertitel: "Große Buchstaben der linken Hand", neu: ["⇧"],
|
||||
woerter: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff",
|
||||
// ---------------------------------------------------------------- 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"] },
|
||||
{ welt: 4, titel: "Umschalttaste links", untertitel: "Große Buchstaben der rechten Hand", neu: [],
|
||||
woerter: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe",
|
||||
{ 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"] },
|
||||
{ welt: 4, titel: "Übung: Namen", untertitel: "Namen fangen groß an", neu: [],
|
||||
woerter: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa",
|
||||
{ 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"] },
|
||||
{ welt: 4, titel: "Übung: große und kleine", untertitel: "Beides gemischt", neu: [],
|
||||
woerter: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel",
|
||||
{ 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"] },
|
||||
|
||||
// ---------------------------------------------------------------- Welt 5 --
|
||||
{ welt: 5, titel: "Der Punkt", untertitel: "Ringfinger rechts nach unten", neu: ["."], art: "saetze",
|
||||
woerter: [
|
||||
// ---------------------------------------------------------------- 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.",
|
||||
] },
|
||||
{ welt: 5, titel: "Das Komma", untertitel: "Mittelfinger rechts nach unten", neu: [","], art: "saetze",
|
||||
woerter: [
|
||||
{ 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.",
|
||||
] },
|
||||
{ welt: 5, titel: "Der Bindestrich", untertitel: "Kleiner Finger rechts, ganz außen", neu: ["-"], art: "saetze",
|
||||
woerter: [
|
||||
{ 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.",
|
||||
] },
|
||||
{ welt: 5, titel: "Fragezeichen und Ausrufezeichen", untertitel: "Mit der Umschalttaste", neu: ["ß", "1"], art: "saetze",
|
||||
woerter: [
|
||||
{ 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!",
|
||||
] },
|
||||
{ welt: 5, titel: "Übung: ganze Sätze", untertitel: "Alles zusammen", neu: [], art: "saetze",
|
||||
woerter: [
|
||||
{ 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!",
|
||||
@@ -253,33 +263,33 @@ function buildLessons(): Lesson[] {
|
||||
const active = new Set<string>();
|
||||
|
||||
PLAN.forEach((entry, i) => {
|
||||
for (const key of entry.neu) active.add(key);
|
||||
// The Leertaste lesson is where the whole Grundstellung comes together, so it
|
||||
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.neu.includes(LEERTASTE)) for (const key of GRUNDSTELLUNG) active.add(key);
|
||||
if (entry.newKeys.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 Fragezeichen lesson
|
||||
// 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 woerter = entry.woerter ?? [];
|
||||
const art: LessonArt = entry.art ?? (woerter.length > 0 ? "woerter" : "buchstaben");
|
||||
const words = entry.words ?? [];
|
||||
const kind: LessonKind = entry.kind ?? (words.length > 0 ? "words" : "letters");
|
||||
|
||||
lessons.push({
|
||||
id: `l${String(i + 1).padStart(2, "0")}`,
|
||||
welt: entry.welt,
|
||||
nummer: i + 1,
|
||||
titel: entry.titel,
|
||||
untertitel: entry.untertitel,
|
||||
neueKeys: entry.neu,
|
||||
world: entry.world,
|
||||
number: i + 1,
|
||||
title: entry.title,
|
||||
subtitle: entry.subtitle,
|
||||
newKeys: entry.newKeys,
|
||||
activeKeys,
|
||||
modi: art === "saetze" ? SATZ_MODI : art === "woerter" ? WORT_MODI : BUCHSTABEN_MODI,
|
||||
woerter,
|
||||
art,
|
||||
istUebung: entry.neu.length === 0,
|
||||
...laengeFuer(entry.welt),
|
||||
modes: kind === "sentences" ? SENTENCE_MODES : kind === "words" ? WORD_MODES : LETTER_MODES,
|
||||
words,
|
||||
kind,
|
||||
isDrill: entry.newKeys.length === 0,
|
||||
...lengthFor(entry.world, kind),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,8 +304,8 @@ export function lessonById(id: string): Lesson | null {
|
||||
return BY_ID.get(id) ?? null;
|
||||
}
|
||||
|
||||
export function lessonsOfWelt(welt: number): readonly Lesson[] {
|
||||
return LESSONS.filter((lesson) => lesson.welt === welt);
|
||||
export function lessonsOfWorld(world: number): readonly Lesson[] {
|
||||
return LESSONS.filter((lesson) => lesson.world === world);
|
||||
}
|
||||
|
||||
/** The lesson after this one, or null at the end of the curriculum. */
|
||||
|
||||
@@ -86,7 +86,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
|
||||
if (expected === undefined) return [state, []];
|
||||
|
||||
// The layout is what decides case, not the run: typing "A" where "a" is wanted is
|
||||
// correct. Capitals are their own lesson (Welt 4), and that lesson's target text
|
||||
// correct. Capitals are their own lesson (world 4), and that lesson's target text
|
||||
// carries the capital, so this comparison still teaches Shift where it matters.
|
||||
const correct = key.toLowerCase() === expected.toLowerCase();
|
||||
const startedAt = state.startedAt ?? now;
|
||||
@@ -117,7 +117,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
|
||||
}
|
||||
|
||||
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
||||
* was typed, so a half-finished Blasenplatzen round still earns its Perlen. */
|
||||
* was typed, so a half-finished bubbles round still earns its pearls. */
|
||||
export function abandonRun(state: RunState, now: number): RunState {
|
||||
if (isFinished(state) || state.startedAt === null) return state;
|
||||
return { ...state, finishedAt: now };
|
||||
|
||||
@@ -8,69 +8,69 @@
|
||||
* Keys are stored lowercase and compared lowercase - `event.key` for a capital letter
|
||||
* is "A", but it is still typed with the same finger as "a". */
|
||||
|
||||
export type Hand = "links" | "rechts";
|
||||
export type Hand = "left" | "right";
|
||||
|
||||
/** Finger ids, left pinky through right pinky, thumbs last. The order matters: it is
|
||||
* the left-to-right order the parent screen lists them in. */
|
||||
export type FingerId =
|
||||
| "links-klein"
|
||||
| "links-ring"
|
||||
| "links-mitte"
|
||||
| "links-zeige"
|
||||
| "rechts-zeige"
|
||||
| "rechts-mitte"
|
||||
| "rechts-ring"
|
||||
| "rechts-klein"
|
||||
| "daumen";
|
||||
| "left-pinky"
|
||||
| "left-ring"
|
||||
| "left-middle"
|
||||
| "left-index"
|
||||
| "right-index"
|
||||
| "right-middle"
|
||||
| "right-ring"
|
||||
| "right-pinky"
|
||||
| "thumb";
|
||||
|
||||
export interface Finger {
|
||||
id: FingerId;
|
||||
hand: Hand;
|
||||
/** What a six-year-old is told out loud: "der kleine Finger links". */
|
||||
label: string;
|
||||
/** The key this finger rests on in the Grundstellung. */
|
||||
/** The key this finger rests on in the home row. */
|
||||
home: string;
|
||||
/** oklch hue for the keyboard overlay, so "der grüne Finger" is a thing you can say. */
|
||||
hue: number;
|
||||
}
|
||||
|
||||
export const FINGERS: Record<FingerId, Finger> = {
|
||||
"links-klein": { id: "links-klein", hand: "links", label: "kleiner Finger links", home: "a", hue: 25 },
|
||||
"links-ring": { id: "links-ring", hand: "links", label: "Ringfinger links", home: "s", hue: 70 },
|
||||
"links-mitte": { id: "links-mitte", hand: "links", label: "Mittelfinger links", home: "d", hue: 140 },
|
||||
"links-zeige": { id: "links-zeige", hand: "links", label: "Zeigefinger links", home: "f", hue: 195 },
|
||||
"rechts-zeige": { id: "rechts-zeige", hand: "rechts", label: "Zeigefinger rechts", home: "j", hue: 250 },
|
||||
"rechts-mitte": { id: "rechts-mitte", hand: "rechts", label: "Mittelfinger rechts", home: "k", hue: 290 },
|
||||
"rechts-ring": { id: "rechts-ring", hand: "rechts", label: "Ringfinger rechts", home: "l", hue: 330 },
|
||||
"rechts-klein": { id: "rechts-klein", hand: "rechts", label: "kleiner Finger rechts", home: "ö", hue: 10 },
|
||||
daumen: { id: "daumen", hand: "rechts", label: "Daumen", home: " ", hue: 220 },
|
||||
"left-pinky": { id: "left-pinky", hand: "left", label: "kleiner Finger links", home: "a", hue: 25 },
|
||||
"left-ring": { id: "left-ring", hand: "left", label: "Ringfinger links", home: "s", hue: 70 },
|
||||
"left-middle": { id: "left-middle", hand: "left", label: "Mittelfinger links", home: "d", hue: 140 },
|
||||
"left-index": { id: "left-index", hand: "left", label: "Zeigefinger links", home: "f", hue: 195 },
|
||||
"right-index": { id: "right-index", hand: "right", label: "Zeigefinger rechts", home: "j", hue: 250 },
|
||||
"right-middle": { id: "right-middle", hand: "right", label: "Mittelfinger rechts", home: "k", hue: 290 },
|
||||
"right-ring": { id: "right-ring", hand: "right", label: "Ringfinger rechts", home: "l", hue: 330 },
|
||||
"right-pinky": { id: "right-pinky", hand: "right", label: "kleiner Finger rechts", home: "ö", hue: 10 },
|
||||
thumb: { id: "thumb", hand: "right", label: "Daumen", home: " ", hue: 220 },
|
||||
};
|
||||
|
||||
/** Which keys each finger owns, in the standard German assignment. The index fingers
|
||||
* carry two columns each (their home column plus the stretch inward), which is why
|
||||
* `links-zeige` has r/t and `rechts-zeige` has z/u. */
|
||||
* `left-index` has r/t and `right-index` has z/u. */
|
||||
const OWNED: Record<FingerId, string> = {
|
||||
"links-klein": "^1qay<",
|
||||
"links-ring": "2wsx",
|
||||
"links-mitte": "3edc",
|
||||
"links-zeige": "45rtfgvb",
|
||||
"rechts-zeige": "67zuhjnm",
|
||||
"rechts-mitte": "8ik,",
|
||||
"rechts-ring": "9ol.",
|
||||
"rechts-klein": "0ßpüöä-+#",
|
||||
daumen: " ",
|
||||
"left-pinky": "^1qay<",
|
||||
"left-ring": "2wsx",
|
||||
"left-middle": "3edc",
|
||||
"left-index": "45rtfgvb",
|
||||
"right-index": "67zuhjnm",
|
||||
"right-middle": "8ik,",
|
||||
"right-ring": "9ol.",
|
||||
"right-pinky": "0ßpüöä-+#",
|
||||
thumb: " ",
|
||||
};
|
||||
|
||||
/** Which row a key sits in, for the on-screen keyboard's layout and for the lesson
|
||||
* titles ("nach oben", "nach unten"). */
|
||||
export type RowId = "zahlen" | "oben" | "grund" | "unten" | "leer";
|
||||
export type RowId = "numbers" | "top" | "home" | "bottom" | "space";
|
||||
|
||||
const ROWS: Record<RowId, string> = {
|
||||
zahlen: "^1234567890ß",
|
||||
oben: "qwertzuiopü+",
|
||||
grund: "asdfghjklöä#",
|
||||
unten: "<yxcvbnm,.-",
|
||||
leer: " ",
|
||||
numbers: "^1234567890ß",
|
||||
top: "qwertzuiopü+",
|
||||
home: "asdfghjklöä#",
|
||||
bottom: "<yxcvbnm,.-",
|
||||
space: " ",
|
||||
};
|
||||
|
||||
const KEY_TO_FINGER = new Map<string, FingerId>();
|
||||
@@ -83,8 +83,8 @@ for (const [row, keys] of Object.entries(ROWS) as [RowId, string][]) {
|
||||
for (const key of keys) KEY_TO_ROW.set(key, row);
|
||||
}
|
||||
|
||||
/** The four rows as the on-screen keyboard draws them, top to bottom. */
|
||||
export const KEYBOARD_ROWS: readonly RowId[] = ["oben", "grund", "unten"];
|
||||
/** The three letter rows as the on-screen keyboard draws them, top to bottom. */
|
||||
export const KEYBOARD_ROWS: readonly RowId[] = ["top", "home", "bottom"];
|
||||
|
||||
export function keysInRow(row: RowId): readonly string[] {
|
||||
return [...(ROWS[row] ?? "")];
|
||||
@@ -92,7 +92,7 @@ export function keysInRow(row: RowId): readonly string[] {
|
||||
|
||||
/** Characters that need Shift on a German layout, mapped to the physical key that
|
||||
* carries them. Only the ones this course teaches; the rest of the number row can be
|
||||
* added when Welt 6 exists. */
|
||||
* added when world 6 exists. */
|
||||
const SHIFTED: Record<string, string> = {
|
||||
"!": "1",
|
||||
'"': "2",
|
||||
@@ -120,8 +120,8 @@ const SHIFTED: Record<string, string> = {
|
||||
* which finger the hint names, and whether a lesson can actually type a word. Without
|
||||
* it, "Wo ist der Delfin?" looks untypable and lights up nothing. */
|
||||
export function keyForChar(char: string): string {
|
||||
const klein = char.toLowerCase();
|
||||
if (klein !== char) return klein;
|
||||
const lower = char.toLowerCase();
|
||||
if (lower !== char) return lower;
|
||||
return SHIFTED[char] ?? char;
|
||||
}
|
||||
|
||||
@@ -151,15 +151,15 @@ export function homeKeyOf(key: string): string | null {
|
||||
return fingerOf(key)?.home ?? null;
|
||||
}
|
||||
|
||||
/** The Grundstellung itself, left to right. `LEERTASTE` is separate because the thumb
|
||||
/** The home row itself, left to right. `SPACE_KEY` is separate because the thumb
|
||||
* is the one finger that does not rest on a letter. */
|
||||
export const GRUNDSTELLUNG = ["a", "s", "d", "f", "j", "k", "l", "ö"] as const;
|
||||
export const LEERTASTE = " ";
|
||||
export const HOME_ROW = ["a", "s", "d", "f", "j", "k", "l", "ö"] as const;
|
||||
export const SPACE_KEY = " ";
|
||||
|
||||
/** A shifted character is typed with the Shift on the *opposite* hand - the single rule
|
||||
* that separates real touch typing from hunt-and-peck with a pinky cramp. */
|
||||
export function shiftHandFor(key: string): Hand | null {
|
||||
const hand = handOf(key);
|
||||
if (hand === null) return null;
|
||||
return hand === "links" ? "rechts" : "links";
|
||||
return hand === "left" ? "right" : "left";
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/** What the child actually types: drill lines built from a lesson's active keys.
|
||||
*
|
||||
* Seeded throughout (`mulberry32`), so a line is reproducible - which is what makes it
|
||||
* testable, and what lets the Delfinrennen replay a ghost against the identical text.
|
||||
* testable, and what lets the race mode replay a ghost against the identical text.
|
||||
*
|
||||
* Two ideas borrowed from keybr, simplified to what a six-year-old needs:
|
||||
*
|
||||
* - a *focus key* gets roughly double its natural share of the line, so the letter she
|
||||
* is slowest on is the letter she sees most;
|
||||
* - real words beat pseudo-words for motivation, so as soon as a lesson's active keys
|
||||
* can spell something real, the curated `woerter` list is preferred and `fjfj dkdk`
|
||||
* can spell something real, the curated `words` list is preferred and `fjfj dkdk`
|
||||
* stops appearing.
|
||||
*
|
||||
* Chunks, not one long string: the line is returned as short groups, because four
|
||||
* letters with a gap after them is something a six-year-old can find her place in and
|
||||
* twenty-four letters in a row is not. Lessons 1 and 2 have no Leertaste yet, so the
|
||||
* gap there is visual only - `lineText` joins without one and she never has to type a
|
||||
* space she has not been taught. */
|
||||
* twenty-four letters in a row is not. The gap is a real space from lesson 1 on, even
|
||||
* before the space-bar lesson formally teaches the thumb - a gap she can see but is
|
||||
* never asked to type would be more confusing, not less. */
|
||||
|
||||
/** A small, fast, seedable PRNG. Identical seed, identical line. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
@@ -45,32 +45,32 @@ export interface LineOptions {
|
||||
|
||||
/** The most of a line one key may ever occupy. Above this it stops being practice and
|
||||
* starts being a stutter - and on a small key set it starves the other fingers. */
|
||||
const MAX_FOKUS_ANTEIL = 0.3;
|
||||
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.
|
||||
*
|
||||
* Without this, Lektion 2 ("Die rechte Hand") drew evenly from all eight home keys and
|
||||
* 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 "die rechte Hand" should drill. */
|
||||
const NEU_ANTEIL = 0.6;
|
||||
* lesson called "the right hand" should drill. */
|
||||
const NEW_KEY_SHARE = 0.6;
|
||||
|
||||
/** Copies of each new key needed to reach `NEU_ANTEIL` of the pool, clamped so a lesson
|
||||
* with one new key and many old ones does not bury the review entirely. */
|
||||
function neuKopien(neu: number, alt: number): number {
|
||||
if (neu === 0 || alt === 0) return 1;
|
||||
const exakt = (NEU_ANTEIL * alt) / (neu * (1 - NEU_ANTEIL));
|
||||
return Math.max(1, Math.min(6, Math.round(exakt)));
|
||||
/** 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 {
|
||||
if (newCount === 0 || oldCount === 0) return 1;
|
||||
const exact = (NEW_KEY_SHARE * oldCount) / (newCount * (1 - NEW_KEY_SHARE));
|
||||
return Math.max(1, Math.min(6, Math.round(exact)));
|
||||
}
|
||||
|
||||
/** How many extra copies of the focus key to add to a pool of `n` letters without its
|
||||
* share passing `MAX_FOKUS_ANTEIL`. Small sets get no boost at all: with four active
|
||||
* share passing `MAX_FOCUS_SHARE`. Small sets get no boost at all: with four active
|
||||
* keys every one of them is already drilled constantly. */
|
||||
function fokusKopien(n: number): number {
|
||||
let kopien = 0;
|
||||
while (kopien < 2 && (1 + kopien + 1) / (n + kopien + 1) <= MAX_FOKUS_ANTEIL) kopien++;
|
||||
return kopien;
|
||||
function focusKeyCopies(n: number): number {
|
||||
let copies = 0;
|
||||
while (copies < 2 && (1 + copies + 1) / (n + copies + 1) <= MAX_FOCUS_SHARE) copies++;
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** Build a weighted alphabet: every active key at least once, the lesson's new keys
|
||||
@@ -85,18 +85,18 @@ function weighted(
|
||||
const letters = activeKeys.filter((key) => key !== " ");
|
||||
if (letters.length === 0) return [];
|
||||
|
||||
// Shift and the Leertaste are taught by the target text, not by the letter pool.
|
||||
const neu = newKeys.filter((key) => letters.includes(key));
|
||||
const alt = letters.filter((key) => !neu.includes(key));
|
||||
// Shift and the space bar are taught by the target text, not by the letter pool.
|
||||
const newActive = newKeys.filter((key) => letters.includes(key));
|
||||
const oldActive = letters.filter((key) => !newActive.includes(key));
|
||||
|
||||
const pool = [...letters];
|
||||
if (neu.length > 0 && alt.length > 0) {
|
||||
const kopien = neuKopien(neu.length, alt.length);
|
||||
for (const key of neu) for (let i = 1; i < kopien; i++) pool.push(key);
|
||||
if (newActive.length > 0 && oldActive.length > 0) {
|
||||
const copies = newKeyCopies(newActive.length, oldActive.length);
|
||||
for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
|
||||
}
|
||||
|
||||
if (focusKey && letters.includes(focusKey)) {
|
||||
for (let i = 0; i < fokusKopien(letters.length); i++) pool.push(focusKey);
|
||||
for (let i = 0; i < focusKeyCopies(letters.length); i++) pool.push(focusKey);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
@@ -104,7 +104,7 @@ function weighted(
|
||||
/** Draw from a shuffled bag rather than sampling independently.
|
||||
*
|
||||
* Independent sampling is lumpy over the length of one line, and lumpy is not a
|
||||
* cosmetic problem here: a real generated Lektion-1 line came out as
|
||||
* cosmetic problem here: a real generated lesson-1 line came out as
|
||||
* `saadaaafasaassfssffsafsf` - `d` once in twenty-four characters, so the middle finger
|
||||
* got one repetition while the little finger got nine. Averaged over a hundred lines
|
||||
* that is fine; the child types one line.
|
||||
@@ -131,7 +131,7 @@ function bagDraw(pool: readonly string[], count: number, rng: Rng): string[] {
|
||||
}
|
||||
|
||||
/** A line of pseudo-word chunks over the lesson's active keys. Used by every lesson,
|
||||
* and the only option for Welt 1 where nothing real can be spelled yet. */
|
||||
* and the only option for world 1 where nothing real can be spelled yet. */
|
||||
export function drillChunks(
|
||||
activeKeys: readonly string[],
|
||||
rng: Rng,
|
||||
@@ -152,23 +152,23 @@ export function drillChunks(
|
||||
/** A line of real German words (or sentences), or `null` when the lesson has none yet.
|
||||
*
|
||||
* Drawn from the same shuffled bag as the letters. Once a round became twenty-five
|
||||
* words long, independent picks from an eight-word list started clumping: a real Welt 5
|
||||
* round came out with "Das Meer ist blau, tief und kalt." four times in ten sentences.
|
||||
* The bag uses every word once before any word twice, so repeats are as far apart as the
|
||||
* list allows. */
|
||||
* words long, independent picks from an eight-word list started clumping: a real
|
||||
* world-5 round came out with "Das Meer ist blau, tief und kalt." four times in ten
|
||||
* sentences. The bag uses every word once before any word twice, so repeats are as far
|
||||
* apart as the list allows. */
|
||||
export function wordChunks(
|
||||
woerter: readonly string[],
|
||||
words: readonly string[],
|
||||
rng: Rng,
|
||||
options: LineOptions = {},
|
||||
): string[] | null {
|
||||
if (woerter.length === 0) return null;
|
||||
if (words.length === 0) return null;
|
||||
const { chunks = 5, focusKey = null } = options;
|
||||
|
||||
// Words containing the focus key go in twice, same trick as the letter pool.
|
||||
const pool = [...woerter];
|
||||
const pool = [...words];
|
||||
if (focusKey) {
|
||||
for (const wort of woerter) {
|
||||
if (wort.toLowerCase().includes(focusKey.toLowerCase())) pool.push(wort);
|
||||
for (const word of words) {
|
||||
if (word.toLowerCase().includes(focusKey.toLowerCase())) pool.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,32 +177,30 @@ export function wordChunks(
|
||||
// later word when there is one - but never loop, since a one-word list must still work.
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
if (out[i] !== out[i - 1]) continue;
|
||||
const tausch = out.findIndex((wort, j) => j > i && wort !== out[i]);
|
||||
if (tausch > 0) [out[i], out[tausch]] = [out[tausch]!, out[i]!];
|
||||
const swapIndex = out.findIndex((word, j) => j > i && word !== out[i]);
|
||||
if (swapIndex > 0) [out[i], out[swapIndex]] = [out[swapIndex]!, out[i]!];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The line a lesson should show, given what it can spell. Word lessons alternate:
|
||||
* `preferWords` lets a mode ask for letters even in a late lesson (Quallenalarm is
|
||||
* always single letters) or for words wherever they exist (Fütterungszeit). */
|
||||
* `preferWords` lets a mode ask for letters even in a late lesson (jellyfish mode is
|
||||
* always single letters) or for words wherever they exist (feed mode). */
|
||||
export function lineFor(
|
||||
lesson: { activeKeys: readonly string[]; woerter: readonly string[]; neueKeys?: readonly string[] },
|
||||
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
|
||||
rng: Rng,
|
||||
options: LineOptions & { preferWords?: boolean } = {},
|
||||
): string[] {
|
||||
const { preferWords = true, ...rest } = options;
|
||||
const withNew = { newKeys: lesson.neueKeys ?? [], ...rest };
|
||||
const withNew = { newKeys: lesson.newKeys ?? [], ...rest };
|
||||
if (preferWords) {
|
||||
const words = wordChunks(lesson.woerter, rng, withNew);
|
||||
const words = wordChunks(lesson.words, rng, withNew);
|
||||
if (words) return words;
|
||||
}
|
||||
return drillChunks(lesson.activeKeys, rng, withNew);
|
||||
}
|
||||
|
||||
/** Join chunks into the string the engine types against. With the Leertaste active the
|
||||
* gaps are real spaces she must type; before that they are purely visual and the text
|
||||
* runs together. */
|
||||
/** Join chunks into the string the engine types against. */
|
||||
export function lineText(chunks: readonly string[], spaceActive: boolean): string {
|
||||
return chunks.join(spaceActive ? " " : "");
|
||||
}
|
||||
@@ -219,8 +217,8 @@ export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): n
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/** Single letters for Blasenplatzen and Quallenalarm: one key per bubble, drawn from the
|
||||
* same bag, so the arcade modes drill the same spread as the Tauchgang. */
|
||||
/** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from
|
||||
* the same bag, so the arcade modes drill the same spread as the dive mode. */
|
||||
export function letterStream(
|
||||
activeKeys: readonly string[],
|
||||
rng: Rng,
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
*
|
||||
* Two decisions here are the whole pedagogy of the game, so they are worth stating:
|
||||
*
|
||||
* *Zeichen pro Minute, not Wörter pro Minute.* German words are long and a six-year-old
|
||||
* types around four words a minute honestly measured. "4" on a results screen reads as
|
||||
* failure; "38 Zeichen pro Minute" reads as a number that visibly grows. Same data,
|
||||
* different message.
|
||||
* *Characters per minute, not words per minute.* German words are long and a
|
||||
* six-year-old types around four words a minute honestly measured. "4" on a results
|
||||
* screen reads as failure; "38 Zeichen pro Minute" reads as a number that visibly
|
||||
* grows. Same data, different message.
|
||||
*
|
||||
* *punkte = tempo × genauigkeit³, not the textbook Netto-WPM.* The standard formula is
|
||||
* `netto = brutto − fehler/minute`, which goes negative for a beginner - the one result
|
||||
* *points = speed × accuracy³, not the textbook net WPM.* The standard formula is
|
||||
* `net = gross − errors/minute`, which goes negative for a beginner - the one result
|
||||
* that must never appear. The multiplicative form cannot: 95% accuracy keeps 86% of the
|
||||
* speed, 90% keeps 73%, 80% keeps 51%. Careful-and-slow beats fast-and-sloppy, which is
|
||||
* the habit worth building at this age.
|
||||
@@ -20,191 +20,191 @@ import type { RunState, Stroke } from "./engine";
|
||||
|
||||
export interface RunResult {
|
||||
/** Correct keystrokes. */
|
||||
zeichen: number;
|
||||
characters: number;
|
||||
/** Wrong keystrokes, counted once per position (see engine.ts rule 2). */
|
||||
fehler: number;
|
||||
errors: number;
|
||||
/** ms from the first keystroke to the last. */
|
||||
dauer: number;
|
||||
/** Zeichen pro Minute. */
|
||||
tempo: number;
|
||||
duration: number;
|
||||
/** Characters per minute. */
|
||||
speed: number;
|
||||
/** 0..1 */
|
||||
genauigkeit: number;
|
||||
/** `tempo × genauigkeit³`, the number the animal is read off. */
|
||||
punkte: number;
|
||||
sterne: 0 | 1 | 2 | 3;
|
||||
tier: TierId;
|
||||
accuracy: number;
|
||||
/** `speed × accuracy³`, the number the animal is read off. */
|
||||
points: number;
|
||||
stars: 0 | 1 | 2 | 3;
|
||||
animal: AnimalId;
|
||||
/** Whether this run unlocks the next lesson on its own. */
|
||||
bestanden: boolean;
|
||||
/** Perlen earned - the aquarium currency. */
|
||||
perlen: number;
|
||||
/** Kept for the Delfinrennen ghost and the per-key stats. */
|
||||
passed: boolean;
|
||||
/** Pearls earned - the aquarium currency. */
|
||||
pearls: number;
|
||||
/** Kept for the race-mode ghost and the per-key stats. */
|
||||
strokes: readonly Stroke[];
|
||||
}
|
||||
|
||||
export type TierId =
|
||||
| "schnecke"
|
||||
| "krabbe"
|
||||
| "schildkroete"
|
||||
| "qualle"
|
||||
| "fisch"
|
||||
| "pinguin"
|
||||
| "robbe"
|
||||
| "delfin"
|
||||
| "hai"
|
||||
| "schwertwal";
|
||||
export type AnimalId =
|
||||
| "snail"
|
||||
| "crab"
|
||||
| "turtle"
|
||||
| "jellyfish"
|
||||
| "fish"
|
||||
| "penguin"
|
||||
| "seal"
|
||||
| "dolphin"
|
||||
| "shark"
|
||||
| "orca";
|
||||
|
||||
export interface Tier {
|
||||
id: TierId;
|
||||
export interface Animal {
|
||||
id: AnimalId;
|
||||
name: string;
|
||||
emoji: string;
|
||||
/** Lower bound in Punkte (Zeichen pro Minute, accuracy-weighted). */
|
||||
ab: number;
|
||||
/** What the Sprachausgabe says when this tier is reached. */
|
||||
lob: string;
|
||||
/** Lower bound in points (characters per minute, accuracy-weighted). */
|
||||
from: number;
|
||||
/** What the speech synthesis says when this animal is reached. */
|
||||
praise: string;
|
||||
}
|
||||
|
||||
/** Nine Meerestiere, slowest first. Thresholds are calibrated against the "5 WPM pro
|
||||
* Klassenstufe" school rule - roughly 25 Zeichen/min at the end of first grade - with
|
||||
* plenty of headroom above it.
|
||||
/** Nine sea animals, slowest first. Thresholds are calibrated against the "5 WPM pro
|
||||
* Klassenstufe" school rule - roughly 25 characters/min at the end of first grade -
|
||||
* with plenty of headroom above it.
|
||||
*
|
||||
* The Delfin sits deliberately high, at about 24 WPM, one below the top. It is a
|
||||
* The dolphin sits deliberately high, at about 24 WPM, one below the top. It is a
|
||||
* realistic second- or third-grade target, which means it stays out of reach and
|
||||
* therefore worth chasing for a long time. Reaching it should feel like an event.
|
||||
*
|
||||
* Every emoji here is checked to render as the animal it names - the first draft used
|
||||
* 🗡️ for a sailfish (it is a dagger) and 🎐 for a jellyfish (it is a wind chime), and
|
||||
* ⭐ for a starfish, which collided with the star rating three lines below it. */
|
||||
export const TIERS: readonly Tier[] = [
|
||||
{ id: "schnecke", name: "Meeresschnecke", emoji: "🐌", ab: 0, lob: "Die Schnecke ist losgekrochen!" },
|
||||
{ id: "krabbe", name: "Krabbe", emoji: "🦀", ab: 15, lob: "Eine Krabbe! Die krabbelt schon los." },
|
||||
{ id: "schildkroete", name: "Schildkröte", emoji: "🐢", ab: 25, lob: "Die Schildkröte ist stetig und sicher." },
|
||||
{ id: "qualle", name: "Qualle", emoji: "🪼", ab: 40, lob: "Die Qualle gleitet dahin!" },
|
||||
{ id: "fisch", name: "Fisch", emoji: "🐟", ab: 55, lob: "Ein Fisch! Der schwimmt richtig flott." },
|
||||
{ id: "pinguin", name: "Pinguin", emoji: "🐧", ab: 75, lob: "Ein Pinguin! Der flitzt durchs Wasser." },
|
||||
{ id: "robbe", name: "Robbe", emoji: "🦭", ab: 95, lob: "Die Robbe ist schnell und geschickt!" },
|
||||
{ id: "delfin", name: "Delfin", emoji: "🐬", ab: 120, lob: "Ein Delfin! Das ist richtig, richtig schnell." },
|
||||
{ id: "hai", name: "Hai", emoji: "🦈", ab: 150, lob: "Ein Hai! Unglaublich schnell." },
|
||||
{ id: "schwertwal", name: "Schwertwal", emoji: "🐋", ab: 190, lob: "Ein Schwertwal! Schneller wird es im Meer nicht." },
|
||||
export const ANIMALS: readonly Animal[] = [
|
||||
{ id: "snail", name: "Meeresschnecke", emoji: "🐌", from: 0, praise: "Die Schnecke ist losgekrochen!" },
|
||||
{ id: "crab", name: "Krabbe", emoji: "🦀", from: 15, praise: "Eine Krabbe! Die krabbelt schon los." },
|
||||
{ id: "turtle", name: "Schildkröte", emoji: "🐢", from: 25, praise: "Die Schildkröte ist stetig und sicher." },
|
||||
{ id: "jellyfish", name: "Qualle", emoji: "🪼", from: 40, praise: "Die Qualle gleitet dahin!" },
|
||||
{ id: "fish", name: "Fisch", emoji: "🐟", from: 55, praise: "Ein Fisch! Der schwimmt richtig flott." },
|
||||
{ id: "penguin", name: "Pinguin", emoji: "🐧", from: 75, praise: "Ein Pinguin! Der flitzt durchs Wasser." },
|
||||
{ id: "seal", name: "Robbe", emoji: "🦭", from: 95, praise: "Die Robbe ist schnell und geschickt!" },
|
||||
{ id: "dolphin", name: "Delfin", emoji: "🐬", from: 120, praise: "Ein Delfin! Das ist richtig, richtig schnell." },
|
||||
{ id: "shark", name: "Hai", emoji: "🦈", from: 150, praise: "Ein Hai! Unglaublich schnell." },
|
||||
{ id: "orca", name: "Schwertwal", emoji: "🐋", from: 190, praise: "Ein Schwertwal! Schneller wird es im Meer nicht." },
|
||||
];
|
||||
|
||||
/** The last animal shown on the ladder before a run has been fast enough to earn it.
|
||||
* Everything above the Delfin stays hidden until it is actually reached - see
|
||||
* `sichtbareTiers`. */
|
||||
export const UEBERRASCHUNG_AB: TierId = "delfin";
|
||||
* Everything above the dolphin stays hidden until it is actually reached - see
|
||||
* `visibleAnimals`. */
|
||||
export const SURPRISE_FROM: AnimalId = "dolphin";
|
||||
|
||||
export function tierIndex(id: TierId): number {
|
||||
return TIERS.findIndex((tier) => tier.id === id);
|
||||
export function animalIndex(id: AnimalId): number {
|
||||
return ANIMALS.findIndex((animal) => animal.id === id);
|
||||
}
|
||||
|
||||
/** Which slice of the ladder a result screen may show.
|
||||
*
|
||||
* Everything up to the Delfin is always visible, earned or not: seeing the animals you
|
||||
* Everything up to the dolphin is always visible, earned or not: seeing the animals you
|
||||
* have not reached yet is the whole reason to try again, and a six-year-old needs the
|
||||
* next rung to be visible to aim at it. Above the Delfin the ladder goes dark - those
|
||||
* next rung to be visible to aim at it. Above the dolphin the ladder goes dark - those
|
||||
* are a surprise, revealed only once they have actually been reached, and then they stay
|
||||
* revealed. `bestEver` is the fastest animal earned on any lesson so far. */
|
||||
export function sichtbareTiers(earned: TierId, bestEver: TierId | null): {
|
||||
tiers: readonly Tier[];
|
||||
export function visibleAnimals(earned: AnimalId, bestEver: AnimalId | null): {
|
||||
animals: readonly Animal[];
|
||||
/** True when faster animals exist that have not been revealed yet. */
|
||||
mehrVerborgen: boolean;
|
||||
moreHidden: boolean;
|
||||
} {
|
||||
const grenze = Math.max(
|
||||
tierIndex(UEBERRASCHUNG_AB),
|
||||
tierIndex(earned),
|
||||
bestEver ? tierIndex(bestEver) : -1,
|
||||
const boundary = Math.max(
|
||||
animalIndex(SURPRISE_FROM),
|
||||
animalIndex(earned),
|
||||
bestEver ? animalIndex(bestEver) : -1,
|
||||
);
|
||||
return {
|
||||
tiers: TIERS.slice(0, grenze + 1),
|
||||
mehrVerborgen: grenze < TIERS.length - 1,
|
||||
animals: ANIMALS.slice(0, boundary + 1),
|
||||
moreHidden: boundary < ANIMALS.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
const TIER_BY_ID = new Map(TIERS.map((tier) => [tier.id, tier]));
|
||||
const ANIMAL_BY_ID = new Map(ANIMALS.map((animal) => [animal.id, animal]));
|
||||
|
||||
export function tierById(id: TierId): Tier {
|
||||
// Every TierId comes from TIERS itself, so this cannot miss - but the map lookup is
|
||||
// typed as possibly-undefined and `noUncheckedIndexedAccess` is on.
|
||||
return TIER_BY_ID.get(id) ?? TIERS[0]!;
|
||||
export function animalById(id: AnimalId): Animal {
|
||||
// Every AnimalId comes from ANIMALS itself, so this cannot miss - but the map lookup
|
||||
// is typed as possibly-undefined and `noUncheckedIndexedAccess` is on.
|
||||
return ANIMAL_BY_ID.get(id) ?? ANIMALS[0]!;
|
||||
}
|
||||
|
||||
/** The animal for a score. Walks from the fastest down, so the first match wins. */
|
||||
export function tierFor(punkte: number): Tier {
|
||||
for (let i = TIERS.length - 1; i >= 0; i--) {
|
||||
const tier = TIERS[i]!;
|
||||
if (punkte >= tier.ab) return tier;
|
||||
export function animalFor(points: number): Animal {
|
||||
for (let i = ANIMALS.length - 1; i >= 0; i--) {
|
||||
const animal = ANIMALS[i]!;
|
||||
if (points >= animal.from) return animal;
|
||||
}
|
||||
return TIERS[0]!;
|
||||
return ANIMALS[0]!;
|
||||
}
|
||||
|
||||
/** How far along the current tier this score is, 0..1 - drives the progress bar that
|
||||
* shows how close the next animal is. The top tier is always full. */
|
||||
export function tierProgress(punkte: number): number {
|
||||
const index = TIERS.findIndex((tier) => tier.id === tierFor(punkte).id);
|
||||
const next = TIERS[index + 1];
|
||||
/** How far along the current animal this score is, 0..1 - drives the progress bar that
|
||||
* shows how close the next animal is. The top animal is always full. */
|
||||
export function animalProgress(points: number): number {
|
||||
const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id);
|
||||
const next = ANIMALS[index + 1];
|
||||
if (!next) return 1;
|
||||
const floor = TIERS[index]!.ab;
|
||||
return Math.min(1, Math.max(0, (punkte - floor) / (next.ab - floor)));
|
||||
const floor = ANIMALS[index]!.from;
|
||||
return Math.min(1, Math.max(0, (points - floor) / (next.from - floor)));
|
||||
}
|
||||
|
||||
/** Star thresholds, on accuracy alone. The familiar 1/2/3 pattern from every other
|
||||
* game she will ever play, so it needs no explaining. */
|
||||
export const STERNE_SCHWELLEN = { eins: 0.85, zwei: 0.93, drei: 0.97 } as const;
|
||||
export const STAR_THRESHOLDS = { one: 0.85, two: 0.93, three: 0.97 } as const;
|
||||
|
||||
export function sterneFor(genauigkeit: number): 0 | 1 | 2 | 3 {
|
||||
if (genauigkeit >= STERNE_SCHWELLEN.drei) return 3;
|
||||
if (genauigkeit >= STERNE_SCHWELLEN.zwei) return 2;
|
||||
if (genauigkeit >= STERNE_SCHWELLEN.eins) return 1;
|
||||
export function starsFor(accuracy: number): 0 | 1 | 2 | 3 {
|
||||
if (accuracy >= STAR_THRESHOLDS.three) return 3;
|
||||
if (accuracy >= STAR_THRESHOLDS.two) return 2;
|
||||
if (accuracy >= STAR_THRESHOLDS.one) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Two stars unlocks the next lesson. No speed condition anywhere - that is the point. */
|
||||
export function isPassed(genauigkeit: number): boolean {
|
||||
return genauigkeit >= STERNE_SCHWELLEN.zwei;
|
||||
export function isPassed(accuracy: number): boolean {
|
||||
return accuracy >= STAR_THRESHOLDS.two;
|
||||
}
|
||||
|
||||
/** One Perle per five correct keys, plus a bonus per star. Small numbers that go up
|
||||
/** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
|
||||
* every single run, including a bad one - the aquarium should never stall. */
|
||||
function perlenFor(zeichen: number, sterne: number): number {
|
||||
return Math.floor(zeichen / 5) + sterne * 2;
|
||||
function pearlsFor(characters: number, stars: number): number {
|
||||
return Math.floor(characters / 5) + stars * 2;
|
||||
}
|
||||
|
||||
export function grade(state: RunState): RunResult {
|
||||
const zeichen = state.strokes.filter((stroke) => stroke.correct).length;
|
||||
const fehler = state.missed.size;
|
||||
const characters = state.strokes.filter((stroke) => stroke.correct).length;
|
||||
const errors = state.missed.size;
|
||||
const start = state.startedAt;
|
||||
const end = state.finishedAt ?? state.strokes.at(-1)?.at ?? start;
|
||||
|
||||
// A run of one keystroke has no elapsed time between first and last. Treating that as
|
||||
// "infinitely fast" would hand out a Segelfisch for a single letter, so anything under
|
||||
// a second of real typing is scored as if it took a second.
|
||||
const dauer = start !== null && end !== null ? Math.max(0, end - start) : 0;
|
||||
const minuten = Math.max(dauer, 1000) / 60000;
|
||||
// "infinitely fast" would hand out a top-tier animal for a single letter, so anything
|
||||
// under a second of real typing is scored as if it took a second.
|
||||
const duration = start !== null && end !== null ? Math.max(0, end - start) : 0;
|
||||
const minutes = Math.max(duration, 1000) / 60000;
|
||||
|
||||
const tempo = zeichen / minuten;
|
||||
const versuche = zeichen + fehler;
|
||||
const genauigkeit = versuche === 0 ? 0 : zeichen / versuche;
|
||||
const punkte = tempo * genauigkeit ** 3;
|
||||
const sterne = sterneFor(genauigkeit);
|
||||
const speed = characters / minutes;
|
||||
const attempts = characters + errors;
|
||||
const accuracy = attempts === 0 ? 0 : characters / attempts;
|
||||
const points = speed * accuracy ** 3;
|
||||
const stars = starsFor(accuracy);
|
||||
|
||||
return {
|
||||
zeichen,
|
||||
fehler,
|
||||
dauer,
|
||||
tempo,
|
||||
genauigkeit,
|
||||
punkte,
|
||||
sterne,
|
||||
tier: tierFor(punkte).id,
|
||||
bestanden: isPassed(genauigkeit),
|
||||
perlen: perlenFor(zeichen, sterne),
|
||||
characters,
|
||||
errors,
|
||||
duration,
|
||||
speed,
|
||||
accuracy,
|
||||
points,
|
||||
stars,
|
||||
animal: animalFor(points).id,
|
||||
passed: isPassed(accuracy),
|
||||
pearls: pearlsFor(characters, stars),
|
||||
strokes: state.strokes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Which of two results is the better one, for the per-lesson personal best. Stars come
|
||||
* first, Punkte break the tie - so a careful run is never displaced by a sloppy fast
|
||||
* first, points break the tie - so a careful run is never displaced by a sloppy fast
|
||||
* one, and the best animal on a lesson card can never go down. */
|
||||
export function isBetter(candidate: RunResult, best: { sterne: number; punkte: number }): boolean {
|
||||
if (candidate.sterne !== best.sterne) return candidate.sterne > best.sterne;
|
||||
return candidate.punkte > best.punkte;
|
||||
export function isBetter(candidate: RunResult, best: { stars: number; points: number }): boolean {
|
||||
if (candidate.stars !== best.stars) return candidate.stars > best.stars;
|
||||
return candidate.points > best.points;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
let context: AudioContext | null = null;
|
||||
|
||||
function blip(from: number, to: number, gainStart: number, dauer: number): void {
|
||||
function blip(from: number, to: number, gainStart: number, duration: number): void {
|
||||
try {
|
||||
context ??= new AudioContext();
|
||||
const now = context.currentTime;
|
||||
@@ -15,12 +15,12 @@ function blip(from: number, to: number, gainStart: number, dauer: number): void
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(from, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + dauer * 0.55);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration * 0.55);
|
||||
gain.gain.setValueAtTime(gainStart, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + dauer);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start();
|
||||
oscillator.stop(now + dauer + 0.01);
|
||||
oscillator.stop(now + duration + 0.01);
|
||||
} catch {
|
||||
// No audio context before the first user gesture, and on some browsers never.
|
||||
}
|
||||
@@ -33,18 +33,18 @@ export function playPop(frequency: number): void {
|
||||
|
||||
/** A correct key. The pitch climbs with the streak, so a good run audibly builds - it
|
||||
* caps at an octave up, past which it just sounds shrill. */
|
||||
export function playRichtig(streak: number): void {
|
||||
const halbtoene = Math.min(streak, 12);
|
||||
blip(440 * 2 ** (halbtoene / 12), 660 * 2 ** (halbtoene / 12), 0.1, 0.09);
|
||||
export function playCorrect(streak: number): void {
|
||||
const semitones = Math.min(streak, 12);
|
||||
blip(440 * 2 ** (semitones / 12), 660 * 2 ** (semitones / 12), 0.1, 0.09);
|
||||
}
|
||||
|
||||
/** A wrong key: low, falling, quiet. */
|
||||
export function playDaneben(): void {
|
||||
export function playWrong(): void {
|
||||
blip(200, 150, 0.06, 0.12);
|
||||
}
|
||||
|
||||
/** Finishing a line. */
|
||||
export function playFertig(): void {
|
||||
export function playDone(): void {
|
||||
blip(520, 900, 0.16, 0.4);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
* a quota that is full - none of those may stop the game from being playable. They just
|
||||
* make it forgetful. */
|
||||
|
||||
import { kreaturAus } from "./aquarium";
|
||||
import type { KreaturId } from "./aquarium";
|
||||
import { FIRST_LESSON_ID, LESSONS, WELTEN, nextLesson } from "./curriculum";
|
||||
import type { RunResult, TierId } from "./grading";
|
||||
import { creatureFromRaw } from "./aquarium";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import { FIRST_LESSON_ID, LESSONS, WORLDS, nextLesson } from "./curriculum";
|
||||
import type { RunResult, AnimalId } from "./grading";
|
||||
import { isBetter } from "./grading";
|
||||
|
||||
const STORAGE_KEY = "delfin-tippen:v1";
|
||||
@@ -20,15 +20,15 @@ const STORAGE_KEY = "delfin-tippen:v1";
|
||||
/** How many attempts at one lesson unlock the next regardless of score. The safety
|
||||
* valve against getting stuck on a single stubborn key - which at six is the difference
|
||||
* between a game she returns to and one she does not. */
|
||||
export const FLEISS_VERSUCHE = 5;
|
||||
export const DILIGENCE_ATTEMPTS = 5;
|
||||
|
||||
export interface LessonProgress {
|
||||
unlocked: boolean;
|
||||
runs: number;
|
||||
bestSterne: 0 | 1 | 2 | 3;
|
||||
bestTier: TierId | null;
|
||||
bestPunkte: number;
|
||||
/** Best-run keystrokes, replayed as the opponent in Delfinrennen. */
|
||||
bestStars: 0 | 1 | 2 | 3;
|
||||
bestAnimal: AnimalId | null;
|
||||
bestPoints: number;
|
||||
/** Best-run keystrokes, replayed as the opponent in race mode. */
|
||||
ghost: { key: string; at: number }[] | null;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface KeyStat {
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
speech: boolean;
|
||||
sound: boolean;
|
||||
keyboardHint: "auto" | "on" | "off";
|
||||
}
|
||||
@@ -50,15 +49,15 @@ export interface Progress {
|
||||
version: 1;
|
||||
lessons: Record<string, LessonProgress>;
|
||||
keyStats: Record<string, KeyStat>;
|
||||
perlen: number;
|
||||
pearls: number;
|
||||
/** Pets that have moved into the aquarium, in the order they arrived. */
|
||||
aquarium: KreaturId[];
|
||||
aquarium: CreatureId[];
|
||||
streak: { days: number; lastPlayed: string | null };
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
function emptyLesson(unlocked: boolean): LessonProgress {
|
||||
return { unlocked, runs: 0, bestSterne: 0, bestTier: null, bestPunkte: 0, ghost: null };
|
||||
return { unlocked, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null };
|
||||
}
|
||||
|
||||
export function freshProgress(): Progress {
|
||||
@@ -70,10 +69,10 @@ export function freshProgress(): Progress {
|
||||
version: 1,
|
||||
lessons,
|
||||
keyStats: {},
|
||||
perlen: 0,
|
||||
pearls: 0,
|
||||
aquarium: [],
|
||||
streak: { days: 0, lastPlayed: null },
|
||||
settings: { speech: true, sound: true, keyboardHint: "auto" },
|
||||
settings: { sound: true, keyboardHint: "auto" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,7 +100,7 @@ export function migrate(raw: unknown): Progress {
|
||||
version: 1,
|
||||
lessons,
|
||||
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {},
|
||||
perlen: typeof stored.perlen === "number" ? stored.perlen : 0,
|
||||
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
|
||||
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
|
||||
streak:
|
||||
typeof stored.streak === "object" && stored.streak !== null
|
||||
@@ -112,15 +111,15 @@ export function migrate(raw: unknown): Progress {
|
||||
}
|
||||
|
||||
/** Saves from before the pets were drawings hold emoji here. Those map onto the creature
|
||||
* that fills the same Welt today; duplicates and anything unrecognisable are dropped, so
|
||||
* one stray entry cannot put a broken image in the tank. */
|
||||
function migrateAquarium(raw: readonly unknown[]): KreaturId[] {
|
||||
const tiere: KreaturId[] = [];
|
||||
for (const eintrag of raw) {
|
||||
const id = kreaturAus(eintrag);
|
||||
if (id && !tiere.includes(id)) tiere.push(id);
|
||||
* that fills the same world today; duplicates and anything unrecognisable are dropped,
|
||||
* so one stray entry cannot put a broken image in the tank. */
|
||||
function migrateAquarium(raw: readonly unknown[]): CreatureId[] {
|
||||
const creatures: CreatureId[] = [];
|
||||
for (const entry of raw) {
|
||||
const id = creatureFromRaw(entry);
|
||||
if (id && !creatures.includes(id)) creatures.push(id);
|
||||
}
|
||||
return tiere;
|
||||
return creatures;
|
||||
}
|
||||
|
||||
export function loadProgress(): Progress {
|
||||
@@ -181,20 +180,20 @@ function foldKeyStats(stats: Record<string, KeyStat>, result: RunResult): Record
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Welt number to the creature it releases, read straight off the Welten table so it
|
||||
/** World number to the creature it releases, read straight off the worlds table so it
|
||||
* cannot drift from curriculum.ts. */
|
||||
const WELT_BELOHNUNG = new Map(WELTEN.map((welt) => [welt.nummer, welt.belohnung]));
|
||||
const WORLD_REWARD = new Map(WORLDS.map((world) => [world.number, world.reward]));
|
||||
|
||||
export interface RecordOutcome {
|
||||
progress: Progress;
|
||||
/** Set when this run unlocked the following lesson, for the celebration. */
|
||||
unlockedLessonId: string | null;
|
||||
/** Set when this run finished a Welt, for the creature that moved in. */
|
||||
neuesTier: KreaturId | null;
|
||||
bestseit: boolean;
|
||||
/** Set when this run finished a world, for the creature that moved in. */
|
||||
newCreature: CreatureId | null;
|
||||
isNewBest: boolean;
|
||||
}
|
||||
|
||||
/** Record a finished run: stars, animal, Perlen, key stats, streak, and the unlock.
|
||||
/** Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
|
||||
*
|
||||
* The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
||||
* fifth attempt whatever the score. Speed is nowhere in it. */
|
||||
@@ -207,38 +206,38 @@ export function recordRun(
|
||||
const before = progress.lessons[lessonId] ?? emptyLesson(true);
|
||||
const runs = before.runs + 1;
|
||||
|
||||
const improved = isBetter(result, { sterne: before.bestSterne, punkte: before.bestPunkte });
|
||||
const improved = isBetter(result, { stars: before.bestStars, points: before.bestPoints });
|
||||
const lessons = { ...progress.lessons };
|
||||
lessons[lessonId] = {
|
||||
...before,
|
||||
runs,
|
||||
bestSterne: improved ? result.sterne : before.bestSterne,
|
||||
bestTier: improved ? result.tier : before.bestTier,
|
||||
bestPunkte: improved ? result.punkte : before.bestPunkte,
|
||||
bestStars: improved ? result.stars : before.bestStars,
|
||||
bestAnimal: improved ? result.animal : before.bestAnimal,
|
||||
bestPoints: improved ? result.points : before.bestPoints,
|
||||
ghost: improved
|
||||
? result.strokes.filter((s) => s.correct).map((s) => ({ key: s.key, at: s.at }))
|
||||
: before.ghost,
|
||||
};
|
||||
|
||||
const verdient = result.bestanden || runs >= FLEISS_VERSUCHE;
|
||||
const folgend = nextLesson(lessonId);
|
||||
const earned = result.passed || runs >= DILIGENCE_ATTEMPTS;
|
||||
const next = nextLesson(lessonId);
|
||||
let unlockedLessonId: string | null = null;
|
||||
if (verdient && folgend && !lessons[folgend.id]?.unlocked) {
|
||||
lessons[folgend.id] = { ...(lessons[folgend.id] ?? emptyLesson(false)), unlocked: true };
|
||||
unlockedLessonId = folgend.id;
|
||||
if (earned && next && !lessons[next.id]?.unlocked) {
|
||||
lessons[next.id] = { ...(lessons[next.id] ?? emptyLesson(false)), unlocked: true };
|
||||
unlockedLessonId = next.id;
|
||||
}
|
||||
|
||||
// Finishing the last lesson of a Welt releases that Welt's creature. Checked against
|
||||
// the aquarium so it is only ever awarded once.
|
||||
// Finishing the last lesson of a world releases that world's creature. Checked
|
||||
// against the aquarium so it is only ever awarded once.
|
||||
const aquarium = [...progress.aquarium];
|
||||
let neuesTier: KreaturId | null = null;
|
||||
if (unlockedLessonId && folgend) {
|
||||
const beendet = LESSONS.find((l) => l.id === lessonId);
|
||||
if (beendet && folgend.welt !== beendet.welt) {
|
||||
const belohnung = WELT_BELOHNUNG.get(beendet.welt);
|
||||
if (belohnung && !aquarium.includes(belohnung)) {
|
||||
aquarium.push(belohnung);
|
||||
neuesTier = belohnung;
|
||||
let newCreature: CreatureId | null = null;
|
||||
if (unlockedLessonId && next) {
|
||||
const finished = LESSONS.find((l) => l.id === lessonId);
|
||||
if (finished && next.world !== finished.world) {
|
||||
const reward = WORLD_REWARD.get(finished.world);
|
||||
if (reward && !aquarium.includes(reward)) {
|
||||
aquarium.push(reward);
|
||||
newCreature = reward;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,37 +248,37 @@ export function recordRun(
|
||||
lessons,
|
||||
aquarium,
|
||||
keyStats: foldKeyStats(progress.keyStats, result),
|
||||
perlen: progress.perlen + result.perlen,
|
||||
pearls: progress.pearls + result.pearls,
|
||||
streak: bumpStreak(progress.streak, day),
|
||||
},
|
||||
unlockedLessonId,
|
||||
neuesTier,
|
||||
bestseit: improved,
|
||||
newCreature,
|
||||
isNewBest: improved,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** How many times a key must be typed before its stats mean anything. */
|
||||
const GENUG_VERSUCHE = 3;
|
||||
const ENOUGH_ATTEMPTS = 3;
|
||||
|
||||
/** The key a lesson should drill hardest - the generator's focus key, or `null` for an
|
||||
* even spread.
|
||||
*
|
||||
* `null` on a brand-new lesson is the important case. Every key starts unpractised, so
|
||||
* "pick an unpractised key" would pick whichever sorted first and drill it half the
|
||||
* line - which on Lektion 1 means typing `a` thirteen times out of twenty-four while
|
||||
* line - which on lesson 1 means typing `a` thirteen times out of twenty-four while
|
||||
* three other fingers go untrained. A lesson she has never played gets an even spread;
|
||||
* a focus key only emerges once there is evidence of what she is actually slow at. */
|
||||
export function focusKeyFor(progress: Progress, activeKeys: readonly string[]): string | null {
|
||||
const keys = activeKeys.filter((key) => key !== " ");
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const geuebt = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= GENUG_VERSUCHE);
|
||||
if (geuebt.length === 0) return null;
|
||||
const practiced = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= ENOUGH_ATTEMPTS);
|
||||
if (practiced.length === 0) return null;
|
||||
|
||||
// Some keys practised and some not: the gap is the most useful thing to close.
|
||||
const unbekannt = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < GENUG_VERSUCHE);
|
||||
if (unbekannt) return unbekannt;
|
||||
const unpracticed = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < ENOUGH_ATTEMPTS);
|
||||
if (unpracticed) return unpracticed;
|
||||
|
||||
let worst: string | null = null;
|
||||
let worstScore = -Infinity;
|
||||
@@ -298,13 +297,13 @@ export function focusKeyFor(progress: Progress, activeKeys: readonly string[]):
|
||||
|
||||
/** The fastest animal earned on any lesson so far. Drives the aquarium's headline stat
|
||||
* and, on the result screen, how far up the ladder is allowed to be revealed. */
|
||||
export function besteTier(progress: Progress): TierId | null {
|
||||
let best: TierId | null = null;
|
||||
let bestPunkte = -1;
|
||||
export function overallBestAnimal(progress: Progress): AnimalId | null {
|
||||
let best: AnimalId | null = null;
|
||||
let bestPoints = -1;
|
||||
for (const lesson of Object.values(progress.lessons)) {
|
||||
if (lesson.bestTier && lesson.bestPunkte > bestPunkte) {
|
||||
best = lesson.bestTier;
|
||||
bestPunkte = lesson.bestPunkte;
|
||||
if (lesson.bestAnimal && lesson.bestPoints > bestPoints) {
|
||||
best = lesson.bestAnimal;
|
||||
bestPoints = lesson.bestPoints;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/** German speech, for a player who cannot reliably read yet.
|
||||
*
|
||||
* This is the accessibility feature that makes the game usable at six: the target letter
|
||||
* or word is spoken, so not being able to read it is never what stops her. It doubles as
|
||||
* phonics practice - hearing "eff" while pressing f is exactly the association to build.
|
||||
*
|
||||
* Everything here degrades to silence. `speechSynthesis` is missing in some browsers,
|
||||
* has no German voice in others, and on several platforms refuses to speak until the
|
||||
* user has interacted with the page. None of that may throw. */
|
||||
|
||||
let stimme: SpeechSynthesisVoice | null = null;
|
||||
let gesucht = false;
|
||||
|
||||
/** Pick a German voice once. Voices load asynchronously, so this may find nothing on
|
||||
* the first call and succeed later - hence the retry rather than a cached `null`. */
|
||||
function germanVoice(): SpeechSynthesisVoice | null {
|
||||
if (stimme) return stimme;
|
||||
try {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length === 0) {
|
||||
if (!gesucht) {
|
||||
gesucht = true;
|
||||
window.speechSynthesis.addEventListener("voiceschanged", () => {
|
||||
stimme = null;
|
||||
germanVoice();
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
stimme = voices.find((voice) => voice.lang.startsWith("de")) ?? null;
|
||||
return stimme;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function speechAvailable(): boolean {
|
||||
return typeof window !== "undefined" && "speechSynthesis" in window;
|
||||
}
|
||||
|
||||
/** Say something, cancelling whatever was being said. A six-year-old clicks fast; a
|
||||
* queue of four stale sentences talking over the game is worse than silence. */
|
||||
export function say(text: string, enabled: boolean): void {
|
||||
if (!enabled || !speechAvailable()) return;
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = "de-DE";
|
||||
// Slower than default: the instruction has to be followable by someone who is also
|
||||
// hunting for a key.
|
||||
utterance.rate = 0.9;
|
||||
utterance.pitch = 1.1;
|
||||
const voice = germanVoice();
|
||||
if (voice) utterance.voice = voice;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} catch {
|
||||
// Blocked, unsupported, or no voice. The game reads fine on screen.
|
||||
}
|
||||
}
|
||||
|
||||
/** How a single key should be *said*, which is not how it is written. "a" spoken alone
|
||||
* is the letter name; the Leertaste has no letter name at all. */
|
||||
export function spellKey(key: string): string {
|
||||
if (key === " ") return "Leertaste";
|
||||
if (key === "⇧") return "Umschalttaste";
|
||||
const upper = key.toUpperCase();
|
||||
// A capital in a target means Shift is the lesson, so name it.
|
||||
return key !== key.toLowerCase() ? `großes ${upper}` : upper;
|
||||
}
|
||||
|
||||
export function stop(): void {
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
} catch {
|
||||
// Nothing was speaking.
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const SHOW_BUBBLES = true;
|
||||
|
||||
/** The earned pets swimming behind every screen. The reward that is always in view - off
|
||||
* only to rule it out when chasing a performance problem. */
|
||||
export const SHOW_AQUARIUM_TIERE = true;
|
||||
export const SHOW_AQUARIUM_CREATURES = true;
|
||||
|
||||
/** Fade screens in on entry. */
|
||||
export const ANIMATE_VIEW_TRANSITIONS = true;
|
||||
@@ -31,9 +31,9 @@ export const SPEECH_DEFAULT = true;
|
||||
|
||||
export const SOUND_DEFAULT = true;
|
||||
|
||||
/** How many letters one Blasenplatzen or Quallenalarm round sends up - matched to the
|
||||
* Tauchgang's length so a mode swap is not also a difficulty swap. Tauchgang line
|
||||
* length lives on the lesson itself (`laengeFuer` in lib/curriculum.ts). */
|
||||
export function blasenAnzahlFuer(welt: number): number {
|
||||
return welt === 1 ? 50 : 100;
|
||||
/** How many letters one bubbles or jellyfish round sends up - matched to the dive
|
||||
* mode's length so a mode swap is not also a difficulty swap. Dive-mode line length
|
||||
* lives on the lesson itself (`lengthFor` in lib/curriculum.ts). */
|
||||
export function bubbleCountFor(world: number): number {
|
||||
return world === 1 ? 50 : 100;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
/* This app's own additions: the feedback colours. Note there is no red - a wrong key
|
||||
is amber and gentle, never an alarm. See `wrongShake` below. */
|
||||
--richtig: oklch(80% 0.17 150);
|
||||
--daneben: oklch(80% 0.13 75);
|
||||
--correct: oklch(80% 0.17 150);
|
||||
--wrong: oklch(80% 0.13 75);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -230,7 +230,7 @@ button {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- die Bildschirmtastatur -- */
|
||||
/* ------------------------------------------------------- the on-screen keyboard -- */
|
||||
|
||||
.kb {
|
||||
display: flex;
|
||||
@@ -313,9 +313,9 @@ button {
|
||||
width: calc(var(--kb-size, 42px) * 6);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- die Zielzeile -- */
|
||||
/* ------------------------------------------------------------- the target line -- */
|
||||
|
||||
.ziel {
|
||||
.target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
@@ -324,29 +324,33 @@ button {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
/* Wrapping beats shrinking: the line must never run into the edges of the screen.
|
||||
Target.tsx overrides this per block length (see `satzspiegel`); this is only the
|
||||
Target.tsx overrides this per block length (see `layoutFor`); this is only the
|
||||
fallback for a caller that does not. */
|
||||
max-width: min(900px, 88vw);
|
||||
}
|
||||
|
||||
.ziel-chunk {
|
||||
.target-chunk {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ziel-zeichen {
|
||||
.target-char {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0.78em;
|
||||
/* Fixed at all times, not just while "current": if the current letter alone grew
|
||||
wider, the whole line would reflow around the cursor as she types, and chunks
|
||||
would visibly jump between lines mid-word. */
|
||||
padding: 0 6px;
|
||||
color: oklch(97% 0.01 175 / 0.45);
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.ziel-zeichen[data-state="fertig"] {
|
||||
color: var(--richtig);
|
||||
.target-char[data-state="done"] {
|
||||
color: var(--correct);
|
||||
}
|
||||
|
||||
.ziel-zeichen[data-state="aktuell"] {
|
||||
.target-char[data-state="current"] {
|
||||
color: oklch(25% 0.05 175);
|
||||
background: var(--paper);
|
||||
border-radius: 10px;
|
||||
@@ -354,19 +358,19 @@ button {
|
||||
animation: correctPop 200ms ease-out;
|
||||
}
|
||||
|
||||
.ziel-zeichen[data-state="aktuell"][data-daneben="true"] {
|
||||
background: var(--daneben);
|
||||
.target-char[data-state="current"][data-wrong="true"] {
|
||||
background: var(--wrong);
|
||||
animation: wrongShake 260ms ease;
|
||||
}
|
||||
|
||||
/* A space inside the target needs a visible body, or the cursor lands on nothing. Only
|
||||
the space at the cursor shows ␣: marking every upcoming one turned a sentence into
|
||||
"Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */
|
||||
.ziel-zeichen[data-leer="true"] {
|
||||
.target-char[data-blank="true"] {
|
||||
min-width: 0.9em;
|
||||
}
|
||||
|
||||
/* The two Shift keys, drawn either side of the Leertaste. Wider than a letter key so
|
||||
/* The two Shift keys, drawn either side of the space bar. Wider than a letter key so
|
||||
they read as the modifier they are, and dim until a capital actually needs one. */
|
||||
.kb-shift {
|
||||
width: calc(var(--kb-size, 42px) * 1.8);
|
||||
@@ -374,10 +378,10 @@ button {
|
||||
font-size: calc(var(--kb-size, 42px) * 0.4);
|
||||
}
|
||||
|
||||
/* A dome plus three trailing tentacles. Without them the Quallen read as plain circles,
|
||||
and "Quallenalarm" stops being a picture of anything. Drawn in CSS rather than as an
|
||||
emoji so the letter stays centred and legible inside the dome. */
|
||||
.qualle::after {
|
||||
/* A dome plus three trailing tentacles. Without them the jellyfish read as plain
|
||||
circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather
|
||||
than as an emoji so the letter stays centred and legible inside the dome. */
|
||||
.jellyfish::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -11px;
|
||||
|
||||
Reference in New Issue
Block a user