tools/seed-actor.mjs builds the project Actor demo: it uploads tools/assets/Actor1.png, slices the 576x384 sheet into the 96 cells of a 12x8 grid (48x48 each, no remainder) and wears the character in the top left corner. Walking is four ping-pong animations of three frames, which is the original RPG-Maker walk cycle; standing is the middle frame of the row, not an animation. The graph pastes walk- or idle- in front of the direction faced and wears the result, so eight cases need no branch tree - the List node only holds numbers. Which way it looks is the key pressed last, corrected whenever only one axis is still held, or letting go of one key of a diagonal would walk backwards. Axis input already normalizes its vector. tools/lib/projects.mjs gains uploadTexture, since data/textures/ is git-ignored and a seed that ships its picture has to send it. The harness gains w/a/s/d and keyDown/keyUp halves, because holdKey cannot express two presses that overlap - which is what a diagonal is. tools/smoke-actor.mjs plays it in 19 checks and reads the drawn pixels at the camera edge, where the bounds hold him walking on the spot: three distinct frames walking, one standing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.7 KiB
JavaScript
206 lines
7.7 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 animations, `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 = 46;
|
||
const EDGES = 56;
|
||
|
||
/** 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;
|
||
|
||
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, three frames each, ping-pong.
|
||
const anims = await json(
|
||
`__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, n: a.frames.length, fps: a.props.fps, pb: a.props.playback, mid: a.frames[1] }))`,
|
||
);
|
||
check(
|
||
'four walk animations of three frames play ping-pong',
|
||
anims.length === 4 && anims.every((a) => a.n === 3 && a.pb === 'pingpong' && a.fps === 8),
|
||
JSON.stringify(anims.map((a) => `${a.id}:${a.n}`)),
|
||
);
|
||
check(
|
||
'each walk animation has the standing frame in the middle',
|
||
anims.every((a) => a.mid === a.id.replace('walk-', 'idle-')),
|
||
JSON.stringify(anims.map((a) => a.mid)),
|
||
);
|
||
|
||
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);
|
||
const walking = await entity('oplayer');
|
||
await shot('03-walk-right');
|
||
await keyUp('d');
|
||
await sleep(250);
|
||
const stopped = await entity('oplayer');
|
||
check(
|
||
'holding D walks right and plays the right walk animation',
|
||
walking.x > 30 && walking.props.texture === 'walk-right',
|
||
JSON.stringify({ x: Math.round(walking.x), texture: walking.props.texture }),
|
||
);
|
||
check(
|
||
'letting go stops him on the standing frame of the way he faces',
|
||
stopped.props.texture === 'idle-right' && Math.abs(stopped.x - walking.x) < 40,
|
||
JSON.stringify({ x: Math.round(stopped.x), 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',
|
||
beforeW.props.texture === 'walk-right' && diagonal.props.texture === 'walk-up',
|
||
JSON.stringify({ beforeW: beforeW.props.texture, diagonal: 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',
|
||
afterW.props.texture === 'walk-right',
|
||
JSON.stringify({ 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`);
|
||
|
||
await stop();
|
||
check('nothing threw', (await exceptions()).length === 0, (await exceptions()).join(' | '));
|
||
});
|