#!/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(' | ')); });