Files
musicmouse/web/src/lib/tippen/aquarium.ts
Martin Bauer 7f5e2733c2 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>
2026-09-12 21:25:42 +02:00

209 lines
8.0 KiB
TypeScript

/** The aquarium's pets, and how they swim.
*
* 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 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: `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 CreatureId = "clownfish" | "octopus" | "seahorse" | "turtle" | "pearlmussel";
export interface Creature {
id: CreatureId;
name: string;
/** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */
article: "Der" | "Die" | "Das";
/** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */
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. */
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. */
speed: number;
/** Which way the drawing faces. Side-view creatures are mirrored to look where they
* swim; front-view ones never are - an octopus flipping on every turn looks broken. */
facing: "side" | "front";
}
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 CREATURE_BY_ID = new Map(CREATURES.map((creature) => [creature.id, creature]));
export function creatureById(id: CreatureId): Creature {
// Every CreatureId is in CREATURES, so this cannot miss; the fallback only satisfies
// `noUncheckedIndexedAccess`.
return CREATURE_BY_ID.get(id) ?? CREATURES[0]!;
}
/** 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 creatureFromRaw(raw: unknown): CreatureId | null {
if (typeof raw !== "string") return null;
if (CREATURE_BY_ID.has(raw as CreatureId)) return raw as CreatureId;
return LEGACY_EMOJI[raw] ?? null;
}
// --- swimming ---------------------------------------------------------------
export interface Tank {
width: number;
height: number;
}
export interface Swimmer {
/** Centre, in px. */
x: number;
y: number;
/** px per second. */
vx: number;
vy: number;
/** Where it is currently drifting towards. */
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. */
facing: number;
/** Seconds swum, for the bob. Started at a random offset so pets do not bob in step. */
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 STEER_TAU = 1.4;
/** How long a turn-around takes. */
const TURN_TAU = 0.25;
/** One gentle bob per this many seconds. */
const BOB_PERIOD = 3.6;
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 span = (length: number) => Math.max(0, length - 2 * margin);
return {
x: margin + rng() * span(tank.width),
y: margin + rng() * span(tank.height),
};
}
/** 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 createSwimmer(
tank: Tank,
margin: number,
rng: () => number,
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,
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.
*
* `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,
tank: Tank,
margin: number,
speed: number,
rng: () => number,
): 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 = 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 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 targetVx = (directionX / distance) * speed;
const targetVy = (directionY / distance) * speed * 0.5;
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 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,
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: 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 tilt = speed > 0 ? Math.max(-1, Math.min(1, s.vy / speed)) : 0;
return {
x: s.x,
y: s.y + bob * 7,
mirror: isSide ? s.facing : 1,
rotation: isSide ? tilt * 10 * Math.sign(s.facing || 1) : bob * 3,
};
}