diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 6b7a53e5..e1a80727 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -1319,8 +1319,9 @@ Enforced by `tests/nav-current.spec.ts`, `tests/mobile-menu.spec.ts`, field). - `HeroField` loads `client:load`. - The About cats (`src/components/AboutCats.astro`, `src/scripts/about-cats.ts`) wake when their band is - first on screen, play along it (games like the yarn and the fly carry them, - leaps move them on), and sit still under reduced motion. Each cat's button + first on screen, play along it in short bursts between long rests (games + like the yarn and the fly carry them, leaps move them on), and sit still + under reduced motion. Each cat's button ("Meet Minerva") opens its card; while it is open the cat sits where it is and looks round at you, and under reduced motion it stays as it was. - Each cat's SC 2.2.2 control sits in its band's corner: Zz ("Put Minerva to @@ -1330,7 +1331,7 @@ Enforced by `tests/nav-current.spec.ts`, `tests/mobile-menu.spec.ts`, Random first, then its own moves, each an icon and a name (`src/lib/about-cats-tricks.ts`, fetched from its JSON after load, so `/about` stays within its script budget). A pick plays once, wakes a - sleeping cat first, and trots the cat to room if the move needs it; the cat + sleeping cat first, and walks the cat to room if the move needs it; the cat then plays on at random. The list leaves out moves that cannot fit the band's width. - A talk in full screen (`src/components/DeckBar.astro`, diff --git a/src/lib/about-cats-frames.ts b/src/lib/about-cats-frames.ts index efd41c4a..aff32ff5 100644 --- a/src/lib/about-cats-frames.ts +++ b/src/lib/about-cats-frames.ts @@ -139,31 +139,55 @@ export const POSE_FRAME = { export type NamedPose = keyof typeof POSE_FRAME; -/** The in-between frame drawn halfway from one named pose to another, either way. */ -const BETWEEN: [NamedPose, NamedPose, Frame][] = [ - ['stand', 'sit', 'rise'], - ['stand', 'crouch', 'crouchdown'], - ['crouch', 'air', 'takeoff'], - ['air', 'stand', 'landing'], - ['stand', 'stretch', 'stretchhalf'], - ['sit', 'loaf', 'loafsink'], - ['stand', 'loaf', 'loafsink'], - ['sit', 'rear', 'pawrise'], - ['sit', 'low', 'pawrise'], - ['stand', 'rear', 'pawrise'], - ['rear', 'leap', 'swat'], +/** Frames one can follow the other, either way: a cat moves between them in one drawing's step. */ +const LINKS: [Frame, Frame][] = [ + ['sit', 'rise'], + ['rise', 'stand'], + ['sit', 'headturn'], + ['headturn', 'look'], + ['sit', 'pawrise'], + ['stand', 'pawrise'], + ['pawrise', 'swat'], + ['sit', 'loafsink'], + ['stand', 'loafsink'], + ['loafsink', 'loaf'], + ['loaf', 'sleep'], + ['loaf', 'lieside'], + ['lieside', 'roll'], + ['roll', 'belly'], + ['sit', 'crouchdown'], + ['stand', 'crouchdown'], + ['crouchdown', 'crouch'], + ['crouch', 'loaf'], + ['stand', 'stretchhalf'], + ['stretchhalf', 'stretch'], + ['crouch', 'takeoff'], + ['leap', 'landing'], + ['takeoff', 'leap'], + ['takeoff', 'landing'], + ['landing', 'crouch'], + ...WALK.map((walk): [Frame, Frame] => ['stand', walk]), ]; -/** Whether `frame` is drawn in passing between `from` and another pose. */ -export const bridges = (from: Frame, frame: Frame): boolean => - BETWEEN.some( - ([a, b, between]) => - between === frame && (POSE_FRAME[a] === from || POSE_FRAME[b] === from), - ); +/** The frames linked to `frame`. */ +const linkedTo = (frame: Frame): Frame[] => + LINKS.flatMap(([a, b]) => (a === frame ? [b] : b === frame ? [a] : [])); + +/** Whether `a` and `b` follow each other directly. */ +const linked = (a: Frame, b: Frame): boolean => linkedTo(a).includes(b); -const betweenFrame = (a: NamedPose, b: NamedPose): Frame | null => - BETWEEN.find(([x, y]) => (x === a && y === b) || (x === b && y === a))?.[2] ?? - null; +/** + * The frame to show next on the way from `from` to `to`: the first of at most two + * in-betweens. A longer way would lag the pose, so the frame cuts straight to `to`. + */ +export const towards = (from: Frame, to: Frame): Frame => { + const near = linkedTo(from); + return from === to || near.includes(to) + ? to + : (near.find((a) => linked(a, to)) ?? + near.find((a) => linkedTo(a).some((b) => linked(b, to))) ?? + to); +}; /** px or degrees of each pose value that count as one unit of difference. */ const UNIT = { @@ -188,8 +212,6 @@ const poseDistance = (p: Pose, q: Pose): number => Math.abs(p.eyes - q.eyes) / UNIT.eyes + Math.abs(p.zz - q.zz) / UNIT.zz; -/** Share of the way between the nearest pose and the next past which the in-between frame shows. */ -const BETWEEN_FROM = 0.3; /** Roll angle in degrees past which a cat is on its side, and past which it is on its back. */ const ON_SIDE = 25; const ON_BACK = 135; @@ -205,6 +227,15 @@ const WALK_STRIDE_PX = 52; /** px the body travels per walk frame, so a planted paw stays put. */ const WALK_FRAME_PX = WALK_STRIDE_PX / WALK.length; +/** Frames the flip-book shows a second. */ +export const FRAMES_PER_SECOND = 12; + +/** Share of a frame per step a walk covers: frames change only on a screen refresh, so a walk at a full frame per step falls behind and skips one. */ +const WALK_HEADROOM = 0.85; + +/** px per second a cat walks: faster, the walk would skip frames and the paws slide. */ +export const WALK_SPEED = WALK_FRAME_PX * FRAMES_PER_SECOND * WALK_HEADROOM; + export interface Motion { /** px travelled along the ground since the cat started walking. */ travelled: number; @@ -226,30 +257,23 @@ export const frameFor = ( if (turned > ON_SIDE) return turned > (ON_SIDE + ON_BACK) / 2 ? 'roll' : 'lieside'; - const ranked = (Object.keys(named) as NamedPose[]) - .map((name) => ({ name, d: poseDistance(p, named[name]) })) - .sort((a, b) => a.d - b.d); - /* `named` holds every pose, so neither default applies. */ - const [near = { name: 'sit', d: 0 }, next = near] = ranked; + const near = (Object.keys(named) as NamedPose[]).reduce((best, name) => + poseDistance(p, named[name]) < poseDistance(p, named[best]) ? name : best, + ); /* A low hop from sitting, such as a turn on the spot, keeps the sitting frame: jump frames would flash a leap. */ - const sitHop = near.name === 'sit' && motion.lift <= HIGH; + const sitHop = near === 'sit' && motion.lift <= HIGH; if (motion.lift > AIRBORNE && !sitHop) { if (motion.lift > HIGH) return 'leap'; return motion.lift >= motion.lastLift ? 'takeoff' : 'landing'; } - const share = near.d / (near.d + next.d || 1); - const between = - share > BETWEEN_FROM ? betweenFrame(near.name, next.name) : null; - - if (motion.moving && (near.name === 'stand' || near.name === 'crouch')) - return near.name === 'crouch' + if (motion.moving && (near === 'stand' || near === 'crouch')) + return near === 'crouch' ? 'crouch' : walkFrame(Math.floor(motion.travelled / WALK_FRAME_PX)); - if (between) return between; - if (near.name === 'sit' && Math.abs(p.hr) > LOOK_BACK) return 'look'; - if (near.name === 'sit' && Math.abs(p.hr) > GLANCE) return 'headturn'; - return POSE_FRAME[near.name]; + if (near === 'sit' && Math.abs(p.hr) > LOOK_BACK) return 'look'; + if (near === 'sit' && Math.abs(p.hr) > GLANCE) return 'headturn'; + return POSE_FRAME[near]; }; diff --git a/src/lib/about-cats-moves.ts b/src/lib/about-cats-moves.ts index 677404cb..f6481809 100644 --- a/src/lib/about-cats-moves.ts +++ b/src/lib/about-cats-moves.ts @@ -10,6 +10,7 @@ import type { PropKind, PropState, } from './about-cats-types'; +import { WALK_SPEED } from './about-cats-frames'; export type { Pose }; @@ -389,16 +390,13 @@ type Route = readonly [RoutePoint, ...RoutePoint[]]; /* Arcs in px; with them the tallest move stays under --spacing-cat-band (tests/about-cats.spec.ts). */ const POUNCE_HEIGHT = 56; -/** A stalk's steps are full strides taken slowly, so each paw is seen to lift and set down. */ -const STALK_STRIDE = 1; const STALK_STEPS = 5; const STALK_STEP_PX = 30; -const STALK_CROUCH_MS = 400; +/** ms to stand up before the first step, and to crouch before the spring: long enough to pass through the half crouch. */ +const STALK_READY_MS = 400; const STALK_STEP_MS = 700; -/** Between steps the cat freezes with the near front paw held up, as a stalking cat does. */ -const STALK_PAW_UP: Pair = [20, 9]; -const STALK_PAW_MS = 170; -const STALK_FREEZE_MS = 620; +/** Between steps the cat freezes, watching. */ +const STALK_FREEZE_MS = 960; const STALK_REACH = 50; const POUNCE_REACH = 76; /** The pounce crouches, then wiggles until it springs. */ @@ -535,6 +533,8 @@ const YARN_LAND_2 = 308; const YARN_BAT_1 = BAT_HIT_MS; const YARN_POUNCE_LANDED = 4320; const YARN_BAT_2 = YARN_POUNCE_LANDED + BAT_HIT_MS; +/** ms to stand up after the second bat: the sit, rise and stand frames take that long. */ +const YARN_RISE_MS = 250; /** The ball hugged on the back, between the hind paws, from the cat's place. */ const YARN_HUG = 9; const YARN_HUG_Y = -34; @@ -580,10 +580,9 @@ const along = ([first, ...rest]: Route, t: number): [number, number] => { }; /* - * Rolling onto the back: low, then over all at once with a small flop. Easing - * the 180° roll would stand the cat on its head halfway. + * Rolling onto the back: low, then over all at once; the frames pass through + * lying on the side. Easing the 180° roll would stand the cat on its head halfway. */ -const FLOP_HOP = 4; /** Body height before the roll; upright poses keep their paws on the ground there. */ const FLOP_LOW = 9; const groundedAt = (name: PoseName, by: number): Partial => @@ -614,23 +613,24 @@ export const MOVES = { ], mods: [], }), - /* Step, freeze with a front paw held up, step again; then the pounce. */ + /* Step slowly, freeze, step again; then crouch where it stands and pounce. A crouch never travels: no drawing walks crouched. */ stalk: (): Move => { - const steps = [step(STALK_CROUCH_MS, 'crouch')]; + const steps = [step(STALK_READY_MS, 'stand')]; const mods: Mod[] = []; - let t = STALK_CROUCH_MS; + let t = STALK_READY_MS; for (let i = 1; i <= STALK_STEPS; i += 1) { const x = STALK_STEP_PX * i; steps.push( - step(STALK_STEP_MS, 'crouch', { x }, 'linear'), - step(STALK_PAW_MS, 'crouch', { x, fN: STALK_PAW_UP }, 'out'), - step(STALK_FREEZE_MS, 'crouch', { x, fN: STALK_PAW_UP }), - step(STALK_PAW_MS, 'crouch', { x }, 'in'), + step(STALK_STEP_MS, 'stand', { x }, 'linear'), + step(STALK_FREEZE_MS, 'stand', { x }), ); - mods.push(walking(t, t + STALK_STEP_MS, STALK_STRIDE)); - t += STALK_STEP_MS + 2 * STALK_PAW_MS + STALK_FREEZE_MS; + mods.push(walking(t, t + STALK_STEP_MS)); + t += STALK_STEP_MS + STALK_FREEZE_MS; } - const jump = pounceFrom(t, STALK_STEP_PX * STALK_STEPS, STALK_REACH); + const reach = STALK_STEP_PX * STALK_STEPS; + steps.push(step(STALK_READY_MS, 'crouch', { x: reach })); + t += STALK_READY_MS; + const jump = pounceFrom(t, reach, STALK_REACH); return { steps: [...steps, ...jump.steps], mods: [...mods, ...jump.mods] }; }, /* Wiggle, leap, land front paws first, hold the catch pinned, then sit up. */ @@ -725,7 +725,7 @@ export const MOVES = { step(400, 'loaf'), step(500, 'sit'), ], - mods: [arc(700, 900, FLOP_HOP), arc(3000, 3200, FLOP_HOP)], + mods: [], }), /* * The fly lands; the cat stalks it and pounces, misses, swats at it overhead, @@ -739,9 +739,9 @@ export const MOVES = { step(350, 'air', { x: 50 }, 'linear'), step(150, 'crouch', { x: 60, sq: 0.86 }, 'out'), step(700, 'crouch', { x: 60, hr: 6 }), - step(1000, 'crouch', { x: 72 }, 'linear'), - step(600, 'crouch', { x: 72 }), - step(140, 'crouch', { x: 72, sq: 0.9 }, 'in'), + step(1000, 'crouch', { x: 60 }), + step(600, 'crouch', { x: 60 }), + step(140, 'crouch', { x: 60, sq: 0.9 }, 'in'), step(350, 'air', { x: 100 }, 'linear'), step(150, 'crouch', { x: 108, sq: 0.84 }, 'out'), step(600, 'sit', { x: 108, hr: -22 }), @@ -767,7 +767,6 @@ export const MOVES = { }), wiggle(1200, 1500), arc(1620, 2120, FLY_HOP_HEIGHT), - walking(2820, 3820, 0.6), wiggle(3820, 4420), arc(4560, 5060, POUNCE_HEIGHT), wiggle(6640, 6940), @@ -818,14 +817,16 @@ export const MOVES = { ...bat([30, 14]), step(300 - BAT_PUSH_MS, 'sit'), step(300, 'stand'), - step(2400, 'stand', { x: 150 }), + step(2400, 'stand', { x: 150 }, 'linear'), step(300, 'crouch', { x: 150 }), step(140, 'crouch', { x: 150, sq: 0.88 }, 'in'), step(300, 'air', { x: 198 }, 'linear'), step(180, 'crouch', { x: YARN_LAND_1, sq: 0.84 }, 'out'), ...bat([30, 14], { x: YARN_LAND_1 }), step(150 - BAT_PUSH_MS, 'sit', { x: YARN_LAND_1 }), - step(1500, 'stand', { x: 280 }), + /* Up on all fours before the run, so the walk starts from the standing frame. */ + step(YARN_RISE_MS, 'stand', { x: YARN_LAND_1 }), + step(1500 - YARN_RISE_MS, 'stand', { x: 280 }, 'linear'), step(250, 'crouch', { x: 280 }), step(140, 'crouch', { x: 280, sq: 0.88 }, 'in'), step(280, 'air', { x: 298 }, 'linear'), @@ -1117,56 +1118,29 @@ export const withTurn = (move: Move, from: Pose): Move => { }; }; -/** px per second of a crouched creep, slower than a trot. */ -const CREEP_SPEED = 45; -const CREEP_MIN_MS = 300; -const CREEP_READY_MS = 250; -const SETTLE_BACK_MS = 400; -/** Farther than this, a cat trots to the spot first and creeps only the last of it. */ -const CREEP_REACH = 60; -/** px per second of a purposeful trot, twice the creep. */ -const TROT_SPEED = 110; -const TROT_READY_MS = 250; +const WALK_READY_MS = 250; -/** Trots, then creeps, `distance` ahead first, so an edge move starts at the track end from anywhere on it. */ -/* - * Also shuffles `settleBack` px back once the move is done, so a cat that crept - * past its track's end finishes on it again. - */ -export const withApproach = ( - move: Move, - distance: number, - settleBack = 0, -): Move => { - const trot = Math.max(0, distance - CREEP_REACH); - const trotMs = trot > 0 ? (trot / TROT_SPEED) * 1000 : 0; - const trotLead = trot > 0 ? TROT_READY_MS + trotMs : 0; - const creep = Math.max( - CREEP_MIN_MS, - ((distance - trot) / CREEP_SPEED) * 1000, - ); - const last = move.steps.at(-1)?.pose.x ?? 0; - const lead = trotLead + CREEP_READY_MS + creep; +/** Walks `distance` ahead first, at the walk's own speed, so an edge move starts at the track end from anywhere on it. */ +export const withApproach = (move: Move, distance: number): Move => { + const walkMs = (distance / WALK_SPEED) * 1000; + const lead = distance > 0 ? WALK_READY_MS + walkMs : 0; const propAt = move.propAt; return { ...move, steps: [ - ...(trot > 0 - ? [step(TROT_READY_MS, 'stand'), step(trotMs, 'stand', { x: trot })] + ...(distance > 0 + ? [ + step(WALK_READY_MS, 'stand'), + step(walkMs, 'stand', { x: distance }, 'linear'), + ] : []), - step(CREEP_READY_MS, 'crouch', { x: trot }), - step(creep, 'crouch', { x: distance }), ...move.steps.map((s) => ({ ...s, pose: { ...clonePose(s.pose), x: s.pose.x + distance }, })), - ...(settleBack > 0 - ? [step(SETTLE_BACK_MS, 'sit', { x: distance + last - settleBack })] - : []), ], mods: [ - ...(trot > 0 ? [walking(TROT_READY_MS, trotLead)] : []), - walking(trotLead + CREEP_READY_MS, lead, 0.6), + ...(distance > 0 ? [walking(WALK_READY_MS, lead)] : []), ...move.mods.map((mod) => ({ ...mod, from: mod.from + lead, diff --git a/src/lib/about-cats-rig.ts b/src/lib/about-cats-rig.ts index 4d254a65..47a0d039 100644 --- a/src/lib/about-cats-rig.ts +++ b/src/lib/about-cats-rig.ts @@ -6,8 +6,9 @@ */ import { PAD, SHEET } from './about-cats-sheet'; import { + FRAMES_PER_SECOND, POSE_FRAME, - bridges, + towards, WALK, walkFrame, frameFor, @@ -22,8 +23,10 @@ import type { CatId, Pose, PropKind, PropState } from './about-cats-types'; const NS = 'http://www.w3.org/2000/svg'; const D = Math.PI / 180; -/** One step of the flip-book: 12 a second. A new frame waits until the last has shown this long, so every in-between is seen. */ -const TICK_MS = 1000 / 12; +/** One step of the flip-book. A new frame waits until the last has shown this long, so every in-between is seen. */ +const TICK_MS = 1000 / FRAMES_PER_SECOND; +/** Jump frames follow the cat's height off the ground, so they show at once: never filtered as a blip or delayed by in-betweens. */ +const AIRBORNE: ReadonlySet = new Set(['takeoff', 'leap', 'landing']); /** A frame wanted for less than this is a passing blip of the pose blend, not a pose: one 60Hz frame is 16.7ms. */ const BLIP_MS = 25; /** Walk frames a fast gait may step at once: 2 of 8 keeps the 4 phases (contact and passing, each side). */ @@ -282,11 +285,8 @@ const stepFrame = ( } const at = s.frame === null ? -1 : WALK.indexOf(s.frame); const to = WALK.indexOf(wanted); - const blip = - !s.cut && - to < 0 && - !(s.frame && bridges(s.frame, wanted)) && - now - s.wantedAt < BLIP_MS; + const airborne = AIRBORNE.has(wanted); + const blip = !s.cut && !airborne && to < 0 && now - s.wantedAt < BLIP_MS; if (blip) { s.pending = wanted !== s.frame; return false; @@ -298,7 +298,9 @@ const stepFrame = ( ? x === s.stepX ? s.frame! : walkFrame(at + Math.min(ahead, MAX_WALK_STEP)) - : wanted; + : s.frame && !s.cut && !airborne + ? towards(s.frame, wanted) + : wanted; s.pending = next !== s.frame && now - s.shownAt < TICK_MS; if (next === s.frame) s.cut = false; if (next === s.frame || s.pending) return false; @@ -307,6 +309,8 @@ const stepFrame = ( s.cut = false; s.stepX = x; s.frame = next; + /* An in-between on the way to `wanted`: the next one follows a step later. */ + s.pending = next !== wanted; return true; }; diff --git a/src/lib/about-cats.ts b/src/lib/about-cats.ts index 358f6a65..81f59adb 100644 --- a/src/lib/about-cats.ts +++ b/src/lib/about-cats.ts @@ -47,8 +47,11 @@ import { type IdleState, } from './about-cats-idle'; -/** The rest after each move, in ms, while the cat idles: cats act in bursts. */ -const PAUSE_MS = [1500, 4500] as const; +/** The rest after each move, in ms, while the cat idles: cats rest most of the time and play in short bursts. */ +export const PAUSE_MS = [8000, 25000] as const; +/** Chance a cat lies down for its rest after a move, and how long it takes. */ +const LIE_DOWN_CHANCE = 0.5; +const LIE_DOWN_MS = 600; /** What a frame did for a cat: drew it, left it settled, or held it in a pause. */ type StepResult = 'draw' | 'still' | 'rest'; @@ -67,7 +70,7 @@ const FIT_STEPS = 24; const LEAP_CHANCE = 0.15; /** Chance a cat on a stepped band climbs to the next brick instead. */ const CLIMB_CHANCE = 0.5; -/* Ways to get about, one at a time and weighted; there is no walking. */ +/* Ways to move along the band in play, one at a time and weighted. */ const LEAPS: Partial> = { pounce: 3, stalk: 1, @@ -156,6 +159,8 @@ interface CatState extends CatSpot { hiddenAt: number | undefined; /** No new move before this; a nap still starts at once. */ restUntil: number; + /** The move it played last, which it does not pick again next. */ + lastMove?: MoveName; idle: IdleState; /** Resting between moves: breathing and glances are drawn on its pose. */ idling: boolean; @@ -164,14 +169,23 @@ interface CatState extends CatSpot { idleShown: IdleOffsets; } -const rand = (lo: number, hi: number): number => lo + Math.random() * (hi - lo); - -const pickWeighted = (weights: Partial>): MoveName => { - const entries = Object.entries(weights) as [MoveName, number][]; - let roll = Math.random() * entries.reduce((sum, [, w]) => sum + w, 0); +/** A rest's length in ms for a `roll` from 0 to 1. */ +export const restMs = (roll: number): number => + PAUSE_MS[0] + roll * (PAUSE_MS[1] - PAUSE_MS[0]); + +/** A move from `weights` for a `roll` from 0 to 1, never `last`, so a cat does not play the same move twice running. */ +export const pickMove = ( + weights: Partial>, + last: MoveName | undefined, + roll: number, +): MoveName => { + const entries = (Object.entries(weights) as [MoveName, number][]).filter( + ([name]) => name !== last, + ); + let left = roll * entries.reduce((sum, [, w]) => sum + w, 0); for (const [name, w] of entries) { - roll -= w; - if (roll <= 0) return name; + left -= w; + if (left <= 0) return name; } /* Rounding can leave a sliver of roll; an empty table has the cat look around. */ return entries[0]?.[0] ?? 'look'; @@ -206,7 +220,7 @@ export interface Colony { playable: (id: CatSpot['id'], name: MoveName) => boolean; /** * A chosen move, or a random one, now: wakes a sleeping cat first, cuts a move - * in progress short, and trots to the track's nearer end if the move needs room. + * in progress short, and walks to the track's nearer end if the move needs room. */ trick: (id: CatSpot['id'], name: MoveName | 'random', now: number) => void; wake: (id: CatSpot['id'], now: number) => void; @@ -264,7 +278,7 @@ export const planMove = ( }; /** - * The cup push from anywhere on the track, heading left: a trot and a creep to + * The cup push from anywhere on the track, heading left: a walk to * `edge`, the track's left end, so the cup goes over the card's or the step's edge. * Left only: the right end is the sleep control's corner. */ @@ -387,7 +401,25 @@ export const createColony = ( onLedge(cat); } drop(cat, false, false); - if (!playing.settle) cat.restUntil = performance.now() + rand(...PAUSE_MS); + if (!playing.settle) { + cat.restUntil = performance.now() + restMs(Math.random()); + if ( + !playing.then && + cat.holds.size === 0 && + Math.random() < LIE_DOWN_CHANCE + ) + play( + cat, + { + steps: [{ ms: LIE_DOWN_MS, pose: pose('loaf'), ease: 'inOut' }], + mods: [], + }, + performance.now(), + Math.sign(cat.pose.face) || 1, + undefined, + true, + ); + } playing.then?.(); }; @@ -453,8 +485,10 @@ export const createColony = ( /** One leap along the band, ahead if it fits, else back; false if neither fits. */ const leap = (cat: CatState, now: number): boolean => { - const plan = planFor(cat, pickWeighted(LEAPS)); + const name = pickMove(LEAPS, cat.lastMove, Math.random()); + const plan = planFor(cat, name); if (!plan) return false; + cat.lastMove = name; play(cat, plan.move, now, plan.dir); return true; }; @@ -512,11 +546,13 @@ export const createColony = ( /** Plays `name` from where the cat stands; false if it fits nowhere on the track. */ const begin = (cat: CatState, name: MoveName, now: number): boolean => { if (EDGE_MOVES.has(name)) { + cat.lastMove = name; play(cat, cupPush(cat.pose.x, cat.min), now, -1); return true; } const plan = planFor(cat, name); if (!plan) return false; + cat.lastMove = name; play(cat, plan.move, now, plan.dir); return true; }; @@ -530,7 +566,14 @@ export const createColony = ( if (cat.holds.size > 0 || now < cat.restUntil) return; if (Math.random() < CLIMB_CHANCE && climb(cat, now)) return; if (Math.random() < LEAP_CHANCE && leap(cat, now)) return; - if (!begin(cat, pickWeighted(CAT_WEIGHTS[cat.id]), now)) leap(cat, now); + if ( + !begin( + cat, + pickMove(CAT_WEIGHTS[cat.id], cat.lastMove, Math.random()), + now, + ) + ) + leap(cat, now); }; /* One test for both reading and waking: written two ways, float rounding can make them disagree at the deadline. */ @@ -789,11 +832,11 @@ export const createColony = ( if (begin(cat, name, at)) return; /* The list offers only moves that fit somewhere, so a null here is a band that shrank. */ const x = fitPoint(cat, name) ?? cat.pose.x; - const trot = withApproach( + const walk = withApproach( { steps: [], mods: [] }, Math.abs(x - cat.pose.x), ); - play(cat, trot, at, Math.sign(x - cat.pose.x) || 1, () => + play(cat, walk, at, Math.sign(x - cat.pose.x) || 1, () => begin(cat, name, performance.now()), ); }; diff --git a/tests/about-cats.spec.ts b/tests/about-cats.spec.ts index 94e01b6a..64174c51 100644 --- a/tests/about-cats.spec.ts +++ b/tests/about-cats.spec.ts @@ -14,6 +14,7 @@ import { cssColorToken } from './source'; import { NODE } from './tags'; import { readFileSync } from 'node:fs'; import { + CAT_WEIGHTS, EDGE_MOVES, MOVES, type MoveName, @@ -39,10 +40,13 @@ import { CONTROL_ROOM, MAX_OVERHANG, NAP_AFTER_MS, + PAUSE_MS, POINTER_IDLE_MS, TRACK_MARGIN, cupPush, + pickMove, planMove, + restMs, } from '../src/lib/about-cats'; import { climbMove } from '../src/lib/about-cats-climb'; import { itemAt } from '../src/lib/defined'; @@ -125,13 +129,23 @@ const LEG_HEIGHT = 0.15; /** A cat that lies down settles its tail within this. */ const SETTLE_MS = 3000; -/* Several moves, each followed by a pause of 1 to 3 s (PAUSE_MS in about-cats.ts). */ +/* Several frames of a move and the pause after it (PAUSE_MS in about-cats.ts). */ const PAUSES_WINDOW_MS = 8000; /* * A pause ends in at most three empty frames: the one that finds nothing to draw, one timestamped * just before the pause ends, and a new move's unchanged first pose. Running through a pause is 20 or more. */ const MAX_EMPTY_FRAMES_IN_A_ROW = 6; +/** A roll for every choice: 0.6 is past every chance to climb, leap or lie down, so Rudra only plays and rests. */ +const REST_ROLL = 0.6; +/** Page time for a first move, its rest of restMs(REST_ROLL) and the start of the next move, within NAP_AFTER_MS. */ +const REST_WINDOW_MS = 28_000; +/** Two drawn frames this close are one move in motion, not idle breaths. */ +const MOVING_FRAME_MS = 20; +/** A move's still opening and closing poses, which the cat holds without redrawing either side of a rest. */ +const MOVE_STILL_ENDS_MS = 2000; +/** Rolls sampled from 0 to 1 for the rest and pick checks. */ +const ROLLS = 1000; /** Long enough for a loop running every frame to pass MAX_EMPTY_FRAMES_IN_A_ROW many times over. */ const LOOP_WINDOW_MS = 500; @@ -2515,6 +2529,103 @@ test.describe('About cats', () => { await context.close(); }); + test('a cat rests between moves for PAUSE_MS with its loop idle, then plays again', async ({ + browser, + }) => { + /* A flat band and a fixed roll: no climb, leap or lie-down, so one move, a rest of restMs(REST_ROLL), then the next move. */ + const context = await browser.newContext({ viewport: PHONE_VIEWPORT }); + const page = await context.newPage(); + await page.addInitScript((roll) => { + Math.random = () => roll; + }, REST_ROLL); + await page.clock.install(); + await gotoSettled(page, ROUTE); + await page.evaluate(() => + document + .getElementById('cat-spot-rudra')! + .scrollIntoView({ block: 'center' }), + ); + await showCat(page, 'rudra'); + await pauseClock(page); + const watch = await page.evaluateHandle(() => { + const drawing = document + .getElementById('cat-spot-rudra') + ?.querySelector('.cat-svg'); + if (!drawing) + throw new Error('#cat-spot-rudra has no .cat-svg to watch.'); + const observer = new MutationObserver(() => {}); + observer.observe(drawing, { + attributes: true, + childList: true, + subtree: true, + }); + const request = window.requestAnimationFrame.bind(window); + const drawnAt: number[] = []; + window.requestAnimationFrame = (callback) => + request((now) => { + callback(now); + if (observer.takeRecords().length > 0) drawnAt.push(now); + }); + return { drawnAt, stop: () => observer.disconnect() }; + }); + await page.clock.runFor(REST_WINDOW_MS); + const drawnAt = await watch.evaluate((seen) => { + seen.stop(); + return seen.drawnAt; + }); + /* Moving frames come one animation frame apart; a resting cat's breaths come at the idle rate. */ + const moving = drawnAt.filter( + (t, i) => i > 0 && t - drawnAt[i - 1]! <= MOVING_FRAME_MS, + ); + const gaps = moving.slice(1).map((t, i) => ({ from: moving[i]!, to: t })); + const rest = gaps.reduce( + (longest, gap) => + gap.to - gap.from > longest.to - longest.from ? gap : longest, + { from: 0, to: 0 }, + ); + const restLength = rest.to - rest.from; + expect( + restLength, + 'Rudra never rested between two moves for at least PAUSE_MS[0]', + ).toBeGreaterThanOrEqual(PAUSE_MS[0]); + expect( + restLength, + 'Rudra rested longer than PAUSE_MS[1] plus the still ends of the moves around it', + ).toBeLessThanOrEqual(PAUSE_MS[1] + MOVE_STILL_ENDS_MS); + const drawnInRest = drawnAt.filter( + (t) => t > rest.from && t < rest.to, + ).length; + expect( + drawnInRest, + 'the loop drew a resting cat faster than the idle rate', + ).toBeLessThanOrEqual((restLength / 1000) * IDLE_MAX_FPS); + await context.close(); + }); + + test( + 'every rest lasts PAUSE_MS and no cat picks the move it just played', + NODE, + () => { + const outOfRange: number[] = []; + const repeats: string[] = []; + const tables = [ + ...Object.values(CAT_WEIGHTS), + { pounce: 3, stalk: 1 } satisfies Partial>, + ]; + for (let i = 0; i <= ROLLS; i += 1) { + const roll = i / ROLLS; + const rest = restMs(roll); + if (rest < PAUSE_MS[0] || rest > PAUSE_MS[1]) outOfRange.push(rest); + for (const weights of tables) + for (const last of Object.keys(weights) as MoveName[]) + if (pickMove(weights, last, roll) === last) + repeats.push(`${last} at roll ${roll}`); + } + expect(outOfRange, 'a rest fell outside PAUSE_MS').toEqual([]); + expect(repeats, 'a cat picked the move it had just played').toEqual([]); + }, + ); + for (const viewport of [REFLOW_VIEWPORT, PHONE_VIEWPORT, DESKTOP_VIEWPORT]) { test(`each sleep control is on top at its center at ${viewport.width}px (SC 2.2.2)`, async ({ browser,