Files
feuerwerk/tools/seed-actor.mjs
T
rootandClaude Fable 5.1 0e5e5ef925
game-maker / suite (push) Successful in 8m24s
Shaders draw in the sprite's own pass: a Mesh with the graph's fragment, no filter (#77)
A sprite's picture is now a Mesh (unit quad, sibling of its Graphics node, which keeps
only the hit area); a worn shader replaces Pixi's fragment in that draw, so there is no
intermediate texture and no extra render pass. Measured on the HD 630: 400 shaded
sprites hold 60 fps (was 34), 800 hold 56 (was 20).

Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader,
so GraphShader.wear hands it over; and MeshPipe caches whether a mesh batches and
re-checks it only in a frame without structure changes, so switching a shader on or
off calls mesh.unload().

The console panel mirrors console.group/groupCollapsed/groupEnd and fills the %s %d
%o %c directives, so Pixi's deprecation warning shows its head, not only its stack.
E2E 12 guards against the addChild-on-Graphics warning, 28 counts shaders instead of
filters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 15:31:08 +02:00

267 lines
11 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, tagged, variable, written } from './lib/builders.mjs';
import { PROJECTS, cliArgs, uploadTexture, writeProject } from './lib/projects.mjs';
import { adjustColorsShader } from './lib/shaders.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,
// Present but worn by nothing: the smoke reads the actor's pixels, a shader must not touch them.
adjustColorsShader(),
{
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);