game-maker / suite (push) Successful in 8m18s
A frame drawn straight from the sheet showed a hairline of the neighbouring cell along any edge that fell inside a screen pixel: with antialias on, a partly covered pixel is still evaluated at its centre, past the edge. On top of that Pixi rewrites a Graphics fill texture from clamp-to-edge to repeat at draw time (generateTextureFillMatrix.mjs), so the top row of a sheet read its bottom row. TextureLibrary.get(ref) now cuts the rectangle out of the loaded sheet with an OffscreenCanvas on first use and uploads the cut (key file|x,y,w,h, kept for the session). Address mode is mirror-repeat: Pixi only rewrites clamp-to-edge, and within a texel of the border mirror behaves like clamp, for linear as well as nearest. Loaded sheets get it too, since whole pictures wrapped at their own borders the same way. viewport/textureFill.ts and its test are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
10 KiB
JavaScript
257 lines
10 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 = 44;
|
||
const EDGES = 56;
|
||
|
||
/**
|
||
* 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;
|
||
|
||
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 === 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 the texture 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 two picture lists hold four tagged textures each, indexed by facing',
|
||
pictures.walk.length === 4 &&
|
||
pictures.idle.length === 4 &&
|
||
pictures.walk.every((item, row) => item.$texture === `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 && walking.props.texture === 'walk-right',
|
||
JSON.stringify({ x: Math.round(walking.x), 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 && Math.abs(still.x - stopped.x) < 1 && stopped.x > walking.x - 1,
|
||
JSON.stringify({ x: Math.round(stopped.x), later: Math.round(still.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`);
|
||
|
||
// 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(' | '));
|
||
});
|