From e147dd6d21f89174ded1c8008f0267af46300c9f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 12 Sep 2026 12:13:13 +0200 Subject: [PATCH] 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 --- README.md | 16 ++ todo-notes/frame-edge-bleeding.md | 20 +++ tools/assets/Actor1.png | Bin 0 -> 44291 bytes tools/lib/harness.mjs | 9 ++ tools/lib/projects.mjs | 39 +++++ tools/seed-actor.mjs | 245 ++++++++++++++++++++++++++++++ tools/smoke-actor.mjs | 205 +++++++++++++++++++++++++ 7 files changed, 534 insertions(+) create mode 100644 todo-notes/frame-edge-bleeding.md create mode 100644 tools/assets/Actor1.png create mode 100644 tools/seed-actor.mjs create mode 100644 tools/smoke-actor.mjs diff --git a/README.md b/README.md index 7094a87b..6c432ca5 100644 --- a/README.md +++ b/README.md @@ -730,6 +730,7 @@ GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 ./e2e.sh status # live progress, or the last run's result node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end +node tools/seed-actor.mjs && node tools/smoke-actor.mjs # 19 checks: the walk demo (the seed uploads its own sheet) ``` **A run needs a working GPU.** An idle editor page keeps rendering a picture that never changes, and @@ -849,6 +850,21 @@ no loop and no list access of its own, which `tools/smoke-tetris.mjs` checks. Th the game in a headless browser (fall, move, turn, drop, land, clear a line, fill the well) – it is the regression test for loops, lists and behaviors used as nodes. +**The walk demo** (`tools/seed-actor.mjs`, project *Actor demo*) is the smallest of the three and the +only one that ships its own picture: it uploads `tools/assets/Actor1.png` and slices the 576×384 +sheet into the 96 cells of a 12×8 grid. That is the usual RPG-Maker layout – eight characters, each +three columns (left foot / standing / right foot) by four rows (down, left, right, up) – and the demo +wears the one in the top left corner. Walking is four **ping-pong** animations of three frames, which +is exactly the original walk cycle; standing is no animation at all but the middle frame of the row. +The graph builds what the sprite wears **by name**, pasting `walk-` or `idle-` in front of the +direction it faces, which is why the standing frames carry named ids. Which way it looks is the key +pressed last, corrected whenever only one axis is held – otherwise letting go of one key of a +diagonal would leave it walking backwards. It is the worked example for animations, for a seed that +brings its own texture, and for `Axis input`, whose vector is already normalized so a diagonal is not +faster than a straight line. `tools/smoke-actor.mjs` walks it in a headless browser; that the +animation really advances it reads off the **drawn pixels**, standing at the edge of the camera where +he walks on the spot – a clock that ticks without the picture following would pass otherwise. + The workspace folder itself is what nginx serves, so `npm run build` is the deployment. ## API diff --git a/todo-notes/frame-edge-bleeding.md b/todo-notes/frame-edge-bleeding.md new file mode 100644 index 00000000..2aefbd90 --- /dev/null +++ b/todo-notes/frame-edge-bleeding.md @@ -0,0 +1,20 @@ +Ein Sprite, das ein Frame aus einem Sheet zeigt, zeichnet am Rand eine Pixelzeile, die zum +Nachbarframe gehört. Sichtbar im Projekt *Actor demo* als heller Streifen über dem Kopf. + +Gemessen (`__gm.pixelAt`, Spielansicht): Beim Stehen nach unten (Sheet-Zeile 0, deren oberste +Pixelzeile in der Datei komplett leer ist) liegt dort `#394349` – bläulich. Das passt zur +*untersten* Sheet-Zeile (Schuhe von Character 6, `(87,87,154)`) über den grünen Hintergrund +gemischt, die Kante bricht also auf die Gegenseite um. Beim Laufen nach rechts (Zeile 2) ist der +Streifen stattdessen hellgrau – die Füße aus Zeile 1 direkt darüber. + +Ursache: `frameFillMatrix` (`src/viewport/textureFill.ts`) bildet die **ganze** Textur auf die Shape +ab, weil Pixi-v8-Graphics-Fills `Texture.frame` ignorieren. Eine Fill-Matrix kann keine Grenze +ausdrücken, also sampelt die Randzeile in den Nachbarn, sobald die Skalierung nicht ganzzahlig ist +(die Spielansicht streckt 640×360 auf etwa 735×409, Faktor ~1,13). + +Nicht die Ursache: die Frame-Rechtecke. Die sind exakt ganzzahlig; den fraktionalen Fall behandelt +`normalizeFrameRect` bereits. + +Abzuklären: halber Pixel Einzug in der Matrix (kostet einen halben Pixel Bildinhalt, trifft jedes +Frame) oder ein echtes `Sprite` mit `new Texture({source, frame})` statt des Graphics-Fills (Pixi +klemmt dann selbst, ändert aber den Zeichenweg für alle Sprites). diff --git a/tools/assets/Actor1.png b/tools/assets/Actor1.png new file mode 100644 index 0000000000000000000000000000000000000000..f2769dc0e79aaf6c1900735a64ef72b9e1434ce1 GIT binary patch literal 44291 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 keyEvent('keyDown', name); + const keyUp = (name) => keyEvent('keyUp', name); /** * Typed per character to exercise per-keystroke paths, then verified: a partial type is otherwise * indistinguishable from a wrong value. @@ -337,6 +344,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { dragAsset, press, holdKey, + keyDown, + keyUp, typeText, undo, redo, diff --git a/tools/lib/projects.mjs b/tools/lib/projects.mjs index 6e961c0a..6ecdc1dd 100644 --- a/tools/lib/projects.mjs +++ b/tools/lib/projects.mjs @@ -6,14 +6,19 @@ * fixture rewritten by every e2e run (tools/seed-e2e.mjs + tools/e2e.mjs). * jumpRun the example game (tools/seed-jumprun.mjs, played by tools/smoke-jumprun.mjs). * tetris the second example game (tools/seed-tetris.mjs, played by tools/smoke-tetris.mjs). + * actor the four-direction walk demo (tools/seed-actor.mjs). * * All ids exist on the server (created via POST ?r=projects); PUT does not create projects. */ +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; + export const PROJECTS = { playground: '649188c15414', fixture: '3caa9b6a0c92', jumpRun: '3d271f3bb58b', tetris: '2072b56583af', + actor: '3ccb5c67220b', }; export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/'; @@ -32,6 +37,40 @@ export function cliArgs(script, defaultId) { return { projectId, baseUrl }; } +const MIME = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + gif: 'image/gif', +}; + +/** + * Uploads an image and returns `{file, width, height}` for a texture asset. Storage is + * content-addressed, so re-seeding the same file is a no-op rather than a second copy – which is + * what makes a seed that ships its own image reproducible (`data/textures/` is git-ignored). + */ +export async function uploadTexture(baseUrl, filePath) { + const bytes = await readFile(filePath); + const type = MIME[extname(filePath).slice(1).toLowerCase()]; + if (!type) { + console.error(`unsupported image type: ${filePath}`); + process.exit(1); + } + const res = await fetch(`${baseUrl}api/index.php?r=textures`, { + method: 'POST', + headers: { 'Content-Type': type }, + body: bytes, + signal: AbortSignal.timeout(20_000), + }); + const result = await res.json(); + if (!res.ok) { + console.error('texture upload failed', res.status, result); + process.exit(1); + } + return { file: result.file, width: result.width, height: result.height }; +} + /** Overwrites a project, keeping the name it already has on the server. */ export async function writeProject(projectId, baseUrl, project) { const url = `${baseUrl}api/index.php?r=projects/${projectId}`; diff --git a/tools/seed-actor.mjs b/tools/seed-actor.mjs new file mode 100644 index 00000000..da148383 --- /dev/null +++ b/tools/seed-actor.mjs @@ -0,0 +1,245 @@ +#!/usr/bin/env node +/** + * Writes the four-direction walk demo: one character sheet, one sprite, one behavior – nothing else. + * + * The sheet (Actor1.png, 576×384) is the usual RPG-Maker layout: a 12×8 grid of 48×48 cells holding + * eight characters, each of them three columns (left foot / standing / right foot) by four rows + * (down, left, right, up). This demo uses the character in the top left corner, so its frames are + * the columns 1-3 of the rows 1-4. + * + * Walking is one animation per direction, playing those three frames as `pingpong` + * (left-stand-right-stand …) – the original walk cycle. Standing is not an animation at all but the + * middle frame of the row, which is why the idle frames carry the ids the graph builds by name. + * + * Usage: node tools/seed-actor.mjs [projectId] [baseUrl] + */ +import { fileURLToPath } from 'node:url'; + +import { edge, node, port, propertiesOf, sceneObject, variable, written } from './lib/builders.mjs'; +import { PROJECTS, cliArgs, uploadTexture, writeProject } from './lib/projects.mjs'; + +const { projectId, baseUrl } = cliArgs('seed-actor.mjs', PROJECTS.actor); + +const SHEET_FILE = fileURLToPath(new URL('./assets/Actor1.png', import.meta.url)); + +const GRASS = 0x2f3d33; +const CELL = 48; +const COLUMNS = 12; +const ROWS = 8; + +// The camera shows x −320…320, y −180…180; +y is down. Half a sprite of margin keeps the whole +// character inside the frame rather than half of it. +const MAX_X = 320 - CELL / 2; +const MAX_Y = 180 - CELL / 2; + +const SPEED = 150; + +/** The rows of a character, in sheet order. The row index is the direction. */ +const DIRECTIONS = ['down', 'left', 'right', 'up']; + +const WALK = (dir) => `walk-${dir}`; +const IDLE = (dir) => `idle-${dir}`; + +/** + * The id of a grid cell. The standing frames of the demo character get named ids because the graph + * builds the texture to wear by pasting `idle-` in front of the direction it faces. + */ +const frameId = (row, column) => + column === 1 && row < DIRECTIONS.length ? IDLE(DIRECTIONS[row]) : `fr-${row}-${column}`; + +/** Every cell of the 12×8 grid, in reading order and named like the slice dialog names them. */ +const frames = []; +for (let row = 0; row < ROWS; row++) + for (let column = 0; column < COLUMNS; column++) + frames.push({ + id: frameId(row, column), + name: `Frame ${row * COLUMNS + column + 1}`, + x: column * CELL, + y: row * CELL, + w: CELL, + h: CELL, + }); + +const walkAnimations = DIRECTIONS.map((dir, row) => ({ + id: WALK(dir), + type: 'animation', + name: `Walk ${dir}`, + props: { fps: 8, playback: 'pingpong' }, + frames: [frameId(row, 0), frameId(row, 1), frameId(row, 2)], +})); + +/** Sets `facing` to a constant – one per direction, so no edge has to cross the graph. */ +const face = (id, dir, x, y) => node(id, 'Variables', x, y, { facing: dir }, [written('facing', 'text')]); + +const nodes = [ + // Which way the character looks: the key pressed last wins. Holding W and tapping D turns him + // right while he keeps walking up and right. + node('k-up', 'OnKeyDown', 0, -560, { key: 'w' }), + node('k-left', 'OnKeyDown', 0, -440, { key: 'a' }), + node('k-down', 'OnKeyDown', 0, -320, { key: 's' }), + node('k-right', 'OnKeyDown', 0, -200, { key: 'd' }), + face('a-up', 'up', 300, -560), + face('a-left', 'left', 300, -440), + face('a-down', 'down', 300, -320), + face('a-right', 'right', 300, -200), + + // Walking. `Axis input` already normalizes, so a diagonal is not faster than a straight line. + node('upd', 'OnUpdate', 0, 0), + node('owner', 'Owner', 0, 160), + node('axis', 'AxisInput', 0, 300, { left: 'a', right: 'd', up: 'w', down: 's' }), + node('v-speed', 'Variables', 0, 580, {}, [port('speed', 'number')]), + node('step', 'Multiply', 300, 580), + node('mx', 'Multiply', 540, 360), + node('my', 'Multiply', 540, 500), + node('pos', 'GetPosition', 300, 140), + node('sum-x', 'Add', 780, 180), + node('sum-y', 'Add', 780, 340), + node('clamp-x', 'Clamp', 1000, 180, { min: -MAX_X, max: MAX_X }), + node('clamp-y', 'Clamp', 1000, 360, { min: -MAX_Y, max: MAX_Y }), + node('setpos', 'SetPosition', 1240, 0), + node('seq', 'Sequence', 1460, 0), + + // Letting go of the key he faces has to turn him, or he would keep walking backwards. Whenever + // exactly one axis is held, that axis alone decides – only a diagonal leaves the last key standing. + node('x-on', 'Compare', 540, 720, { op: '!=', b: 0 }), + node('y-on', 'Compare', 540, 860, { op: '!=', b: 0 }), + node('no-y', 'Not', 780, 720), + node('no-x', 'Not', 780, 980), + node('only-x', 'And', 1000, 720), + node('only-y', 'And', 1000, 900), + node('x-neg', 'Compare', 1240, 720, { op: '<', b: 0 }), + node('y-neg', 'Compare', 1240, 860, { op: '<', b: 0 }), + node('br-x', 'Branch', 1460, 660), + node('br-x-dir', 'Branch', 1680, 660), + face('c-left', 'left', 1920, 620), + face('c-right', 'right', 1920, 760), + node('br-y', 'Branch', 1460, 980), + node('br-y-dir', 'Branch', 1680, 980), + face('c-up', 'up', 1920, 940), + face('c-down', 'down', 1920, 1080), + + // What the sprite shows. The texture is built by name: "walk-" or "idle-" plus the direction. + // Setting the same animation again lets it keep running, so writing it every frame is free. + node('br-move', 'Branch', 1460, 1280), + node('s-walk', 'String', 1460, 1420, { value: 'walk-' }), + node('s-idle', 'String', 1460, 1660, { value: 'idle-' }), + node('v-facing', 'Variables', 1460, 1540, {}, [port('facing', 'text')]), + node('join-walk', 'Join', 1680, 1430), + node('join-idle', 'Join', 1680, 1610), + node('wear-walk', 'Properties', 1920, 1280, propertiesOf('sprite'), [written('texture', 'text')]), + node('wear-idle', 'Properties', 1920, 1510, propertiesOf('sprite'), [written('texture', 'text')]), +]; + +const edges = [ + edge('e1', 'k-up', 'exec', 'a-up', 'exec'), + edge('e2', 'k-left', 'exec', 'a-left', 'exec'), + edge('e3', 'k-down', 'exec', 'a-down', 'exec'), + edge('e4', 'k-right', 'exec', 'a-right', 'exec'), + + edge('e5', 'v-speed', 'speed', 'step', 'a'), + edge('e6', 'upd', 'dt', 'step', 'b'), + edge('e7', 'axis', 'x', 'mx', 'a'), + edge('e8', 'step', 'result', 'mx', 'b'), + edge('e9', 'axis', 'y', 'my', 'a'), + edge('e10', 'step', 'result', 'my', 'b'), + edge('e11', 'owner', 'object', 'pos', 'object'), + edge('e12', 'pos', 'x', 'sum-x', 'a'), + edge('e13', 'mx', 'result', 'sum-x', 'b'), + edge('e14', 'pos', 'y', 'sum-y', 'a'), + edge('e15', 'my', 'result', 'sum-y', 'b'), + edge('e16', 'sum-x', 'result', 'clamp-x', 'value'), + edge('e17', 'sum-y', 'result', 'clamp-y', 'value'), + edge('e18', 'upd', 'exec', 'setpos', 'exec'), + edge('e19', 'owner', 'object', 'setpos', 'object'), + edge('e20', 'clamp-x', 'result', 'setpos', 'x'), + edge('e21', 'clamp-y', 'result', 'setpos', 'y'), + edge('e22', 'setpos', 'exec', 'seq', 'exec'), + + edge('e23', 'axis', 'x', 'x-on', 'a'), + edge('e24', 'axis', 'y', 'y-on', 'a'), + edge('e25', 'y-on', 'result', 'no-y', 'value'), + edge('e26', 'x-on', 'result', 'no-x', 'value'), + edge('e27', 'x-on', 'result', 'only-x', 'a'), + edge('e28', 'no-y', 'result', 'only-x', 'b'), + edge('e29', 'y-on', 'result', 'only-y', 'a'), + edge('e30', 'no-x', 'result', 'only-y', 'b'), + edge('e31', 'axis', 'x', 'x-neg', 'a'), + edge('e32', 'axis', 'y', 'y-neg', 'a'), + edge('e33', 'seq', 'a', 'br-x', 'exec'), + edge('e34', 'only-x', 'result', 'br-x', 'condition'), + edge('e35', 'br-x', 'then', 'br-x-dir', 'exec'), + edge('e36', 'x-neg', 'result', 'br-x-dir', 'condition'), + edge('e37', 'br-x-dir', 'then', 'c-left', 'exec'), + edge('e38', 'br-x-dir', 'else', 'c-right', 'exec'), + edge('e39', 'seq', 'b', 'br-y', 'exec'), + edge('e40', 'only-y', 'result', 'br-y', 'condition'), + edge('e41', 'br-y', 'then', 'br-y-dir', 'exec'), + edge('e42', 'y-neg', 'result', 'br-y-dir', 'condition'), + edge('e43', 'br-y-dir', 'then', 'c-up', 'exec'), + edge('e44', 'br-y-dir', 'else', 'c-down', 'exec'), + + edge('e45', 'seq', 'c', 'br-move', 'exec'), + edge('e46', 'axis', 'active', 'br-move', 'condition'), + edge('e47', 's-walk', 'value', 'join-walk', 'a'), + edge('e48', 'v-facing', 'facing', 'join-walk', 'b'), + edge('e49', 's-idle', 'value', 'join-idle', 'a'), + edge('e50', 'v-facing', 'facing', 'join-idle', 'b'), + edge('e51', 'br-move', 'then', 'wear-walk', 'exec'), + edge('e52', 'owner', 'object', 'wear-walk', 'object'), + edge('e53', 'join-walk', 'text', 'wear-walk', 'texture'), + edge('e54', 'br-move', 'else', 'wear-idle', 'exec'), + edge('e55', 'owner', 'object', 'wear-idle', 'object'), + edge('e56', 'join-idle', 'text', 'wear-idle', 'texture'), +]; + +const sheet = await uploadTexture(baseUrl, SHEET_FILE); + +const project = { + formatVersion: 2, + id: projectId, + name: 'Actor demo', + activeSceneId: 'scene1', + assets: [ + { + id: 'scene1', + type: 'scene', + name: 'Scene', + props: { backgroundColor: GRASS }, + objects: [ + sceneObject('oplayer', 'actor-sprite', { name: 'Player' }), + sceneObject('oplayer-logic', 'player', { parentId: 'oplayer', y: -90 }), + sceneObject('ocam', 'cam', { name: 'Main Camera' }), + ], + }, + { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, + { + id: 'sheet', + type: 'texture', + name: 'Actor1', + // Pixel art: scaling it smoothly would blur the 48×48 cells into each other. + props: { filter: 'nearest' }, + file: sheet.file, + width: sheet.width, + height: sheet.height, + frames, + }, + ...walkAnimations, + { + id: 'actor-sprite', + type: 'sprite', + name: 'Actor', + // White, or the tint would colour the frames. The texture is the standing frame it starts on. + props: { width: CELL, height: CELL, color: 0xffffff, texture: IDLE('down') }, + }, + { + id: 'player', + type: 'behavior', + name: 'Player', + props: { speed: SPEED, facing: 'down' }, + variables: [variable('speed', 'number', SPEED), variable('facing', 'text', 'down')], + graph: { nodes, edges }, + }, + ], +}; + +await writeProject(projectId, baseUrl, project); diff --git a/tools/smoke-actor.mjs b/tools/smoke-actor.mjs new file mode 100644 index 00000000..d9b30e2a --- /dev/null +++ b/tools/smoke-actor.mjs @@ -0,0 +1,205 @@ +#!/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(' | ')); +});