A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.
List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.
Legacy lists that declared items: 'color' are tagged on load.
Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.
Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.
Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
227 lines
8.8 KiB
JavaScript
227 lines
8.8 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);
|
||
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(' | '));
|
||
});
|