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:
210
web/src/lib/tippen/grading.ts
Normal file
210
web/src/lib/tippen/grading.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user