A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.
Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.
Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.
Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
6.5 KiB
JavaScript
163 lines
6.5 KiB
JavaScript
import { edge, node, port, variable } from '../lib/builders.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 } = 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 proto = scene.objects[0];
|
||
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);
|
||
const make = (id, assetId, parentId, name) => {
|
||
const o = structuredClone(proto);
|
||
Object.assign(o, {
|
||
id, assetId, name,
|
||
position: { x: 0, y: 900, z: 0 },
|
||
parentId, overrides: {}, instanceEdits: {},
|
||
});
|
||
return o;
|
||
};
|
||
scene.objects.push(make('vp-box', 'crate', '', 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', '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');
|
||
|
||
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));
|
||
}
|