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>
246 lines
9.8 KiB
JavaScript
246 lines
9.8 KiB
JavaScript
#!/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);
|