Files
feuerwerk/tools/seed-actor.mjs
T
rootandClaude Fable 5.1 11c900c998 Drop the per-list item declaration; every item carries its own kind
A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.

List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.

Legacy lists that declared items: 'color' are tagged on load.

Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.

Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.

Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 13:35:02 +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', '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', '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);