Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.
The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.
Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.
The "Add node..." header button is gone; nodes come from the context menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
17 KiB
JavaScript
399 lines
17 KiB
JavaScript
import { edge, node, port } from '../lib/builders.mjs';
|
||
import { MAKE_OBJECT } from './lib/scaffold.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 mover = p.assets.find((a) => a.id === 'mover');
|
||
// At the front of the list, not the end: these get dragged out of the asset panel below, and
|
||
// by this point in the suite the list is long enough to scroll a row at the end out of reach.
|
||
const behavior = (id, name, graph) => {
|
||
const a = structuredClone(mover);
|
||
Object.assign(a, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
|
||
p.assets.unshift(a);
|
||
};
|
||
behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)});
|
||
behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)});
|
||
behavior('nb-empty', 'Empty', { nodes: [], edges: [] });
|
||
${MAKE_OBJECT}
|
||
scene.objects.push(make('nb-box', 'crate', '', 700, 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 700, '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. Dragging a behavior out of the asset panel drops a call into the graph, where the
|
||
// pointer let go of it.
|
||
const newNodeIn = async (before) =>
|
||
(await asset('nb-caller')).graph.nodes.find((n) => !before.includes(n.id));
|
||
const beforeDrag = await nodeIds();
|
||
// Points taken from the drawing area itself: an offset from the centre can fall outside it,
|
||
// and a drop that never arrives looks exactly like one that was refused.
|
||
const rect = await json('__gm.graph.canvasRect()');
|
||
const spot = (fx, fy) => ({
|
||
x: Math.round(rect.x + rect.w * fx),
|
||
y: Math.round(rect.y + rect.h * fy),
|
||
});
|
||
const dropAt = spot(0.25, 0.75);
|
||
await t.dragAsset('nb-double', dropAt.x, dropAt.y);
|
||
await sleep(300);
|
||
const dropped = await newNodeIn(beforeDrag);
|
||
check(
|
||
'a behavior dragged from the asset panel becomes a call node',
|
||
dropped?.type === 'RunBehavior' && dropped?.params.behavior === 'nb-double',
|
||
JSON.stringify(dropped && { type: dropped.type, params: dropped.params }),
|
||
);
|
||
const droppedAt = dropped && (await json(`__gm.graph.nodeAt(${JSON.stringify(dropped.id)})`));
|
||
check(
|
||
'and it lands under the pointer, not at some default spot',
|
||
Boolean(droppedAt) && Math.hypot(droppedAt.x - dropAt.x, droppedAt.y - dropAt.y) < 20,
|
||
`dropped at ${JSON.stringify(droppedAt)}, let go at ${JSON.stringify(dropAt)}`,
|
||
);
|
||
|
||
// 19h. A behavior with no start node can still be added, but says why nothing happens.
|
||
const beforeEmpty = await nodeIds();
|
||
const emptyRow = await json(`__gm.assetRow('nb-empty')`);
|
||
await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y);
|
||
await sleep(300);
|
||
const emptyCall = await newNodeIn(beforeEmpty);
|
||
check(
|
||
'a behavior without a start node can be added just the same',
|
||
emptyCall?.params.behavior === 'nb-empty',
|
||
`${JSON.stringify(emptyCall && { type: emptyCall.type, params: emptyCall.params })} row=${JSON.stringify(emptyRow)}`,
|
||
);
|
||
const problem = await evaluate(`__gm.graph.nodeProblem(${JSON.stringify(emptyCall?.id ?? '')})`);
|
||
check(
|
||
'and the node says why it does nothing, instead of failing silently',
|
||
problem === 'No start node – does nothing',
|
||
`problem=${JSON.stringify(problem)}`,
|
||
);
|
||
check(
|
||
'a healthy call carries no such warning',
|
||
(await evaluate(`__gm.graph.nodeProblem('nb-call')`)) === null,
|
||
);
|
||
await shot('39-call-problem');
|
||
|
||
// 19i. Dropping a behavior into its own graph is refused – and says so, rather than quietly
|
||
// adding a node that would throw the moment it ran.
|
||
const beforeSelf = await nodeIds();
|
||
const selfRow = await json(`__gm.assetRow('nb-caller')`);
|
||
await t.dragAsset('nb-caller', spot(0.45, 0.5).x, spot(0.45, 0.5).y);
|
||
await sleep(300);
|
||
const afterSelf = await nodeIds();
|
||
const { notice } = await t.state();
|
||
// `dragging` tells a refusal apart from a drag that never arrived – both leave the graph alone.
|
||
const dragging = await json('__gm.dragPayload()');
|
||
check(
|
||
'dropping a behavior into its own graph is refused, out loud',
|
||
afterSelf.length === beforeSelf.length && notice === 'A behavior cannot run itself',
|
||
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
|
||
);
|
||
|
||
// 19j. Double-clicking a call opens the behavior it runs. Double calls Empty in turn, so the
|
||
// breadcrumb below gets a third level. The call sits far down: only a graph tall enough to
|
||
// fill the canvas shows whether it was fitted to the canvas before or after the bar.
|
||
await mutate(`
|
||
const double = __gm.state.project.assets.find((a) => a.id === 'nb-double');
|
||
double.graph.nodes.push(${JSON.stringify(node('nb-call2', 'RunBehavior', 240, 900, { behavior: 'nb-empty' }))});
|
||
`);
|
||
const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
|
||
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
|
||
check('no breadcrumb on a graph opened from the outside', (await json('__gm.graph.crumbs()')).length === 0);
|
||
await t.doubleClick(callNode.x, callNode.y);
|
||
await sleep(400);
|
||
const opened = await evaluate('__gm.graph.current()');
|
||
check(
|
||
'double-clicking a call node opens the behavior it runs',
|
||
opened === 'nb-double',
|
||
`current=${JSON.stringify(opened)}`,
|
||
);
|
||
|
||
// 19k. The breadcrumb: every behavior entered by double-click, each earlier one a way back.
|
||
const crumbs = () => json('__gm.graph.crumbs()');
|
||
const trail = async () => (await crumbs()).map((c) => `${c.name}${c.clickable ? '' : '*'}`).join(' › ');
|
||
check('opening a call shows the way back', (await trail()) === 'Caller › Double*', await trail());
|
||
const cut = await json(`(() => {
|
||
const c = __gm.graph.canvasRect();
|
||
return __gm.state.project.assets.find((a) => a.id === 'nb-double').graph.nodes
|
||
.map((n) => ({ id: n.id, r: __gm.graph.nodeRect(n.id) }))
|
||
.filter(({ r }) => r.y < c.y || r.y + r.h > c.y + c.h)
|
||
.map(({ id }) => id);
|
||
})()`);
|
||
check('the graph opened is fitted below the bar, not under it', cut.length === 0, `cut off: ${cut}`);
|
||
const inner = await json(`__gm.graph.nodeAt('nb-call2')`);
|
||
await click(inner.x, inner.y);
|
||
// Past the double-click window: the pair below must count as its own two presses.
|
||
await sleep(500);
|
||
check(
|
||
'selecting a node inside keeps the breadcrumb',
|
||
(await trail()) === 'Caller › Double*',
|
||
await trail(),
|
||
);
|
||
await t.doubleClick(inner.x, inner.y);
|
||
await sleep(400);
|
||
check(
|
||
'each call opened further down adds a crumb',
|
||
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await trail()) === 'Caller › Double › Empty*',
|
||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||
);
|
||
// A width of its own too: crumbs never laid out are all 0 wide at one spot, and overlap nothing.
|
||
const overlaps = (await crumbs()).filter(
|
||
(c, i, all) => c.right - c.left < 10 || (i > 0 && c.left < all[i - 1].right),
|
||
);
|
||
check(
|
||
'the crumbs sit side by side, not on top of each other',
|
||
overlaps.length === 0,
|
||
JSON.stringify((await crumbs()).map((c) => [c.name, Math.round(c.left), Math.round(c.right)])),
|
||
);
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Twice'`);
|
||
check('a renamed behavior renames its crumb', (await trail()) === 'Caller › Twice › Empty*', await trail());
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Double'`);
|
||
await shot('39b-breadcrumb');
|
||
|
||
const clickCrumb = async (name) => {
|
||
const crumb = (await crumbs()).find((c) => c.name === name);
|
||
await click(crumb.x, crumb.y);
|
||
await sleep(300);
|
||
};
|
||
await clickCrumb('Double');
|
||
await shot('39c-breadcrumb-back');
|
||
check(
|
||
'a crumb leads back, and cuts the trail there',
|
||
(await evaluate('__gm.graph.current()')) === 'nb-double' && (await trail()) === 'Caller › Double*',
|
||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||
);
|
||
await clickCrumb('Caller');
|
||
check(
|
||
'back at the root, the breadcrumb is gone',
|
||
(await evaluate('__gm.graph.current()')) === 'nb-caller' && (await crumbs()).length === 0,
|
||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||
);
|
||
|
||
// Opening another behavior any other way ends the trail: the asset panel, then the picker.
|
||
const enterDouble = async () => {
|
||
const call = await json(`__gm.graph.nodeAt('nb-call')`);
|
||
await t.doubleClick(call.x, call.y);
|
||
await sleep(400);
|
||
};
|
||
await enterDouble();
|
||
const emptyAgain = await json(`__gm.assetRow('nb-empty')`);
|
||
await click(emptyAgain.x, emptyAgain.y);
|
||
await sleep(300);
|
||
check(
|
||
'choosing another behavior in the asset panel ends the trail',
|
||
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
|
||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||
);
|
||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'nb-caller' })`);
|
||
await sleep(200);
|
||
await enterDouble();
|
||
const picker = await json('__gm.graph.pickerAt()');
|
||
await click(picker.x, picker.y);
|
||
await sleep(150);
|
||
const emptyItem = await locate('Empty', true);
|
||
await click(emptyItem.x, emptyItem.y);
|
||
await sleep(300);
|
||
check(
|
||
'choosing another behavior in the picker ends the trail',
|
||
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
|
||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||
);
|
||
|
||
// 19l. Clean up: the three 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;
|
||
const mine = ['nb-double', 'nb-caller', 'nb-empty'];
|
||
p.assets = p.assets.filter((a) => !mine.includes(a.id));
|
||
scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic');
|
||
`);
|
||
await sleep(300);
|
||
const left = await json(`{
|
||
objects: __gm.sceneObjects().filter((o) => o.id.startsWith('nb-')).length,
|
||
assets: __gm.state.project.assets.filter((a) => a.id.startsWith('nb-')).length,
|
||
}`);
|
||
check(
|
||
'the step leaves nothing of its own behind',
|
||
left.objects === 0 && left.assets === 0,
|
||
JSON.stringify(left),
|
||
);
|
||
}
|