Merge the typing game into the music player as a tab, with lock/unlock UI

Moves tippen from a standalone app into web/ as a third tab (audio player /
smarthome / typing), replacing the old single room-toggle corner button with a
vertical icon tab rail. Curriculum and progress now come from the backend
(musicmouse/tippen/*) instead of a build-time YAML import and localStorage.

Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question
mark for locked albums/tracks with a hint on what unlocks them, and
ResultSheet gets a new unlock-animation block alongside the existing
lesson-unlock and aquarium-creature celebrations.

CSS from the two apps is merged carefully: identical rules (bubble/card/
key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is,
while rules that bake in each app's own hue are kept separate under a
`tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so
neither app's look bleeds into the other's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 21:25:42 +02:00
parent f7a5d24d8d
commit 7f5e2733c2
84 changed files with 1577 additions and 4642 deletions

View File

@@ -0,0 +1,210 @@
/** Turning a run into a number, a star count and an animal.
*
* Two decisions here are the whole pedagogy of the game, so they are worth stating:
*
* *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.
*
* *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.
*
* Crucially, speed only ever buys the *animal*. The unlock gate below is accuracy-only
* (see `isPassed`), so a slow, careful child still reaches the end of the curriculum. */
import type { RunState, Stroke } from "./engine";
export interface RunResult {
/** Correct keystrokes. */
characters: number;
/** Wrong keystrokes, counted once per position (see engine.ts rule 2). */
errors: number;
/** ms from the first keystroke to the last. */
duration: number;
/** Characters per minute. */
speed: number;
/** 0..1 */
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. */
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 AnimalId =
| "snail"
| "crab"
| "turtle"
| "jellyfish"
| "fish"
| "penguin"
| "seal"
| "dolphin"
| "shark"
| "orca";
export interface Animal {
id: AnimalId;
name: string;
emoji: 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 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 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 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 dolphin stays hidden until it is actually reached - see
* `visibleAnimals`. */
export const SURPRISE_FROM: AnimalId = "dolphin";
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 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 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 visibleAnimals(earned: AnimalId, bestEver: AnimalId | null): {
animals: readonly Animal[];
/** True when faster animals exist that have not been revealed yet. */
moreHidden: boolean;
} {
const boundary = Math.max(
animalIndex(SURPRISE_FROM),
animalIndex(earned),
bestEver ? animalIndex(bestEver) : -1,
);
return {
animals: ANIMALS.slice(0, boundary + 1),
moreHidden: boundary < ANIMALS.length - 1,
};
}
const ANIMAL_BY_ID = new Map(ANIMALS.map((animal) => [animal.id, animal]));
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 animalFor(points: number): Animal {
for (let i = ANIMALS.length - 1; i >= 0; i--) {
const animal = ANIMALS[i]!;
if (points >= animal.from) return animal;
}
return ANIMALS[0]!;
}
/** 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 = 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 STAR_THRESHOLDS = { one: 0.85, two: 0.93, three: 0.97 } as const;
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(accuracy: number): boolean {
return accuracy >= STAR_THRESHOLDS.two;
}
/** 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 pearlsFor(characters: number, stars: number): number {
return Math.floor(characters / 5) + stars * 2;
}
export function grade(state: RunState): RunResult {
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 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 speed = characters / minutes;
const attempts = characters + errors;
const accuracy = attempts === 0 ? 0 : characters / attempts;
const points = speed * accuracy ** 3;
const stars = starsFor(accuracy);
return {
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, 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: { stars: number; points: number }): boolean {
if (candidate.stars !== best.stars) return candidate.stars > best.stars;
return candidate.points > best.points;
}