Files
feuerwerk/tools/seed-actor.mjs
T
rootandClaude Opus 5 e147dd6d21 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>
2026-09-12 12:13:13 +02:00

246 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);