Files
feuerwerk/tools/e2e/23-variable-nodes.mjs
T
rootandClaude Opus 5 e9fa592078 Add the Variable node and show the variable name in node titles
Get/Set variable nodes now title themselves "Get <name>" / "Set <name>", and
the new Variable node just "<name>"; the title is the same folded or open,
and a collapsed node shrinks to it. The redundant "Variable: <name>" row is
gone unless the variable was deleted ("<name>?" is the only hint then).

The Variable node sets its value when an exec flow arrives and exposes the
value at any time. Its output is read when a downstream node pulls it, not
frozen at the write, so count = count + 1 is one Variable node plus an Add.
Get variable keeps reading once per pulse: switching it would change the
behaviour of existing games.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:09 +02:00

316 lines
12 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.
import { edge, node, port, variable } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Get variables / Set variables: one node for several of them. Their ports are the behavior's
* variables, so the step checks all three sides of that – the run, the picking of a port from the
* variable list, and pointing an existing port at another variable without losing its edge.
*
* The step brings its own box and behavior; everything it adds is removed at the end.
*/
const SCALE = 100;
/**
* `On update → Set variables{vx = 2, vy = 3} → Move(owner, dx = vx, dy = vy)`, where the two
* numbers reach Move through the *variables* – one node writes both, one node reads both back.
* Neither speed is on the Move node, so a node that dropped a port would leave the box still.
*/
const logicGraph = {
nodes: [
node('vp-update', 'OnUpdate', 0, 0),
node('vp-owner', 'Owner', 0, 300),
node('vp-set', 'SetVariables', 260, 0, { vx: 2, vy: 3 }, [port('vx', 'number'), port('vy', 'number')]),
node('vp-get', 'GetVariables', 260, 180, {}, [port('vx', 'number'), port('vy', 'number')]),
node('vp-move', 'Move', 560, 0, { scale: SCALE }),
],
edges: [
edge('vp-e1', 'vp-update', 'exec', 'vp-set', 'exec'),
edge('vp-e2', 'vp-set', 'exec', 'vp-move', 'exec'),
edge('vp-e3', 'vp-get', 'vx', 'vp-move', 'dx'),
edge('vp-e4', 'vp-get', 'vy', 'vp-move', 'dy'),
edge('vp-e5', 'vp-update', 'dt', 'vp-move', 'dt'),
edge('vp-e6', 'vp-owner', 'object', 'vp-move', 'object'),
],
};
export async function variableNodes(t) {
const {
check,
sleep,
evaluate,
json,
shot,
click,
locate,
undo,
entity,
asset,
mutate,
stop,
typeText,
press,
} = t;
const portAt = (nodeId, key, isOutput) =>
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const portsOf = async (nodeId) =>
(await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? [];
const select = async (nodeId) => {
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'vp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
);
await sleep(300);
};
/** The inspector is drawn over the graph, so the last match is the row rather than the port. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
await sleep(200);
};
// 23a. A box wearing a behavior whose two speeds travel through its variables.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const mover = p.assets.find((a) => a.id === 'mover');
const a = structuredClone(mover);
Object.assign(a, {
id: 'vp-logic',
name: 'Speeds',
props: { enabled: true, runInEditor: false, vx: 0, vy: 0, vz: 0 },
variables: ${JSON.stringify([variable('vx', 'number', 0), variable('vy', 'number', 0), variable('vz', 'number', 0)])},
graph: ${JSON.stringify(logicGraph)},
});
p.assets.unshift(a);
${MAKE_OBJECT}
scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds'));
`);
await sleep(400);
// 23b. The run: both variables are written by one node and read back by another, and the two
// speeds stay apart – 2 to the right for every 3 down.
await t.play();
await sleep(300);
const before = await entity('vp-box');
await sleep(1000);
const after = await entity('vp-box');
const dx = after.x - before.x;
const dy = after.y - before.y;
check(
'one node writes both variables, another reads both back, and the run uses them',
dx > SCALE * 1.4 && dx < SCALE * 2.6 && dy > SCALE * 2.4 && dy < SCALE * 3.6,
`moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`,
);
await stop();
await sleep(300);
// 23c. The node draws one port per variable it names, on the side its type says.
await select('vp-set');
const vxIn = await portAt('vp-set', 'vx', false);
const vyIn = await portAt('vp-set', 'vy', false);
const vxOut = await portAt('vp-get', 'vx', true);
check(
'the picked variables are ports: inputs on Set, outputs on Get',
Boolean(vxIn) && Boolean(vyIn) && vyIn.y > vxIn.y && Boolean(vxOut),
JSON.stringify({ vxIn, vyIn, vxOut }),
);
// 23d. The inspector picks a port from the behavior's variables instead of having it typed.
const add = await locate('+ Variable');
check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add));
await shot('01-variable-ports');
await click(add.x, add.y);
await sleep(200);
await clickLast('vz');
await sleep(300);
const grown = await portsOf('vp-set');
check(
'picking one adds it as a port, typed from the variable',
grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number',
JSON.stringify(grown),
);
check('and the node grew that input', Boolean(await portAt('vp-set', 'vz', false)));
await shot('02-variable-added');
await undo();
await sleep(300);
check('undo takes it back', (await portsOf('vp-set')).length === 2);
// 23e. Pointing a port at another variable keeps the edge hanging on it – a correction, not a
// rewiring. `vp-e3` runs from this port to Move's dx.
await select('vp-get');
await clickLast('vx');
await sleep(200);
await clickLast('vz');
await sleep(300);
const swapped = await portsOf('vp-get');
const edgeStill = await json(
`(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`,
);
check(
'a port pointed at another variable keeps its edge',
swapped[0].key === 'vz' && edgeStill.port === 'vz',
JSON.stringify({ swapped, edgeStill }),
);
await undo();
await sleep(300);
check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx');
// 23f. A Pack keeps declaring its own fields, but a variable that already carries the shape can
// hand it over. A color field arrives as a number port – the graph has no colors.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'vp-logic');
a.variables.push(
${JSON.stringify(
variable('piece', 'struct', { shape: 0, tint: 0, cells: [] }, undefined, [
{ key: 'shape', kind: 'number' },
{ key: 'tint', kind: 'color' },
{ key: 'cells', kind: 'list', items: 'number' },
]),
)},
${JSON.stringify(
variable('shapes', 'list', [], 'struct', [
{ key: 'x', kind: 'number' },
{ key: 'y', kind: 'number' },
]),
)},
);
a.props.piece = { shape: 0, tint: 0, cells: [] };
a.props.shapes = [];
a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))});
`);
await sleep(300);
await select('vp-pack');
const from = await locate('From variable');
check('a Pack offers the shapes its behavior declares', Boolean(from), JSON.stringify(from));
await click(from.x, from.y);
await sleep(200);
await shot('03-from-variable');
await clickLast('piece');
await sleep(300);
const seeded = await portsOf('vp-pack');
check(
'picking a struct variable declares every field at once, a color among them as a number',
JSON.stringify(seeded) ===
'[{"key":"shape","kind":"number"},{"key":"tint","kind":"number"},{"key":"cells","kind":"list"}]',
JSON.stringify(seeded),
);
check('and the node grew those inputs', Boolean(await portAt('vp-pack', 'cells', false)));
await shot('04-fields-seeded');
await undo();
await sleep(300);
check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0);
// 23g. A list of structs declares the shape of its items, which is the bundle a Pack builds for it.
await click(from.x, from.y);
await sleep(200);
await clickLast('shapes (item)');
await sleep(300);
const fromItem = await portsOf('vp-pack');
check(
'a list of structs offers its item shape too',
fromItem.map((p) => p.key).join(',') === 'x,y',
JSON.stringify(fromItem),
);
await undo();
await sleep(300);
// 23h. A behavior's own inputs are not fields of a bundle, so the helper stays out of that section.
await mutate(`
__gm.state.project.assets.find((x) => x.id === 'vp-logic').graph.nodes.push(
${JSON.stringify(node('vp-start', 'BehaviorStart', 560, 600))},
);
`);
await sleep(200);
await select('vp-start');
// `locate` throws when the text is absent, which is the wrong shape for "is it there?".
const helper = await json(`__gm.locate('From variable', false)`);
check(
'a behavior start declares inputs, not fields, and is not offered a shape',
Boolean(await locate('+ Input')) && helper === null,
JSON.stringify(helper),
);
// 23i. The Variable node: picked from the palette, titled by the variable picked in the inspector,
// and in the run `vz = vz + 1` once a frame – its own output feeding its input via Add.
const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id);
const idsBefore = await nodeIds();
const centre = await json('__gm.graph.center()');
// Below the Owner node; centre + (250, 250) would land on the Pack.
await click(centre.x - 250, centre.y + 250, 'right');
await sleep(150);
// Its description is the one that says "any time"; "variable" would put Get variable first.
await typeText('any time');
await sleep(200);
await press('Enter');
await sleep(300);
const added = (await asset('vp-logic')).graph.nodes.find((n) => !idsBefore.includes(n.id));
check('the palette offers the Variable node', added?.type === 'Variable', JSON.stringify(added));
const variableId = added?.id ?? '';
const titleOf = (id) => json(`__gm.graph.nodeLabel(${JSON.stringify(id)})`);
const untitled = await titleOf(variableId);
// The node's own title says "Variable" too, and the canvas is drawn first.
const picker = await json(`__gm.inspectorField('Variable', true)`);
await click(picker.x, picker.y);
await sleep(200);
await clickLast('vz');
await sleep(300);
check(
'picking its variable makes that the title, and drops the row that would repeat it',
untitled === 'Variable' &&
(await titleOf(variableId)) === 'vz' &&
(await json(`__gm.graph.nodeSettings(${JSON.stringify(variableId)})`)).length === 0,
JSON.stringify({ untitled, titled: await titleOf(variableId), picker }),
);
await shot('05-variable-node');
await mutate(`
const g = __gm.state.project.assets.find((x) => x.id === 'vp-logic').graph;
g.nodes.push(
${JSON.stringify(node('vp-add', 'Add', 800, 200, { b: 1 }))},
${JSON.stringify(node('vp-set-vx', 'SetVariable', 800, 400, { variable: 'vx' }))},
${JSON.stringify(node('vp-set-gone', 'SetVariable', 800, 500, { variable: 'gone' }))},
);
g.edges.push(
${JSON.stringify(edge('vp-e7', 'vp-move', 'exec', variableId, 'exec'))},
${JSON.stringify(edge('vp-e8', variableId, 'value', 'vp-add', 'a'))},
${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'value'))},
);
`);
await sleep(300);
const settingsOf = (id) => json(`__gm.graph.nodeSettings(${JSON.stringify(id)})`);
const named = { title: await titleOf('vp-set-vx'), rows: await settingsOf('vp-set-vx') };
const gone = { title: await titleOf('vp-set-gone'), rows: await settingsOf('vp-set-gone') };
check(
'Set variable names its variable too, and a deleted one keeps the row that marks it',
named.title === 'Set vx' &&
named.rows.length === 0 &&
gone.title === 'Set gone' &&
JSON.stringify(gone.rows) === '["Variable: gone?"]',
JSON.stringify({ named, gone }),
);
await t.play();
await sleep(1000);
const counted = (await entity('vp-brain'))?.props.vz;
await stop();
await sleep(300);
check(
'in the run it reads through its own output and writes the sum back, so vz climbs',
typeof counted === 'number' && counted > 20,
`vz = ${counted} after ~1 s`,
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'vp-logic');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'vp-box' && o.id !== 'vp-brain');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(300);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length');
check('the step leaves the fixture as it found it', left === 0, String(left));
}