/** 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> = { "🐠": "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, }; }