import { edge, node, port, variable, written } from '../lib/builders.mjs'; import { MAKE_OBJECT } from './lib/scaffold.mjs'; /** * The Variables node: its ports are the behavior's variables, each read at any time and written * where it is switched to Set. The step checks the run, picking a port from the variable list, * switching Set, and pointing an existing port at another variable without losing its edge. * * The step brings its own box and behavior; everything it adds is removed at the end. */ const SCALE = 100; /** * `On update → Variables{set vx = 2, vy = 3} → Move(owner, dx = vx, dy = vy)`, where the two * numbers reach Move through the *variables* – one node writes both, one node reads both back. * Neither speed is on the Move node, so a node that dropped a port would leave the box still. */ const logicGraph = { nodes: [ node('vp-update', 'OnUpdate', 0, 0), node('vp-owner', 'Owner', 0, 300), node('vp-set', 'Variables', 260, 0, { vx: 2, vy: 3 }, [written('vx', 'number'), written('vy', 'number')]), node('vp-get', 'Variables', 260, 180, {}, [port('vx', 'number'), port('vy', 'number')]), node('vp-move', 'Move', 560, 0, { scale: SCALE }), ], edges: [ edge('vp-e1', 'vp-update', 'exec', 'vp-set', 'exec'), edge('vp-e2', 'vp-set', 'exec', 'vp-move', 'exec'), edge('vp-e3', 'vp-get', 'vx', 'vp-move', 'dx'), edge('vp-e4', 'vp-get', 'vy', 'vp-move', 'dy'), edge('vp-e5', 'vp-update', 'dt', 'vp-move', 'dt'), edge('vp-e6', 'vp-owner', 'object', 'vp-move', 'object'), ], }; export async function variableNodes(t) { const { check, sleep, json, shot, click, locate, undo, entity, asset, mutate, stop, typeText, press, has } = t; const portAt = (nodeId, key, isOutput) => json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); const portsOf = async (nodeId) => (await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []; const select = async (nodeId) => { await t.select({ kind: 'node', assetId: 'vp-logic', nodeId, nodeIds: [nodeId] }); }; /** The inspector is drawn over the graph, so the last match is the row rather than the port. */ const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); }; /** The Set checkbox of the newest variable row: the control right of the last "Set" label. */ const clickLastSet = async () => { const box = await json(`__gm.inspectorField('Set', true)`); await click(box.x, box.y); return box; }; // 23a. A box wearing a behavior whose two speeds travel through its variables. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const mover = p.assets.find((a) => a.id === 'mover'); const a = structuredClone(mover); Object.assign(a, { id: 'vp-logic', name: 'Speeds', props: { enabled: true, runInEditor: false, vx: 0, vy: 0, vz: 0 }, variables: ${JSON.stringify([variable('vx', 'number', 0), variable('vy', 'number', 0), variable('vz', 'number', 0)])}, graph: ${JSON.stringify(logicGraph)}, }); p.assets.unshift(a); ${MAKE_OBJECT} scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds')); `); // 23b. The run: both variables are written by one node and read back by another, and the two // speeds stay apart – 2 to the right for every 3 down. await t.play(); const before = await entity('vp-box'); await sleep(1000); const after = await entity('vp-box'); const dx = after.x - before.x; const dy = after.y - before.y; check( 'one node writes both variables, another reads both back, and the run uses them', dx > SCALE * 1.4 && dx < SCALE * 2.6 && dy > SCALE * 2.4 && dy < SCALE * 3.6, `moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`, ); await stop(); // 23c. Every variable is an output; a written one is an input too, and only a writing node has // flow pins. await select('vp-set'); const pins = { setIn: Boolean(await portAt('vp-set', 'vx', false)), setOut: Boolean(await portAt('vp-set', 'vx', true)), getOut: Boolean(await portAt('vp-get', 'vx', true)), getIn: Boolean(await portAt('vp-get', 'vx', false)), getFlow: Boolean(await portAt('vp-get', 'exec', false)), }; check( 'a written variable is an input and an output, a read one only an output, without flow pins', pins.setIn && pins.setOut && pins.getOut && !pins.getIn && !pins.getFlow, JSON.stringify(pins), ); // 23d. The inspector picks a port from the behavior's variables instead of having it typed, and // a picked one is read until it is switched to Set. const add = await locate('+ Variable'); check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add)); await shot('01-variable-ports'); await click(add.x, add.y); await clickLast('vz'); const grown = await portsOf('vp-set'); check( 'picking one adds it as a read port, typed from the variable', grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number' && !grown[2].write, JSON.stringify(grown), ); check( 'and the node grew that output only', Boolean(await portAt('vp-set', 'vz', true)) && !(await portAt('vp-set', 'vz', false)), ); await clickLastSet(); check( 'switching it to Set gives it an input as well', (await portsOf('vp-set'))[2]?.write === true && Boolean(await portAt('vp-set', 'vz', false)), JSON.stringify(await portsOf('vp-set')), ); const drawn = await json(`__gm.graph.nodeInputs('vp-set')`); check( 'with nothing typed the node shows the value it writes beside the name, as the inspector does', drawn?.includes('vz 0') && drawn.includes('vx 2'), JSON.stringify(drawn), ); await shot('02-variable-set'); await undo(); await undo(); check('undo takes both back', (await portsOf('vp-set')).length === 2); // 23e. Pointing a port at another variable keeps the edge hanging on it – a correction, not a // rewiring. `vp-e3` runs from this port to Move's dx. await select('vp-get'); await clickLast('vx'); await clickLast('vz'); const swapped = await portsOf('vp-get'); const edgeStill = await json( `(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`, ); check( 'a port pointed at another variable keeps its edge', swapped[0].key === 'vz' && edgeStill.port === 'vz', JSON.stringify({ swapped, edgeStill }), ); await undo(); check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx'); // 23f. Collapsed, the ports are out of sight, so the title says what the node does – and only then. const titleOf = (id) => json(`__gm.graph.nodeLabel(${JSON.stringify(id)})`); const toggle = async (id) => { const chevron = await json(`__gm.graph.chevronAt(${JSON.stringify(id)})`); await click(chevron.x, chevron.y); }; /** `locate` matches exactly, so a title cut short by a too-narrow node is not found. */ const drawnWhole = async (text) => { const canvas = await json('__gm.graph.canvasRect()'); const hits = await json(`__gm.locateAll(${JSON.stringify(text)})`); return hits.some((p) => p.x > canvas.x && p.x < canvas.x + canvas.w); }; await toggle('vp-set'); await toggle('vp-get'); const shut = { set: await titleOf('vp-set'), get: await titleOf('vp-get') }; check( 'collapsed, a node writing two variables says so, and one only reading two says that', shut.set === 'Set 2 variables' && shut.get === 'Get 2 variables' && (await drawnWhole('Set 2 variables')) && (await drawnWhole('Get 2 variables')), JSON.stringify(shut), ); await shot('02b-collapsed-titles'); const narrow = (await json(`__gm.graph.nodeRect('vp-get')`)).w; await clickLastSet(); const switched = await titleOf('vp-get'); check( 'switching one to Set while collapsed retitles it and resizes it to the new title', switched === 'Set vy' && (await drawnWhole('Set vy')) && (await json(`__gm.graph.nodeRect('vp-get')`)).w < narrow, JSON.stringify({ switched, narrow }), ); await undo(); await toggle('vp-set'); await toggle('vp-get'); const open = [await titleOf('vp-set'), await titleOf('vp-get')]; check( 'expanded again, both are plain Variables nodes', open.join() === 'Variables,Variables', JSON.stringify(open), ); // 23g. A Pack keeps declaring its own fields, but a variable that already carries the shape can // hand it over. A color field arrives as a number port – the graph has no colors. await mutate(` const a = __gm.state.project.assets.find((x) => x.id === 'vp-logic'); a.variables.push( ${JSON.stringify( variable('piece', 'struct', { shape: 0, tint: 0, cells: [] }, [ { key: 'shape', kind: 'number' }, { key: 'tint', kind: 'color' }, { key: 'cells', kind: 'list' }, ]), )}, ${JSON.stringify(variable('shapes', 'list', [{ x: 0, y: 0 }]))}, ); a.props.piece = { shape: 0, tint: 0, cells: [] }; a.props.shapes = [{ x: 0, y: 0 }]; a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))}); `); await select('vp-pack'); const from = await locate('From variable'); check('a Pack offers the shapes its behavior declares', Boolean(from), JSON.stringify(from)); await click(from.x, from.y); await shot('03-from-variable'); await clickLast('piece'); const seeded = await portsOf('vp-pack'); check( 'picking a struct variable declares every field at once, each of its own kind', JSON.stringify(seeded) === '[{"key":"shape","kind":"number"},{"key":"tint","kind":"color"},{"key":"cells","kind":"list"}]', JSON.stringify(seeded), ); check('and the node grew those inputs', Boolean(await portAt('vp-pack', 'cells', false))); await shot('04-fields-seeded'); await undo(); check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0); // 23h. A list declares nothing, so the shape comes off the struct items in it – the bundle a Pack // builds for such a list. await click(from.x, from.y); await clickLast('shapes (item)'); const fromItem = await portsOf('vp-pack'); check( 'a list offers the shape of the struct items it holds', fromItem.map((p) => p.key).join(',') === 'x,y', JSON.stringify(fromItem), ); await undo(); // 23i. A behavior's own inputs are not fields of a bundle, so the helper stays out of that section. await mutate(` __gm.state.project.assets.find((x) => x.id === 'vp-logic').graph.nodes.push( ${JSON.stringify(node('vp-start', 'BehaviorStart', 560, 600))}, ); `); await select('vp-start'); // `locate` throws when the text is absent, which is the wrong shape for "is it there?". const helper = await json(`__gm.locate('From variable', false)`); check( 'a behavior start declares inputs, not fields, and is not offered a shape', Boolean(await locate('+ Input')) && helper === null, JSON.stringify(helper), ); // 23j. From the palette the node comes empty; picking a variable keeps its title, and switched to Set // it runs `vz = vz + 1` once a frame – its own output feeding its input via Add. const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id); const idsBefore = await nodeIds(); // Bare canvas below every node, so the click cannot land on the Pack. const bareSpot = await json('__gm.graph.emptySpot()'); await click(bareSpot.x, bareSpot.y, 'right'); // Enter takes the first hit in category order, so search by a phrase only its description has. await typeText('switched to Set'); await press('Enter'); const added = (await asset('vp-logic')).graph.nodes.find((n) => !idsBefore.includes(n.id)); check('the palette offers the Variables node', added?.type === 'Variables', JSON.stringify(added)); const variableId = added?.id ?? ''; const untitled = await titleOf(variableId); const bare = !(await portAt(variableId, 'exec', false)); await clickLast('+ Variable'); await clickLast('vz'); await clickLastSet(); check( 'it starts without pins, and picking its variable leaves the title Variables', untitled === 'Variables' && bare && (await titleOf(variableId)) === 'Variables', JSON.stringify({ untitled, bare, titled: await titleOf(variableId) }), ); check('switched to Set it grows its flow pins', Boolean(await portAt(variableId, 'exec', false))); await shot('05-variable-node'); await mutate(` const g = __gm.state.project.assets.find((x) => x.id === 'vp-logic').graph; g.nodes.push( ${JSON.stringify(node('vp-add', 'Add', 800, 200, { b: 1 }))}, ${JSON.stringify(node('vp-gone', 'Variables', 800, 500, {}, [port('gone', 'number')]))}, ); g.edges.push( ${JSON.stringify(edge('vp-e7', 'vp-move', 'exec', variableId, 'exec'))}, ${JSON.stringify(edge('vp-e8', variableId, 'vz', 'vp-add', 'a'))}, ${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'vz'))}, ); `); await select('vp-gone'); check( 'a port naming a variable the behavior does not have is marked in the inspector', await has('"gone" is not a variable of this behavior.'), ); await t.play(); await sleep(1000); const counted = (await entity('vp-brain'))?.props.vz; await stop(); check( 'in the run it reads through its own output and writes the sum back, so vz climbs', typeof counted === 'number' && counted > 20, `vz = ${counted} after ~1 s`, ); // 23k. An object variable: declared from the menu like any other, but with nothing to type – it is // set by a graph while the game runs, so the row says (null) and the file keeps only the // empty object's place. await t.select({ kind: 'asset', assetId: 'vp-logic' }); await clickLast('+ Variable'); await clickLast('Object'); const declared = (await asset('vp-logic')).variables.at(-1); check( 'an Object variable is declared from the menu and stores nothing as its value', declared?.kind === 'object' && declared.default?.$object === '' && (await has('(null)')), JSON.stringify(declared), ); await shot('06-object-variable'); await undo(); check( 'undo takes it back', (await asset('vp-logic')).variables.every((v) => v.kind !== 'object'), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'vp-logic'); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'vp-box' && o.id !== 'vp-brain'); __gm.state.select(null); __gm.state.preview(); `); const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length'); check('the step leaves the fixture as it found it', left === 0, String(left)); }