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 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-12 12:13:13 +02:00
1 parent 1135bf4562
commit e147dd6d21
7 files changed
+534

No files matched your search

+16
View File
@@ -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
+20
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+9
View File
@@ -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,
+39
View File
@@ -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}`;
+245
View File
@@ -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);
+205
View File
@@ -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(' | '));
});