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:
1 parent
1135bf4562
commit
e147dd6d21
7 files changed
+534
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
@@ -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(' | '));
|
||||
});
|
||||
Reference in new issue
Block a user