From 08b3b5b1b8d278885b81de44daddfc60dd538b87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:04:26 +0000 Subject: [PATCH 1/2] Turn forms with a head on one side round as a whole where they go So far only the face turned when a pet went right, and a chameleon walked with its tail in front. Forms with a head on one side and a tail, shell or ball on the other (HEADING in the scene: 7 babies, 12 children, 19 adults) are now drawn mirrored when they face the other way, at home, on trips, in a scuffle, as a visitor and in the yard. Round forms and those with something held in one hand (tray, lance, plan) only turn their face, as before. Effects tied to a spot of the body follow the turned body: the part that moves by itself (a lion's head, a sun ball), the backpack and where food lands, the key's ticks, the yawn, the paper planes, the fireworks, the moss and the pompoms. Palettino and Axodrake no longer turn round by hand for their special move: facing the opponent, they already are. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01329FGaZ2R2yreSBvZMdcz4 --- core/src/scene.ts | 114 +++++++++++++++++++++++++++++----------- core/test/scene.test.ts | 16 ++++++ docs/gameplay.md | 5 ++ 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/core/src/scene.ts b/core/src/scene.ts index 1b30d18..d4e49f4 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -329,6 +329,21 @@ const HAT_SPOTS: Record = { "adult:mirrorleon": [-8, 3, 0, true], "adult:pharabug": [5, 10, 1], "child:octopus": [-4, 3, 0], "adult:inkasso": [0, 6, 0, true], "adult:fountaingle": [5, 1, 0], }; +// Which way a form looks as it is drawn, when it has a head on one side of its body and a tail, a +// shell or a ball on the other (-1: to the left). Going the other way, its whole body turns round, +// drawn mirrored (its light then comes from the top right). A round form or one with its head in +// the middle only turns its face, and what it holds in one hand (a tray, a lance, a plan) stays on its side. +const HEADING: Record = { + "baby:chameleon": -1, "baby:dog": -1, "baby:mouse": -1, "baby:beaver": -1, "baby:kite": -1, "baby:crab": -1, + "baby:scarab": -1, + "child:chameleon": -1, "child:dog": -1, "child:mouse": -1, "child:beaver": -1, "child:kite": -1, "child:crab": -1, + "child:scarab": -1, "child:fox": -1, "child:axolotl": -1, "child:gargoyle": -1, "child:caterpillar": 1, + "child:dragonfly": 1, + "adult:palettino": -1, "adult:hidewell": -1, "adult:mirrorleon": -1, "adult:lionkite": -1, "adult:origrane": -1, + "adult:roamtail": -1, "adult:filchfox": -1, "adult:drakwing": -1, "adult:rotorfly": -1, "adult:dambert": -1, + "adult:timberjack": -1, "adult:mossolotl": -1, "adult:axodrake": -1, "adult:cogsqueak": -1, "adult:snorbear": -1, + "adult:glidepin": -1, "adult:solroll": 1, "adult:rapierre": 1, "adult:fountaingle": 1, +}; const LIT_NEAR = ["torch"]; // things along the path that glow by themselves at night // The coast: sand, the sea in front, the dunes behind; at low tide the beach is wider (trip.tide) const BEACH: Color = [228, 202, 140]; @@ -1448,6 +1463,17 @@ function widen(img: Image, extra: number): Image { return out; } +/** Which way a form looks as it is drawn (HEADING): -1 or 1, 0 for one that only turns its face. */ +function headingOf(stage: string, species: string): number { + return HEADING[`${stage}:${stage === "adult" ? species : life.lineOf(species)}`] ?? 0; +} + +/** Whether a form facing this way is drawn mirrored, its whole body turned round (HEADING). */ +function turnsRound(stage: string, species: string, facing: number): boolean { + const head = headingOf(stage, species); + return head !== 0 && facing !== 0 && Math.sign(facing) !== head; +} + /** Turned around the vertical axis (spin): f = 1 from the front, 0 from the side, -1 mirrored. */ function turned(img: Image, f: number): Image { const width = Math.max(2, Math.round(img.width * Math.abs(f))); @@ -1640,6 +1666,8 @@ interface PetLook { glow?: boolean; /** The face is mirrored; as drawn, the pupils sit towards the right. The body stays as it is: its light comes from the top left. */ lookLeft?: boolean; + /** Where it is heading (-1 left, 1 right): a form with its head on one side (HEADING) turns its whole body that way. */ + facing?: number; /** The weather as it shows on a form that follows it (skyColors). */ sky?: SkyLook; /** The phase of the moon, for Lunarock's moonstone (0 = new moon, 0.5 = full). */ @@ -1831,7 +1859,7 @@ export class PetScene { sick: view.sick, whiteOut: pose.white, 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: pose.rainbow ? "after_rain" : this.skyLook, + glow: this.night > 0.5, lookLeft: this.facing < 0, facing: this.facing, sky: pose.rainbow ? "after_rain" : this.skyLook, moon: species === "lunarock" ? Math.round(this.moon * 16) / 16 : null, starless: pose.starless, paint: pose.paint, tint: pose.tint, }); @@ -1846,7 +1874,9 @@ export class PetScene { } let piece: [Image, number, number] | null = null; // the part that moves by itself, and where it goes if (pose.piece) { - const { box: [x0, y0, x1, y1], dx, up, turn, rotate } = pose.piece; + const { box: [bx0, y0, bx1, y1], dx, up, turn, rotate } = pose.piece; + // the box is in its frame as drawn; a body turned round has the part on its other side + const [x0, x1] = turnsRound(stage, species, this.facing) ? [SPRITE - bx1, SPRITE - bx0] : [bx0, bx1]; let part = pet.crop(x0 + side, y0 + above, x1 + side, y1 + above); const rest = pet.copy(); rest.blit(new Image(x1 - x0, y1 - y0), x0 + side, y0 + above); @@ -2688,7 +2718,7 @@ export class PetScene { const a = (1 - Math.cos(q * Math.PI)) * Math.PI; Object.assign(pose, { turn: Math.cos(a), body: q > 0.2 && q < 0.85 ? "stretch" : "idle", face: "blink" }); if (q > 0.15 && q < 0.9) { - const back = this.rng.uniform(-3, 14) * Math.cos(a) * this.facing; // the moss on its back turns with it + const back = this.art(pose.form, this.rng.uniform(-3, 14)) * Math.cos(a); // the moss on its back turns with it this.trickFx(t, "unfurl", 0.1, MOSS_BIT, 2, back, 13, [-6, 6], [-10, -4], 1.8, 8); this.trickFx(t, "unfurl_leaf", 0.35, LEAFLET, 1, back, 14, [-8, 8], [-9, -5], 1.6, 6); } @@ -2738,12 +2768,11 @@ export class PetScene { } else if (animation === "wind_up") { // the key in its back turns, it shakes harder and harder, then it whirrs round and round on the spot // with sparks and little cogs flying off its wheels, and runs down - const f = this.facing; if (p < 0.45) { const shake = p > 0.1 ? Math.trunc(t * (10 + p * 30)) % 2 : 0; Object.assign(pose, { body: this.bodyOf(...pose.form, "walk"), dx: shake, face: p > 0.3 ? "normal" : "blink" }); if (p > 0.1) { // ticks off the key - this.trickFx(t, "wind_up_tick", 0.15, COG_SPARK, 2, 6 * f, 19, [-6, 6], [-10, -4], 0.5); + this.trickFx(t, "wind_up_tick", 0.15, COG_SPARK, 2, this.art(pose.form, 6), 19, [-6, 6], [-10, -4], 0.5); } } else if (p < 0.9) { const q = (p - 0.45) / 0.45; @@ -3005,7 +3034,7 @@ export class PetScene { paint: p > 0.05 && p < 0.92 ? p * 3 : null }); if (p > 0.1 && p < 0.9) { this.trickFx(t, "mirror_dance", 0.07, LIGHT_SPOT[Math.trunc(t * 11) % LIGHT_SPOT.length], 1, - (MIRRORLEON_CASQUE[0] - SPRITE / 2) * this.facing + pose.dx * this.facing, SPRITE - MIRRORLEON_CASQUE[1], + this.art(pose.form, MIRRORLEON_CASQUE[0] - SPRITE / 2) + pose.dx * this.facing, SPRITE - MIRRORLEON_CASQUE[1], [-28, 28], [-22, 6], 0.7); } } else if (animation === "ball_vault") { @@ -3014,12 +3043,14 @@ export class PetScene { const [a, b] = [hop(0.1), hop(0.56)]; const f = a < 1 ? a : b; const over = a < 1 ? (1 - Math.cos(a * Math.PI)) / 2 : 1 - (1 - Math.cos(b * Math.PI)) / 2; - Object.assign(pose, { dx: Math.round(22 * over), jump: f > 0 && f < 1 ? Math.sin(f * Math.PI) * 14 : 0, - rotate: f > 0 && f < 1 ? (a < 1 ? -360 : 360) * f : 0, face: f > 0 && f < 1 ? "happy" : "laugh", + const side = this.art(pose.form, this.facing); // where its ball is: in front of it, as its body turns round + Object.assign(pose, { dx: Math.round(22 * over) * side, jump: f > 0 && f < 1 ? Math.sin(f * Math.PI) * 14 : 0, + rotate: f > 0 && f < 1 ? (a < 1 ? -360 : 360) * f * side : 0, face: f > 0 && f < 1 ? "happy" : "laugh", body: (a > 0 && a < 1) || (b > 0 && b < 1) ? "idle" : "squash" }); pose.piece = { box: SOLROLL_BALL, dx: -pose.dx, up: -Math.round(pose.jump), turn: null, rotate: 0 }; if (a === 1 && b === 0) { // it stands beyond its ball, and the sun shines - this.trickFx(t, "ball_vault", 0.15, SUN_SPARK, 1, 10 * this.facing + this.rng.uniform(-5, 5), 8, [-6, 6], [-14, -6], 0.6); + this.trickFx(t, "ball_vault", 0.15, SUN_SPARK, 1, this.art(pose.form, 10) + this.rng.uniform(-5, 5), 8, [-6, 6], + [-14, -6], 0.6); } } else if (animation === "cog_dance") { // it bobs to the beat, two beats a second, and on each its cog clicks round a step, ticking @@ -3055,8 +3086,8 @@ export class PetScene { pose.piece = on ? { box: LIONKITE_HEAD, dx: Math.round(shake), up: Math.abs(Math.sin(p * 4 * Math.PI)) * 5, turn: null, rotate: shake * 10 } : null; if (on) { - this.trickFx(t, "lion_dance", 0.1, POMPOM[Math.trunc(t * 7) % POMPOM.length], 2, (pose.dx - 7) * this.facing, 18, - [-26, 26], [-36, -22], 1.0, 70); + this.trickFx(t, "lion_dance", 0.1, POMPOM[Math.trunc(t * 7) % POMPOM.length], 2, + pose.dx * this.facing + this.art(pose.form, -7), 18, [-26, 26], [-36, -22], 1.0, 70); } } else if (animation === "storm_dive") { // the wind tears it up high, it turns over at the top, and dives down nose first, pulling up at the last moment @@ -3732,10 +3763,9 @@ export class PetScene { } } break; - case "axodrake": // up on its wings and round to face the opponent, its gills glow brighter and brighter, and - // it breathes a long glowing stream of bubbles. Its head is drawn on the left: it turns round for this + case "axodrake": // up on its wings, facing the opponent (turned round: HEADING), its gills glow brighter and + // brighter, and it breathes a long glowing stream of bubbles pose.body = this.bodyOf(...pose.form, "flutter"); - pose.turn = Math.cos(clamp((q - 0.1) / 0.2, 0, 1) * Math.PI - clamp((q - 0.7) / 0.2, 0, 1) * Math.PI); if (q < 0.36) { Object.assign(pose, { jump: Math.sin(clamp(q / 0.25, 0, 1) * Math.PI / 2) * 8, face: "smile", white: q > 0.26 && Math.trunc(t * 12) % 2 === 0 }); @@ -3778,7 +3808,7 @@ export class PetScene { // open on a spring, cogs flying if (q < 0.2) { Object.assign(pose, { body: this.bodyOf(...pose.form, "walk"), dx: Math.trunc(t * 16) % 2, face: "normal" }); - this.trickFx(t, "sp_cogsqueak_tick", 0.15, COG_SPARK, 2, 6 * this.facing, 19, [-6, 6], [-10, -4], 0.5); + this.trickFx(t, "sp_cogsqueak_tick", 0.15, COG_SPARK, 2, this.art(pose.form, 6), 19, [-6, 6], [-10, -4], 0.5); } else if (q < 0.26) { Object.assign(pose, { body: "stretch", face: "laugh" }); } @@ -3876,8 +3906,8 @@ export class PetScene { case "snorbear": // a great yawn, and its drowsy breath drifts over to the opponent, who nods off: Zzz if (q < 0.34) { Object.assign(pose, { body: q < 0.1 ? "idle" : "stretch", face: q < 0.1 ? "blink" : "eat_open" }); - if (q > 0.12) { // up out of its mouth, on the left of its head - this.trickFx(t, "sp_snorbear_yawn", 0.12, YAWN, 1, -9 * this.facing, 16, [4, 10], [-14, -10], 0.9); + if (q > 0.12) { // up out of its mouth, on the left of its head as it is drawn + this.trickFx(t, "sp_snorbear_yawn", 0.12, YAWN, 1, this.art(pose.form, -9), 16, [4, 10], [-14, -10], 0.9); } } else if (q < 0.5) { // the breath rolls over its mound to the opponent Object.assign(pose, { body: "idle", face: "blink" }); @@ -4141,10 +4171,10 @@ export class PetScene { break; } case "palettino": { // its colors run as it takes aim, then its tongue shoots out with a blob of paint on its - // tip: splash! It turns round for it, its head towards the opponent + // tip: splash! Facing the opponent, it is turned round, its head to the right (HEADING) const shoot = clamp((q - 0.3) / 0.18, 0, 1); const back = clamp((q - 0.56) / 0.12, 0, 1); - Object.assign(pose, { turn: -1, paint: q < 0.3 ? q * 4 : null, face: shoot > 0 && back < 1 ? "eat_open" : q < 0.3 ? "smile" : "laugh", + Object.assign(pose, { paint: q < 0.3 ? q * 4 : null, face: shoot > 0 && back < 1 ? "eat_open" : q < 0.3 ? "smile" : "laugh", body: q > 0.2 && q < 0.3 ? "squash" : shoot > 0 && back < 1 ? "stretch" : "idle" }); const points: [Bitmap, number, number][] = []; const reach = shoot * (1 - back); @@ -4258,7 +4288,7 @@ export class PetScene { for (let i = 0; i < 7; i++) { const from = 0.12 + i * 0.035; const f = (q - from) / 0.24; - const [x0, y0] = [this.x + 18 + (i % 2) * 6, GROUND - 26 + (i % 3) * 2]; + const [x0, y0] = [this.x + this.column(pose.form, 18 + (i % 2) * 6), GROUND - 26 + (i % 3) * 2]; const [x1, y1] = [foeLeft(from + 0.24) + 2 + (i % 3) * 3, GROUND - 6 - (i % 4) * 3]; if (f > 0 && f < 1) { points.push([PLANE[f < 0.45 ? 0 : 1], x0 + (x1 - x0) * f, y0 + (y1 - y0) * f - Math.sin(f * Math.PI) * 14 @@ -4277,7 +4307,7 @@ export class PetScene { for (let i = 0; i < 3; i++) { const from = 0.12 + i * 0.1; const f = (q - from) / 0.14; - const [x0, y0] = [this.x + 9, GROUND - 22]; + const [x0, y0] = [this.x + this.column(pose.form, 9), GROUND - 22]; const [x1, y1] = [foeLeft(from + 0.14) + 6 + (i - 1) * 7, i === 2 ? GROUND - 14 : GROUND - 34 + i * 6]; if (f > -0.1 && f < 0.15) { rear = 1; @@ -4539,6 +4569,19 @@ export class PetScene { } } + /** + * A spot of its body, `d` columns right of its middle as the form is drawn, as trickFx takes it (towards + * where it looks): on a form that turns round (HEADING) it goes round with it, on the others it stays on its side. + */ + private art(form: readonly [string, string], d: number): number { + return d * (headingOf(...form) || this.facing); + } + + /** Column `c` of the form as drawn, in its frame as it is now (a form turned round is mirrored). */ + private column(form: readonly [string, string], c: number): number { + return turnsRound(...form, this.facing) ? SPRITE - 1 - c : c; + } + /** * 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), @@ -4631,12 +4674,15 @@ export class PetScene { /** Left edge of an item lying in front of the pet (at x), `overlap` pixels in front of its body. */ private beside(x: number, width: number, overlap: number, form: readonly [string, string], facing = this.facing): number { - const [left, right] = this.reach(...form); + const [left, right] = this.reach(...form, facing); return facing > 0 ? x + right - overlap : x + left - width + overlap; } - /** Where a form's body reaches in its sprite frame, outline and all: [left, right) columns, standing still. */ - private reach(stage: string, species: string): readonly [number, number] { + /** + * Where a form's body reaches in its sprite frame, outline and all: [left, right) columns, standing still + * and facing this way (HEADING). + */ + private reach(stage: string, species: string, facing = this.facing): readonly [number, number] { const key = `${stage}|${species}`; let reach = this.reachCache.get(key); if (!reach) { @@ -4646,7 +4692,7 @@ export class PetScene { reach = [x0 - shift, x1 - shift]; this.reachCache.set(key, reach); } - return reach; + return turnsRound(stage, species, facing) ? [SPRITE - reach[1], SPRITE - reach[0]] : reach; } /** A thrown item drops in and the pet hops over. Returns what it is doing right now. */ @@ -4673,8 +4719,8 @@ export class PetScene { this.throwFacing = Number(where) >= 0.5 ? 1 : -1; } else { const left = Number(where) * (this.width - width); - const [body0, body1] = this.reach(...pose.form); - const spots: [number, number][] = [[left - body1 + overlap, 1], [left + width - overlap - body0, -1]]; + const spots: [number, number][] = [[left - this.reach(...pose.form, 1)[1] + overlap, 1], + [left + width - overlap - this.reach(...pose.form, -1)[0], -1]]; const cost = (spot: [number, number]) => [Math.abs(spot[0] - clamp(spot[0], lo, hi)), Math.abs(spot[0] - this.x)]; const [first, second] = spots.map(cost); [x, this.throwFacing] = second[0] < first[0] || (second[0] === first[0] && second[1] < first[1]) @@ -5452,7 +5498,7 @@ export class PetScene { const lift = Math.round(jump); let [sprite, top] = this.pet(stage, species, body, t, face, { glow: this.night > 0.5, lookLeft: move ? move.lookLeft : walking && heading < 0, - sky: this.skyLook }); + facing: move ? (move.lookLeft ? -1 : 1) : walking ? heading : undefined, sky: this.skyLook }); let px = x + (move?.dx ?? 0) - Math.floor((sprite.width - SPRITE) / 2); let py = GROUND - SPRITE - lift; if (move?.turn !== null && move?.turn !== undefined) { @@ -5757,7 +5803,7 @@ export class PetScene { body = Math.trunc(local * 4) % 2 ? "squash" : "idle"; // splashing about, as when it is cleaned } const [image, top] = this.pet(pet.stage, pet.species, body, local, face, { - sick: pet.sick, heavy: pet.heavy, hat: pet.hat, glow: this.night > 0.5, lookLeft: facing < 0, sky: this.skyLook, + sick: pet.sick, heavy: pet.heavy, hat: pet.hat, glow: this.night > 0.5, lookLeft: facing < 0, facing, sky: this.skyLook, }); const room = image.height - SPRITE - 2; const height = SPRITE - top; @@ -5907,7 +5953,7 @@ export class PetScene { } else if (activity === "fish_empty") { Object.assign(pose, view.activity_item === "early" ? { body: "flat", face: "no", symbol: QUESTION } : { body: "squash", face: "sad" }); - } else if (this.moveTowards(rim + FISH_REACH - this.reach(...pose.form)[1], THROW_SPEED, dt)) { + } else if (this.moveTowards(rim + FISH_REACH - this.reach(...pose.form, 1)[1], THROW_SPEED, dt)) { this.hop(pose, t, 3.5); pose.face = "happy"; } else { @@ -7439,8 +7485,9 @@ export class PetScene { const changing = ownArt && species in CHANGING; const paint = changing && look.paint !== undefined && look.paint !== null ? Math.round(look.paint * 16) % 16 / 16 : null; const friendColors = changing && look.tint ? this.colorsOf(look.tint) : null; + const turn = look.facing !== undefined && turnsRound(stage, species, look.facing); const key = [stage, species, bodyTag, index, faceName, sick, whiteOut, flame, heavy, hat, hint, glow, - lookLeft, weathered?.[0], look.moon, starless, paint, friendColors && look.tint].join("|"); + lookLeft, turn, weathered?.[0], look.moon, starless, paint, friendColors && look.tint].join("|"); let cached = this.petCache.get(key); if (!cached) { let img = hint ? swapColors(body, LURES[line].map((from, i) => [from, LURE_HINT[hint][i]] as const)) : body.copy(); @@ -7470,7 +7517,7 @@ export class PetScene { if (faceHue) { face = recolor(face, 100, 145, faceHue); // eyelids and mouth in the body color } - if (lookLeft) { + if (lookLeft !== turn) { // a body that turns round takes its face with it face = face.mirrored(); } const [cx, anchor] = faceAnchor(body, bodies.faceTop, bodies.faceCenter, bodies.faceShift); @@ -7492,6 +7539,9 @@ export class PetScene { img = withHat(img, this.sheet("trip").image(stage === "baby" || spot?.[2] ? `hat_${hat}_small` : `hat_${hat}`), stage === "child" || species === "flicker", spot); } + if (turn) { + img = img.mirrored(); + } if (whiteOut) { img = white(img); } diff --git a/core/test/scene.test.ts b/core/test/scene.test.ts index 6bdca4e..5dc5004 100644 --- a/core/test/scene.test.ts +++ b/core/test/scene.test.ts @@ -203,6 +203,22 @@ test("some forms do a trick in their own way", () => { assert.ok(toss.some((image) => differing(image, toss[0]) > 20)); // up goes the top stone }); +test("a form with its head on one side turns round as a whole where it is heading; a round one only turns its face", () => { + type Draw = (stage: string, species: string, body: string, t: number, face: string, + look: { lookLeft?: boolean; facing?: number }) => [Image, number]; + const pet = scene() as unknown as { pet: Draw }; + const draw = (stage: string, species: string, look: { lookLeft?: boolean; facing?: number }) => + [...pet.pet(stage, species, "idle", 0, "normal", look)[0].data]; + for (const [stage, species] of [["baby", "chameleon"], ["child", "chameleon"], ["adult", "palettino"], ["adult", "rapierre"]]) { + const left = pet.pet(stage, species, "idle", 0, "normal", { lookLeft: true, facing: -1 })[0]; + assert.deepEqual(draw(stage, species, { lookLeft: false, facing: 1 }), [...left.mirrored().data], `${stage} ${species}`); + } + for (const [stage, species] of [["child", "blob"], ["adult", "angloop"], ["adult", "tuxwaddle"]]) { + assert.deepEqual(draw(stage, species, { lookLeft: false, facing: 1 }), draw(stage, species, { lookLeft: false })); + assert.deepEqual(draw(stage, species, { lookLeft: true, facing: -1 }), draw(stage, species, { lookLeft: true })); + } +}); + test("every activity can be drawn from its start to its end", () => { const items: Record = { throw_meal: "meal@0.90", throw_snack: "cookie@0.05", throw_play: "ball@0.50", throw_medicine: "medicine@0.50", diff --git a/docs/gameplay.md b/docs/gameplay.md index e825a8e..b5852db 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -1299,6 +1299,10 @@ A new creature should make you think "that one is different". version side by side. - **Half and mirror.** Draw the left half, mirror it (light comes from the top left, so the right side gets the shadow), then break the symmetry by hand. + A creature with its head on one side and a tail, a shell or a ball on the + other is drawn looking one way; going the other way, the scene turns its + whole body round, mirrored (`HEADING`), and its light then comes from the + top right. A round one only turns its face. - **In the scene.** Look at the creature in `npm run preview`: with hats, in the rain, on a trip, as a visitor and as a wild pet coming in, and as a GIF for the movement. @@ -1320,6 +1324,7 @@ For an adult form with pixel art of its own: | 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, where its hat sits, special move | `LURE_HINT`, `WALK_HOPS`, `NO_UMBRELLA`, `HAT_SPOTS`, `special` and `hit` in `core/src/scene.ts` | +| Which way it looks as drawn, if its head is on one side (it turns round as a whole) | `HEADING` in `core/src/scene.ts` | | A trick in its own way: the same gesture and effect, another animation | `OWN_TRICKS` in `core/src/care.ts` 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` | From 5b48aa0f3f0e86adad996fa439008e75a891bbe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:06:17 +0000 Subject: [PATCH 2/2] Fix the API contract test to a festival day (from #141) The contract test read the pet's answers on today's date, so the festival fields came and went with the poster and the test failed on main. Same change as in #141; it no-ops once that is merged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01329FGaZ2R2yreSBvZMdcz4 --- core/test/api.test.ts | 5 +++-- core/test/helpers.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/core/test/api.test.ts b/core/test/api.test.ts index e128040..3a70c6f 100644 --- a/core/test/api.test.ts +++ b/core/test/api.test.ts @@ -7,7 +7,7 @@ import { test } from "node:test"; import { API_OLDEST, API_VERSION, NOT_A_PET_ROUTE, ownTarget, petRequest } from "../src/api"; import { ACTIONS, RequestError } from "../src/model"; import type { Pet } from "../src/pet"; -import { Disk } from "./helpers"; +import { at, Disk } from "./helpers"; function ask(pet: Pet, method: string, path: string, body?: unknown): Promise { return petRequest(ownTarget(pet), method, new URL(path, "http://pet"), async () => body ?? null); @@ -88,7 +88,8 @@ function fields(value: unknown, path = "", into: Record = {}): R /** What an app may rely on: the routes, the actions and the fields of what the routes answer. */ async function contract() { - const pet = new Disk().pet(); + // a fixed day, with the festival's poster up: what the answers hold must not depend on the date + const pet = new Disk().pet(() => at("2026-10-22 12:00")); const routes = [...readFileSync("core/src/api.ts", "utf-8").matchAll(/case "((?:GET|POST) \/api\/pet[^"]*)"/g)] .map((match) => match[1]).sort(); const answers: Record = {}; diff --git a/core/test/helpers.ts b/core/test/helpers.ts index 1034169..da6a972 100644 --- a/core/test/helpers.ts +++ b/core/test/helpers.ts @@ -75,13 +75,14 @@ export class Disk { this.text = JSON.stringify(file); }; - /** A pet as it is after a restart: read from what was saved. */ - pet(): Pet { + /** A pet as it is after a restart: read from what was saved. `clock`: a fixed time instead of the real one. */ + pet(clock?: () => number): Pet { const i18n = new I18n(readLocales()); return new Pet({ t: i18n.t, saved: this.text === null ? null : readSave(JSON.parse(this.text)), save: this.save, + clock, }); } }