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>
This commit is contained in:
208
web/src/lib/tippen/aquarium.ts
Normal file
208
web/src/lib/tippen/aquarium.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user