diff --git a/core/src/scene.ts b/core/src/scene.ts index 46439d1..713af7c 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,9 @@ 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 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 +1354,194 @@ 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, 10, SPRITE / 2 + 14, 2, [-10, 10], [-36, -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.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.45) { + Object.assign(pose, { body: "squash", dx: towards(3) }); + this.trickFx(t, "sp_flarelet_ball", once, FIREBALL, 1, 6, 20, [28, 28], [6, 6], 0.7); + } + break; + 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 }); + 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); + } + } + 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 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": // plants its feet, shakes its crown, and a gust of leaves blows at the opponent + if (q < 0.5) { + 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": // 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 + 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(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 + 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.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 + 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": // 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.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) { + 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"; + } + 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 +1800,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/docs/gameplay.md b/docs/gameplay.md index fc15a90..1462472 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 @@ -777,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` | 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