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:
1 parent
d4b1ffa051
commit
922774778b
36 files changed
+2969
-147
No files matched your search
+103
-6
@@ -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
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user