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>
This commit is contained in:
2026-10-02 23:05:41 +02:00
parent 21d65cad9e
commit 2a629faf50
64 changed files with 1257 additions and 486 deletions

View File

@@ -16,6 +16,8 @@
* 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 {
@@ -39,6 +41,9 @@ export interface RunResult {
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"
@@ -51,43 +56,42 @@ export type AnimalId =
| "shark"
| "orca";
export interface Animal {
export interface Animal extends AnimalLook {
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
/** 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 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
* 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.
*
* Every emoji here is checked to render as the animal it names - the first draft used
* 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. */
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." },
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 stays hidden until it is actually reached - see
* `visibleAnimals`. */
* 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 {