From ca3d495dcf781a3fe7d79b68f678a1f14c6407c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:30:02 +0000 Subject: [PATCH 01/13] A special move of its own for every adult form, starting with Angloop's flash of its lure Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 163 ++++++++++++++++++++++++++++++++++++++++++- tools/pet_preview.ts | 3 +- 2 files changed, 164 insertions(+), 2 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index 46439d1..f20b016 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -83,6 +83,10 @@ const OWN_TRICKS: Record> = { "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 }; +// Forms whose special move in a scuffle is a move of its own (`special`), not the trick it used +const SPECIAL_MOVES = ["angloop", "gemperor", "purrloop", "lanternling", "flarelet", "roamtail", "filchfox", "rumblefluff", + "prismist", "twiggle", "needlenub", "quill", "drakwing", "lillypot", "steamo", "parcelbeak", "rapierre", "glimmerock", + "bouldergrin"]; // The skipping stone: [start, length, height] of each hop, over the time in the air (0..1) const SKIPS: readonly (readonly [number, number, number])[] = [[0, 0.45, 12], [0.45, 0.3, 6], [0.75, 0.25, 3]]; const SPIN_PAD = 8; @@ -374,6 +378,8 @@ const FLAME_TONGUE: Bitmap = [[255, 200, 70], [".X.", "XXX", "XXX"]]; // a tongu 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 FIREBALL: Bitmap = [[255, 150, 40], [".XX.", "XXXX", "XXXX", ".XX."]]; // what Flarelet hurls +const BOULDER: Bitmap = [[160, 165, 180], [".XXX.", "XXXXX", "XXXXX", ".XXX."]]; // Bouldergrin's top stone 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 @@ -1347,6 +1353,157 @@ export class PetScene { } } + /** + * The special move of the species in a scuffle, over one round (q from 0 to 1). It builds up in + * the first half and strikes at q = 0.5, when `hit` sends the species' particles over. + */ + private special(pose: Pose, q: number, t: number): void { + const towards = (d: number) => Math.round(d) * this.facing; + const out = Math.sin(clamp((q - 0.3) / 0.4, 0, 1) * Math.PI); // over to the opponent and back + const once = BATTLE_ROUND; + Object.assign(pose, { body: "idle", face: "laugh" }); + switch (pose.form[1]) { + case "angloop": // casts its lure at the opponent, and the bulb flares up blinding bright + if (q < 0.42) { + Object.assign(pose, { body: "lure", face: "smile" }); + } else if (q < 0.6) { + Object.assign(pose, { body: "stretch", dx: towards(4), white: q < 0.5, face: "eat_open" }); + } + break; + case "gemperor": // a leap, and when it lands, crystals burst up out of the ground under the opponent + if (q < 0.45) { + Object.assign(pose, { body: q < 0.1 ? "squash" : "stretch", jump: Math.sin(q / 0.45 * Math.PI) * 16 }); + } else if (q < 0.6) { + pose.body = "flat"; + this.trickFx(t, "sp_gemperor", once, CRYSTAL, 8, SPRITE + 8, 2, [-8, 8], [-34, -16]); + } + break; + case "purrloop": // crouches, wiggles its back end, and pounces + if (q < 0.3) { + Object.assign(pose, { body: "squash", dx: Math.trunc(t * 14) % 2, face: "normal" }); + } else if (q < 0.6) { + const f = (q - 0.3) / 0.3; + Object.assign(pose, { body: "stretch", dx: towards(Math.sin(f * Math.PI) * 14), jump: Math.sin(f * Math.PI) * 10 }); + } + break; + case "lanternling": // turns its light up until it is white, and the moths come + if (q < 0.5) { + Object.assign(pose, { body: "stretch", white: Math.trunc(q * 16) % 2 === 1 && q > 0.25, face: "smile" }); + } + break; + case "flarelet": // the flame swells, and it hurls a ball of fire + if (q < 0.45) { + Object.assign(pose, { body: Math.trunc(t * 10) % 2 ? "stretch" : "idle", jump: q * 8 }); + this.trickFx(t, "sp_flarelet", 0.1, EMBER, 2, 0, 24, [-8, 8], [-24, -12]); + } else if (q < 0.6) { + Object.assign(pose, { body: "squash", dx: towards(3) }); + this.trickFx(t, "sp_flarelet_ball", once, FIREBALL, 1, SPRITE / 2, 14, [40, 40], [0, 0], 0.6); + } + break; + case "roamtail": // dashes over and back in a cloud of dust + case "rumblefluff": // head down, a scrape, and it charges in + if (q < 0.3) { + Object.assign(pose, { body: "squash", face: "normal", dx: -Math.trunc(t * 8) % 2 * this.facing }); + this.trickFx(t, "sp_scrape", 0.3, CLOD, 3, -SPRITE / 2 + 6, 2, [-22, -8], [-18, -6], 0.6); + } else { + Object.assign(pose, { body: out > 0.2 ? "stretch" : "idle", dx: towards(out * 18) }); + if (out > 0.3) { + this.trickFx(t, "sp_dash", 0.08, pose.form[1] === "roamtail" ? DUST : BOLT, 2, -SPRITE / 2, 4, [-20, -6], [-10, 0], 0.5); + } + } + break; + case "filchfox": // vanishes in smoke and turns up right behind the opponent + if (q > 0.2 && q < 0.4) { + pose.hidden = true; + } else if (q >= 0.4 && q < 0.65) { + Object.assign(pose, { dx: towards(24), face: "laugh" }); + } + if (q > 0.18 && q < 0.25) { + this.trickFx(t, "sp_filch", once, SMOKE, 12, 0, 12, [-16, 16], [-16, 4], 1.0); + } else if (q > 0.38 && q < 0.45) { + this.trickFx(t, "sp_filch_in", once, SMOKE, 8, 24, 12, [-12, 12], [-12, 4], 0.7); + } + break; + case "prismist": // spins faster and faster, its rainbow running, and throws the shimmer + Object.assign(pose, { turn: Math.cos(q * q * 8 * (2 * Math.PI)), jump: Math.sin(Math.min(1, q * 2) * Math.PI) * 4, + rainbow: true }); + break; + case "twiggle": // a whirlwind of leaves + Object.assign(pose, { turn: Math.cos(q * 5 * (2 * Math.PI)), face: "smile" }); + if (q < 0.5) { + this.trickFx(t, "sp_twiggle", 0.06, LEAFLET, 2, 0, 18, [-26, 26], [-20, 4], 1.0); + } + break; + case "needlenub": // closes into a bud, then bursts open in a shower of hearts + if (q < 0.45) { + Object.assign(pose, { body: "squash", face: "blink", bud: true, dx: Math.trunc(t * 12) % 2 }); + } else { + Object.assign(pose, { body: q < 0.55 ? "stretch" : "idle", bud: false }); + this.trickFx(t, "sp_needlenub", once, PETAL, 12, 0, 26, [-18, 18], [-22, -6], 1.0); + } + break; + case "quill": // fires its cannon: a bang, smoke, and it rocks back + if (q < 0.45) { + Object.assign(pose, { body: "stretch", face: "normal" }); + } else if (q < 0.65) { + Object.assign(pose, { body: "squash", dx: towards(-3), face: "laugh" }); + this.trickFx(t, "sp_quill", once, SMOKE, 8, SPRITE / 2, 14, [6, 18], [-10, 2], 0.9); + } + break; + case "drakwing": // flaps up and breathes fire down at the opponent + Object.assign(pose, { body: q < 0.4 && Math.trunc(t * 12) % 2 ? "squash" : "stretch", + jump: Math.sin(clamp(q / 0.8, 0, 1) * Math.PI) * 12, face: q > 0.4 ? "eat_open" : "laugh" }); + if (q > 0.4 && q < 0.6) { + this.trickFx(t, "sp_drakwing", 0.06, FIRE, 2, SPRITE / 2, 14 + pose.jump, [24, 36], [6, 16], 0.6); + } + break; + case "lillypot": // dives into its pond and comes up with a fountain of bubbles + if (q < 0.4) { + Object.assign(pose, { body: "flat", face: "blink" }); + } else if (q < 0.6) { + Object.assign(pose, { body: "stretch", jump: 4 }); + this.trickFx(t, "sp_lillypot", 0.05, BUBBLE, 2, 0, 22, [-6, 26], [-30, -14], 0.9); + } + break; + case "steamo": // swells up red-hot and lets the steam out all at once + if (q < 0.45) { + Object.assign(pose, { body: Math.trunc(t * 8) % 2 ? "stretch" : "idle", face: "blink", dx: Math.trunc(t * 16) % 2 }); + this.trickFx(t, "sp_steamo", 0.15, STEAM, 1, this.rng.uniform(-8, 8), 22, [-3, 3], [-10, -6], 0.8); + } else if (q < 0.6) { + Object.assign(pose, { body: "squash", face: "laugh" }); + } + break; + case "parcelbeak": // reaches into its pouch again and again: a whole sack of letters + pose.body = Math.trunc(q * 12) % 2 && q < 0.6 ? "squash" : "idle"; + if (q > 0.1 && q < 0.5) { + this.trickFx(t, "sp_parcelbeak", 0.12, ENVELOPE, 1, 6, 10, [24, 34], [-8, 4], 0.9); + } + break; + case "rapierre": // a flurry of lunges, too quick to follow + if (q < 0.6) { + const lunge = Math.trunc(q * 14) % 2 === 1; + Object.assign(pose, { body: lunge ? "stretch" : "squash", dx: towards(lunge ? 10 : 2) }); + if (lunge) { + this.trickFx(t, "sp_rapierre", 0.12, TOUCHE, 1, SPRITE / 2 + 12, 20 - this.rng.uniform(0, 8), [0, 0], [0, 0], 0.25); + } + } + break; + case "glimmerock": // a spin with its crystals flashing, faster and faster + Object.assign(pose, { body: "shine", turn: Math.cos(q * q * 8 * (2 * Math.PI)), face: "smile" }); + break; + case "bouldergrin": // throws its top stone at the opponent; a new one grows back + if (q < 0.3) { + Object.assign(pose, { body: "toss_low", jump: 2 }); + } else if (q < 0.5) { + pose.body = "toss_high"; + this.trickFx(t, "sp_bouldergrin", once, BOULDER, 1, 0, 28, [34, 34], [-10, -10], 0.7); + } else { + pose.body = q < 0.6 ? "squash" : "idle"; + } + break; + } + } + /** * 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), @@ -1605,7 +1762,11 @@ export class PetScene { } else if (index < rounds.length) { const [move, trick, result] = rounds[index]; const landed = battle.damageOf(result) > 0; - this.trick(pose, trick, q * BATTLE_ROUND, landed, t); + if (result === "special" && SPECIAL_MOVES.includes(pose.form[1])) { + this.special(pose, q, t); + } else { + this.trick(pose, trick, q * BATTLE_ROUND, landed, t); + } // the opponent's move carries it towards the pet and back to its place const reach = Math.sin(Math.min(1.0, q / 0.7) * Math.PI); x -= reach * (battle.isGuardian(name) ? GUARDIAN_REACH : FOE_REACH); diff --git a/tools/pet_preview.ts b/tools/pet_preview.ts index 2128165..95786e6 100644 --- a/tools/pet_preview.ts +++ b/tools/pet_preview.ts @@ -63,7 +63,8 @@ const NEW_SCENES = (["egg", "hatch", "baby", "child", "hint", "evolve"] as const 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", + drakwing: "flip", lillypot: "bow", steamo: "dance", parcelbeak: "bow", rapierre: "spin", glimmerock: "spin", + bouldergrin: "flip", }; 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 From 984a5fca8094d04ae81e7352299687b3bf89d5c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:30:27 +0000 Subject: [PATCH 02/13] Gemperor's special move: a leap, and crystals burst up under the opponent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index f20b016..24c4fa4 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1375,7 +1375,7 @@ export class PetScene { Object.assign(pose, { body: q < 0.1 ? "squash" : "stretch", jump: Math.sin(q / 0.45 * Math.PI) * 16 }); } else if (q < 0.6) { pose.body = "flat"; - this.trickFx(t, "sp_gemperor", once, CRYSTAL, 8, SPRITE + 8, 2, [-8, 8], [-34, -16]); + this.trickFx(t, "sp_gemperor", once, CRYSTAL, 10, SPRITE / 2 + 14, 2, [-10, 10], [-36, -16]); } break; case "purrloop": // crouches, wiggles its back end, and pounces From dae79235dd1dd6710764c71c506f2311be82470c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:31:11 +0000 Subject: [PATCH 03/13] Flarelet's special move: its flame swells and it hurls a fireball Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index 24c4fa4..a351530 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -378,7 +378,7 @@ const FLAME_TONGUE: Bitmap = [[255, 200, 70], [".X.", "XXX", "XXX"]]; // a tongu 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 FIREBALL: Bitmap = [[255, 150, 40], [".XX.", "XXXX", "XXXX", ".XX."]]; // what Flarelet hurls +const FIREBALL: Bitmap = [[255, 150, 40], ["..XX..", ".XXXX.", "XXXXXX", "XXXXXX", ".XXXX."]]; // what Flarelet hurls const BOULDER: Bitmap = [[160, 165, 180], [".XXX.", "XXXXX", "XXXXX", ".XXX."]]; // Bouldergrin's top stone 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 @@ -1392,12 +1392,12 @@ export class PetScene { } break; case "flarelet": // the flame swells, and it hurls a ball of fire - if (q < 0.45) { - Object.assign(pose, { body: Math.trunc(t * 10) % 2 ? "stretch" : "idle", jump: q * 8 }); + if (q < 0.25) { + Object.assign(pose, { body: Math.trunc(t * 10) % 2 ? "stretch" : "idle", jump: q * 16 }); this.trickFx(t, "sp_flarelet", 0.1, EMBER, 2, 0, 24, [-8, 8], [-24, -12]); - } else if (q < 0.6) { + } else if (q < 0.45) { Object.assign(pose, { body: "squash", dx: towards(3) }); - this.trickFx(t, "sp_flarelet_ball", once, FIREBALL, 1, SPRITE / 2, 14, [40, 40], [0, 0], 0.6); + this.trickFx(t, "sp_flarelet_ball", once, FIREBALL, 1, 6, 20, [28, 28], [6, 6], 0.7); } break; case "roamtail": // dashes over and back in a cloud of dust From 42c63ce439f2f0b0866cc03745e4fdfa3651d7df Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:31:45 +0000 Subject: [PATCH 04/13] Roamtail leaps right over the opponent, Rumblefluff charges Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index a351530..9efa257 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1400,7 +1400,18 @@ export class PetScene { this.trickFx(t, "sp_flarelet_ball", once, FIREBALL, 1, 6, 20, [28, 28], [6, 6], 0.7); } break; - case "roamtail": // dashes over and back in a cloud of dust + case "roamtail": // a running leap right over the opponent, and a hop back with dust flying + if (q < 0.2) { + Object.assign(pose, { body: "squash", face: "normal" }); + } else if (q < 0.5) { + const f = (q - 0.2) / 0.3; + Object.assign(pose, { body: "stretch", dx: towards(f * 30), jump: Math.sin(f * Math.PI) * 18, rotate: -20 * this.facing }); + } else if (q < 0.85) { + const f = (q - 0.5) / 0.35; + Object.assign(pose, { body: "idle", dx: towards((1 - f) * 30), jump: Math.abs(Math.sin(f * 3 * Math.PI)) * 4 }); + this.trickFx(t, "sp_roamtail", 0.12, DUST, 3, 30 * (1 - f), 2, [-6, 6], [-10, -2], 0.6); + } + break; case "rumblefluff": // head down, a scrape, and it charges in if (q < 0.3) { Object.assign(pose, { body: "squash", face: "normal", dx: -Math.trunc(t * 8) % 2 * this.facing }); @@ -1408,7 +1419,7 @@ export class PetScene { } else { Object.assign(pose, { body: out > 0.2 ? "stretch" : "idle", dx: towards(out * 18) }); if (out > 0.3) { - this.trickFx(t, "sp_dash", 0.08, pose.form[1] === "roamtail" ? DUST : BOLT, 2, -SPRITE / 2, 4, [-20, -6], [-10, 0], 0.5); + this.trickFx(t, "sp_dash", 0.08, BOLT, 2, -SPRITE / 2, 4, [-20, -6], [-10, 0], 0.5); } } break; From 73080145c3292bafa8c2ee1e88d3cd41f63a3ae0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:32:11 +0000 Subject: [PATCH 05/13] Rumblefluff's charge calls down lightning Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/src/scene.ts b/core/src/scene.ts index 9efa257..64786ae 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1418,6 +1418,12 @@ export class PetScene { this.trickFx(t, "sp_scrape", 0.3, CLOD, 3, -SPRITE / 2 + 6, 2, [-22, -8], [-18, -6], 0.6); } else { Object.assign(pose, { body: out > 0.2 ? "stretch" : "idle", dx: towards(out * 18) }); + if (q > 0.42 && q < 0.5) { // and lightning strikes the opponent from above + pose.white = Math.trunc(t * 20) % 2 === 0; + for (let i = 0; i < 5; i++) { + this.trickFx(t, `sp_strike${i}`, once, BOLT, 1, SPRITE / 2 + 14, 60 - i * 3, [0, 0], [120, 120], 0.4); + } + } if (out > 0.3) { this.trickFx(t, "sp_dash", 0.08, BOLT, 2, -SPRITE / 2, 4, [-20, -6], [-10, 0], 0.5); } From adfbe462c07fff8c3eeefd4724f964d0e64e6ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:33:15 +0000 Subject: [PATCH 06/13] Prismist throws a burst in the colors of its rainbow Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index 64786ae..339cb0d 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -378,6 +378,7 @@ const FLAME_TONGUE: Bitmap = [[255, 200, 70], [".X.", "XXX", "XXX"]]; // a tongu 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 RAINBOW_BURST: Bitmap[] = RAINBOW.map((color) => [color, ["XX", "XX"]]); // Prismist's rainbow, thrown at an opponent const FIREBALL: Bitmap = [[255, 150, 40], ["..XX..", ".XXXX.", "XXXXXX", "XXXXXX", ".XXXX."]]; // what Flarelet hurls const BOULDER: Bitmap = [[160, 165, 180], [".XXX.", "XXXXX", "XXXXX", ".XXX."]]; // Bouldergrin's top stone const FIRE: Bitmap = [[255, 110, 40], [".X.", "XXX", "XXX"]]; // puffs of Drakwing's fire @@ -1441,9 +1442,17 @@ export class PetScene { this.trickFx(t, "sp_filch_in", once, SMOKE, 8, 24, 12, [-12, 12], [-12, 4], 0.7); } break; - case "prismist": // spins faster and faster, its rainbow running, and throws the shimmer - Object.assign(pose, { turn: Math.cos(q * q * 8 * (2 * Math.PI)), jump: Math.sin(Math.min(1, q * 2) * Math.PI) * 4, - rainbow: true }); + case "prismist": // spins up, its rainbow running, and throws it at the opponent in a burst of colors + if (q < 0.2) { + Object.assign(pose, { turn: Math.cos(q * q * 40 * (2 * Math.PI)), rainbow: true, face: "smile" }); + } else if (q < 0.5) { + Object.assign(pose, { body: "stretch", rainbow: true, face: "laugh" }); + if (q > 0.35) { // a burst in all the colors of its rainbow + RAINBOW_BURST.forEach((sprite, i) => { + this.trickFx(t, `sp_burst${i}`, once, sprite, 3, 4, 18, [14, 40], [-20, 14], 0.9); + }); + } + } break; case "twiggle": // a whirlwind of leaves Object.assign(pose, { turn: Math.cos(q * 5 * (2 * Math.PI)), face: "smile" }); From 54dd1e90038b19d14702d53785ee53701c8807a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:33:44 +0000 Subject: [PATCH 07/13] Twiggle blows a gust of leaves at the opponent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index 339cb0d..11bff9c 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1454,10 +1454,12 @@ export class PetScene { } } break; - case "twiggle": // a whirlwind of leaves - Object.assign(pose, { turn: Math.cos(q * 5 * (2 * Math.PI)), face: "smile" }); + case "twiggle": // plants its feet, shakes its crown, and a gust of leaves blows at the opponent if (q < 0.5) { - this.trickFx(t, "sp_twiggle", 0.06, LEAFLET, 2, 0, 18, [-26, 26], [-20, 4], 1.0); + Object.assign(pose, { body: "squash", dx: Math.trunc(t * 16) % 2, face: "normal" }); + if (q > 0.15) { + this.trickFx(t, "sp_twiggle", 0.05, LEAFLET, 2, 0, 22, [30, 50], [-6, 6], 0.8); + } } break; case "needlenub": // closes into a bud, then bursts open in a shower of hearts From d44a3754a1ccb2726c50eeba59321ffdec3030c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:34:06 +0000 Subject: [PATCH 08/13] Needlenub gives the opponent a prickly hug Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index 11bff9c..87bab27 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1462,12 +1462,13 @@ export class PetScene { } } break; - case "needlenub": // closes into a bud, then bursts open in a shower of hearts - if (q < 0.45) { - Object.assign(pose, { body: "squash", face: "blink", bud: true, dx: Math.trunc(t * 12) % 2 }); - } else { - Object.assign(pose, { body: q < 0.55 ? "stretch" : "idle", bud: false }); - this.trickFx(t, "sp_needlenub", once, PETAL, 12, 0, 26, [-18, 18], [-22, -6], 1.0); + case "needlenub": // hurries over to give the opponent a big, prickly hug + if (q < 0.25) { + Object.assign(pose, { body: "stretch", face: "happy", bud: false }); + } else if (q < 0.7) { + const there = clamp((q - 0.25) / 0.15, 0, 1) * (1 - clamp((q - 0.6) / 0.1, 0, 1)); + Object.assign(pose, { body: q > 0.4 && q < 0.6 ? "squash" : "stretch", dx: towards(there * 14), + jump: q < 0.4 ? Math.abs(Math.sin(q * 6 * Math.PI)) * 3 : 0, face: q > 0.4 ? "blink" : "happy", bud: false }); } break; case "quill": // fires its cannon: a bang, smoke, and it rocks back From 391477d34635f2f4b1a941f1fcfd7fee024e0a25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:34:22 +0000 Subject: [PATCH 09/13] Captain Quill fires its cannon Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index 87bab27..b692f86 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1472,11 +1472,12 @@ export class PetScene { } break; case "quill": // fires its cannon: a bang, smoke, and it rocks back - if (q < 0.45) { - Object.assign(pose, { body: "stretch", face: "normal" }); + if (q < 0.42) { // stands to attention and takes aim + Object.assign(pose, { body: Math.trunc(q * 10) % 2 ? "stretch" : "idle", face: "normal" }); } else if (q < 0.65) { - Object.assign(pose, { body: "squash", dx: towards(-3), face: "laugh" }); - this.trickFx(t, "sp_quill", once, SMOKE, 8, SPRITE / 2, 14, [6, 18], [-10, 2], 0.9); + Object.assign(pose, { body: "squash", dx: towards(q < 0.5 ? -5 : -3), face: "laugh", white: q < 0.45 }); + this.trickFx(t, "sp_quill_bang", once, TOUCHE, 1, SPRITE / 2 + 2, 14, [0, 0], [0, 0], 0.2); + this.trickFx(t, "sp_quill", once, SMOKE, 10, SPRITE / 2, 14, [4, 20], [-12, 2], 1.1); } break; case "drakwing": // flaps up and breathes fire down at the opponent From 2ea4bd3c97ca7bdc68ebf56f7097b6e5dc0a9d49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:35:20 +0000 Subject: [PATCH 10/13] Steamo lets off a blast of steam Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index b692f86..f7d2a57 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1498,9 +1498,10 @@ export class PetScene { case "steamo": // swells up red-hot and lets the steam out all at once if (q < 0.45) { Object.assign(pose, { body: Math.trunc(t * 8) % 2 ? "stretch" : "idle", face: "blink", dx: Math.trunc(t * 16) % 2 }); - this.trickFx(t, "sp_steamo", 0.15, STEAM, 1, this.rng.uniform(-8, 8), 22, [-3, 3], [-10, -6], 0.8); + this.trickFx(t, "sp_steamo", 0.08, STEAM, 2, this.rng.uniform(-8, 8), 22, [-4, 4], [-14, -6], 0.8); } else if (q < 0.6) { Object.assign(pose, { body: "squash", face: "laugh" }); + this.trickFx(t, "sp_steamo_blast", once, STEAM_CLOUD, 12, 6, 14, [18, 46], [-14, 6], 0.9); } break; case "parcelbeak": // reaches into its pouch again and again: a whole sack of letters From 35193e85176cdfe145b023935574736df528de24 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:35:49 +0000 Subject: [PATCH 11/13] Glimmerock dazzles the opponent with its crystals Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index f7d2a57..3c47fc3 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1519,8 +1519,13 @@ export class PetScene { } } break; - case "glimmerock": // a spin with its crystals flashing, faster and faster - Object.assign(pose, { body: "shine", turn: Math.cos(q * q * 8 * (2 * Math.PI)), face: "smile" }); + case "glimmerock": // strikes a pose and lets its crystals flash until the opponent is dazzled + if (q < 0.5) { + Object.assign(pose, { body: "shine", face: "smile", white: q > 0.35 && Math.trunc(t * 12) % 2 === 0 }); + } + if (q > 0.4 && q < 0.5) { + this.trickFx(t, "sp_glimmerock", once, TOUCHE, 3, 4, 22, [16, 36], [-12, 8], 0.6); + } break; case "bouldergrin": // throws its top stone at the opponent; a new one grows back if (q < 0.3) { From 6d904f6be8ccc4200633e8ff755670c9411f4316 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:36:38 +0000 Subject: [PATCH 12/13] Bouldergrin hurls its top stone; docs for the special moves Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- core/src/scene.ts | 10 ++++++---- docs/gameplay.md | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index 3c47fc3..713af7c 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -1528,11 +1528,13 @@ export class PetScene { } break; case "bouldergrin": // throws its top stone at the opponent; a new one grows back - if (q < 0.3) { - Object.assign(pose, { body: "toss_low", jump: 2 }); + if (q < 0.15) { + Object.assign(pose, { body: "squash", face: "normal" }); + } else if (q < 0.3) { + Object.assign(pose, { body: "toss_high", jump: 3 }); // winds up with the stone above its head } else if (q < 0.5) { - pose.body = "toss_high"; - this.trickFx(t, "sp_bouldergrin", once, BOULDER, 1, 0, 28, [34, 34], [-10, -10], 0.7); + Object.assign(pose, { body: "stretch", dx: towards(2) }); + this.trickFx(t, "sp_bouldergrin", once, BOULDER, 1, 4, 30, [30, 30], [22, 22], 0.8); } else { pose.body = q < 0.6 ? "squash" : "idle"; } diff --git a/docs/gameplay.md b/docs/gameplay.md index fc15a90..a1ddcb5 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -417,7 +417,8 @@ each one the opponent makes a move, and the pet answers with a trick: - **Weight:** a pet that is too heavy has more force and less speed; one that is too light, the other way round. - **Special move:** an adult that knows how to dance lets its species' special - move go after every second trick that worked. It counts double. + move go after every second trick that worked. It counts double. Every form + has a move of its own for it: Angloop flashes its lure, for example. An opponent gives in after three hits (Drizzleshell, Puddlin and the ones in the cave and on the mountain after four, Duskwisp after five) and leaves @@ -747,7 +748,8 @@ A new creature should make you think "that one is different". 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. Every adult form has one trick of its own and a special move - (the particles in `hit`) that no other form uses. + that no other form uses: a move of its own (`special`) and its own + particles (`hit`). #### How to work From 69f844d02f74b3a09c74a351c63932f997cef2cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:36:47 +0000 Subject: [PATCH 13/13] Docs: where the special moves live Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vgysrGmoBLvH2ruoFpC5R --- docs/gameplay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gameplay.md b/docs/gameplay.md index a1ddcb5..1462472 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -779,7 +779,7 @@ For an adult form with pixel art of its own: | `write_sheet` for the body and the faces, and the list at the top of the script | `tools/build_blob_sprites.py` | | Body and face sheet | `SPECIES_SHEETS` in `core/src/scene.ts` | | A face that is not to sit in the middle of its row (`faceTop`, `faceCenter`, `faceShift` in `write_sheet`) | `tools/build_blob_sprites.py`, `faceAnchor` in `core/src/sprites.ts` | -| Colors of the child's lure, walking and hopping, umbrella, special move | `LURE_HINT`, `WALK_HOPS`, `NO_UMBRELLA`, `hit` in `core/src/scene.ts` | +| Colors of the child's lure, walking and hopping, umbrella, special move | `LURE_HINT`, `WALK_HOPS`, `NO_UMBRELLA`, `special` and `hit` in `core/src/scene.ts` | | A trick in its own way: the same gesture and effect, another animation | `OWN_TRICKS` and `trick` in `core/src/scene.ts`, frames of its own if it needs them | | Tests: every form in every state, how it moves, where the face sits | `core/test/scene.test.ts`, `core/test/sprites.test.ts` | | Text and picture, spoilers folded shut | [pets.md](pets.md), `npm run preview -- --pets` |