Add the Variable node and show the variable name in node titles

Get/Set variable nodes now title themselves "Get <name>" / "Set <name>", and
the new Variable node just "<name>"; the title is the same folded or open,
and a collapsed node shrinks to it. The redundant "Variable: <name>" row is
gone unless the variable was deleted ("<name>?" is the only hint then).

The Variable node sets its value when an exec flow arrives and exposes the
value at any time. Its output is read when a downstream node pulls it, not
frozen at the write, so count = count + 1 is one Variable node plus an Add.
Get variable keeps reading once per pulse: switching it would change the
behaviour of existing games.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 13:34:09 +02:00
1 parent 5f8a308d62
commit e9fa592078
19 files changed
+354 -38

No files matched your search

+10 -5
View File
@@ -99,8 +99,12 @@ shown on the node and editable in the inspector.
- **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they
appear in the inspector, can be overridden per placed instance, and each running instance
gets its own mutable copy. `Get variable` / `Set variable` read and write them, and their plurals
`Get variables` / `Set variables` do the same for several at once: their settings are a *list* of
gets its own mutable copy. `Get variable` / `Set variable` read and write them, and are titled
by the variable they name (*Get speed*, *Set speed*). `Variable` is both in one node, titled by the
variable alone: its output can be read at any time – no flow has to reach it – and is read at the
moment it is asked, so a read after a write sees the new value; an arriving flow writes its input.
`count = count + 1` is one `Variable` node feeding its own output through an `Add` back into its
input. Their plurals `Get variables` / `Set variables` do the same for several at once: their settings are a *list* of
variables, one port per entry, so reading a handful is one node instead of a column of five. Those
ports are picked from the behavior's variables rather than named, they take their type from the
declaration, and renaming a variable carries them (and their edges) along. A variable is a
@@ -149,7 +153,7 @@ shown on the node and editable in the inspector.
- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`,
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
variable`, `Get/Set variables`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
variable`, `Variable`, `Get/Set variables`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`,
`Substring`, `Find text`, `Equals`,
lists `List` (a constant), `Fill` (one value repeated – how an empty board is made), `Count`,
@@ -268,7 +272,8 @@ shown on the node and editable in the inspector.
- **A node can be collapsed** to its title bar: the chevron in front of the title (it leaves the
selection alone, like the hierarchy's), or right-click → *Collapse node* / *Expand node*, which
acts on the whole selection as one undo step. Every link of one side then meets at a single
point on the title bar, and its hidden links share one mark per side. A collapsed node has no
point on the title bar, and its hidden links share one mark per side. It narrows to its title
(never wider than an open node, where a long title is cut). A collapsed node has no
ports to grab, so a link dropped on it goes nowhere – expand it to wire it. Like hiding a link,
this is drawing only: `collapsed` on the node is saved with the project.
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
@@ -644,7 +649,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 537 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 542 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
+5 -1
View File
@@ -96,7 +96,7 @@ export class GraphInterpreter {
const inputs = this.collectInputs(node, ctx);
const result = def.run(ctx, inputs, node.params, def);
const returned = result.call ? this.call(result.call, ctx, depth) : {};
this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
if (!def.read) this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
this.trace?.onNode(node.id);
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
@@ -167,6 +167,10 @@ export class GraphInterpreter {
if (!source) return null;
const def = this.def(source);
if (!def) return null;
if (def.read) {
this.trace?.onNode(nodeId);
return def.read(ctx, source.params)[port] ?? null;
}
// Exec nodes that have not run in this pulse have no value yet.
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
if (++this.steps > MAX_STEPS_PER_FIRE)
+5
View File
@@ -81,6 +81,11 @@ export interface NodeDef {
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
*/
derivePorts?(source: PortSource): DerivedShape;
/**
* Outputs readable at any moment, even while the node's exec input has not fired in this pulse.
* Never cached, so a read after a write sees the new value.
*/
read?(ctx: NodeContext, params: PropertyBag): Record<string, Value>;
/**
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
* hand, the only way a node with derived ports knows how many it has.
+2
View File
@@ -85,6 +85,7 @@ import { GetVariable } from './variable/GetVariable';
import { GetVariables } from './variable/GetVariables';
import { SetVariable } from './variable/SetVariable';
import { SetVariables } from './variable/SetVariables';
import { Variable } from './variable/Variable';
/**
* Importing this module registers the built-in nodes. The palette sorts by category and label, so
@@ -110,6 +111,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
RunBehavior,
BehaviorEnd,
// variables
Variable,
GetVariable,
GetVariables,
SetVariable,
+29 -3
View File
@@ -1,7 +1,8 @@
import type { PropertyValue } from '../../model/Properties';
import { type PropertyBag, type PropertyValue, textOf } from '../../model/Properties';
import type { PropertyDef } from '../../properties/PropertySchema';
import type { NodeDef, PortDef } from '../NodeRegistry';
import { asBoolean, asNumber } from '../values';
import type { NodeContext } from '../NodeContext';
import type { DerivedShape, NodeDef, PortDef, PortSource, RunResult } from '../NodeRegistry';
import { asBoolean, asNumber, type Value, writeTyped } from '../values';
export const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
export const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
@@ -33,6 +34,31 @@ export const variableParam: PropertyDef = {
options: [],
};
const variableKey = (params: PropertyBag) => textOf(params.variable ?? '');
export const readVariable = (ctx: NodeContext, params: PropertyBag): Record<string, Value> => ({
value: ctx.variables[variableKey(params)] ?? null,
});
export function writeVariable(
ctx: NodeContext,
inputs: Record<string, Value>,
params: PropertyBag,
): RunResult {
const key = variableKey(params);
if (key) writeTyped(ctx.variables, key, inputs.value);
return { next: 'exec' };
}
/** Once a variable is picked, the title names it: "Get speed", or just "speed". */
export const variableTitle =
(verb: string) =>
({ node }: PortSource): DerivedShape => {
const name = variableKey(node.params);
if (!name) return {};
return { label: verb ? `${verb} ${name}` : name };
};
/** No `assetTypes`: any placeable asset is a valid answer here. */
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
+3 -5
View File
@@ -1,7 +1,6 @@
import type { NodeDef } from '../../NodeRegistry';
import { any, variableParam } from '../shared';
import { any, readVariable, variableParam, variableTitle } from '../shared';
import { textOf } from '../../../model/Properties';
export const GetVariable: NodeDef = {
type: 'GetVariable',
label: 'Get variable',
@@ -10,7 +9,6 @@ export const GetVariable: NodeDef = {
inputs: [],
outputs: [any('value')],
params: [variableParam],
run: (ctx, _inputs, params) => ({
outputs: { value: ctx.variables[textOf(params.variable ?? '')] ?? null },
}),
derivePorts: variableTitle('Get'),
run: (ctx, _inputs, params) => ({ outputs: readVariable(ctx, params) }),
};
+3 -8
View File
@@ -1,8 +1,6 @@
import type { NodeDef } from '../../NodeRegistry';
import { writeTyped } from '../../values';
import { any, exec, variableParam } from '../shared';
import { any, exec, variableParam, variableTitle, writeVariable } from '../shared';
import { textOf } from '../../../model/Properties';
export const SetVariable: NodeDef = {
type: 'SetVariable',
label: 'Set variable',
@@ -11,9 +9,6 @@ export const SetVariable: NodeDef = {
inputs: [exec(), any('value')],
outputs: [exec()],
params: [variableParam],
run: (ctx, inputs, params) => {
const key = textOf(params.variable ?? '');
if (key) writeTyped(ctx.variables, key, inputs.value);
return { next: 'exec' };
},
derivePorts: variableTitle('Set'),
run: writeVariable,
};
+18
View File
@@ -0,0 +1,18 @@
import type { NodeDef } from '../../NodeRegistry';
import { any, exec, readVariable, variableParam, variableTitle, writeVariable } from '../shared';
/** Get and Set in one node: the output can be read at any time, an arriving flow writes the input. */
export const Variable: NodeDef = {
type: 'Variable',
label: 'Variable',
category: 'variable',
description:
"Reads one of this behavior's variables at any time, and writes it when the flow arrives. " +
'A read after the write sees the new value.',
inputs: [exec(), any('value')],
outputs: [exec(), any('value')],
params: [variableParam],
derivePorts: variableTitle(''),
read: readVariable,
run: writeVariable,
};
+2 -2
View File
@@ -56,10 +56,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
* *minimum* width, so a label wider than the column pushes its control right – which is why a
* fixed offset from the label's own text is the wrong way to click one.
*/
const fieldOf = (label: string): { x: number; y: number; left: number } | null => {
const fieldOf = (label: string, last = false): { x: number; y: number; left: number } | null => {
let found: Text | null = null;
const walk = (node: Container): void => {
if (found !== null || !node.visible) return;
if ((found !== null && !last) || !node.visible) return;
if (node instanceof Text && node.text === label) {
found = node;
return;
+21 -8
View File
@@ -1,4 +1,4 @@
import { Graphics, Rectangle, Text } from 'pixi.js';
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import {
asString,
edgesInto,
@@ -8,6 +8,7 @@ import {
inlineValue,
type NodeCategory,
type NodeDef,
nodeDef,
type PortDef,
type PortType,
type Value,
@@ -19,6 +20,7 @@ import { assetRefLabel } from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
import { collapsedNodeWidth } from './folding';
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
export const NODE_WIDTH = 176;
@@ -30,6 +32,9 @@ export const PORT_HIT_RADIUS = 9;
const PAD = 6;
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
const CHEVRON_SLOT = 14;
const TITLE_X = PAD + CHEVRON_SLOT;
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
export function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
@@ -126,15 +131,17 @@ export class NodeView extends Widget {
this.def = def;
this.problem = problem;
const hidden = new Set(this.def.hiddenParams?.(node.params) ?? []);
const title = this.def.label;
const derived = title !== nodeDef(node.type)?.label;
this.paramLines = this.def.params
.filter((p) => !hidden.has(p.key))
.map((p) => ({
label: p.label,
value: formatParam(p, node.params[p.key] ?? p.default, asset, project),
}))
// A setting the title already states is not worth a row: a nested-behavior node is titled
// with the behavior it runs, so "Behavior: Double" under "Double" says nothing new.
.filter((p) => p.value !== this.def.label)
// A title made from a setting states it ("Behavior: Double" under "Double", "Variable: speed"
// under "Get speed"). A dangling reference keeps its row: "speed?" is not in the title.
.filter((p) => !derived || (p.value !== title && !title.endsWith(` ${p.value}`)))
.map((p) => `${p.label}: ${p.value}`);
this.connected = new Set(
inlineInputs(this.def)
@@ -146,17 +153,23 @@ export class NodeView extends Widget {
outputs: graph.edges.some((e) => e.from.node === node.id),
};
const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
// Sized before the texts: output labels are placed against the right edge.
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
}
private collapsedWidth(): number {
const title = CanvasTextMetrics.measureText(this.def.label, textStyle('accent')).width;
return collapsedNodeWidth(title, TITLE_X + COLLAPSED_TITLE_END, NODE_WIDTH);
}
get collapsed(): boolean {
return this.node.collapsed === true;
}
chevronAt(localX: number, localY: number): boolean {
return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < PAD + CHEVRON_SLOT;
return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < TITLE_X;
}
chevronGlobal(): { x: number; y: number } {
@@ -233,10 +246,10 @@ export class NodeView extends Widget {
add(
this.def.label,
'accent',
PAD + CHEVRON_SLOT,
TITLE_X,
(TITLE_HEIGHT - 16) / 2,
'left',
NODE_WIDTH - PAD * 2 - CHEVRON_SLOT,
this.w - TITLE_X - (this.collapsed ? COLLAPSED_TITLE_END : PAD),
);
if (this.collapsed) return;
let y = TITLE_HEIGHT + PAD;
+5
View File
@@ -21,6 +21,11 @@ export function foldMarkKey(
return `${side.node}\0${collapsed ? '' : side.port}\0${isOutput ? 'out' : 'in'}`;
}
/** A collapsed node is as wide as its title needs, but never wider than an open one. */
export function collapsedNodeWidth(titleWidth: number, chrome: number, openWidth: number): number {
return Math.min(openWidth, Math.ceil(titleWidth + chrome));
}
/** One name plus a count: three names do not fit, and the count is what says "there is more here". */
export function foldMarkLabel(names: readonly string[]): string {
if (names.length === 0) return '';
+11 -1
View File
@@ -4,7 +4,7 @@ import { CollapseNodesCommand } from '../../src/core/commands/graphCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { foldMarkKey } from '../../src/editor/graph/folding';
import { collapsedNodeWidth, foldMarkKey } from '../../src/editor/graph/folding';
import { graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
function threeNodes() {
@@ -61,6 +61,16 @@ describe('collapsing nodes', () => {
});
});
describe('the width of a collapsed node', () => {
it('fits the title plus its chrome, rounded up so the title is not cut', () => {
expect(collapsedNodeWidth(40.2, 31, 176)).toBe(72);
});
it('never grows past an open node – a long title is cut instead', () => {
expect(collapsedNodeWidth(300, 31, 176)).toBe(176);
});
});
describe('hidden links at a collapsed node', () => {
it('share one mark per side, because every port sits on the same point', () => {
const a = foldMarkKey({ node: 'n', port: 'x' }, false, true);
+16
View File
@@ -150,6 +150,22 @@ const CASES: Record<string, Case[]> = {
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
// Reading without a flow needs the interpreter: see variable-node.test.ts.
Variable: [
{
name: 'writes its variable when the flow arrives, keeping the declared type',
inputs: { value: '250' },
params: { variable: 'speed' },
next: 'exec',
after: (ctx) => expect(ctx.variables.speed).toBe(250),
},
{
name: 'no variable chosen writes nothing',
inputs: { value: 5 },
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
// references and properties
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { GraphInterpreter, resolveDef } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
function setup() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['count', 'copy']);
return { fixture, behavior, edit: graphEditor(fixture, behavior) };
}
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>, count = 5) {
const ctx = fakeContext({ variables: { count, copy: 0 } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
return ctx.variables;
}
const titleOf = (behavior: BehaviorAsset, index: number) =>
resolveDef(behavior.graph.nodes[index]!, behavior.graph, () => null)!.label;
describe('the Variable node', () => {
it('can be read without a flow ever reaching it', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count' });
const copy = edit.addNode('SetVariable', { variable: 'copy' });
edit.wire([
[start, 'exec', copy, 'exec'],
[variable, 'value', copy, 'value'],
]);
expect(run(behavior, fixture)).toEqual({ count: 5, copy: 5 });
});
it('writes its input when the flow arrives, unconnected as well as through an edge', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const inline = edit.addNode('Variable', { variable: 'count', value: 7 });
const number = edit.addNode('Number', { value: 3 });
const wired = edit.addNode('Variable', { variable: 'copy' });
edit.wire([
[start, 'exec', inline, 'exec'],
[inline, 'exec', wired, 'exec'],
[number, 'value', wired, 'value'],
]);
expect(run(behavior, fixture)).toEqual({ count: 7, copy: 3 });
});
it('counts up through its own output: count = count + 1', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count' });
const add = edit.addNode('Add', { b: 1 });
edit.wire([
[start, 'exec', variable, 'exec'],
[variable, 'value', add, 'a'],
[add, 'result', variable, 'value'],
]);
expect(run(behavior, fixture).count).toBe(6);
});
it('is read at the moment it is asked, so it sees a later write by another node', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count', value: 7 });
const overwrite = edit.addNode('SetVariable', { variable: 'count', value: 9 });
const copy = edit.addNode('SetVariable', { variable: 'copy' });
edit.wire([
[start, 'exec', variable, 'exec'],
[variable, 'exec', overwrite, 'exec'],
[overwrite, 'exec', copy, 'exec'],
[variable, 'value', copy, 'value'],
]);
expect(run(behavior, fixture)).toEqual({ count: 9, copy: 9 });
});
});
describe('variable node titles', () => {
it('name the picked variable, and keep the plain title until one is picked', () => {
const { behavior, edit } = setup();
edit.addNode('GetVariable', { variable: 'count' });
edit.addNode('SetVariable', { variable: 'count' });
edit.addNode('Variable', { variable: 'count' });
edit.addNode('GetVariable');
edit.addNode('Variable');
expect([0, 1, 2, 3, 4].map((i) => titleOf(behavior, i))).toEqual([
'Get count',
'Set count',
'count',
'Get variable',
'Variable',
]);
});
it('follow a renamed variable', () => {
const { fixture, behavior, edit } = setup();
edit.addNode('Variable', { variable: 'count' });
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'count', 'score'));
expect(titleOf(behavior, 0)).toBe('score');
});
});
@@ -0,0 +1,14 @@
man kann ja nun graph nodes zusammenklappen.
hier soll auch die breite veringert werden auf die titeltext breite wenn möglich?
---
EDIT:
speziell für "Get variable" node, "Set variable" node und der zukünftige "Variable" node:
Es soll in der titelbar vom node der variablennamen (falls gesetzt!) angezeigt werden (egal ob zusammengeklappt oder nicht, ist unabhängig davon)
hier die beispiele:
- "Get dummyX" (beim node "Get variable")
- "Set dummyX" (beim node "Set variable")
- "dummyX" (beim node "Variable")
@@ -0,0 +1,9 @@
Kann man einen quasi 2 in 1 node machen "Variable" - dieser soll Get/Set gleichzeichtig können
- kommt ein exec flow rein, dann wird gesetzt (vom input port - wert oder edge)
- get gibt es quasi immer, natürlich der neue wert falls er gesetzt wird
---
Umgesetzt: der Ausgang liest die Variable im Moment des Abrufs, nicht beim Schreiben.
`Get variable` liest dagegen weiter einmal pro Puls — auf dieselbe Technik umstellen hiesse,
bestehende Spiele im Verhalten zu aendern (Tetris liest das Brett deshalb zweimal).
-2
View File
@@ -1,2 +0,0 @@
man kann ja nun graph nodes zusammenklappen.
hier soll auch die breite veringert werden auf die titeltext breite wenn möglich?
+13 -2
View File
@@ -780,13 +780,24 @@ export async function graph(t) {
await click(chevron.x, chevron.y);
await sleep(200);
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 / shut.w - 24 / 176) < 0.01 &&
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 = [
@@ -894,7 +905,7 @@ export async function graph(t) {
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 - 24 / 176) < 0.01,
(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')`);
+86 -1
View File
@@ -35,7 +35,22 @@ const logicGraph = {
};
export async function variableNodes(t) {
const { check, sleep, evaluate, json, shot, click, locate, undo, entity, asset, mutate, stop } = t;
const {
check,
sleep,
evaluate,
json,
shot,
click,
locate,
undo,
entity,
asset,
mutate,
stop,
typeText,
press,
} = t;
const portAt = (nodeId, key, isOutput) =>
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const portsOf = async (nodeId) =>
@@ -216,6 +231,76 @@ export async function variableNodes(t) {
JSON.stringify(helper),
);
// 23i. The Variable node: picked from the palette, titled by the variable picked in the inspector,
// and in the run `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();
const centre = await json('__gm.graph.center()');
// Below the Owner node; centre + (250, 250) would land on the Pack.
await click(centre.x - 250, centre.y + 250, 'right');
await sleep(150);
// Its description is the one that says "any time"; "variable" would put Get variable first.
await typeText('any time');
await sleep(200);
await press('Enter');
await sleep(300);
const added = (await asset('vp-logic')).graph.nodes.find((n) => !idsBefore.includes(n.id));
check('the palette offers the Variable node', added?.type === 'Variable', JSON.stringify(added));
const variableId = added?.id ?? '';
const titleOf = (id) => json(`__gm.graph.nodeLabel(${JSON.stringify(id)})`);
const untitled = await titleOf(variableId);
// The node's own title says "Variable" too, and the canvas is drawn first.
const picker = await json(`__gm.inspectorField('Variable', true)`);
await click(picker.x, picker.y);
await sleep(200);
await clickLast('vz');
await sleep(300);
check(
'picking its variable makes that the title, and drops the row that would repeat it',
untitled === 'Variable' &&
(await titleOf(variableId)) === 'vz' &&
(await json(`__gm.graph.nodeSettings(${JSON.stringify(variableId)})`)).length === 0,
JSON.stringify({ untitled, titled: await titleOf(variableId), picker }),
);
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-set-vx', 'SetVariable', 800, 400, { variable: 'vx' }))},
${JSON.stringify(node('vp-set-gone', 'SetVariable', 800, 500, { variable: 'gone' }))},
);
g.edges.push(
${JSON.stringify(edge('vp-e7', 'vp-move', 'exec', variableId, 'exec'))},
${JSON.stringify(edge('vp-e8', variableId, 'value', 'vp-add', 'a'))},
${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'value'))},
);
`);
await sleep(300);
const settingsOf = (id) => json(`__gm.graph.nodeSettings(${JSON.stringify(id)})`);
const named = { title: await titleOf('vp-set-vx'), rows: await settingsOf('vp-set-vx') };
const gone = { title: await titleOf('vp-set-gone'), rows: await settingsOf('vp-set-gone') };
check(
'Set variable names its variable too, and a deleted one keeps the row that marks it',
named.title === 'Set vx' &&
named.rows.length === 0 &&
gone.title === 'Set gone' &&
JSON.stringify(gone.rows) === '["Variable: gone?"]',
JSON.stringify({ named, gone }),
);
await t.play();
await sleep(1000);
const counted = (await entity('vp-brain'))?.props.vz;
await stop();
await sleep(300);
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`,
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'vp-logic');