Let a behavior be a node in another graph

A behavior that has a Start and an End node can now be dropped into any
other graph as a node of its own. What is declared on the start node
becomes the inputs of that node, what hangs on the end node its outputs;
the port list is derived from the sub graph, not copied, so declaring a
port in one graph grows the node in the other without touching it. The
node carries the behavior's name and the palette lists behaviors by
name, so a call is one grab instead of a node plus a dropdown.

An embedded behavior has no object and therefore no place for
variables: every call starts on the asset defaults and communicates
only through its ports. That is the conservative direction - state can
still be added later, and shared state already has a home in a behavior
object in the scene. Node ids are unique per graph only, so each call
gets its own pulse cache; without that, two graphs would collide.

Renaming a port used to silently drop the value typed on the calling
node. It is carried along now, project wide, like the edges.
This commit is contained in:
root committed 2026-09-09 20:01:49 +02:00
1 parent 64e034ffb9
commit 6702ff8abe
31 files changed
+1402 -107

No files matched your search

+216
View File
@@ -0,0 +1,216 @@
import { edge, node, port } from '../lib/builders.mjs';
/**
* A behavior used as a node inside another behavior's graph: the ports it grows from the other
* graph's start/end nodes, the call itself while playing, and declaring a port across the two.
*
* The step brings its own box and its own two behaviors; everything it adds is removed at the end.
*/
const SCALE = 100;
/** `in1` → ×2 → `out1`, entered at the start node and left at the end node. */
const doubleGraph = {
nodes: [
node('nb-start', 'BehaviorStart', 0, 0, {}, [port('in1', 'number')]),
node('nb-times', 'Multiply', 240, 60, { b: 2 }),
node('nb-end', 'BehaviorEnd', 520, 0, {}, [port('out1', 'number')]),
],
edges: [
edge('nb-e1', 'nb-start', 'exec', 'nb-end', 'exec'),
edge('nb-e2', 'nb-start', 'in1', 'nb-times', 'a'),
edge('nb-e3', 'nb-times', 'result', 'nb-end', 'out1'),
],
};
/**
* `On update → Run(Double, in1 = 1) → Move(owner, dx = out1)`. The caller does no arithmetic of
* its own: the speed it moves at *is* the value the nested behavior handed back.
*/
const callerGraph = {
nodes: [
node('nb-update', 'OnUpdate', 0, 0),
node('nb-owner', 'Owner', 0, 160),
node('nb-call', 'RunBehavior', 260, 0, { behavior: 'nb-double', in1: 1 }),
node('nb-move', 'Move', 560, 0, { scale: SCALE }),
],
edges: [
edge('nb-c1', 'nb-update', 'exec', 'nb-call', 'exec'),
edge('nb-c2', 'nb-call', 'exec', 'nb-move', 'exec'),
edge('nb-c3', 'nb-call', 'out1', 'nb-move', 'dx'),
edge('nb-c4', 'nb-update', 'dt', 'nb-move', 'dt'),
edge('nb-c5', 'nb-owner', 'object', 'nb-move', 'object'),
],
};
export async function nestedBehavior(t) {
const {
check,
sleep,
evaluate,
json,
shot,
click,
clickText,
typeText,
locate,
press,
undo,
object,
entity,
asset,
mutate,
stop,
} = t;
const portAt = (nodeId, key, isOutput) =>
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const startPorts = async () =>
(await asset('nb-double')).graph.nodes.find((n) => n.id === 'nb-start').ports ?? [];
// 19a. Two behaviors and a box wearing the calling one. Written into the model directly: what
// is under test is the call, not another way of drawing a graph.
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 behavior = (id, name, graph) => {
const a = structuredClone(mover);
Object.assign(a, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
p.assets.push(a);
};
behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)});
behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)});
const make = (id, assetId, parentId, name) => {
const o = structuredClone(proto);
Object.assign(o, {
id, assetId, name,
position: { x: 0, y: 700, z: 0 },
parentId, overrides: {}, instanceEdits: {},
});
return o;
};
scene.objects.push(make('nb-box', 'crate', '', 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 'Caller'));
`);
await sleep(400);
// 19b. The call really runs, and the value really comes back: the box moves at dx × scale, and
// dx is nothing the caller holds – it is what the nested graph returned. Without the result
// dx would be 0 and the box would not move at all.
const boxX = async () => (await object('nb-box')).position.x;
const idleX = await boxX();
await t.play();
await sleep(300);
const before = (await entity('nb-box')).x;
await sleep(1000);
const after = (await entity('nb-box')).x;
const speed = after - before;
check(
'a behavior used as a node runs, and what it returns is what drives the caller',
speed > SCALE * 1.4 && speed < SCALE * 2.6,
`moved ${speed.toFixed(1)} px in ~1 s – 2 × ${SCALE} means the returned value arrived, 0 would mean nothing came back`,
);
await stop();
await sleep(300);
check(
'the stored object is untouched by the run, as in any play session',
(await boxX()) === idleX,
`x=${await boxX()}`,
);
// 19c. The node in the graph carries the *other* behavior's ports and name – neither is part of
// its own type.
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`,
);
await sleep(300);
const inPort = await portAt('nb-call', 'in1', false);
const outPort = await portAt('nb-call', 'out1', true);
check(
'the calling node grows the ports the other behavior declares',
Boolean(inPort) && Boolean(outPort) && outPort.x > inPort.x,
JSON.stringify({ inPort, outPort }),
);
const title = await evaluate(`__gm.graph.nodeLabel('nb-call')`);
check(
'and it is drawn under that behavior\'s name, not as "Run behavior"',
title === 'Double',
`title=${JSON.stringify(title)}`,
);
await shot('37-nested-behavior');
// 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the
// check that a port list derived from another graph is really derived and not copied.
check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false)));
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] })`,
);
await sleep(300);
const addButton = await locate('+ Input');
check('the start node offers its inputs in the inspector', Boolean(addButton), JSON.stringify(addButton));
await shot('38-behavior-ports');
await click(addButton.x, addButton.y);
await sleep(200);
await clickText('Number');
await sleep(300);
const declared = await startPorts();
check(
'adding an input declares it on the start node',
declared.length === 2 && declared[1].key === 'in2' && declared[1].kind === 'number',
JSON.stringify(declared),
);
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`,
);
await sleep(300);
check(
'and the node in the other graph grew that input without being touched',
Boolean(await portAt('nb-call', 'in2', false)),
);
// 19e. Undo takes the port back, and with it the port on the calling node.
await undo();
await sleep(300);
const afterUndo = await startPorts();
check(
'undo removes it in both graphs',
afterUndo.length === 1 && !(await portAt('nb-call', 'in2', false)),
`${JSON.stringify(afterUndo)}`,
);
// 19f. The palette offers a behavior under its own name, and picking it leaves a node that
// already names it – one pick, not a pick plus a dropdown. ("Double" also labels the asset
// in the list on the left, so the proof is the node that appears, not the row.)
const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id);
const idsBefore = await nodeIds();
const centre = await json('__gm.graph.center()');
await click(centre.x + 250, centre.y + 250, 'right');
await sleep(150);
check('node palette opens', Boolean(await locate('Search nodes…')));
await typeText('Double');
await sleep(200);
// Enter takes the first match. NOT a click on the label: "Double" also names the asset in the
// list on the left, and that is what a click by text would find first.
await press('Enter');
await sleep(300);
const added = (await asset('nb-caller')).graph.nodes.find((n) => !idsBefore.includes(n.id));
check(
'picking a behavior in the palette drops in a node already set to it',
(await nodeIds()).length === idsBefore.length + 1 &&
added?.type === 'RunBehavior' &&
added?.params.behavior === 'nb-double',
JSON.stringify(added && { type: added.type, params: added.params }),
);
await undo();
await sleep(200);
// 19g. Clean up: the two behaviors and the box are gone again.
await evaluate(`__gm.state.select(null)`);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets = p.assets.filter((a) => a.id !== 'nb-double' && a.id !== 'nb-caller');
scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic');
`);
await sleep(300);
}
+2
View File
@@ -21,6 +21,7 @@ import { aspect } from './15-aspect.mjs';
import { containerTab } from './16-container.mjs';
import { editorRun } from './17-editor-run.mjs';
import { pause } from './18-pause.mjs';
import { nestedBehavior } from './19-nested-behavior.mjs';
export const STEPS = [
hierarchy,
@@ -41,4 +42,5 @@ export const STEPS = [
containerTab,
editorRun,
pause,
nestedBehavior,
];