Skip to content
Closed
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
7 changes: 4 additions & 3 deletions docs/STYLEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`,
Expand Down
104 changes: 64 additions & 40 deletions src/lib/about-cats-frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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];
};
104 changes: 39 additions & 65 deletions src/lib/about-cats-moves.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
PropKind,
PropState,
} from './about-cats-types';
import { WALK_SPEED } from './about-cats-frames';

export type { Pose };

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Pose> =>
Expand Down Expand Up @@ -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. */
Expand Down Expand 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,
Expand All @@ -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 }),
Expand All @@ -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),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading