Files
feuerwerk/tools/smoke-actor.mjs
T
rootandClaude Opus 5 e147dd6d21 Add a four-direction walk demo that seeds its own sprite sheet
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>
2026-09-12 12:13:13 +02:00

206 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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(' | '));
});