A kind like colour or texture used to be spelled out in five parallel lists, its port type, its empty value, its glyph, its label and its list tag - 22 literal places for texture alone. core/properties/kinds.ts now holds one row per kind and the rest is derived from it, so adding a kind is one row. The five literal nodes collapse into literals.ts, the six kind menus into one helper. Number, Text, Boolean is now the one order everywhere; the variable and port menus used to list Boolean before Text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
10 KiB
JavaScript
264 lines
10 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.
|
||
*
|
||
* The graph picks a picture the way the editor does: two list variables hold the four walk
|
||
* animations and the four standing frames, each item chosen with the texture picker, and `facing`
|
||
* is the row index that indexes both. Nothing is built out of an id spelled by hand.
|
||
*
|
||
* Usage: node tools/seed-actor.mjs [projectId] [baseUrl]
|
||
*/
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import { edge, node, port, propertiesOf, sceneObject, tagged, 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}`;
|
||
|
||
/** The id of a grid cell. Plain: no part of the project builds one of these out of a name. */
|
||
const frameId = (row, column) => `fr-${row}-${column}`;
|
||
|
||
/** The standing frame of a direction: the middle column of its row. */
|
||
const idleFrame = (row) => frameId(row, 1);
|
||
|
||
/** 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)],
|
||
}));
|
||
|
||
/** The two lists the graph indexes: one walk animation and one standing frame per direction. */
|
||
const WALK_PICTURES = DIRECTIONS.map((dir) => tagged('texture', WALK(dir)));
|
||
const IDLE_PICTURES = DIRECTIONS.map((_, row) => tagged('texture', idleFrame(row)));
|
||
|
||
/**
|
||
* Sets `facing` to a constant – one per direction, so no edge has to cross the graph. It is the row
|
||
* of the sheet, which is also the index into both picture lists.
|
||
*/
|
||
const face = (id, dir, x, y) =>
|
||
node(id, 'Variables', x, y, { facing: DIRECTIONS.indexOf(dir) }, [written('facing', 'number')]);
|
||
|
||
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. Both picture lists are indexed by the row the character faces, so there
|
||
// is one node per list and no branch per direction.
|
||
// Setting the same animation again lets it keep running, so writing it every frame is free.
|
||
node('br-move', 'Branch', 1460, 1280),
|
||
node('v-pics', 'Variables', 1400, 1480, {}, [
|
||
port('walk', 'list'),
|
||
port('idle', 'list'),
|
||
port('facing', 'number'),
|
||
]),
|
||
node('pick-walk', 'ItemAt', 1680, 1430),
|
||
node('pick-idle', 'ItemAt', 1680, 1640),
|
||
node('wear-walk', 'Properties', 1920, 1280, propertiesOf('sprite'), [written('texture', 'texture')]),
|
||
node('wear-idle', 'Properties', 1920, 1510, propertiesOf('sprite'), [written('texture', 'texture')]),
|
||
];
|
||
|
||
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', 'v-pics', 'walk', 'pick-walk', 'list'),
|
||
edge('e48', 'v-pics', 'facing', 'pick-walk', 'index'),
|
||
edge('e49', 'v-pics', 'idle', 'pick-idle', 'list'),
|
||
edge('e50', 'v-pics', 'facing', 'pick-idle', 'index'),
|
||
edge('e51', 'br-move', 'then', 'wear-walk', 'exec'),
|
||
edge('e52', 'owner', 'object', 'wear-walk', 'object'),
|
||
edge('e53', 'pick-walk', 'item', 'wear-walk', 'texture'),
|
||
edge('e54', 'br-move', 'else', 'wear-idle', 'exec'),
|
||
edge('e55', 'owner', 'object', 'wear-idle', 'object'),
|
||
edge('e56', 'pick-idle', 'item', '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: idleFrame(0) },
|
||
},
|
||
{
|
||
id: 'player',
|
||
type: 'behavior',
|
||
name: 'Player',
|
||
props: { speed: SPEED, facing: 0, walk: WALK_PICTURES, idle: IDLE_PICTURES },
|
||
variables: [
|
||
variable('speed', 'number', SPEED),
|
||
variable('facing', 'number', 0),
|
||
variable('walk', 'list', WALK_PICTURES),
|
||
variable('idle', 'list', IDLE_PICTURES),
|
||
],
|
||
graph: { nodes, edges },
|
||
},
|
||
],
|
||
};
|
||
|
||
await writeProject(projectId, baseUrl, project);
|