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 00000000..f2769dc0 Binary files /dev/null and b/tools/assets/Actor1.png differ diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 6af46c33..217bdc9d 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -34,7 +34,10 @@ const KEYS = { ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 }, Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 }, a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 }, + d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 }, g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 }, + s: { key: 's', code: 'KeyS', windowsVirtualKeyCode: 83 }, + w: { key: 'w', code: 'KeyW', windowsVirtualKeyCode: 87 }, z: { key: 'z', code: 'KeyZ', windowsVirtualKeyCode: 90 }, }; @@ -237,6 +240,10 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await sleep(ms); await keyEvent('keyUp', name); }; + // Separate halves, because `holdKey` cannot express two keys whose presses overlap – which is + // exactly what a diagonal is. + const keyDown = (name) => 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(' | ')); +});