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>
209 lines
8.0 KiB
TypeScript
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,
|
|
};
|
|
}
|