#!/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 `Play animation`, `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 = 45; const EDGES = 58; /** * 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; /** The walk that decides the picture: of the animations running on him, the one started last. */ const walkOf = (e) => (e.animations ?? []) .filter((p) => p.playing) .reduce((best, p) => (best && best.order > p.order ? best : p), null)?.animationId ?? ''; /** The row of the sheet a frame id lies in – what a picture of a direction looks like. */ const rowOfFrame = (texture) => Number(String(texture).split('-')[1]); await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, async (t) => { const { check, sleep, json, evaluate, 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 over the sprite, each one texture row of three keys, ping-pong. const anims = await json( `__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, root: a.root, rows: a.tracks.map((t) => t.target + ':' + t.property), n: a.tracks[0]?.keys.length, fps: a.props.fps, pb: a.props.playback, mid: a.tracks[0]?.keys[1]?.value }))`, ); check( 'four walk animations key three frames on the sprite and play ping-pong', anims.length === 4 && anims.every( (a) => a.root === 'actor-sprite' && a.rows.join() === ':texture' && a.n === 3 && a.pb === 'pingpong' && a.fps === 8, ), JSON.stringify(anims.map((a) => `${a.id}:${a.root}:${a.rows}:${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 its 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 walk list holds four tagged animations, the idle list four tagged textures, indexed by facing', pictures.walk.length === 4 && pictures.idle.length === 4 && pictures.walk.every( (item, row) => item.$animation === `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 && walkOf(walking) === 'walk-right' && rowOfFrame(walking.props.texture) === 2, JSON.stringify({ x: Math.round(walking.x), walk: walkOf(walking), 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 && walkOf(stopped) === '' && Math.abs(still.x - stopped.x) < 1 && stopped.x > walking.x - 1, JSON.stringify({ x: Math.round(stopped.x), later: Math.round(still.x), walk: walkOf(stopped), 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', walkOf(beforeW) === 'walk-right' && walkOf(diagonal) === 'walk-up' && rowOfFrame(diagonal.props.texture) === 3, JSON.stringify({ beforeW: walkOf(beforeW), diagonal: walkOf(diagonal), texture: 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, onto the walk still running', walkOf(afterW) === 'walk-right' && rowOfFrame(afterW.props.texture) === 2, JSON.stringify({ walk: walkOf(afterW), 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. // At the game view's 100% a frame at full speed moves 2.5 px, so the edge only ever lands on // .0 or .5. Slowed to 0.2 px a frame, it walks through every part 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 }; }; await evaluate('__gm.setTimeScale(0.08)'); 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)); await evaluate('__gm.setTimeScale(1)'); 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(' | ')); });