Files
feuerwerk/tools/seed-actor.mjs
T
rootandClaude Fable 5.1 8a7185948d Make texture and colour full kinds and drop the load-time migrations
- `Texture` and `Color` can be picked as a variable kind, a struct field
  kind and a declared port kind; a new `Texture` node hands a picture id
  into the graph. Declared, a texture is its bare id – the `$texture` tag
  stays a list-only thing.
- The `String` node is labelled "Text" like its kind; the type id stays.
- Whole-number inputs (`Item at`, `Set item`, `Sub list`, `Fill`,
  `Repeat`, `Substring`, `To text`) show no decimals (`int()` in shared.ts).
- All server projects are reseeded, so `legacyNodes.ts`,
  `migrateDeclaredLists` and the `ownerId` fallback go, with their tests.
- Note `reseed-because-of-list` moved to done/; new note `refactor-types`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:06:14 +02:00

273 lines
10 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.
*
* 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,
textureItem,
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) => textureItem(WALK(dir)));
const IDLE_PICTURES = DIRECTIONS.map((_, row) => textureItem(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);