diff --git a/app/pet/assets/angloop_body.json b/app/pet/assets/angloop_body.json index a0d486b..516e98d 100644 --- a/app/pet/assets/angloop_body.json +++ b/app/pet/assets/angloop_body.json @@ -109,13 +109,33 @@ "h": 32 }, "duration": 500 + }, + { + "filename": "angloop_body lure 11", + "frame": { + "x": 352, + "y": 0, + "w": 32, + "h": 32 + }, + "duration": 150 + }, + { + "filename": "angloop_body lure 12", + "frame": { + "x": 384, + "y": 0, + "w": 32, + "h": 32 + }, + "duration": 150 } ], "meta": { "app": "tools/build_blob_sprites.py", "image": "angloop_body.png", "size": { - "w": 352, + "w": 416, "h": 32 }, "frameTags": [ @@ -160,6 +180,12 @@ "from": 9, "to": 10, "direction": "forward" + }, + { + "name": "lure", + "from": 11, + "to": 12, + "direction": "forward" } ], "faceTop": 9, diff --git a/app/pet/assets/angloop_body.png b/app/pet/assets/angloop_body.png index 049e262..0375d6a 100644 Binary files a/app/pet/assets/angloop_body.png and b/app/pet/assets/angloop_body.png differ diff --git a/app/pet/assets/purrloop_body.json b/app/pet/assets/purrloop_body.json index baa06d4..d1c4843 100644 --- a/app/pet/assets/purrloop_body.json +++ b/app/pet/assets/purrloop_body.json @@ -109,13 +109,23 @@ "h": 32 }, "duration": 500 + }, + { + "filename": "purrloop_body stretch_low 11", + "frame": { + "x": 352, + "y": 0, + "w": 32, + "h": 32 + }, + "duration": 1000 } ], "meta": { "app": "tools/build_blob_sprites.py", "image": "purrloop_body.png", "size": { - "w": 352, + "w": 384, "h": 32 }, "frameTags": [ @@ -160,6 +170,12 @@ "from": 9, "to": 10, "direction": "forward" + }, + { + "name": "stretch_low", + "from": 11, + "to": 11, + "direction": "forward" } ], "faceTop": 14 diff --git a/app/pet/assets/purrloop_body.png b/app/pet/assets/purrloop_body.png index 0fe5060..a1ff909 100644 Binary files a/app/pet/assets/purrloop_body.png and b/app/pet/assets/purrloop_body.png differ diff --git a/core/src/scene.ts b/core/src/scene.ts index ad67ae5..46439d1 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -64,6 +64,23 @@ const OWN_TRICKS: Record> = { "adult:lumoth": { spin: "circle" }, // a moth: it flutters round in circles, as round a lamp "adult:glimmerock": { spin: "shine" }, // a show: its crystals flash as it turns "adult:bouldergrin": { flip: "toss" }, // its top stone does the flip for it and lands back on top + "adult:gemperor": { flip: "slam" }, // a king doesn't somersault: it leaps up and lands so hard that crystals shoot up + "adult:purrloop": { bow: "stretch" }, // a cat's bow: front down, a long stretch and a yawn, then a shake + "adult:lanternling": { spin: "swing" }, // it swings to and fro as a lantern does on its handle + "adult:flarelet": { flip: "flare" }, // the flame shoots up out of its bowl and rains embers + "adult:roamtail": { spin: "chase" }, // goes round in circles after its own tail + "adult:filchfox": { spin: "vanish" }, // a puff of smoke, gone, and it pops up again a little further on + "adult:rumblefluff": { bow: "charge" }, // head down, a scrape with the hoof, and a charge with sparks + "adult:prismist": { spin: "rainbow" }, // it turns, and a rainbow of shimmer arcs over it + "adult:twiggle": { spin: "rustle" }, // it turns, and leaves whirl off its crown + "adult:needlenub": { bow: "bloom" }, // it closes into a bud and blooms, and petals fly + "adult:quill": { dance: "hornpipe" }, // the sailor's dance: quick steps from one foot to the other + "adult:drakwing": { flip: "glide" }, // a few beats of its wings up, and it glides down in a long arc + "adult:lillypot": { bow: "soak" }, // sinks low, as into its pond, and blows bubbles + "adult:steamo": { dance: "sauna" }, // a bath dance that makes it steam + "adult:parcelbeak": { bow: "deliver" }, // a bow, and a letter flies out of its pouch + "adult:rapierre": { spin: "fence" }, // a fencer's lunge, forward and back, twice, with a flash + "adult:angloop": { bow: "lure" }, // it casts out its lure, lets the bulb dangle and flash, and snaps "child:stone": { flip: "skip" }, // Pebblet gets over an opponent like a flat stone skipping across water }; // The skipping stone: [start, length, height] of each hop, over the time in the air (0..1) @@ -350,6 +367,20 @@ const LOOT: Bitmap = [[255, 210, 80], ["XX", "XX"]]; // what Filchfox has filche const BOLT: Bitmap = [[255, 225, 60], ["X.", ".X", "X."]]; // Rumblefluff's lightning const SHIMMER: Bitmap = [[255, 170, 210], [".X.", "X.X", ".X."]]; // the shimmer of Prismist's rainbow const LEAFLET: Bitmap = [[150, 225, 110], ["XX", ".X"]]; // a leaf off Twiggle's crown +const CRYSTAL: Bitmap = [[190, 150, 255], [".X", "XX", "XX"]]; // a shard of Gemperor's crystals +const PAW: Bitmap = [[255, 130, 170], [".X.X.", "X...X", "..X..", ".XXX."]]; // Purrloop's paw prints +const MOTH: Bitmap = [[235, 220, 190], ["X.X", ".X."]]; // moths drawn to Lanternling's light +const FLAME_TONGUE: Bitmap = [[255, 200, 70], [".X.", "XXX", "XXX"]]; // a tongue of Flarelet's flame +const STEAM_CLOUD: Bitmap = [[225, 240, 250], [".XX.", "XXXX", ".XX."]]; // Steamo's sauna steam +const ENVELOPE: Bitmap = [[250, 240, 215], ["XXXXX", "X.X.X", "XX.XX", "XXXXX"]]; // what Parcelbeak delivers +const TOUCHE: Bitmap = [[255, 255, 230], ["..X..", "..X..", "XX.XX", "..X..", "..X.."]]; +const FIRE: Bitmap = [[255, 110, 40], [".X.", "XXX", "XXX"]]; // puffs of Drakwing's fire +const CANNONBALL: Bitmap = [[125, 125, 140], [".X.", "XXX", ".X."]]; // Captain Quill's cannon +const SLASH: Bitmap = [[230, 235, 255], ["X..", ".X.", "..X"]]; // Rapierre's blade +const PETAL: Bitmap = [[255, 170, 200], ["XX"]]; // off Needlenub's flower +const CLOD: Bitmap = [[150, 110, 70], ["XX", "X."]]; // what Rumblefluff's hoof kicks up +const SMOKE: Bitmap = [[175, 175, 185], [".X.", "XXX", ".X."]]; // Filchfox's puff of smoke +const LURE_GLOW: Bitmap = [[255, 230, 130], [".XX.", "XXXX", ".XX."]]; // little lights like Angloop's bulb const LETTER: Bitmap = [[250, 240, 215], ["XXX", "XXX"]]; // a letter out of Parcelbeak's pouch const DIRT: Bitmap = [[60, 60, 60], ["X"]]; const SICK_TINT: readonly [Color, number] = [[185, 195, 90], 0.28]; // sickly pale, for everything that isn't the teal slime @@ -622,6 +653,10 @@ interface Pose { found: string | null; foe: Foe | null; wild: Guest | null; + /** Prismist's rainbow runs, as after the rain. */ + rainbow: boolean; + /** Needlenub's flower: closed into a bud or open; null = as its mood has it. */ + bud: boolean | null; /** Smaller than it is (going into the house, or coming out of it), and whether it can't be seen. */ scale: number; hidden: boolean; @@ -666,6 +701,7 @@ export class PetScene { private readonly eggCache = new Map(); private petTop = GROUND - 20; // top row of the pet on the panel (for particles) private burstFor: number | null = null; // one-off effects only once per animation + private trickFxAt = new Map(); // when an effect of a trick of its own last went off private throwFor: number | null = null; private throwX: number; private throwFacing = 1; @@ -775,9 +811,9 @@ export class PetScene { let pet: Image; [pet, top] = this.pet(stage, species, pose.body, t, pose.face, { sick: view.sick, whiteOut: pose.white, - flame: species === "flicker" ? flickerFlame(view.mood) : species === "needlenub" && view.mood !== "happy" ? "bud" : null, + flame: species === "flicker" ? flickerFlame(view.mood) : species === "needlenub" && (pose.bud ?? view.mood !== "happy") ? "bud" : null, heavy: view.weight_status === "heavy", hat: view.hat, hint: view.hint || view.species, - glow: this.night > 0.5, lookLeft: this.facing < 0, sky: this.skyLook, + glow: this.night > 0.5, lookLeft: this.facing < 0, sky: pose.rainbow ? "after_rain" : this.skyLook, }); let px = x - Math.floor((pet.width - SPRITE) / 2); let py = y - 1; // -1 because of the outline @@ -862,7 +898,7 @@ export class PetScene { symbol: null, turn: null, rotate: 0, heart: null, form: [view.stage, view.species], white: false, egg: null, eggT: t, item: view.activity_item, propAt: null, thrown: null, - activity, pack: false, found: null, foe: null, wild: null, scale: 1, hidden: false, + activity, pack: false, found: null, foe: null, wild: null, scale: 1, hidden: false, bud: null, rainbow: false, }; this.play = null; if (activity === "hatch") { @@ -1121,6 +1157,163 @@ export class PetScene { Object.assign(pose, { body: "flutter", dx: Math.round(Math.sin(turn) * 7 * up), jump: up * (8 + Math.cos(turn) * 3), face: "happy" }); this.facing = Math.cos(turn) > 0 ? 1 : -1; + } else if (animation === "slam") { + // up high without turning over, down hard, and crystals shoot up out of the ground on both sides + const q = (p - 0.15) / 0.55; + if (q > 0 && q < 1) { + pose.body = "stretch"; + pose.jump = q < 0.6 ? Math.sin(q / 0.6 * Math.PI / 2) * 14 : 14 * (1 - ((q - 0.6) / 0.4) ** 2); // slow up, fast down + } else if (q >= 1 && p < 0.85) { + pose.body = "flat"; + for (const side of [-1, 1]) { + this.trickFx(t, `slam${side}`, TRICK_SHOW, CRYSTAL, 4, side * (SPRITE / 2 - 2), 3, [8 * side, 22 * side], [-30, -14]); + } + } else { + pose.body = q <= 0 ? "squash" : "idle"; + } + pose.face = q > 0 && q < 0.6 ? "happy" : "laugh"; + } else if (animation === "stretch") { + // leans down at the front and stretches with a yawn, settles, then gives itself a shake + if (p < 0.1 || p > 0.9) { + Object.assign(pose, { body: "idle", face: "smile" }); + } else if (p < 0.45) { + Object.assign(pose, { body: "stretch_low", face: "yawn" }); // front down and the back up (the "stretch_low" frame) + } else if (p < 0.7) { + Object.assign(pose, { body: "squash", face: "blink" }); + } else { + Object.assign(pose, { body: "idle", dx: Math.trunc(p * 60) % 2 ? 1 : -1, face: "happy" }); + } + } else if (animation === "swing") { + // lifted a little, as if it hung from its handle, and swinging further out each time and back + const env = Math.sin(p * Math.PI); + Object.assign(pose, { rotate: Math.sin(p * 3 * (2 * Math.PI)) * 22 * env, jump: env * 4, face: "smile" }); + } else if (animation === "flare") { + // stretches up high, flickering, and embers shoot out of its top + const up = Math.sin(p * Math.PI); + Object.assign(pose, { body: Math.trunc(p * 20) % 2 ? "stretch" : "idle", jump: up * 9, face: "laugh" }); + if (p > 0.25 && p < 0.75) { + this.trickFx(t, "flare", 0.08, EMBER, 4, 0, 24 + up * 9, [-16, 16], [-40, -20], 1.0); + this.trickFx(t, "flare_tongue", 0.25, FLAME_TONGUE, 1, 0, 26 + up * 9, [-4, 4], [-26, -18], 0.7); + } + } else if (animation === "chase") { + // hops round a small circle, turning round and round after its tail, faster than in the spin + const turn = p * 2 * (2 * Math.PI); + Object.assign(pose, { dx: Math.round(Math.sin(turn) * 7), jump: Math.abs(Math.sin(turn * 2)) * 4, + turn: Math.cos(p * 5 * (2 * Math.PI)), face: "laugh" }); + } else if (animation === "vanish") { + // a puff of smoke, gone, and it is back a few steps further on with a grin + const gone = p > 0.3 && p < 0.6; + const back = clamp((p - 0.82) / 0.12, 0, 1); // and hops back to where it was + Object.assign(pose, { hidden: gone, dx: p >= 0.6 ? Math.round(10 * (1 - back)) * this.facing : 0, + jump: Math.sin(back * Math.PI) * 4, face: p >= 0.6 ? "laugh" : "smile", body: p > 0.2 && p < 0.3 ? "squash" : "idle" }); + if (p > 0.28 && p < 0.4) { + this.trickFx(t, "vanish", TRICK_SHOW, SMOKE, 14, 0, 12, [-16, 16], [-16, 4], 1.0); + } else if (p > 0.58 && p < 0.7) { + this.trickFx(t, "appear", TRICK_SHOW, SMOKE, 8, 10, 12, [-12, 12], [-12, 4], 0.7); + } + } else if (animation === "charge") { + // lowers its head and scrapes the ground twice, then charges forward and sparks fly off its horns + if (p < 0.5) { + Object.assign(pose, { body: p > 0.1 ? "squash" : "idle", dx: p > 0.1 ? -Math.trunc(p * 16) % 2 * this.facing : 0, + face: "normal" }); + if (p > 0.15) { + this.trickFx(t, "scrape", 0.4, CLOD, 4, -SPRITE / 2 + 6, 2, [-22, -8], [-18, -6], 0.6); + } + } else if (p < 0.75) { + Object.assign(pose, { body: "stretch", dx: Math.round((p - 0.5) / 0.25 * 10) * this.facing, face: "laugh" }); + if (p > 0.65) { + this.trickFx(t, "charge", TRICK_SHOW, BOLT, 8, SPRITE / 2 + 4, 18, [6, 30], [-20, 12]); + } + } else { + Object.assign(pose, { body: "squash", dx: Math.round((1 - p) / 0.25 * 10) * this.facing, face: "happy" }); + } + } else if (animation === "rainbow") { + // the spin, its rainbow's colors running round, and shimmer flying off it + Object.assign(pose, { turn: Math.cos(p * 2 * (2 * Math.PI)), jump: Math.sin(p * Math.PI) * 3, face: "smile", + rainbow: true }); + if (p > 0.15 && p < 0.85) { + this.trickFx(t, "rainbow", 0.15, SHIMMER, 2, 0, 22, [-20, 20], [-16, 0], 0.9); + } + } else if (animation === "rustle") { + // the spin, and leaves whirl off its crown + Object.assign(pose, { turn: Math.cos(p * 2 * (2 * Math.PI)), jump: Math.sin(p * Math.PI) * 3, face: "smile" }); + if (p > 0.15 && p < 0.85) { + this.trickFx(t, "rustle", 0.1, LEAFLET, 3, 0, 24, [-26, 26], [-14, 4], 1.3); + } + } else if (animation === "bloom") { + // closes into a bud, squeezes, and blooms with a pop, petals flying + if (p < 0.1 || p > 0.9) { + Object.assign(pose, { body: "idle", face: "smile" }); + } else if (p < 0.5) { + Object.assign(pose, { body: "squash", face: "blink", bud: true }); + } else { + Object.assign(pose, { body: p < 0.6 ? "stretch" : "idle", jump: p < 0.6 ? 2 : 0, face: "laugh", bud: false }); + this.trickFx(t, "bloom", TRICK_SHOW, PETAL, 12, 0, 26, [-18, 18], [-22, -6], 1.0); + } + } else if (animation === "hornpipe") { + // quick steps from one foot to the other, arms (wings) out, faster and faster + const beat = Math.trunc(p * (6 + p * 8)); + const side = beat % 4 < 2 ? 1 : -1; + Object.assign(pose, { body: beat % 2 ? "stretch" : "idle", dx: side * 3, jump: beat % 2 ? 3 : 0, rotate: side * 10, + face: "laugh" }); + this.trickFx(t, "hornpipe", 0.5, NOTE, 1, -side * 10, 26, [-6, 6], [-14, -8], 1.2); // someone plays the fiddle + } else if (animation === "glide") { + // flaps up and glides down in a long arc out in front and back + const q = (p - 0.1) / 0.8; + if (q > 0 && q < 1) { + Object.assign(pose, { body: q < 0.35 ? (Math.trunc(q * 30) % 2 ? "stretch" : "squash") : "stretch", + jump: Math.sin(q * Math.PI) * 14, dx: Math.round(Math.sin(q * 2 * Math.PI) * 8) * this.facing, + rotate: q > 0.35 ? -8 * this.facing : 0 }); + if (q > 0.4 && q < 0.6) { + this.trickFx(t, "glide", TRICK_SHOW, FIRE, 4, 0, 26 + pose.jump, [-8, 8], [-26, -14], 0.7); // a puff of fire for joy + } + } else { + pose.body = "squash"; + } + pose.face = "happy"; + } else if (animation === "soak") { + // sinks low and blows bubbles, eyes shut in bliss + if (p < 0.1 || p > 0.9) { + Object.assign(pose, { body: "idle", face: "smile" }); + } else { + Object.assign(pose, { body: "flat", face: "blink" }); + if (p > 0.2 && p < 0.85) { + this.trickFx(t, "soak", 0.12, BUBBLE, 2, 6, 8, [-6, 10], [-16, -8], 1.6); + } + } + } else if (animation === "sauna") { + // a slow bouncy dance with steam rising off it + Object.assign(pose, { body: Math.sin(p * 4 * (2 * Math.PI)) > 0 ? "stretch" : "squash", + dx: Math.round(Math.sin(p * 2 * (2 * Math.PI)) * 2), face: "blink" }); + if (p > 0.1 && p < 0.9) { + this.trickFx(t, "sauna", 0.12, STEAM_CLOUD, 1, this.rng.uniform(-10, 10), 20, [-4, 4], [-14, -8], 1.4); + } + } else if (animation === "deliver") { + // the bow, and a letter flies out of its pouch at the bottom + pose.body = p < 0.1 || p > 0.85 ? "idle" : p < 0.3 || p > 0.7 ? "squash" : "flat"; + pose.face = p >= 0.3 && p <= 0.7 ? "happy" : "smile"; + if (p > 0.35) { + this.trickFx(t, "deliver", TRICK_SHOW, ENVELOPE, 1, 6, 10, [10, 12], [-14, -12], 2.0); + } + } else if (animation === "fence") { + // two quick lunges forward and back, a flash at the tip each time + const q = (p * 2) % 1; + const out = q > 0.3 && q < 0.6; + Object.assign(pose, { body: out ? "stretch" : "squash", dx: (out ? 8 : 0) * this.facing, face: out ? "laugh" : "normal" }); + if (out) { + this.trickFx(t, "fence", 0.5, TOUCHE, 1, SPRITE / 2 + 10, 20, [0, 0], [0, 0], 0.5); // a flash at the tip of its beak + } + } else if (animation === "lure") { + // the line goes out, the bulb dangles and flashes (the "lure" frames), then it lunges and snaps + if (p < 0.1 || p > 0.9) { + Object.assign(pose, { body: "idle", face: "smile" }); + } else if (p < 0.62) { + Object.assign(pose, { body: "lure", face: "smile" }); + } else if (p < 0.76) { + Object.assign(pose, { body: "stretch", dx: 4 * this.facing, jump: 2, face: "eat_open" }); + } else { + Object.assign(pose, { body: "squash", dx: 2 * this.facing, face: "laugh" }); + } } else if (trick === "bow") { pose.body = p < 0.1 || p > 0.85 ? "idle" : p < 0.3 || p > 0.7 ? "squash" : "flat"; pose.face = p >= 0.1 && p <= 0.85 ? "blink" : "smile"; @@ -1154,6 +1347,25 @@ export class PetScene { } } + /** + * Particles of a trick of its own: `count` of them, at most once in `every` seconds per `key` + * (once per trick: every = TRICK_SHOW). `dx` from the pet's middle (towards where it looks), + * `dy` up from the ground; speeds as [from, to], vx towards where it looks. + */ + private trickFx(t: number, key: string, every: number, sprite: Bitmap, count: number, dx: number, dy: number, + vx: [number, number], vy: [number, number], life = 0.8): void { + if (t - (this.trickFxAt.get(key) ?? -Infinity) < every) { + return; + } + this.trickFxAt.set(key, t); + for (let i = 0; i < count; i++) { + this.particles.push({ + sprite, x: this.x + SPRITE / 2 + dx * this.facing, y: GROUND - dy, + vx: this.rng.uniform(...vx) * this.facing, vy: this.rng.uniform(...vy), life, + }); + } + } + private elapsed(view: SceneView, now: number): number { return ACTIVITY_SECONDS[view.activity] - (view.activity_until - now); } @@ -1677,10 +1889,10 @@ export class PetScene { } if (special) { const sprite = ({ - sneezle: STEAM, flicker: EMBER, grumpcap: SPORE, lumoth: DUST, lanternling: EMBER, flarelet: EMBER, - glimmerock: GLINT, bouldergrin: PEBBLE, gemperor: GLINT, angloop: BUBBLE, purrloop: LOVE, + sneezle: STEAM, flicker: EMBER, grumpcap: SPORE, lumoth: DUST, lanternling: MOTH, flarelet: FLAME_TONGUE, + glimmerock: GLINT, bouldergrin: PEBBLE, gemperor: CRYSTAL, angloop: LURE_GLOW, purrloop: PAW, roamtail: DUST, filchfox: LOOT, rumblefluff: BOLT, prismist: SHIMMER, twiggle: LEAFLET, needlenub: LOVE, - drakwing: EMBER, quill: BUBBLE, steamo: STEAM, lillypot: BUBBLE, rapierre: GLINT, parcelbeak: LETTER, + drakwing: FIRE, quill: CANNONBALL, steamo: STEAM_CLOUD, lillypot: BUBBLE, rapierre: SLASH, parcelbeak: LETTER, } as Record)[view.species] ?? SPARK; for (let i = 0; i < 10; i++) { this.particles.push({ diff --git a/docs/gameplay.md b/docs/gameplay.md index 83b0b79..fc15a90 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -278,8 +278,8 @@ the more often it works. Once it knows a trick, it shows it off by itself now and then when it is in a good mood. While nobody is there, it practices a little on its own (see [While nobody is there](#while-nobody-is-there)). -Some forms do a trick in their own way. It looks different on the panel, but -the gesture and what it does are the same. +Every adult form, and some children, do one trick in their own way. It looks +different on the panel, but the gesture and what it does are the same. ## The guessing game @@ -746,7 +746,8 @@ A new creature should make you think "that one is different". They show in a trick done in its own way (`OWN_TRICKS`: a creature with wings flutters through its flip; the gesture and the effect stay the same), in its special move in a scuffle, or now and then while it stands - around. + around. Every adult form has one trick of its own and a special move + (the particles in `hit`) that no other form uses. #### How to work diff --git a/docs/pets.md b/docs/pets.md index eed75cd..fce09f9 100644 --- a/docs/pets.md +++ b/docs/pets.md @@ -34,6 +34,7 @@ over its head, and the bulb hangs in front of its brow as a light that swings when it breathes and glows at night. Its sides have grown into fins it uses as arms, and bubbles float in its jelly. It has the child's round eyes and a wide grin with two little fangs, and that big mouth makes it a little hungrier. +For its bow it casts out its lure, lets the bulb dangle and flash, and snaps; in a scuffle its special move is a swarm of little lights like its bulb.
Show how to get it @@ -57,6 +58,7 @@ into a crown of crystals, one of them broken off, and the bulb of the lure has sunk into its belly, where it glows as its core. One crystal fist is planted on the ground, the other raised, and it looks out from under brave brows. It rarely gets sick. At night its crystals and its core glow. +A king doesn't somersault: for its flip it leaps up and lands so hard that crystals shoot up out of the ground, and crystal shards are its special move.
@@ -80,6 +82,7 @@ The child was well cared for at least 85 % of the time. heart where the bulb was, whiskers and two little paws that knead. Its amber cat eyes mostly give you the slow, content blink of a cat that loves you. Hearts mean more to it, and it stays clean longer. +Its bow is a cat's stretch, front down with a yawn, and its special move leaves paw prints. @@ -233,6 +236,7 @@ With a fire of its own, nothing of this line ever shivers on a cold morning. its mood drops more slowly, it gets sick less often, and in the rain it needs no umbrella. Its face is calm, with soft round eyes and rosy cheeks. Asleep, it burns low. +For its spin it swings to and fro like a lantern on its handle, and its special move is a flight of moths drawn to its light. ![Flarelet](images/pets/flarelet.png) @@ -240,6 +244,7 @@ it burns low. what is left of the crust is the bowl it sits in. It burns through its food and hardly gets tired, and it throws sparks. It looks eager: wide eyes under brows that flick up, and a big grin with a fang. +For its flip its flame shoots up out of the bowl and rains embers, and tongues of flame are its special move. @@ -349,6 +354,7 @@ knotty walking stick with a ribbon of the scarf tied round it. It needs no backpack of its own: on a trip it wears the one everybody gets. Used to long roads, it hardly tires. It looks calm and friendly, under lids lowered in peace, with a gentle smile. +For its spin it chases its own tail. ![Filchfox](images/pets/filchfox.png) @@ -357,6 +363,7 @@ at the back, one ear is bent, its fur is ruffled and the tip of its tail is ragged. It is always after something to eat and up for any game. Its look is cheeky: one eye narrowed, the other with a glint in it, and a sly grin with a fang. +For its spin it vanishes in a puff of smoke and pops up a little further on. @@ -405,6 +412,7 @@ high, and its horns are two golden curls of lightning that end in a bolt; the right one is bigger and forks. In a real thunderstorm they flash now and then. It looks stern, under heavy brows, and even a bad day hardly makes it grumpier than it is. +For its bow it lowers its head, scrapes the ground and charges, sparks flying off its horns. ![Prismist](images/pets/prismist.png) @@ -414,6 +422,7 @@ rainbow fades; when the sun comes out after it, its colors run along the arch for half an hour. It still wears Nimbit's bell. It looks kind, with lashes and a small smile, loves being petted, and dirt hardly weighs it down. +When it spins, the colors of its rainbow run round as they do after the rain. @@ -463,6 +472,7 @@ leaves like clouds sit on a gnarled trunk, one reaching far to the right, and the pot stands on its roots. It has a calm old face with half-shut eyes and a beard of moss, and it is patient: it gets tired more slowly. In a scuffle, leaves fly off its crown. +When it spins, leaves whirl off its crown. ![Needlenub](images/pets/needlenub.png) @@ -470,6 +480,7 @@ leaves fly off its crown. be petted. Its little arms are always up for a hug, petting means more to it than to anyone, and the one flower on its head only opens when it is happy; otherwise it is a bud. It stays in its pot rather than walking. +For its bow it closes into a bud and blooms with a pop, petals flying. @@ -515,12 +526,14 @@ duck likes the rain, so nothing of this line needs an umbrella. horn where the curl was, its wide orange bill puffs little flames, it has small bat wings and a spiky tail, and its belly is still duckling yellow. A fire needs feeding: it gets hungry faster. In a scuffle, it breathes embers. +For its flip it flaps up and glides down in a long arc with a puff of fire, and its special move is a burst of fire. ![Captain Quill](images/pets/quill.png) **Captain Quill**, an old sea duck in a captain's cap with the curl sticking out under it, a sailor collar and a red scarf. It loves a good game, and in a scuffle it blows bubbles. +Its dance is a sailor's hornpipe, and in a scuffle it fires cannonballs. @@ -567,6 +580,7 @@ The stages last as long as the slime's. What the adults keep is the towel. is tucked into the towel on its head. It is calm and half asleep anyway: it gets tired more slowly, and it is wet enough not to need an umbrella. In a scuffle, it blows bubbles. +For its bow it sinks low, as into its pond, and blows bubbles. ![Steamo](images/pets/steamo.png) @@ -574,6 +588,7 @@ scuffle, it blows bubbles. towel wound round its head like a turban and steam rising off it. It is always fresh from the sauna: it stays clean longer and gets sick less often. In a scuffle, it lets off steam. +Its dance is a sauna dance in clouds of steam, and clouds of steam are its special move. @@ -620,6 +635,7 @@ crest; only the beak tells them apart. long needle beak like a rapier, the crest swept back like a musketeer's plume. It never lands: it hovers, one wing a blur. A hummingbird burns through everything: it gets tired and hungry faster. +Its spin is a fencer's lunge, twice, with a flash at the tip of its beak, and in a scuffle its blade flashes. ![Parcelbeak](images/pets/parcelbeak.png) @@ -627,6 +643,7 @@ everything: it gets tired and hungry faster. sticking through it, a huge orange bill and a mail bag across its chest. With a letter for everyone, it is always in a good mood: its mood drops more slowly. In a scuffle, letters fly out of its bag. +For its bow a letter flies out of its pouch. diff --git a/tools/build_blob_sprites.py b/tools/build_blob_sprites.py index dd24187..4c12d79 100644 --- a/tools/build_blob_sprites.py +++ b/tools/build_blob_sprites.py @@ -561,6 +561,26 @@ def wax_arms(candle): ANGLOOP_B = ANGLOOP[:4] + [r[:20] + r[21:27] + "." + r[27:] for r in ANGLOOP[4:9]] + ANGLOOP[9:] ANGLOOP_FACE_TOP = 9 # counted from the bulb ANGLOOP_TAGS = species_tags([ANGLOOP, ANGLOOP_B], 20, 14) +# Its own bow, the lure: it casts its line out in front and lets the bulb dangle, flashing (the +# bulb light, then bright), before it snaps. The stalk arcs further out, the rest stays the same. +ANGLOOP_LURE_TOP = [ # columns 17 on; a space keeps what is there + "..ooooooo....", + ".obbbbbbbo...", + "obooooooobo..", + "obo.....obo..", + "obo.....obo..", + "obo.....obo..", + "obo.....obo..", + "bbo.....obo..", + "bbo......o...", + " ggg.", + " ggwgg", + " ggggG", + " GGG.", +] +ANGLOOP_LURE = [r[:17] + "".join(o if t == " " else t for o, t in zip(r[17:], top)) + for r, top in zip(ANGLOOP, ANGLOOP_LURE_TOP)] + ANGLOOP[len(ANGLOOP_LURE_TOP):] +ANGLOOP_TAGS["lure"] = [(ANGLOOP_LURE, 150), ([r[:25] + r[25:].replace("g", "w").replace("G", "g") for r in ANGLOOP_LURE], 150)] # Purrloop: what the slime becomes when it is petted a lot – a cat of jelly. Ears of jelly (the # right one folded over at the tip), the bulb of the lure turned into a heart, whiskers, and two @@ -603,6 +623,22 @@ def wax_arms(candle): PURRLOOP_FACE_TOP = 14 # counted from the heart PURRLOOP_TAGS = species_tags([PURRLOOP, PURRLOOP_B], 20, 14) + +def cat_stretch(grid, row, drop): + """A cat's stretch: the front (on the right) sinks and the back stays up; column x loses + round(drop * x / width) rows at `row` and moves down by as much.""" + width = len(grid[0]) + cols = [] + for x in range(width): + col = [r[x] for r in grid] + n = round(drop * x / (width - 1)) + cols.append(["."] * n + col[:row] + col[row + n:]) + return ["".join(c[y] for c in cols) for y in range(len(grid))] + + +# Its own bow: front down in a long stretch with a yawn +PURRLOOP_TAGS["stretch_low"] = [(cat_stretch(PURRLOOP, 10, 7), 1000)] + # Gemperor: the slime grown into a king. The jelly on its back has hardened into a crown of # crystals, one of them broken off, and the bulb of the child's lure has sunk into its belly, # where it glows as its core. One crystal fist planted on the ground, the other raised. It still diff --git a/tools/pet_preview.ts b/tools/pet_preview.ts index 0d08606..2128165 100644 --- a/tools/pet_preview.ts +++ b/tools/pet_preview.ts @@ -58,6 +58,14 @@ const NEW_SCENES = (["egg", "hatch", "baby", "child", "hint", "evolve"] as const NEW_LINES.map(([line]) => `${line}_${what}`)).concat(NEW_LINES.flatMap(([, , a, b]) => [a, b].flatMap((adult) => ["", "_sad", "_sleep", "_trick", "_rain"].map((what) => `${adult}${what}`))), ["wild_forest_leaf", "wild_stream_hippo", "visit_beak", "visit_duck"]); +// The adult forms with a trick of their own (OWN_TRICKS in core/src/scene.ts) and that trick; each +// gets a scene with the trick ("
_own") and one with its special move in a scuffle ("_special") +const OWN_TRICK_FORMS: Record = { + angloop: "bow", gemperor: "flip", purrloop: "bow", lanternling: "spin", flarelet: "flip", roamtail: "spin", + filchfox: "spin", rumblefluff: "bow", prismist: "spin", twiggle: "spin", needlenub: "bow", quill: "dance", + drakwing: "flip", lillypot: "bow", steamo: "dance", parcelbeak: "bow", rapierre: "spin", +}; +const OWN_SCENES = Object.keys(OWN_TRICK_FORMS).flatMap((adult) => [`${adult}_own`, `${adult}_special`]); // The spoiler-free overview shows one adult form only, the one docs/pets.md shows openly. It // leaves out the scenes that are only there to show another form, another line or what the lure // gives away, and plays the remaining scenes of an adult with that form. Of the trips it shows @@ -77,7 +85,7 @@ const SPOILERS = ["hint", "evolve", "cyclopuff", "angloop_hint", "sneezle", "fli "prismist_hat", "prismist_sad", "prismist_sleep", "prismist_trip", "prismist_rain", "gemperor", "gemperor_night", "gemperor_sad", "gemperor_hint", "angloop", "angloop_night", "angloop_hungry", "angloop_hint", - "purrloop", "purrloop_happy", "purrloop_night", "purrloop_hint", ...NEW_SCENES]; + "purrloop", "purrloop_happy", "purrloop_night", "purrloop_hint", ...NEW_SCENES, ...OWN_SCENES]; const PETS_DIR = "docs/images/pets"; const DAY = new Date(2026, 9, 12, 13, 0); const NIGHT = new Date(2026, 9, 12, 23, 30); @@ -460,6 +468,13 @@ const SCENES: Record = { visit_beak: [{ stage: "adult", species: "needlenub", visitor: { species: "rapierre", age: 60, left: 500 } }, DAY, null], visit_duck: [{ stage: "adult", species: "parcelbeak", visitor: { species: "drakwing", age: 60, left: 500 } }, DAY, null], + ...Object.fromEntries(Object.entries(OWN_TRICK_FORMS).flatMap(([adult, trick]) => [ + [`${adult}_own`, [{ stage: "adult", species: adult, activity: "trick", activity_item: `${trick}:ok` }, DAY, null]], + [`${adult}_special`, [{ + stage: "adult", species: adult, + trip: trip("stream", { event: "battle", foe: "puddlin", outcome: "win", rounds: [["charge", "flip", "special"]] }), + }, DAY, null]], + ])), }; const assets = readAssets(BDF_FONT_DIR);