Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 200 additions & 1 deletion core/src/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ const OWN_TRICKS: Record<string, Record<string, string>> = {
"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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions docs/gameplay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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` |
Expand Down
3 changes: 2 additions & 1 deletion tools/pet_preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ const NEW_SCENES = (["egg", "hatch", "baby", "child", "hint", "evolve"] as const
const OWN_TRICK_FORMS: Record<string, string> = {
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
Expand Down
Loading