Play, Pause and Stop animation take the animation on a port instead of a fixed setting, fed by the new Animation literal or a list item tagged `$animation`. Playing an animation already running keeps its frame but counts it as started last, so turning back to a walk that never stopped shows that walk. The actor demo walks through one Play animation over `ItemAt(walk, facing)` again and stops every animation to stand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Plays the walk demo (tools/seed-actor.mjs) in a headless browser: standing, walking in four
|
||
* directions, the diagonal, the turn when a key is let go, and the edge of the camera. It is the
|
||
* proof that `Play animation`, `Axis input` and a sliced sheet work together.
|
||
*
|
||
* Usage: node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||
* Screenshots land in /tmp/gm-actor-*.png.
|
||
*/
|
||
import { runChecks } from './lib/harness.mjs';
|
||
import { PROJECTS, cliArgs } from './lib/projects.mjs';
|
||
|
||
const { projectId, baseUrl } = cliArgs('smoke-actor.mjs', PROJECTS.actor);
|
||
|
||
/** What the seeder writes. A mismatch means the loader dropped a node type or an edge. */
|
||
const NODES = 45;
|
||
const EDGES = 58;
|
||
|
||
/**
|
||
* The standing frame of each direction: the middle column of its row in the sheet. The graph reaches
|
||
* these through the `idle` list, indexed by `facing` – no id is spelled anywhere in it.
|
||
*/
|
||
const IDLE = { down: 'fr-0-1', left: 'fr-1-1', right: 'fr-2-1', up: 'fr-3-1' };
|
||
|
||
/** The 12×8 grid over the 576×384 sheet, and the corner the camera holds him at. */
|
||
const CELLS = 96;
|
||
const CELL = 48;
|
||
const EDGE_X = 296;
|
||
const EDGE_Y = 156;
|
||
|
||
/** The walk that decides the picture: of the animations running on him, the one started last. */
|
||
const walkOf = (e) =>
|
||
(e.animations ?? [])
|
||
.filter((p) => p.playing)
|
||
.reduce((best, p) => (best && best.order > p.order ? best : p), null)?.animationId ?? '';
|
||
|
||
/** The row of the sheet a frame id lies in – what a picture of a direction looks like. */
|
||
const rowOfFrame = (texture) => Number(String(texture).split('-')[1]);
|
||
|
||
await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, async (t) => {
|
||
const { check, sleep, json, shot, entity, play, stop, navigate, exceptions, holdKey, keyDown, keyUp } = t;
|
||
|
||
await navigate();
|
||
|
||
// 1. Nothing was dropped by the loader – an unknown node type vanishes silently.
|
||
const graph = await json(
|
||
`(() => { const b = __gm.state.project.assets.find((a) => a.id === 'player'); return { n: b.graph.nodes.length, e: b.graph.edges.length }; })()`,
|
||
);
|
||
check(
|
||
`the player graph loads complete (${NODES} nodes, ${EDGES} edges)`,
|
||
graph.n === NODES && graph.e === EDGES,
|
||
JSON.stringify(graph),
|
||
);
|
||
|
||
// 2. The sheet: a full 12×8 slice, unsmoothed.
|
||
const sheet = await json(
|
||
`(() => { const t = __gm.state.project.assets.find((a) => a.id === 'sheet'); return { frames: t.frames.length, w: t.width, h: t.height, filter: t.props.filter, cell: { w: t.frames[0].w, h: t.frames[0].h }, last: t.frames[95] }; })()`,
|
||
);
|
||
check(
|
||
`the sheet is sliced into ${CELLS} cells of ${CELL}×${CELL}, filtered nearest`,
|
||
sheet.frames === CELLS && sheet.cell.w === CELL && sheet.cell.h === CELL && sheet.filter === 'nearest',
|
||
JSON.stringify(sheet),
|
||
);
|
||
check(
|
||
'the last cell sits in the bottom right corner',
|
||
sheet.last.x === 528 && sheet.last.y === 336 && sheet.last.name === 'Frame 96',
|
||
JSON.stringify(sheet.last),
|
||
);
|
||
|
||
// 3. Four walk animations over the sprite, each one texture row of three keys, ping-pong.
|
||
const anims = await json(
|
||
`__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, root: a.root, rows: a.tracks.map((t) => t.target + ':' + t.property), n: a.tracks[0]?.keys.length, fps: a.props.fps, pb: a.props.playback, mid: a.tracks[0]?.keys[1]?.value }))`,
|
||
);
|
||
check(
|
||
'four walk animations key three frames on the sprite and play ping-pong',
|
||
anims.length === 4 &&
|
||
anims.every(
|
||
(a) =>
|
||
a.root === 'actor-sprite' &&
|
||
a.rows.join() === ':texture' &&
|
||
a.n === 3 &&
|
||
a.pb === 'pingpong' &&
|
||
a.fps === 8,
|
||
),
|
||
JSON.stringify(anims.map((a) => `${a.id}:${a.root}:${a.rows}:${a.n}`)),
|
||
);
|
||
check(
|
||
'each walk animation has the standing frame in the middle',
|
||
anims.every((a) => a.mid === IDLE[a.id.replace('walk-', '')]),
|
||
JSON.stringify(anims.map((a) => a.mid)),
|
||
);
|
||
|
||
// 3b. How the graph reaches a picture: two list variables of tagged texture items, indexed by the
|
||
// row the character faces. Each item is what its picker writes, not a pasted id.
|
||
const pictures = await json(
|
||
`(() => { const b = __gm.state.project.assets.find((a) => a.id === 'player'); return { walk: b.props.walk, idle: b.props.idle, facing: b.props.facing }; })()`,
|
||
);
|
||
check(
|
||
'the walk list holds four tagged animations, the idle list four tagged textures, indexed by facing',
|
||
pictures.walk.length === 4 &&
|
||
pictures.idle.length === 4 &&
|
||
pictures.walk.every(
|
||
(item, row) => item.$animation === `walk-${['down', 'left', 'right', 'up'][row]}`,
|
||
) &&
|
||
pictures.idle.every((item, row) => item.$texture === `fr-${row}-1`) &&
|
||
pictures.facing === 0,
|
||
JSON.stringify(pictures),
|
||
);
|
||
|
||
await shot('01-editor');
|
||
|
||
// 4. Standing still.
|
||
await play();
|
||
await sleep(300);
|
||
const start = await entity('oplayer');
|
||
check(
|
||
'the player starts at the origin showing the standing frame, facing down',
|
||
Math.abs(start.x) < 1 && Math.abs(start.y) < 1 && start.props.texture === IDLE.down,
|
||
JSON.stringify({ x: start.x, y: start.y, texture: start.props.texture }),
|
||
);
|
||
await shot('02-idle-down');
|
||
|
||
// 5. Walking right, and stopping on the standing frame of the way he faces.
|
||
await keyDown('d');
|
||
await sleep(400);
|
||
await shot('03-walk-right');
|
||
const walking = await entity('oplayer');
|
||
await keyUp('d');
|
||
await sleep(250);
|
||
const stopped = await entity('oplayer');
|
||
await sleep(150);
|
||
const still = await entity('oplayer');
|
||
check(
|
||
'holding D walks right and plays the right walk animation',
|
||
walking.x > 30 && walkOf(walking) === 'walk-right' && rowOfFrame(walking.props.texture) === 2,
|
||
JSON.stringify({ x: Math.round(walking.x), walk: walkOf(walking), texture: walking.props.texture }),
|
||
);
|
||
// Two samples after the release: he must not move between them. Comparing against the walking
|
||
// sample measured how long the screenshot took (22 to 40 px on the CI runner), not the stop.
|
||
check(
|
||
'letting go stops him on the standing frame of the way he faces',
|
||
stopped.props.texture === IDLE.right &&
|
||
walkOf(stopped) === '' &&
|
||
Math.abs(still.x - stopped.x) < 1 &&
|
||
stopped.x > walking.x - 1,
|
||
JSON.stringify({
|
||
x: Math.round(stopped.x),
|
||
later: Math.round(still.x),
|
||
walk: walkOf(stopped),
|
||
texture: stopped.props.texture,
|
||
}),
|
||
);
|
||
|
||
// 6. Walking up is −y.
|
||
await holdKey('w', 400);
|
||
await sleep(200);
|
||
const up = await entity('oplayer');
|
||
check(
|
||
'holding W walks up (−y) and faces up afterwards',
|
||
up.y < -30 && up.props.texture === IDLE.up,
|
||
JSON.stringify({ y: Math.round(up.y), texture: up.props.texture }),
|
||
);
|
||
|
||
// 7. A diagonal: the key pressed last decides where he looks.
|
||
await keyDown('d');
|
||
await sleep(250);
|
||
const beforeW = await entity('oplayer');
|
||
await keyDown('w');
|
||
await sleep(250);
|
||
const diagonal = await entity('oplayer');
|
||
await shot('04-diagonal');
|
||
check(
|
||
'pressing W while D is held turns him up, the key pressed last',
|
||
walkOf(beforeW) === 'walk-right' &&
|
||
walkOf(diagonal) === 'walk-up' &&
|
||
rowOfFrame(diagonal.props.texture) === 3,
|
||
JSON.stringify({ beforeW: walkOf(beforeW), diagonal: walkOf(diagonal), texture: diagonal.props.texture }),
|
||
);
|
||
check(
|
||
'the diagonal moves both axes at once',
|
||
diagonal.x > beforeW.x + 5 && diagonal.y < beforeW.y - 5,
|
||
JSON.stringify({ dx: Math.round(diagonal.x - beforeW.x), dy: Math.round(diagonal.y - beforeW.y) }),
|
||
);
|
||
|
||
// 8. Letting the faced key go turns him onto the one still held, rather than walking backwards.
|
||
await keyUp('w');
|
||
await sleep(250);
|
||
const afterW = await entity('oplayer');
|
||
await keyUp('d');
|
||
await sleep(200);
|
||
check(
|
||
'releasing W while D is held turns him back to the right, onto the walk still running',
|
||
walkOf(afterW) === 'walk-right' && rowOfFrame(afterW.props.texture) === 2,
|
||
JSON.stringify({ walk: walkOf(afterW), texture: afterW.props.texture }),
|
||
);
|
||
|
||
// 9. `Axis input` hands back a normalized vector, so a diagonal is not a faster way to travel.
|
||
const distanceWith = async (keys, ms) => {
|
||
const before = await entity('oplayer');
|
||
for (const k of keys) await keyDown(k);
|
||
await sleep(ms);
|
||
const after = await entity('oplayer');
|
||
for (const k of keys) await keyUp(k);
|
||
await sleep(150);
|
||
return Math.hypot(after.x - before.x, after.y - before.y);
|
||
};
|
||
const straight = await distanceWith(['d'], 400);
|
||
const diag = await distanceWith(['a', 'w'], 400);
|
||
check(
|
||
'walking diagonally covers the same distance as walking straight',
|
||
Math.abs(straight - diag) < straight * 0.25,
|
||
JSON.stringify({ straight: Math.round(straight), diagonal: Math.round(diag) }),
|
||
);
|
||
|
||
// 10. The edge of the camera holds him – and keeping the keys down there walks him on the spot,
|
||
// which is what lets the next check watch the animation from a fixed pixel.
|
||
await keyDown('d');
|
||
await keyDown('s');
|
||
await sleep(4000);
|
||
const corner = await entity('oplayer');
|
||
await shot('05-corner');
|
||
check(
|
||
'he stops at the edge of the camera instead of walking out of it',
|
||
Math.abs(corner.x - EDGE_X) < 1.5 && Math.abs(corner.y - EDGE_Y) < 1.5,
|
||
JSON.stringify({ x: Math.round(corner.x), y: Math.round(corner.y) }),
|
||
);
|
||
|
||
// 11. Read off the drawn pixels, not off a model field: a clock that ticks without the picture
|
||
// following would pass otherwise.
|
||
const spot = await json(`__gm.gameScreen('oplayer')`);
|
||
const stripe = async () =>
|
||
json(
|
||
`[0,1,2,3,4,5,6,7,8,9].map((i) => __gm.pixelAt(${Math.round(spot.x)} - 18 + i * 4, ${Math.round(spot.y)} + 16))`,
|
||
).then((p) => p.join(''));
|
||
|
||
const walkingFrames = new Set();
|
||
for (let i = 0; i < 14; i++) {
|
||
walkingFrames.add(await stripe());
|
||
await sleep(70);
|
||
}
|
||
await keyUp('d');
|
||
await keyUp('s');
|
||
await sleep(250);
|
||
check(
|
||
'the walk animation draws more than one frame',
|
||
walkingFrames.size > 1,
|
||
`${walkingFrames.size} distinct`,
|
||
);
|
||
|
||
const standing = new Set();
|
||
for (let i = 0; i < 8; i++) {
|
||
standing.add(await stripe());
|
||
await sleep(80);
|
||
}
|
||
check('standing still holds a single frame', standing.size === 1, `${standing.size} distinct`);
|
||
|
||
// 12. A frame's top edge shows nothing of the cell above it, wherever the edge lands inside a
|
||
// pixel. Drawn from the sheet, a partly covered edge pixel read the boots of the cell above –
|
||
// the hairline over the head. Nudged upwards until the edge sits in the lower half of a pixel.
|
||
const topEdge = async () => {
|
||
const r = await json(`__gm.gameRect('oplayer')`);
|
||
return { cx: Math.round(r.x), y0: r.y - r.h / 2 };
|
||
};
|
||
let edge;
|
||
let nudges = 0;
|
||
do {
|
||
await holdKey('w', 16);
|
||
await sleep(150);
|
||
edge = await topEdge();
|
||
nudges += 1;
|
||
} while (nudges < 40 && !(edge.y0 % 1 > 0.6 && edge.y0 % 1 < 0.9));
|
||
const facingUp = await entity('oplayer');
|
||
const rowAt = async (y) =>
|
||
json(`[-8,-6,-4,-2,0,2,4,6,8].map((dx) => __gm.pixelAt(${edge.cx} + dx, ${y}))`).then((p) => p.join(' '));
|
||
const above = await rowAt(Math.floor(edge.y0) - 2);
|
||
const atEdge = await rowAt(Math.floor(edge.y0));
|
||
check(
|
||
'the top edge of a frame shows nothing of the cell above it',
|
||
facingUp.props.texture === IDLE.up && edge.y0 % 1 > 0.6 && atEdge === above,
|
||
JSON.stringify({ fraction: (edge.y0 % 1).toFixed(2), texture: facingUp.props.texture, above, atEdge }),
|
||
);
|
||
|
||
await stop();
|
||
check('nothing threw', (await exceptions()).length === 0, (await exceptions()).join(' | '));
|
||
});
|