Files
musicmouse/web/src/lib/tippen/grading.ts
Martin Bauer 2a629faf50 Web frontend: switchable dolphin/dinosaur skins
Two kids, two looks: ?theme=dolphin or ?theme=dinosaur picks one, and the
choice is remembered in the browser so the installed home-screen app keeps it.
Everything that differs per child lives in one Skin object per file under
web/src/lib/skins/; layout and feature toggles in lib/theme.ts stay shared.
The dolphin renders pixel-identical to before.

The hardcoded sea hues (210 music, 175 typing game, 340 accent) are now CSS
variables written once at startup, and the Gecko-friendly sRGB gradient stops
come from the skin. The dinosaur is jungle green with a lava-orange accent,
its own mascot, icons, manifest and browser colour.

Nothing underwater is left in the dinosaur skin: leaves instead of bubbles
(play-view canvas, room page, typing game), a walking mascot, lava blobs in
the bubble game, a hatching egg instead of the treasure chest, dino pets that
walk or fly, a prehistoric speed ladder with the T-Rex as the hero rung, and
renamed games and light colours. Pets and ladder rungs are keyed by the
backend's existing ids, used as slots, so the backend and save format are
unchanged. Pets show as emoji until drawings exist.

python-backend/.gitignore: ignore tippen-curriculum-dino.yml, the dino
device's personal curriculum, like the original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:41 +02:00

206 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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 { SKIN } from "../skins";
import type { AnimalLook } from "../skins";
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;
/** Kept for the race-mode ghost and the per-key stats. */
strokes: readonly Stroke[];
}
/** The ladder's rungs, slowest first. The ids are the backend's and name the dolphin
* skin's sea animals; to any other skin they are just rung 1 … rung 10 - see
* `GameWorld` in ../skins/types.ts. */
export type AnimalId =
| "snail"
| "crab"
| "turtle"
| "jellyfish"
| "fish"
| "penguin"
| "seal"
| "dolphin"
| "shark"
| "orca";
export interface Animal extends AnimalLook {
id: AnimalId;
/** Lower bound in points (characters per minute, accuracy-weighted). */
from: number;
}
/** Ten rungs, 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 eighth rung - the dolphin, the T-Rex - sits deliberately high, at about 24 WPM. 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.
*
* What each rung is called, looks like and says comes from the skin (`game.ladder`).
* Every emoji there 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. */
const RUNGS: readonly { id: AnimalId; from: number }[] = [
{ id: "snail", from: 0 },
{ id: "crab", from: 15 },
{ id: "turtle", from: 25 },
{ id: "jellyfish", from: 40 },
{ id: "fish", from: 55 },
{ id: "penguin", from: 75 },
{ id: "seal", from: 95 },
{ id: "dolphin", from: 120 },
{ id: "shark", from: 150 },
{ id: "orca", from: 190 },
];
export const ANIMALS: readonly Animal[] = RUNGS.map((rung) => ({ ...rung, ...SKIN.game.ladder[rung.id] }));
/** The last animal shown on the ladder before a run has been fast enough to earn it.
* Everything above the dolphin (the rung, whatever the skin draws there) 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;
}
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),
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;
}