/** * The behavior graph editor: binding to the selection, dragging nodes, the palette (row click * and Enter), connecting ports, deleting, the live highlight while playing, variables. */ export async function graph(t) { const { check, sleep, saved, evaluate, json, find, shot, click, clickText, drag, mouse, locate, mutate, typeText, press, holdKey, undo, state, select, stop, asset, api, navigate, projectId, showTab, } = t; const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id); const graphCounts = async () => { const g = (await asset('mover')).graph; return { nodes: g.nodes.length, edges: g.edges.length }; }; const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(','); // 6a. Selecting the Mover asset binds the graph; the Behavior tab shows it. await clickText('Mover'); await showTab('graph'); check( 'graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph', ); await shot('16-graph'); // 6b. Drag a node: clicking its title selects it, dragging moves it (undoable). const axisBefore = await nodeOf('n-axis'); const axisPos = await json(`__gm.graph.nodeAt('n-axis')`); // The fit zoom follows the canvas size, so 60 screen pixels are 60/zoom graph units, not 60. const fitZoom = Number(await evaluate('__gm.graph.zoom()')); await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40); let s = await state(); const axisAfter = await nodeOf('n-axis'); check( 'dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && Math.abs(axisAfter.x - (axisBefore.x + 60 / fitZoom)) <= 2 && Math.abs(axisAfter.y - (axisBefore.y + 40 / fitZoom)) <= 2, `${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`, ); check( 'node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`)), ); await undo(); const axisUndone = await nodeOf('n-axis'); check( 'undo moves the node back', axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y, `${axisUndone.x},${axisUndone.y}`, ); // 6c. Typing a query and then *clicking* a row adds exactly one node: the search field's // blur-commit must not pick the first match as well. // Bare canvas, searched rather than guessed: the console panel below shortens the canvas. const bare = await json('__gm.graph.emptySpot()'); const beforeClickPick = await graphCounts(); await click(bare.x, bare.y, 'right'); check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); await typeText('stop'); const stopRow = await locate('Stop game'); // nothing else on screen shows this label await click(stopRow.x, stopRow.y); const afterClickPick = await graphCounts(); check( 'clicking a palette row adds exactly one node', afterClickPick.nodes === beforeClickPick.nodes + 1, JSON.stringify({ beforeClickPick, afterClickPick }), ); check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`))); await undo(); // so the next step finds the same empty spot and the expected graph check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes); // 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node. const before13 = await graphCounts(); await click(bare.x, bare.y, 'right'); check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); // Unfiltered the palette holds far more nodes than fit, so it grows to the full height it may // have – and that height is the canvas minus a tenth at each end. Shifting it up to fit used to // put the entire gap above it and leave it flush on the bottom edge. Opened this far down it ends // up with a tenth at both ends; the gap above is what is left over, not a rule of its own. const paletteBox = await json('__gm.overlayRect()'); const paletteScreenH = await json('__gm.app.screen.height'); const paletteMargin = Math.round(paletteScreenH * 0.1); const gapAbove = paletteBox.y; const gapBelow = paletteScreenH - (paletteBox.y + paletteBox.h); check( 'a palette shifted up to fit keeps the tenth of the canvas below it', Math.abs(gapBelow - paletteMargin) <= 1 && gapAbove >= 0, JSON.stringify({ gapAbove, gapBelow, paletteMargin, paletteScreenH }), ); await typeText('log'); await press('Enter'); const after13 = await graphCounts(); s = await state(); 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'); // 6d2. The palette ranks by how well an entry answers the query (#43): a node *named* after it // comes before one that merely mentions it in its description. "variable" used to hand // Enter the first row alphabetically, which was `Run behavior`. const beforeRank = await graphCounts(); // A fresh spot: the Log node from 6d now sits where `bare` pointed, and whether a click there // hits it depends on how the layout snaps the node, not on anything this step tests. const bareRank = await json('__gm.graph.emptySpot()'); await click(bareRank.x, bareRank.y, 'right'); await typeText('variable'); const ranked = await json('__gm.graph.paletteRows()'); const hits = (row) => `${row.label} ${row.type}`.toLowerCase().includes('variable'); const lastNamed = ranked.map(hits).lastIndexOf(true); const firstOther = ranked.map(hits).indexOf(false); check( 'the palette lists the nodes named after the query first', lastNamed >= 0 && (firstOther === -1 || lastNamed < firstOther) && hits(ranked[0]), JSON.stringify(ranked), ); await press('Enter'); s = await state(); const rankPick = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null; check( 'Enter takes that node, not the one whose description mentions it', rankPick !== null && rankPick.type.toLowerCase().includes('variable'), JSON.stringify(rankPick), ); await undo(); check('undo removes it again', (await graphCounts()).nodes === beforeRank.nodes); // 6e. Connect: drag from the Move node's exec output onto the Log node's exec input. An // exec output must not connect to a data input: dragging onto "message" does nothing. const fromPort = await json(`__gm.graph.portAt('n-move', 'exec', true)`); const toPort = await json(`__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), ); const dataPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false)`); await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16); // Released on the port itself, which sits at the node's very edge: that is a drop on the node, not // on empty canvas. An open palette here swallows every later click – it did, for four checks. check( 'incompatible ports are rejected without taking the drop for empty canvas', (await graphCounts()).edges === before13.edges + 1 && (await state()).mode === 'edit' && !(await evaluate(`__gm.locate('Search nodes…')`)), ); await shot('18-connected'); // 13d2. A drop on the node's body instead of a port: it takes the first free compatible input, // and the highlight names that port while the button is still down. const axisX = await json(`__gm.graph.portAt('n-axis', 'x', true)`); const logBody = await json(`__gm.graph.nodeAt(${JSON.stringify(newNode.id)})`); await mouse('mouseMoved', axisX.x, axisX.y); await mouse('mousePressed', axisX.x, axisX.y); await mouse('mouseMoved', toPort.x, toPort.y, 'left', { buttons: 1 }); await sleep(50); const overExec = await json(`__gm.graph.dropTarget()`); check('a number drag over an exec port predicts nothing', overExec === null, JSON.stringify(overExec)); await mouse('mouseMoved', logBody.x, logBody.y, 'left', { buttons: 1 }); await sleep(50); const overBody = await json(`__gm.graph.dropTarget()`); check( 'over the node body it predicts the first free compatible input', overBody?.nodeId === newNode.id && overBody.port === 'message' && overBody.isOutput === false, JSON.stringify(overBody), ); await shot('18b-drop-prediction'); await mouse('mouseReleased', logBody.x, logBody.y); await sleep(150); const dropped = await graphCounts(); const messageEdge = (await asset('mover')).graph.edges.find( (e) => e.to.node === newNode.id && e.to.port === 'message', ); check( 'the drop connects exactly the predicted port', dropped.edges === before13.edges + 2 && messageEdge?.from.port === 'x', JSON.stringify({ dropped, messageEdge }), ); check('the prediction is dropped with the drag', (await json(`__gm.graph.dropTarget()`)) === null); await undo(); check('undo removes that edge again', (await graphCounts()).edges === before13.edges + 1); // 13d3. Dropped into empty space, the drag opens the palette filtered to what it could reach, // and the picked node arrives already wired – as one undo step. const empty = await json('__gm.graph.emptySpot()'); const beforeDrop = await graphCounts(); await mouse('mouseMoved', axisX.x, axisX.y); await mouse('mousePressed', axisX.x, axisX.y); await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 }); await sleep(50); await mouse('mouseReleased', empty.x, empty.y); await sleep(150); check('a drop into empty space opens the palette', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); // "Restart game" takes exec only. Its label must not appear – and unlike "On update" it is not // a node on the canvas, where `locate` would find it whatever the palette lists. check( 'it lists a node that takes a number, not one that cannot', Boolean(await evaluate(`__gm.locate('Clamp')`)) && !(await evaluate(`__gm.locate('Restart game')`)), ); check( 'the dragged edge stays on screen while the palette decides', (await json(`__gm.graph.danglingEdge()`)) === true, ); await shot('18c-drop-into-empty'); const clampRow = await locate('Clamp'); await click(clampRow.x, clampRow.y); const afterDrop = await graphCounts(); const wired = await state(); const clampEdge = (await asset('mover')).graph.edges.find( (e) => e.from.node === 'n-axis' && e.from.port === 'x' && e.to.node === wired.selection?.nodeId, ); check( 'the new node arrives connected to its first compatible input', afterDrop.nodes === beforeDrop.nodes + 1 && afterDrop.edges === beforeDrop.edges + 1 && clampEdge?.to.port === 'value', JSON.stringify({ afterDrop, clampEdge }), ); check( 'and the dangling edge gives way to the real one', (await json(`__gm.graph.danglingEdge()`)) === false, ); await undo(); const undone = await graphCounts(); check( 'one undo takes back both the node and its edge', undone.nodes === beforeDrop.nodes && undone.edges === beforeDrop.edges, JSON.stringify(undone), ); // Cancelling the palette has to take the dangling edge with it, or it would hang there forever. await mouse('mouseMoved', axisX.x, axisX.y); await mouse('mousePressed', axisX.x, axisX.y); await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 }); await mouse('mouseReleased', empty.x, empty.y); await sleep(150); await press('Escape'); const cancelled = await graphCounts(); check( 'escaping the palette clears the edge and adds nothing', (await json(`__gm.graph.danglingEdge()`)) === false && cancelled.nodes === beforeDrop.nodes && cancelled.edges === beforeDrop.edges, JSON.stringify(cancelled), ); // 6f. Delete removes the selected node together with its edge; undo brings both back. await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id }); check('the node inspector has no delete button of its own', !(await t.has('Delete node'))); await press('Delete'); const deleted = await graphCounts(); check( 'Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted), ); await undo(); const restored = await graphCounts(); check( 'undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored), ); // 6g. The fps readout runs in both modes. Play always starts on the Game tab; switching to // Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot. const fpsEdit = await evaluate('__gm.fps()'); check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`); check( 'the canvas bypasses the compositor queue by default', (await evaluate('__gm.lowLatency()')) === true, ); // Single evaluate: the readout refreshes twice a second, so read value and label together. check( 'fps label is visible in the toolbar', (await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true, ); await t.play(); check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game'); await sleep(600); const fpsPlay = await evaluate('__gm.fps()'); check( 'fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`, ); await showTab('graph'); const holding = holdKey('ArrowRight', 300); await sleep(250); await shot('19-graph-live'); await holding; await stop(); // 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it. await select({ kind: 'asset', assetId: 'mover' }); await clickText('+ Variable'); await clickText('Number'); check( 'adding a variable declares it on the behavior', (await variableKeys()) === 'speed,var1', await variableKeys(), ); await shot('20-variable-added'); await undo(); check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys()); // 6i. The tab keeps its subject: selecting something else leaves the graph alone, and the // header picker switches behaviors without any selection at all. const size = await graphCounts(); const readout = `${size.nodes} nodes · ${size.edges} links`; check( 'the header reports the size of the shown graph', Boolean(await evaluate(`__gm.locate(${JSON.stringify(readout)})`)), readout, ); check('the header has no Add node button – the right-click palette is the way', !(await find('Add node…'))); await select(null); check( 'graph editor keeps its behavior when nothing is selected', (await evaluate(`__gm.graph.current()`)) === 'mover', ); await select({ kind: 'asset', assetId: 'cam' }); check( 'graph editor keeps its behavior for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === 'mover', ); await select({ kind: 'asset', assetId: 'spawner' }); check( 'selecting another behavior leaves the tab on its own (#171)', (await evaluate(`__gm.graph.current()`)) === 'mover', ); await evaluate(`__gm.state.openAsset('spawner')`); const twoOpen = await json('__gm.documents()'); check( 'opening it brings it up in a tab of its own', (await evaluate(`__gm.graph.current()`)) === 'spawner' && twoOpen.active === 'asset:spawner' && twoOpen.docs.includes('asset:mover'), JSON.stringify(twoOpen), ); await evaluate(`__gm.closeDocument('asset:spawner')`); check( 'closing it shows the behavior next to it again', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await json('__gm.tab()')) === 'graph', String(await evaluate(`__gm.graph.current()`)), ); await select({ kind: 'asset', assetId: 'mover' }); // 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node // under it included – and the left button no longer pans at all. await clickText('Fit'); const positions = async () => (await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' '); const area = await json('__gm.graph.canvasRect()'); const before13i = await positions(); const axisScreenBefore = await json(`__gm.graph.nodeAt('n-axis')`); await drag(axisScreenBefore.x, axisScreenBefore.y, axisScreenBefore.x + 120, axisScreenBefore.y + 60, { button: 'middle', }); const axisScreenPanned = await json(`__gm.graph.nodeAt('n-axis')`); check( 'the middle button pans even when it starts on a node', Math.abs(axisScreenPanned.x - axisScreenBefore.x - 120) < 3 && Math.abs(axisScreenPanned.y - axisScreenBefore.y - 60) < 3, JSON.stringify({ axisScreenBefore, axisScreenPanned }), ); check( 'panning over a node leaves the node where it was', (await positions()) === before13i, await positions(), ); // Fit again: the pan just now pushed a node out of view, and a band takes only what it sees. await clickText('Fit'); // An empty spot: after a fit every node sits at least a margin away from the corner. const corner = { x: area.x + 6, y: area.y + 6 }; const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 }; const axisBeforeBand = await json(`__gm.graph.nodeAt('n-axis')`); await drag(corner.x, corner.y, corner.x + 60, corner.y + 40); const axisAfterBand = await json(`__gm.graph.nodeAt('n-axis')`); check( 'dragging with the left button no longer pans', Math.abs(axisAfterBand.x - axisBeforeBand.x) < 3 && Math.abs(axisAfterBand.y - axisBeforeBand.y) < 3, JSON.stringify({ axisBeforeBand, axisAfterBand }), ); // 6k. A rubber band across the whole canvas takes every node – the count comes from the model, // so this cannot agree with the canvas by sharing its rule. const nodeCount = (await asset('mover')).graph.nodes.length; await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); const banded = await json('__gm.graph.selected()'); check( 'a rubber band selects every node it spans', banded.length === nodeCount, `${banded.length} of ${nodeCount}`, ); await shot('21-graph-multiselect'); // 6l. The whole selection moves as one, and one undo takes all of it back. const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y })); const grabAt = await json(`__gm.graph.nodeAt('n-axis')`); // Screen pixels become graph units through the zoom, which the fit picks from the canvas size. const units = async (px) => px / Number(await evaluate('__gm.graph.zoom()')); const [mx, my] = [await units(40), await units(30)]; await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30); const afterMove = await asset('mover'); const movedTogether = beforeMove.every((n) => { const now = afterMove.graph.nodes.find((m) => m.id === n.id); return Math.abs(now.x - (n.x + mx)) <= 2 && Math.abs(now.y - (n.y + my)) <= 2; }); check('dragging one of them moves the whole selection', movedTogether, await positions()); await undo(); check('one undo takes the group move back', (await positions()) === before13i, await positions()); // 13k2. Grid snapping. Distances are stated in graph units, which `units()` reads off the // current zoom – the canvas is not wide enough here for a zoom of 1. const GRID = 24; const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0; check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false); // The switch is a magnet (#194): a square icon button that stays lit while snapping is on. const magnet = await json('__gm.graph.snapButton()'); await click(magnet.x, magnet.y); check( 'the toggle switches it on without moving a single node', (await json('__gm.graph.snapping()')) === true && (await positions()) === before13i, await positions(), ); const magnetOn = await json('__gm.graph.snapButton()'); check( 'the magnet is a square icon without a caption, and lit while on', magnetOn.toggled && magnetOn.w === magnetOn.h && (await json(`__gm.locate('Snap')`)) === null, JSON.stringify(magnetOn), ); // A single node: dropped between two grid lines, it lands on the nearer one. await click(corner.x, corner.y); const snapPre = await nodeOf('n-axis'); const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`); const snapBy = await units(50); await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50); const snapped = await nodeOf('n-axis'); check( 'a dragged node lands on the grid, not where the pointer let go', onGrid(snapped) && Math.abs(snapped.x - (snapPre.x + snapBy)) <= GRID / 2 && Math.abs(snapped.y - (snapPre.y + snapBy)) <= GRID / 2, `${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`, ); const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis'); check( 'the nodes it was dragged past stay off the grid', others.some((n) => !onGrid(n)), JSON.stringify(others.map((n) => `${n.id}:${n.x},${n.y}`)), ); // A group: every selected node snaps for itself, so the whole selection ends up on grid lines // – off-grid gaps inside it close, rather than travelling along. await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); const groupPre = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y })); check( 'the selection is not on the grid yet', groupPre.some((n) => !onGrid(n)), JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)), ); const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`); const groupBy = await units(30); await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30); const groupPost = (await asset('mover')).graph.nodes; check( 'every selected node lines up on the grid for itself, none by more than half a step', groupPost.every(onGrid) && groupPre.every((n) => { const now = groupPost.find((m) => m.id === n.id); return Math.abs(now.x - (n.x + groupBy)) <= GRID / 2 && Math.abs(now.y - (n.y + groupBy)) <= GRID / 2; }), await positions(), ); await undo(); await undo(); check('undo takes both snapped moves back', (await positions()) === before13i, await positions()); // Switching it off has to give the pointer its exact drop position back. await click(magnet.x, magnet.y); await click(corner.x, corner.y); const offPre = await nodeOf('n-axis'); const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); const offBy = await units(50); await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50); const offPost = await nodeOf('n-axis'); check( 'switched off, a node lands where it was dropped again, off the grid', (await json('__gm.graph.snapping()')) === false && !onGrid(offPost) && Math.abs(offPost.x - (offPre.x + offBy)) <= 2 && Math.abs(offPost.y - (offPre.y + offBy)) <= 2, `${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`, ); await undo(); // 6m. Delete acts on the selection, not just the primary node. await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); const beforeDelete = await graphCounts(); await press('Delete'); const afterDelete = await graphCounts(); check( 'Delete removes every selected node, edges with them', afterDelete.nodes === 0 && afterDelete.edges === 0, JSON.stringify(afterDelete), ); await undo(); const restoredAll = await graphCounts(); check( 'one undo brings the whole graph back', restoredAll.nodes === beforeDelete.nodes && restoredAll.edges === beforeDelete.edges, JSON.stringify(restoredAll), ); // 6n. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen // and a click cannot miss one that the earlier pan pushed over the edge. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); const axisRow = await json(`__gm.graph.nodeAt('n-axis')`); await click(axisRow.x, axisRow.y); const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis'); const secondAt = await json(`__gm.graph.nodeAt(${JSON.stringify(second.id)})`); await click(secondAt.x, secondAt.y, 'left', { ctrl: true }); check( 'ctrl-click adds a second node to the selection', JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis', second.id]), JSON.stringify(await json('__gm.graph.selected()')), ); await click(secondAt.x, secondAt.y, 'left', { ctrl: true }); check( 'ctrl-clicking it again removes it', JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis']), JSON.stringify(await json('__gm.graph.selected()')), ); // 6o. Fit really fits: proven against a node parked far outside, and against the fact that the // graph is then wider than the canvas – otherwise centring alone would pass this check. // The distance follows the canvas: a fixed 2500 units needs a zoom below the 0.25 floor once // the canvas is short, and then Fit cannot fit anything however correct it is. const park = Math.round(area.w); await evaluate( `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += ${park}; __gm.state.preview(); })()`, ); await sleep(250); await clickText('Fit'); const rects = []; for (const node of (await asset('mover')).graph.nodes) rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`)); const span = Math.max(...rects.map((r) => r.x + r.w)) - Math.min(...rects.map((r) => r.x)); check( 'the parked node makes the graph wider than the canvas, so fitting has to zoom out', span > area.w * 0.5, `span=${Math.round(span)} canvas=${Math.round(area.w)}`, ); const outside = rects.filter( (r) => r.x < area.x || r.y < area.y || r.x + r.w > area.x + area.w || r.y + r.h > area.y + area.h, ); check( 'Fit leaves no node hanging out of the view', outside.length === 0, `${outside.length} outside: ${JSON.stringify(outside.slice(0, 2))}`, ); await shot('22-graph-fitted'); await evaluate( `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= ${park}; __gm.state.preview(); })()`, ); await clickText('Fit'); check('the parked node is back where it started', (await positions()) === before13i, await positions()); // 6p. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and // comes back while either of its two nodes is selected. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); const allEdges = (await asset('mover')).graph.edges; const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0]; const id = JSON.stringify(edge.id); const drawn = async () => await json(`__gm.graph.edgeDrawn(${id})`); const edgeNow = async () => (await asset('mover')).graph.edges.find((e) => e.id === edge.id); check('the link is drawn as a curve to start with', (await drawn()) === true); const outPort = await json( `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`, ); await click(outPort.x, outPort.y, 'right'); check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`))); await clickText('Hide link'); await select({ kind: 'asset', assetId: 'mover' }); const hidden = await edgeNow(); check( 'hiding changes nothing but a flag – the connection is still there', Boolean(hidden) && hidden.folded === true && hidden.from.node === edge.from.node && hidden.to.node === edge.to.node, JSON.stringify(hidden), ); check('and the curve is gone from the canvas', (await drawn()) === false); const mark = await json(`__gm.graph.foldMark(${id}, true)`); check( 'a stub with a mark takes its place at the port', mark !== null && Math.abs(mark.x - outPort.x) < 40 && Math.abs(mark.y - outPort.y) < 8, JSON.stringify({ mark, outPort }), ); await shot('23-link-hidden'); // Selecting either end lends the curve back – the source first, then the target. const sourceAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.from.node)})`); await click(sourceAt.x, sourceAt.y); check('selecting the node at one end brings the curve back', (await drawn()) === true); const targetAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.to.node)})`); await click(targetAt.x, targetAt.y); check('selecting the node at the other end does too', (await drawn()) === true); await select({ kind: 'asset', assetId: 'mover' }); check('deselecting hides it again', (await drawn()) === false); // The mark is the handle: clicking it selects the node at the far end, which is also what // puts the curve back – so it answers "where does this go?" in one click. const mark2 = await json(`__gm.graph.foldMark(${id}, true)`); await click(mark2.x, mark2.y); check( 'clicking the mark jumps to the node at the far end', JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]), JSON.stringify(await json('__gm.graph.selected()')), ); check('which shows the curve again', (await drawn()) === true); await undo(); await select({ kind: 'asset', assetId: 'mover' }); const shown = await edgeNow(); check( 'undo unhides the link, with nothing left behind', shown.folded === undefined && (await drawn()) === true, JSON.stringify(shown), ); // 6q. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on // the very same pixels – the names smearing over each other. One port, one mark. const before13p = await graphCounts(); const forkPort = await json( `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`, ); // A spot no node covers, measured rather than guessed: after the Fit above, where the nodes // sit on screen is not something this step gets to assume. const canvasArea = await json('__gm.graph.canvasRect()'); const taken = []; for (const node of (await asset('mover')).graph.nodes) taken.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`)); const freeSpots = [ { x: canvasArea.x + 60, y: canvasArea.y + canvasArea.h - 60 }, { x: canvasArea.x + canvasArea.w - 60, y: canvasArea.y + canvasArea.h - 60 }, { x: canvasArea.x + 60, y: canvasArea.y + 60 }, ]; const free = freeSpots.find((p) => taken.every( (r) => !r || p.x < r.x - 10 || p.y < r.y - 10 || p.x > r.x + r.w + 10 || p.y > r.y + r.h + 10, ), ) ?? freeSpots[0]; // The same gesture as 13d3: dropped into empty space the palette offers only what this port can // reach, and the picked node arrives already wired – so no port name has to be guessed here. await mouse('mouseMoved', forkPort.x, forkPort.y); await mouse('mousePressed', forkPort.x, forkPort.y); await mouse('mouseMoved', free.x, free.y, 'left', { buttons: 1 }); await sleep(50); await mouse('mouseReleased', free.x, free.y); await sleep(200); const forkRow = await locate('Clamp'); if (forkRow) await click(forkRow.x, forkRow.y); await sleep(250); const twin = (await state()).selection?.nodeId; const forked = (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ); check( 'a second link now leaves the same port', forked.length === 2 && (await graphCounts()).nodes === before13p.nodes + 1, JSON.stringify({ forked: forked.length, twin, free, row: Boolean(forkRow) }), ); await click(forkPort.x, forkPort.y, 'right'); check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`))); await clickText('Hide 2 links'); await select({ kind: 'asset', assetId: 'mover' }); const ids = forked.map((e) => JSON.stringify(e.id)); const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`); const markB = await json(`__gm.graph.foldMark(${ids[1]}, true)`); check( 'both hidden links share one mark at the port instead of stacking two', markA !== null && markA.x === markB.x && markA.y === markB.y && markA.links === 2, JSON.stringify({ markA, markB }), ); const marks = await json(`__gm.graph.foldMarks()`); check( 'and only three marks are drawn for the two links: one shared, one at each far end', marks.length === 3 && marks.filter((m) => m.links === 2).length === 1, JSON.stringify(marks), ); check( 'the shared mark names one far node and counts the rest', markA.label.endsWith('+1') && markA.label.length > 2, JSON.stringify(markA.label), ); await shot('24-links-bundled'); // The bundled mark is still a handle: one click selects every far end, which is also what // lends all of its curves back at once. await click(markA.x, markA.y); const picked = await json('__gm.graph.selected()'); check( 'clicking a bundled mark selects every node the port feeds', picked.length === 2 && picked.includes(edge.to.node) && picked.includes(twin), JSON.stringify(picked), ); check( 'and both curves come back at once', (await json(`__gm.graph.edgeDrawn(${ids[0]})`)) === true && (await json(`__gm.graph.edgeDrawn(${ids[1]})`)) === true, ); await click(markA.x, markA.y, 'right'); check( 'the mark menu shows both links back in one go', Boolean(await evaluate(`__gm.locate('Show 2 links')`)), ); await clickText('Show 2 links'); await select({ kind: 'asset', assetId: 'mover' }); const unfolded = (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ); check( 'showing them again leaves no fold behind', unfolded.every((e) => e.folded === undefined) && (await json(`__gm.graph.foldMarks()`)).length === 0, JSON.stringify(unfolded), ); // One step of undo takes both back – hiding two links was one command, so it is one undo. await undo(); await select({ kind: 'asset', assetId: 'mover' }); const refolded = (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ); check( 'and undo re-hides both as one step', refolded.length === 2 && refolded.every((e) => e.folded === true), JSON.stringify(refolded.map((e) => e.folded)), ); await undo(); const forkedNow = async () => (await asset('mover')).graph.edges.filter( (e) => e.from.node === edge.from.node && e.from.port === edge.from.port, ).length; const portNow = await json( `__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`, ); await click(portNow.x, portNow.y, 'right'); const offered = await find('Disconnect 2 links'); if (offered) await click(offered.x, offered.y); await sleep(250); const cut = await forkedNow(); await undo(); check( 'disconnecting both links at a port is one undo step', Boolean(offered) && cut === 0 && (await forkedNow()) === 2, JSON.stringify({ offered: Boolean(offered), cut, after: await forkedNow() }), ); // 6r. Collapsing a node: the title bar alone, every link meeting at one point per side. No new // link lands on it until it is expanded, and the state is saved with the project. await select({ kind: 'asset', assetId: 'mover' }); await clickText('Fit'); const edgesBefore13q = (await graphCounts()).edges; const openRect = await json(`__gm.graph.nodeRect('n-move')`); const chevron = await json(`__gm.graph.chevronAt('n-move')`); await click(chevron.x, chevron.y); const shut = await json(`__gm.graph.nodeRect('n-move')`); const zoom = openRect.w / 176; check( 'the chevron collapses the node to its title bar, and leaves the selection alone', (await nodeOf('n-move')).collapsed === true && Math.abs(shut.h - 24 * zoom) < 1 && (await json('__gm.graph.selected()')).length === 0, JSON.stringify({ openRect, shut, collapsed: (await nodeOf('n-move')).collapsed }), ); // Title starts 20 units in and ends 11 before the edge; `locate` matches exactly, so a cut title is null. const titleText = await json(`__gm.locate('Move')`); check( 'a collapsed node narrows to its whole title, keeping its left edge under the chevron', titleText !== null && Math.abs(shut.x - openRect.x) < 1 && shut.w < openRect.w && Math.abs(shut.w - (2 * (titleText.x - shut.x) - 9 * zoom)) < 2, JSON.stringify({ openRect, shut, titleText }), ); const title = await json(`__gm.graph.nodeAt('n-move')`); const anchorOf = (port, out) => json(`__gm.graph.portAt('n-move', '${port}', ${out})`); const inputs = [ await anchorOf('exec', false), await anchorOf('object', false), await anchorOf('dy', false), ]; const output = await anchorOf('exec', true); check( 'every link meets at one point per side, level with the title', inputs.every((p) => Math.abs(p.x - shut.x) < 1 && Math.abs(p.y - title.y) < 1) && Math.abs(output.x - (shut.x + shut.w)) < 1 && Math.abs(output.y - title.y) < 1, JSON.stringify({ inputs, output, title, shut }), ); check( 'the links are still there and still drawn', (await graphCounts()).edges === edgesBefore13q && (await json(`__gm.graph.edgeDrawn('e3')`)) === true, ); await shot('24b-node-collapsed'); const speedOut = await json(`__gm.graph.portAt('n-speed', 'speed', true)`); await mouse('mouseMoved', speedOut.x, speedOut.y); await mouse('mousePressed', speedOut.x, speedOut.y); await mouse('mouseMoved', title.x, title.y, 'left', { buttons: 1 }); await sleep(50); const aimed = await json('__gm.graph.dropTarget()'); await mouse('mouseReleased', title.x, title.y); await sleep(150); await press('Escape'); check( 'a link dropped on a collapsed node goes nowhere', aimed === null && (await graphCounts()).edges === edgesBefore13q, JSON.stringify(aimed), ); for (const port of ['x', 'y']) { const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`); await click(at.x, at.y, 'right'); await clickText('Hide link'); } await select({ kind: 'asset', assetId: 'mover' }); const markDx = await json(`__gm.graph.foldMark('e3', false)`); const markDy = await json(`__gm.graph.foldMark('e4', false)`); check( 'hidden links into a collapsed node share one mark instead of stacking', markDx !== null && markDx.x === markDy.x && markDx.y === markDy.y && markDx.links === 2, JSON.stringify({ markDx, markDy }), ); await undo(); await undo(); await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); await click(title.x, title.y, 'right'); check( 'a mixed selection is offered both ways', Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) && Boolean(await evaluate(`__gm.locate('Expand node', false)`)), ); await press('Escape'); await undo(); check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined); await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); const openTitle = await json(`__gm.graph.nodeAt('n-move')`); await click(openTitle.x, openTitle.y, 'right'); const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`); check('two expanded nodes are offered as one', collapseBoth !== null); if (collapseBoth) await click(collapseBoth.x, collapseBoth.y); else await press('Escape'); await sleep(200); const both = async () => [(await nodeOf('n-speed')).collapsed, (await nodeOf('n-move')).collapsed]; check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]'); await undo(); check( 'and one undo takes all of it back', JSON.stringify(await both()) === '[null,null]', JSON.stringify(await both()), ); // Saved and loaded: a reload, not just the file, because the drawing is what has to come back. await select({ kind: 'asset', assetId: 'mover' }); const again = await json(`__gm.graph.chevronAt('n-move')`); await click(again.x, again.y); const savedMove = async () => { await saved(); const doc = await api(`projects/${projectId}`); return doc.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move'); }; check('the collapse reaches the saved file', (await savedMove()).collapsed === true); await navigate(); await sleep(500); await select({ kind: 'asset', assetId: 'mover' }); await showTab('graph'); const reloaded = await json(`__gm.graph.nodeRect('n-move')`); check( 'and a reloaded project draws it collapsed again', (await nodeOf('n-move')).collapsed === true && Math.abs(reloaded.h / reloaded.w - shut.h / shut.w) < 0.01, JSON.stringify(reloaded), ); const reopen = await json(`__gm.graph.chevronAt('n-move')`); await click(reopen.x, reopen.y); check('expanding leaves no trace in the file', !('collapsed' in (await savedMove()))); // 6z. Ports carrying one value (#207): the output takes the type both inputs agree on, the // inputs stay open for everything, and an edge the new type breaks is drawn as broken. await mutate(` const p = __gm.state.project; p.assets.push({ id: 'picky', type: 'behavior', name: 'Picky', props: { enabled: true, runInEditor: false }, variables: [], graph: { nodes: [ { id: 'p-a', type: 'Number', x: 0, y: 0, params: { value: 1 } }, { id: 'p-b', type: 'Number', x: 0, y: 150, params: { value: 2 } }, { id: 'p-text', type: 'String', x: 0, y: 300, params: { value: 'hi' } }, { id: 'p-pick', type: 'FirstNotEmpty', x: 300, y: 60, params: {} }, { id: 'p-sub', type: 'Substring', x: 600, y: 60, params: {} }, { id: 'p-name', type: 'NameOf', x: 600, y: 300, params: {} }, ], edges: [], }, }); __gm.state.select({ kind: 'asset', assetId: 'picky' }); `); await showTab('graph'); const pickTypes = async () => json(`__gm.graph.portTypes('p-pick')`); check( 'a group with nothing plugged in stays any', (await pickTypes()).outputs.value === 'any', JSON.stringify(await pickTypes()), ); // An untouched input shows the value the node computes with: empty text reads "(empty)", a // number 0, and an object with nothing plugged in "(null)". const untouched = { pick: await json(`__gm.graph.nodeInputs('p-pick')`), sub: await json(`__gm.graph.nodeInputs('p-sub')`), name: await json(`__gm.graph.nodeInputs('p-name')`), }; check( 'untouched inputs show their default beside the name: (empty), 0 and (null) for an object', JSON.stringify(untouched.pick) === JSON.stringify(['a (empty)', 'b (empty)']) && JSON.stringify(untouched.sub) === JSON.stringify(['text (empty)', 'start 0', 'count -1']) && JSON.stringify(untouched.name) === JSON.stringify(['object (null)']), JSON.stringify(untouched), ); const wire = async (fromNode, fromPort, toNode, toPort) => { const from = await json( `__gm.graph.portAt(${JSON.stringify(fromNode)}, ${JSON.stringify(fromPort)}, true)`, ); const to = await json(`__gm.graph.portAt(${JSON.stringify(toNode)}, ${JSON.stringify(toPort)}, false)`); await drag(from.x, from.y, to.x, to.y, 16); }; // Laid while the output is still any, so a number later makes it a text input fed by a number. await wire('p-pick', 'value', 'p-sub', 'text'); const toSub = async () => (await asset('picky')).graph.edges.find((e) => e.to.node === 'p-sub'); const outEdge = await toSub(); const edgeColor = async () => json(`__gm.graph.edgeColor(${JSON.stringify(outEdge?.id ?? '')})`); const danger = await json(`__gm.themeColor('danger')`); check( 'an any output connects to a text input and is drawn as a working link', outEdge !== undefined && (await edgeColor()) !== danger, JSON.stringify({ edge: Boolean(outEdge), color: await edgeColor(), danger }), ); await wire('p-a', 'value', 'p-pick', 'a'); check( 'one input alone says nothing: the other could still become anything', (await pickTypes()).outputs.value === 'any', JSON.stringify(await pickTypes()), ); await wire('p-b', 'value', 'p-pick', 'b'); const typed = await pickTypes(); check( 'with both inputs on a number the output is a number, the inputs stay any', typed.outputs.value === 'number' && typed.inputs.a === 'any' && typed.inputs.b === 'any', JSON.stringify(typed), ); check( 'both input pins are painted as the number that flows in, though their type is any', typed.paint.a === 'number' && typed.paint.b === 'number', JSON.stringify(typed), ); check( 'the edge the new type breaks stays and is drawn in the danger colour', (await toSub()) !== undefined && (await edgeColor()) === danger, JSON.stringify({ color: await edgeColor(), danger }), ); await shot('19-shared-port-types'); // An input that keeps its `any` is one that can be re-plugged without unplugging first. await wire('p-text', 'value', 'p-pick', 'a'); const mixed = await pickTypes(); check( 'a connected input takes another type, and the group opens up again', mixed.outputs.value === 'any' && (await edgeColor()) !== danger, JSON.stringify({ types: mixed, color: await edgeColor() }), ); check( 'inputs fed by different types are painted apart, which is why the output says nothing', mixed.paint.a === 'string' && mixed.paint.b === 'number' && mixed.inputs.a === 'any', JSON.stringify(mixed), ); // Read off the canvas: the colour is on the pin, not only in the definition. const pinPixel = async (port) => { const at = await json(`__gm.graph.portAt('p-pick', ${JSON.stringify(port)}, false)`); return json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`); }; const pinColors = { a: await pinPixel('a'), b: await pinPixel('b') }; const wanted = { a: await json(`__gm.themeColor('portString')`), b: await json(`__gm.themeColor('portNumber')`), }; check( 'the two pins are drawn in those two colours', pinColors.a === wanted.a && pinColors.b === wanted.b, JSON.stringify({ pinColors, wanted }), ); await shot('20-input-pin-colours'); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'picky'); `); // 6z-ii. An asset is a value (#237): Spawn keeps a pick saved as its old setting as the inline // value of its new `asset` input, an Asset literal plugs into that input, and Child by // name shows its two inputs. await mutate(` const p = __gm.state.project; p.assets.push({ id: 'spawny', type: 'behavior', name: 'Spawny', props: { enabled: true, runInEditor: false }, variables: [], graph: { nodes: [ { id: 'sp-spawn', type: 'Spawn', x: 300, y: 0, params: { asset: 'crate' } }, { id: 'sp-lit', type: 'Asset', x: 0, y: 0, params: { value: 'crate' } }, { id: 'sp-child', type: 'ChildByName', x: 300, y: 300, params: {} }, ], edges: [], }, }); __gm.state.select({ kind: 'asset', assetId: 'spawny' }); `); await showTab('graph'); const spawnRows = await json(`__gm.graph.nodeInputs('sp-spawn')`); const childRows = await json(`__gm.graph.nodeInputs('sp-child')`); check( 'Spawn shows the asset saved before as its input; Child by name shows object and name', JSON.stringify(spawnRows) === JSON.stringify(['', 'asset Crate', 'x 0', 'y 0', 'z 0', 'parent (null)']) && JSON.stringify(childRows) === JSON.stringify(['object (null)', 'name (empty)']), JSON.stringify({ spawnRows, childRows }), ); await wire('sp-lit', 'value', 'sp-spawn', 'asset'); const assetEdge = (await asset('spawny')).graph.edges.find((e) => e.to.node === 'sp-spawn'); check( 'an Asset literal connects to the asset input of Spawn', assetEdge?.from.node === 'sp-lit' && assetEdge.to.port === 'asset', JSON.stringify(assetEdge ?? null), ); await shot('21-asset-input'); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'spawny'); `); await lerpNode(); await forEachNode(); await findMatchNodes(); await splitOutputs(); await pluralLookups(); await eventOrder(); await animationPlaying(); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); await showTab('scene'); // 6z-iii. Lerp (#246): typed into the palette it arrives linear with a 0…1 range, and the ease // picked in the inspector lands on the node and on its setting row. async function lerpNode() { await mutate(` const p = __gm.state.project; p.assets.push({ id: 'lerpy', type: 'behavior', name: 'Lerpy', props: { enabled: true, runInEditor: false }, variables: [], graph: { nodes: [], edges: [] }, }); __gm.state.select({ kind: 'asset', assetId: 'lerpy' }); `); await showTab('graph'); const spot = await json('__gm.graph.emptySpot()'); await click(spot.x, spot.y, 'right'); await typeText('lerp'); await press('Enter'); const lerp = (await asset('lerpy')).graph.nodes[0]; const inputs = lerp ? await json(`__gm.graph.nodeInputs(${JSON.stringify(lerp.id)})`) : null; const settings = lerp ? await json(`__gm.graph.nodeSettings(${JSON.stringify(lerp.id)})`) : null; check( 'typing "lerp" into the palette adds a linear Lerp from 0 to 1', lerp?.type === 'Lerp' && JSON.stringify(inputs) === JSON.stringify(['a 0', 'b 1', 't 0']) && JSON.stringify(settings) === JSON.stringify(['Ease: Linear']), JSON.stringify({ lerp, inputs, settings }), ); const easeField = await json(`__gm.inspectorField('Ease')`); if (easeField) { await click(easeField.x, easeField.y); await clickText('Out'); } const picked = lerp ? (await asset('lerpy')).graph.nodes.find((n) => n.id === lerp.id) : null; const pickedRows = lerp ? await json(`__gm.graph.nodeSettings(${JSON.stringify(lerp.id)})`) : null; check( 'picking "Out" in the inspector sets the node and its setting row', picked?.params.ease === 'out' && JSON.stringify(pickedRows) === JSON.stringify(['Ease: Out']), JSON.stringify({ easeField, params: picked?.params, pickedRows }), ); await shot('22-lerp'); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'lerpy'); `); } // 6z-iv. For each (#249): typed into the palette it arrives with its ports, and a break built on // the item finds the first loot named "Gold" – list.FirstOrDefault(x => x.Name == "Gold"). // The body logs the index of every round it runs, done logs index and stopped. async function forEachNode() { const loots = [{ Name: 'Axe' }, { Name: 'Gold' }, { Name: 'Bow' }, { Name: 'Gold' }]; await mutate(` const p = __gm.state.project; p.assets.push({ id: 'eachy', type: 'behavior', name: 'Eachy', props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} }, variables: [{ key: 'loots', label: 'loots', kind: 'list', default: ${JSON.stringify(loots)} }], graph: { nodes: [], edges: [] }, }); __gm.state.select({ kind: 'asset', assetId: 'eachy' }); `); await showTab('graph'); const spot = await json('__gm.graph.emptySpot()'); await click(spot.x, spot.y, 'right'); await typeText('for each'); await press('Enter'); const each = (await asset('eachy')).graph.nodes[0]; const types = each ? await json(`__gm.graph.portTypes(${JSON.stringify(each.id)})`) : null; check( 'typing "for each" into the palette adds For each with list and break in, item, index and stopped out', each?.type === 'ForEach' && JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['exec', 'list', 'break']) && JSON.stringify(Object.keys(types?.outputs ?? {})) === JSON.stringify(['body', 'item', 'index', 'stopped', 'done']), JSON.stringify({ each, types }), ); if (!each) return; const id = JSON.stringify(each.id); await mutate(` const p = __gm.state.project; const behavior = p.assets.find((a) => a.id === 'eachy'); const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) }); const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); behavior.graph.nodes.push( node('fe-start', 'OnStart', -300, 0), node('fe-loots', 'Variables', -300, 160, {}, [{ key: 'loots', kind: 'list' }]), node('fe-expand', 'ExpandStruct', -300, 300, {}, [{ key: 'Name', kind: 'text' }]), node('fe-equals', 'Equals', -80, 300, { b: 'Gold' }), node('fe-body', 'Log', 400, 0), node('fe-done', 'Log', 400, 200), node('fe-join', 'Join', 200, 260), ); behavior.graph.edges.push( edge('fe-e1', 'fe-start', 'exec', ${id}, 'exec'), edge('fe-e2', 'fe-loots', 'loots', ${id}, 'list'), edge('fe-e3', ${id}, 'item', 'fe-expand', 'value'), edge('fe-e4', 'fe-expand', 'Name', 'fe-equals', 'a'), edge('fe-e5', 'fe-equals', 'result', ${id}, 'break'), edge('fe-e6', ${id}, 'body', 'fe-body', 'exec'), edge('fe-e7', ${id}, 'index', 'fe-body', 'message'), edge('fe-e8', ${id}, 'done', 'fe-done', 'exec'), edge('fe-e9', ${id}, 'index', 'fe-join', 'a'), edge('fe-e10', ${id}, 'stopped', 'fe-join', 'b'), edge('fe-e11', 'fe-join', 'text', 'fe-done', 'message'), ); const scene = __gm.state.activeScene; const holder = structuredClone(scene.objects[0]); Object.assign(holder, { id: 'fe-holder', assetId: 'eachy', parentId: '', name: 'Each holder', position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); delete holder.groupId; scene.objects.push(holder); `); await shot('23-for-each'); const logFrom = t.consoleLog.length; await t.play(); await sleep(600); await stop(); const logged = t.consoleLog .slice(logFrom) .filter((line) => line.startsWith('[behavior Each holder]')) .map((line) => line.split(']').pop().trim()); check( 'the loop stops at the first "Gold": the body ran for Axe only, done sees index 1 and stopped', JSON.stringify(logged) === JSON.stringify(['0', '1true']), JSON.stringify(logged), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'eachy'); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'fe-holder'); `); } // 6z-v. Find … Match: typed into the palette, Find arrives with its Match beside it. The test in // between is dragged into the Match with the mouse, which closes the block and frames it. // Run, it finds the first loot named "Gold" and goes on at found with that one. async function findMatchNodes() { const loots = [{ Name: 'Axe' }, { Name: 'Gold' }, { Name: 'Bow' }, { Name: 'Gold' }]; await mutate(` const p = __gm.state.project; p.assets.push({ id: 'findy', type: 'behavior', name: 'Findy', props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} }, variables: [{ key: 'loots', label: 'loots', kind: 'list', default: ${JSON.stringify(loots)} }], graph: { nodes: [], edges: [] }, }); __gm.state.select({ kind: 'asset', assetId: 'findy' }); `); await showTab('graph'); const spot = await json('__gm.graph.emptySpot()'); await click(spot.x, spot.y, 'right'); await typeText('find'); await press('Enter'); const [find, match] = (await asset('findy')).graph.nodes; const findTypes = find ? await json(`__gm.graph.portTypes(${JSON.stringify(find.id)})`) : null; const matchTypes = match ? await json(`__gm.graph.portTypes(${JSON.stringify(match.id)})`) : null; const lonely = find ? await json(`__gm.graph.nodeProblem(${JSON.stringify(find.id)})`) : null; check( 'typing "find" into the palette adds Find with its Match beside it, both ports as planned', find?.type === 'Find' && match?.type === 'Match' && match.x > find.x && match.y === find.y && JSON.stringify(Object.keys(findTypes?.outputs ?? {})) === JSON.stringify(['item', 'index']) && JSON.stringify(Object.keys(matchTypes?.inputs ?? {})) === JSON.stringify(['match']) && JSON.stringify(Object.keys(matchTypes?.outputs ?? {})) === JSON.stringify(['found', 'item', 'index', 'notFound']) && lonely === 'No Match tests its item', JSON.stringify({ find, match, findTypes, matchTypes, lonely }), ); if (!find || !match) return; const f = JSON.stringify(find.id); const m = JSON.stringify(match.id); await mutate(` const p = __gm.state.project; const behavior = p.assets.find((a) => a.id === 'findy'); const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) }); const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); const at = behavior.graph.nodes.find((n) => n.id === ${f}); behavior.graph.nodes.push( node('fm-start', 'OnStart', at.x - 240, at.y - 60), node('fm-loots', 'Variables', at.x - 240, at.y + 80, {}, [{ key: 'loots', kind: 'list' }]), node('fm-expand', 'ExpandStruct', at.x + 200, at.y + 90, {}, [{ key: 'Name', kind: 'text' }]), node('fm-equals', 'Equals', at.x + 200, at.y + 200, { b: 'Gold' }), node('fm-found', 'Log', at.x + 700, at.y - 60), node('fm-name', 'ExpandStruct', at.x + 700, at.y + 120, {}, [{ key: 'Name', kind: 'text' }]), node('fm-join', 'Join', at.x + 900, at.y + 120), ); behavior.graph.edges.push( edge('fm-e1', 'fm-start', 'exec', ${f}, 'exec'), edge('fm-e2', 'fm-loots', 'loots', ${f}, 'list'), edge('fm-e3', ${f}, 'item', 'fm-expand', 'value'), edge('fm-e4', 'fm-expand', 'Name', 'fm-equals', 'a'), edge('fm-e5', ${m}, 'found', 'fm-found', 'exec'), edge('fm-e6', ${m}, 'item', 'fm-name', 'value'), edge('fm-e9', 'fm-name', 'Name', 'fm-join', 'a'), edge('fm-e7', ${m}, 'index', 'fm-join', 'b'), edge('fm-e8', 'fm-join', 'text', 'fm-found', 'message'), ); const scene = __gm.state.activeScene; const holder = structuredClone(scene.objects[0]); Object.assign(holder, { id: 'fm-holder', assetId: 'findy', parentId: '', name: 'Find holder', position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); delete holder.groupId; scene.objects.push(holder); `); // With the console open the graph is short: fit it, or the ports lie under the console. await clickText('Fit'); const framesBefore = await json('__gm.graph.blockFrames()'); const from = await json(`__gm.graph.portAt('fm-equals', 'result', true)`); const to = await json(`__gm.graph.portAt(${m}, 'match', false)`); if (from && to) await drag(from.x, from.y, to.x, to.y, 16); const wired = (await asset('findy')).graph; const frames = await json('__gm.graph.blockFrames()'); const inside = (id) => { const n = wired.nodes.find((node) => node.id === id); const b = frames[0]?.bounds; return !!n && !!b && n.x > b.minX && n.x < b.maxX && n.y > b.minY && n.y < b.maxY; }; const problems = [await json(`__gm.graph.nodeProblem(${f})`), await json(`__gm.graph.nodeProblem(${m})`)]; check( 'dragging the test into match closes the block: one frame around Find, Expand, Equals and Match only', framesBefore.length === 0 && wired.edges.some( (e) => e.from.node === 'fm-equals' && e.to.node === match.id && e.to.port === 'match', ) && frames.length === 1 && [find.id, 'fm-expand', 'fm-equals', match.id].every(inside) && !['fm-start', 'fm-loots', 'fm-found', 'fm-name', 'fm-join'].some(inside) && problems.every((problem) => problem === null), JSON.stringify({ from, to, framesBefore, frames, problems }), ); await shot('24-find-match'); const logFrom = t.consoleLog.length; await t.play(); await sleep(600); await stop(); const logged = t.consoleLog .slice(logFrom) .filter((line) => line.startsWith('[behavior Find holder]')) .map((line) => line.split(']').pop().trim()); check( 'run, the search goes on at found with the first "Gold", index 1', JSON.stringify(logged) === JSON.stringify(['Gold1']), JSON.stringify(logged), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'findy'); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'fm-holder'); `); } // 6z-vi. Split outputs: the item of a Find over a typed list is split with a right-click on its // pin, straight into the fields of the list's type. A field port is dragged into the test, // the Match's item is split as well, and a run logs the amount of the loot found. A right- // click on a field port puts the output together again. async function splitOutputs() { const loots = [ { Name: 'Axe', Amount: 1 }, { Name: 'Gold', Amount: 7 }, ]; await mutate(` const p = __gm.state.project; p.assets.push({ id: 'sp-loot', type: 'struct', name: 'Loot 6', props: { Name: '', Amount: 0 }, variables: [ { key: 'Name', label: 'Name', kind: 'text', default: '' }, { key: 'Amount', label: 'Amount', kind: 'number', default: 0 }, ], }); p.assets.push({ id: 'splity', type: 'behavior', name: 'Splity', props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} }, variables: [ { key: 'loots', label: 'loots', kind: 'list', itemType: 'sp-loot', default: ${JSON.stringify(loots)} }, ], graph: { nodes: [], edges: [] }, }); const behavior = p.assets.find((a) => a.id === 'splity'); const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) }); const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); behavior.graph.nodes.push( node('sp-start', 'OnStart', -240, -60), node('sp-loots', 'Variables', -240, 80, {}, [{ key: 'loots', kind: 'list' }]), node('sp-find', 'Find', 0, 0), node('sp-equals', 'Equals', 240, 120, { b: 'Gold' }), node('sp-match', 'Match', 480, 0), node('sp-log', 'Log', 760, 0), ); behavior.graph.edges.push( edge('sp-e1', 'sp-start', 'exec', 'sp-find', 'exec'), edge('sp-e2', 'sp-loots', 'loots', 'sp-find', 'list'), edge('sp-e3', 'sp-equals', 'result', 'sp-match', 'match'), edge('sp-e4', 'sp-match', 'found', 'sp-log', 'exec'), ); const scene = __gm.state.activeScene; const holder = structuredClone(scene.objects[0]); Object.assign(holder, { id: 'sp-holder', assetId: 'splity', parentId: '', name: 'Split holder', position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); delete holder.groupId; scene.objects.push(holder); __gm.state.select({ kind: 'asset', assetId: 'splity' }); `); await showTab('graph'); await clickText('Fit'); const outputsOf = async (id) => Object.keys((await json(`__gm.graph.portTypes(${JSON.stringify(id)})`))?.outputs ?? {}); const splitPin = async (id, port, entry) => { const pin = await json(`__gm.graph.portAt(${JSON.stringify(id)}, ${JSON.stringify(port)}, true)`); if (!pin) return false; await click(pin.x, pin.y, 'right'); await sleep(150); await clickText(entry); await sleep(150); return true; }; const opened = await splitPin('sp-find', 'item', 'Split into Loot 6 fields'); const types = await json(`__gm.graph.portTypes('sp-find')`); check( 'right-click on the item of a Find over a typed list splits it into the fields of that type', opened && JSON.stringify(Object.keys(types?.outputs ?? {})) === JSON.stringify(['item.Name', 'item.Amount', 'index']) && types.outputs['item.Name'] === 'string' && types.outputs['item.Amount'] === 'number', JSON.stringify({ opened, types }), ); await clickText('Fit'); const from = await json(`__gm.graph.portAt('sp-find', 'item.Name', true)`); const to = await json(`__gm.graph.portAt('sp-equals', 'a', false)`); if (from && to) await drag(from.x, from.y, to.x, to.y, 16); await splitPin('sp-match', 'item', 'Split into Loot 6 fields'); await clickText('Fit'); const amount = await json(`__gm.graph.portAt('sp-match', 'item.Amount', true)`); const message = await json(`__gm.graph.portAt('sp-log', 'message', false)`); if (amount && message) await drag(amount.x, amount.y, message.x, message.y, 16); const wired = (await asset('splity')).graph.edges; await shot('25-split-outputs'); const logFrom = t.consoleLog.length; await t.play(); await sleep(600); await stop(); const logged = t.consoleLog .slice(logFrom) .filter((line) => line.startsWith('[behavior Split holder]')) .map((line) => line.split(']').pop().trim()); check( 'field ports dragged into the test and the log: a run logs the Amount of the loot named "Gold"', wired.some((e) => e.from.port === 'item.Name' && e.to.node === 'sp-equals') && wired.some((e) => e.from.port === 'item.Amount' && e.to.node === 'sp-log') && JSON.stringify(logged) === JSON.stringify(['7']), JSON.stringify({ from, to, amount, message, logged }), ); // Play shows the Game tab; the pin is only there to be clicked with the graph in front again. await showTab('graph'); await clickText('Fit'); await splitPin('sp-find', 'item.Name', 'Recombine item'); const back = await outputsOf('sp-find'); const left = (await asset('splity')).graph.edges; check( 'right-click on a field port recombines the output, the edge on the field goes with it', JSON.stringify(back) === JSON.stringify(['item', 'index']) && !left.some((e) => e.from.node === 'sp-find' && e.from.port.startsWith('item.')), JSON.stringify({ back, left }), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'splity' && a.id !== 'sp-loot'); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'sp-holder'); `); } // 6z-vii. Plural lookups and Find modes (#259): "filter" in the palette adds a Find set to All, // whose Match hands on lists. Count picked in the inspector reshapes the Match and drops // the edge on a list output it no longer has. A run over a small tree answers every new // node: Objects by name, Children by name, Children (direct and recursive), Objects of // asset, and a Filter over the children. async function pluralLookups() { await mutate(` const p = __gm.state.project; p.assets.push({ id: 'plury', type: 'behavior', name: 'Plury', props: { enabled: true, runInEditor: false }, variables: [], graph: { nodes: [], edges: [] }, }); __gm.state.select({ kind: 'asset', assetId: 'plury' }); `); await showTab('graph'); const spot = await json('__gm.graph.emptySpot()'); await click(spot.x, spot.y, 'right'); await typeText('filter'); await press('Enter'); const [find, match] = (await asset('plury')).graph.nodes; const outputsOf = async (id) => Object.keys((await json(`__gm.graph.portTypes(${JSON.stringify(id)})`))?.outputs ?? {}); const settings = find ? await json(`__gm.graph.nodeSettings(${JSON.stringify(find.id)})`) : null; if (!find || !match) { check( 'typing "filter" into the palette adds a Find and its Match', false, JSON.stringify({ find, match }), ); return; } const f = JSON.stringify(find.id); const m = JSON.stringify(match.id); // Only a Match whose test is built from the Find follows its mode: the test is wired first. await mutate(` const p = __gm.state.project; const behavior = p.assets.find((a) => a.id === 'plury'); const at = behavior.graph.nodes.find((n) => n.id === ${m}); const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); behavior.graph.nodes.push( { id: 'pl-test', type: 'Not', x: at.x - 240, y: at.y + 120, params: {} }, { id: 'pl-count', type: 'Count', x: at.x + 260, y: at.y + 60, params: {} }, ); behavior.graph.edges.push( edge('pl-in', ${f}, 'item', 'pl-test', 'value'), edge('pl-out', 'pl-test', 'result', ${m}, 'match'), edge('pl-items', ${m}, 'items', 'pl-count', 'list'), ); `); const listOutputs = await outputsOf(match.id); check( 'typing "filter" into the palette adds a Find set to All; its Match hands on items and indices', find.type === 'Find' && find.params.mode === 'all' && match.type === 'Match' && JSON.stringify(settings) === JSON.stringify(['Mode: All']) && JSON.stringify(listOutputs) === JSON.stringify(['done', 'items', 'indices']), JSON.stringify({ find, match, settings, listOutputs }), ); await select({ kind: 'node', assetId: 'plury', nodeId: find.id }); const modeField = await json(`__gm.inspectorField('Mode')`); if (modeField) { await click(modeField.x, modeField.y); await clickText('Count'); } const reshaped = await asset('plury'); check( 'picking Count in the inspector gives the Match done and count, and drops the edge on items', reshaped.graph.nodes.find((n) => n.id === find.id)?.params.mode === 'count' && JSON.stringify(await outputsOf(match.id)) === JSON.stringify(['done', 'count']) && !reshaped.graph.edges.some((e) => e.id === 'pl-items'), JSON.stringify({ modeField, edges: reshaped.graph.edges }), ); await clickText('Fit'); await shot('26-find-modes'); await mutate(` const p = __gm.state.project; const behavior = p.assets.find((a) => a.id === 'plury'); const sprite = structuredClone(p.assets.find((a) => a.type === 'sprite')); Object.assign(sprite, { id: 'pl-sprite', name: 'Plural sprite' }); p.assets.push(sprite); const node = (id, type, params = {}) => ({ id, type, x: 0, y: 0, params }); const edge = (from, fromPort, to, toPort) => ({ id: from + '>' + to + '.' + toPort, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); behavior.graph = { nodes: [ node('pl-start', 'OnStart'), node('pl-root', 'ObjectByName', { name: 'Crowd 7' }), node('pl-named', 'ObjectsByName', { name: 'Bee' }), node('pl-below', 'ChildrenByName', { name: 'Bee' }), node('pl-direct', 'Children', { recursive: false }), node('pl-all', 'Children', { recursive: true }), node('pl-kind', 'ObjectsOfAsset', { asset: 'pl-sprite' }), node('pl-find', 'Find', { mode: 'all' }), node('pl-name', 'NameOf'), node('pl-bee', 'Equals', { b: 'Bee' }), node('pl-match', 'Match'), ...[1, 2, 3, 4, 5].map((i) => node('pl-n' + i, 'Count')), ...[1, 2, 3, 4, 5, 6].map((i) => node('pl-log' + i, 'Log')), ], edges: [ edge('pl-start', 'exec', 'pl-log1', 'exec'), edge('pl-root', 'object', 'pl-below', 'object'), edge('pl-root', 'object', 'pl-direct', 'object'), edge('pl-root', 'object', 'pl-all', 'object'), edge('pl-named', 'objects', 'pl-n1', 'list'), edge('pl-below', 'children', 'pl-n2', 'list'), edge('pl-direct', 'children', 'pl-n3', 'list'), edge('pl-all', 'children', 'pl-n4', 'list'), edge('pl-kind', 'objects', 'pl-n5', 'list'), ...[1, 2, 3, 4, 5].map((i) => edge('pl-n' + i, 'count', 'pl-log' + i, 'message')), ...[1, 2, 3, 4].map((i) => edge('pl-log' + i, 'exec', 'pl-log' + (i + 1), 'exec')), edge('pl-log5', 'exec', 'pl-find', 'exec'), edge('pl-all', 'children', 'pl-find', 'list'), edge('pl-find', 'item', 'pl-name', 'object'), edge('pl-name', 'name', 'pl-bee', 'a'), edge('pl-bee', 'result', 'pl-match', 'match'), edge('pl-match', 'done', 'pl-log6', 'exec'), edge('pl-match', 'indices', 'pl-log6', 'message'), ] }; const scene = __gm.state.activeScene; const place = (id, assetId, parentId, name) => { const o = structuredClone(scene.objects[0]); Object.assign(o, { id, assetId, parentId, name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); delete o.groupId; scene.objects.push(o); }; place('pl-holder', 'plury', '', 'Plural holder'); place('pl-crowd', 'pl-sprite', '', 'Crowd 7'); place('pl-a', 'pl-sprite', 'pl-crowd', 'Bee'); place('pl-b', 'pl-sprite', 'pl-a', 'Bee'); place('pl-c', 'pl-sprite', 'pl-crowd', 'Ant'); `); const logFrom = t.consoleLog.length; await t.play(); await sleep(600); await stop(); const logged = t.consoleLog .slice(logFrom) .filter((line) => line.startsWith('[behavior Plural holder]')) .map((line) => line.split(']').pop().trim()); // By name 2 · below Crowd 2 · direct children 2 · all below 3 · of the asset 4 · Bees at 0 and 2. check( 'run over Crowd 7 › (Bee › Bee), Ant: every plural lookup and the Filter answer as planned', JSON.stringify(logged) === JSON.stringify(['2', '2', '2', '3', '4', '0, 2']), JSON.stringify(logged), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'plury' && a.id !== 'pl-sprite'); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('pl-')); `); } // 6z-viii. Event order (#264): an event node shows no Order row at 0. Typed into the inspector, the // order lands on the node and on its row, and a run starts the lower order first, even // when its behavior stands lower in the hierarchy. async function eventOrder() { await mutate(` const p = __gm.state.project; const behavior = (id, name, message) => ({ id, type: 'behavior', name, props: { enabled: true, runInEditor: false }, variables: [], graph: { nodes: [ { id: id + '-start', type: 'OnStart', x: 0, y: 0, params: { order: 0 } }, { id: id + '-log', type: 'Log', x: 260, y: 0, params: { message } }, ], edges: [{ id: id + '-e', from: { node: id + '-start', port: 'exec' }, to: { node: id + '-log', port: 'exec' } }], }, }); p.assets.push(behavior('ord-a', 'Order A', 'A'), behavior('ord-b', 'Order B', 'B')); const scene = __gm.state.activeScene; for (const [id, assetId, name] of [['ord-first', 'ord-a', 'Order first'], ['ord-second', 'ord-b', 'Order second']]) { const o = structuredClone(scene.objects[0]); Object.assign(o, { id, assetId, parentId: '', name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); delete o.groupId; scene.objects.push(o); } __gm.state.select({ kind: 'asset', assetId: 'ord-b' }); `); await showTab('graph'); await clickText('Fit'); const atRest = await json(`__gm.graph.nodeSettings('ord-b-start')`); await select({ kind: 'node', assetId: 'ord-b', nodeId: 'ord-b-start' }); const orderField = await json(`__gm.inspectorField('Order')`); if (orderField) { await click(orderField.x, orderField.y); await typeText('-1'); await press('Enter'); } const params = (await asset('ord-b')).graph.nodes.find((n) => n.id === 'ord-b-start')?.params; const shown = await json(`__gm.graph.nodeSettings('ord-b-start')`); check( 'an event node shows no Order row at 0; -1 typed into the inspector lands on the node and its row', JSON.stringify(atRest) === '[]' && params?.order === -1 && JSON.stringify(shown) === JSON.stringify(['Order: -1']), JSON.stringify({ atRest, orderField, params, shown }), ); await shot('27-event-order'); const logFrom = t.consoleLog.length; await t.play(); await sleep(600); await stop(); const logged = t.consoleLog .slice(logFrom) .filter((line) => line.startsWith('[behavior Order ')) .map((line) => line.split(']').pop().trim()); check( 'a run starts the behavior with Order -1 first, though it stands below the other one', JSON.stringify(logged) === JSON.stringify(['B', 'A']), JSON.stringify(logged), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'ord-a' && a.id !== 'ord-b'); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('ord-')); `); } // 6z-ix. Animation playing (#233): typed into the palette it arrives with its ports. In a run it // says true in the very pulse that plays a once animation, and false when that one finished. async function animationPlaying() { await mutate(` const p = __gm.state.project; const sprite = p.assets.find((a) => a.type === 'sprite'); p.assets.push( { id: 'ap-swing', type: 'animation', name: 'Swing 6', props: { fps: 10, playback: 'once' }, root: sprite.id, tracks: [{ id: 'ap-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 2, value: 20 }] }], }, { id: 'apy', type: 'behavior', name: 'Asker', props: { enabled: true, runInEditor: false }, variables: [], graph: { nodes: [], edges: [] }, }, ); __gm.state.select({ kind: 'asset', assetId: 'apy' }); `); await showTab('graph'); const spot = await json('__gm.graph.emptySpot()'); await click(spot.x, spot.y, 'right'); await typeText('animation playing'); await press('Enter'); const asker = (await asset('apy')).graph.nodes[0]; const types = asker ? await json(`__gm.graph.portTypes(${JSON.stringify(asker.id)})`) : null; const inputs = asker ? await json(`__gm.graph.nodeInputs(${JSON.stringify(asker.id)})`) : null; check( 'typing "animation playing" into the palette adds the node: object and animation in, three answers out', asker?.type === 'AnimationPlaying' && JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['object', 'animation']) && JSON.stringify(types?.outputs) === JSON.stringify({ playing: 'boolean', progress: 'number', timeLeft: 'number' }) && inputs?.[1] === 'animation None', JSON.stringify({ asker, types, inputs }), ); if (!asker) return; const id = JSON.stringify(asker.id); await mutate(` const p = __gm.state.project; const behavior = p.assets.find((a) => a.id === 'apy'); behavior.graph.nodes.find((n) => n.id === ${id}).params.animation = 'ap-swing'; const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params }); const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); behavior.graph.nodes.push( node('ap-start', 'OnStart', -300, -200), node('ap-owner', 'Owner', -560, 0), node('ap-play', 'PlayAnimation', -40, -200, { animation: 'ap-swing', blend: 0, blendOut: 0, restart: false }), node('ap-said', 'Log', 420, -200), node('ap-finished', 'OnAnimationFinished', 0, 260, { animation: 'ap-swing', order: 0 }), node('ap-after', 'Log', 420, 260), ); behavior.graph.edges.push( edge('ap-e1', 'ap-start', 'exec', 'ap-play', 'exec'), edge('ap-e2', 'ap-owner', 'object', 'ap-play', 'object'), edge('ap-e3', 'ap-owner', 'object', ${id}, 'object'), edge('ap-e4', 'ap-play', 'exec', 'ap-said', 'exec'), edge('ap-e5', ${id}, 'playing', 'ap-said', 'message'), edge('ap-e6', 'ap-finished', 'exec', 'ap-after', 'exec'), edge('ap-e7', ${id}, 'playing', 'ap-after', 'message'), ); const scene = __gm.state.activeScene; const sprite = p.assets.find((a) => a.type === 'sprite'); for (const [id, assetId, parentId, name] of [ ['ap-target', sprite.id, '', 'Swinger'], ['ap-holder', 'apy', 'ap-target', 'Ask holder'], ]) { const o = structuredClone(scene.objects[0]); Object.assign(o, { id, assetId, parentId, name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); delete o.groupId; scene.objects.push(o); } `); await clickText('Fit'); await shot('28-animation-playing'); const logFrom = t.consoleLog.length; await t.play(); await sleep(900); await stop(); const logged = t.consoleLog .slice(logFrom) .filter((line) => line.startsWith('[behavior Ask holder]')) .map((line) => line.split(']').pop().trim()); check( 'a run hears true right after Play animation, and false once the animation finished', JSON.stringify(logged) === JSON.stringify(['true', 'false']), JSON.stringify(logged), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'apy' && a.id !== 'ap-swing'); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('ap-')); `); } }