Behaviors with Blueprint-style node graph, graph editor tab and BehaviorSystem

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 12:44:43 +02:00
1 parent d4b1ffa051
commit 922774778b
36 files changed
+2969 -147

No files matched your search

+103 -6
View File
@@ -199,7 +199,8 @@ try {
await shot('07-undo-redo');
// 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the
// origin), arrow keys do nothing, Esc stops without touching the model.
// origin; the behavior symbol is editor-only). The "Mover" behavior owns the red crate:
// holding ArrowRight moves it in the world (speed × dt), Esc stops without touching the model.
await clickText('Renamed crate');
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
await clickText('▶ Play');
@@ -210,15 +211,24 @@ try {
check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info);
await shot('08-playing');
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await sleep(300);
await sleep(400);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
const worldAfterRight = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
const movedCrate = worldAfterRight.entities.find((e) => e.objectId === 'o1');
check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await sleep(250);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
const afterLeft = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o1');
check('inline dx (−3 per frame) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300);
s = JSON.parse(await stateJson());
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
await shot('09-stopped');
const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
check('play did not write back to the model', o2AfterPlay === xBeforePlay, `${o2AfterPlay} vs ${xBeforePlay}`);
const o1AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x`);
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`);
@@ -234,7 +244,7 @@ try {
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(200);
check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 2);
check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 3);
await clickText('▶ Play');
await sleep(300);
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
@@ -246,7 +256,7 @@ try {
await sleep(300);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 3);
check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 4);
// 8. Context menu on the Crate asset → menu shows "Place in scene".
await clickText('Crate', 'right');
@@ -257,7 +267,7 @@ try {
await clickText('Place in scene');
await sleep(200);
const count = await evaluate('__gm.state.activeScene.objects.length');
check('placing from the menu adds an object', count === 4, `objects=${count}`);
check('placing from the menu adds an object', count === 5, `objects=${count}`);
// 9. Create a new asset through the + menu.
await clickText('+');
@@ -330,6 +340,93 @@ try {
check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`);
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
// 13. Behavior graph editor. Selecting the Mover asset binds the graph; the Graph tab shows it.
await clickText('Mover');
await evaluate(`__gm.showTab('graph')`);
await sleep(250);
check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph');
await shot('16-graph');
const nodeOf = (id) => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === ${JSON.stringify(id)}))`).then(JSON.parse);
const graphCounts = () => evaluate(`(() => { const g = __gm.state.project.assets.find(a => a.id === 'mover').graph; return JSON.stringify({ nodes: g.nodes.length, edges: g.edges.length }); })()`).then(JSON.parse);
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
const mulBefore = await nodeOf('n-mul');
const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-mul'))`));
await drag(mulPos.x, mulPos.y, mulPos.x + 60, mulPos.y + 40);
s = JSON.parse(await stateJson());
const mulAfter = await nodeOf('n-mul');
check('dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-mul' && mulAfter.x === mulBefore.x + 60 && mulAfter.y === mulBefore.y + 40, `${JSON.stringify(s.selection)} ${mulBefore.x},${mulBefore.y} → ${mulAfter.x},${mulAfter.y}`);
check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Multiply')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`)));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const mulUndone = await nodeOf('n-mul');
check('undo moves the node back', mulUndone.x === mulBefore.x && mulUndone.y === mulBefore.y, `${mulUndone.x},${mulUndone.y}`);
// 13b. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const gcenter = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.center())`));
const before13 = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await sleep(250);
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('log');
await sleep(100);
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(250);
const after13 = await graphCounts();
s = JSON.parse(await stateJson());
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode }));
await shot('17-node-added');
// 13c. Connect: drag from the upper Move node's exec output onto the Log node's exec input.
const fromPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt('n-move', 'exec', true))`));
const toPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false))`));
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
const connected = await graphCounts();
check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected));
// An exec output must not connect to a data input: dragging onto "message" does nothing.
const dataPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false))`));
await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit');
await shot('18-connected');
// 13d. Delete removes the selected node together with its edge; undo brings both back.
await evaluate(`__gm.state.select({ kind: 'node', assetId: 'mover', nodeId: ${JSON.stringify(newNode.id)} })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(150);
const deleted = await graphCounts();
check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const restored = await graphCounts();
check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored));
// 13e. Live highlight: play with the graph open, hold ArrowRight → the exec edge is hot.
await clickText('▶ Play');
await sleep(200);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await sleep(300);
await shot('19-graph-live');
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
// 13f. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it.
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`);
await sleep(150);
await clickText('+ Variable ▾');
await sleep(150);
await clickText('Number');
await sleep(200);
const vars = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
check('adding a variable declares it on the behavior', vars.join(',') === 'speed,var1', vars.join(','));
await shot('20-variable-added');
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const varsUndone = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
check('undo removes the variable', varsUndone.join(',') === 'speed', varsUndone.join(','));
await evaluate(`__gm.showTab('scene')`);
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close();
} catch (error) {
+37 -3
View File
@@ -10,6 +10,31 @@ if (!projectId) {
process.exit(2);
}
/**
* "Mover" behavior: ArrowRight moves the owner by speed × dt per frame (data flow through
* a variable and a Multiply node), ArrowLeft moves it by an inline −3 per frame.
*/
const moverGraph = {
nodes: [
{ id: 'n-right', type: 'OnKeyHeld', x: 0, y: 0, params: { key: 'ArrowRight' } },
{ id: 'n-owner', type: 'Owner', x: 0, y: 140, params: {} },
{ id: 'n-speed', type: 'GetVariable', x: 0, y: 220, params: { variable: 'speed' } },
{ id: 'n-mul', type: 'Multiply', x: 220, y: 180, params: {} },
{ id: 'n-move', type: 'Move', x: 440, y: 0, params: { dy: 0 } },
{ id: 'n-left', type: 'OnKeyHeld', x: 0, y: 340, params: { key: 'ArrowLeft' } },
{ id: 'n-move2', type: 'Move', x: 440, y: 340, params: { dx: -3, dy: 0 } },
],
edges: [
{ id: 'e1', from: { node: 'n-right', port: 'exec' }, to: { node: 'n-move', port: 'exec' } },
{ id: 'e2', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move', port: 'object' } },
{ id: 'e3', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-mul', port: 'a' } },
{ id: 'e4', from: { node: 'n-right', port: 'dt' }, to: { node: 'n-mul', port: 'b' } },
{ id: 'e5', from: { node: 'n-mul', port: 'result' }, to: { node: 'n-move', port: 'dx' } },
{ id: 'e6', from: { node: 'n-left', port: 'exec' }, to: { node: 'n-move2', port: 'exec' } },
{ id: 'e7', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move2', port: 'object' } },
],
};
const project = {
formatVersion: 1,
id: projectId,
@@ -22,13 +47,22 @@ const project = {
name: 'Level 1',
props: { backgroundColor: 0x22252c },
objects: [
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {} },
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff } },
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {} },
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, ownerId: '' },
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, ownerId: '' },
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, ownerId: '' },
{ id: 'o4', assetId: 'mover', name: '', position: { x: -150, y: 110, z: 0 }, overrides: {}, ownerId: 'o1' },
],
},
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
{ id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } },
{
id: 'mover',
type: 'behavior',
name: 'Mover',
props: { speed: 200 },
variables: [{ key: 'speed', label: 'speed', kind: 'number', default: 0 }],
graph: moverGraph,
},
],
};