From e9fa592078c03dffed895331e4d1194c496a6c8c Mon Sep 17 00:00:00 2001 From: root Date: Fri, 11 Sep 2026 13:34:09 +0200 Subject: [PATCH] Add the Variable node and show the variable name in node titles Get/Set variable nodes now title themselves "Get " / "Set ", and the new Variable node just ""; the title is the same folded or open, and a collapsed node shrinks to it. The redundant "Variable: " row is gone unless the variable was deleted ("?" 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 --- README.md | 15 ++- src/core/graph/GraphInterpreter.ts | 6 +- src/core/graph/NodeRegistry.ts | 5 + src/core/graph/nodes/index.ts | 2 + src/core/graph/nodes/shared.ts | 32 +++++- src/core/graph/nodes/variable/GetVariable.ts | 8 +- src/core/graph/nodes/variable/SetVariable.ts | 11 +- src/core/graph/nodes/variable/Variable.ts | 18 ++++ src/editor/debugHook.ts | 4 +- src/editor/graph/NodeView.ts | 29 ++++-- src/editor/graph/folding.ts | 5 + tests/core/collapsed-node.test.ts | 12 ++- tests/core/node-catalog.test.ts | 16 +++ tests/core/variable-node.test.ts | 102 +++++++++++++++++++ todo-notes/done/graph-node-collapse-width.md | 14 +++ todo-notes/done/merge-get-set-variable.md | 9 ++ todo-notes/graph-node-collapse-width.md | 2 - tools/e2e/06-graph.mjs | 15 ++- tools/e2e/23-variable-nodes.mjs | 87 +++++++++++++++- 19 files changed, 354 insertions(+), 38 deletions(-) create mode 100644 src/core/graph/nodes/variable/Variable.ts create mode 100644 tests/core/variable-node.test.ts create mode 100644 todo-notes/done/graph-node-collapse-width.md create mode 100644 todo-notes/done/merge-get-set-variable.md delete mode 100644 todo-notes/graph-node-collapse-width.md diff --git a/README.md b/README.md index 0b4ed385..22479b09 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 73dba273..e14a9f7d 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -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) diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 010e9a53..7eb5cce6 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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; /** * 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. diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 56ca86c1..3ee31a8c 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 58b8a00a..00712090 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -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 => ({ + value: ctx.variables[variableKey(params)] ?? null, +}); + +export function writeVariable( + ctx: NodeContext, + inputs: Record, + 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: '' }; diff --git a/src/core/graph/nodes/variable/GetVariable.ts b/src/core/graph/nodes/variable/GetVariable.ts index a9f4b971..2e72e33e 100644 --- a/src/core/graph/nodes/variable/GetVariable.ts +++ b/src/core/graph/nodes/variable/GetVariable.ts @@ -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) }), }; diff --git a/src/core/graph/nodes/variable/SetVariable.ts b/src/core/graph/nodes/variable/SetVariable.ts index 10f3f5c3..a8f72b65 100644 --- a/src/core/graph/nodes/variable/SetVariable.ts +++ b/src/core/graph/nodes/variable/SetVariable.ts @@ -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, }; diff --git a/src/core/graph/nodes/variable/Variable.ts b/src/core/graph/nodes/variable/Variable.ts new file mode 100644 index 00000000..b7881137 --- /dev/null +++ b/src/core/graph/nodes/variable/Variable.ts @@ -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, +}; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 5e252458..5f231caf 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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; diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index a80c9629..85c5c921 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -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; diff --git a/src/editor/graph/folding.ts b/src/editor/graph/folding.ts index bc6d8175..bb119d92 100644 --- a/src/editor/graph/folding.ts +++ b/src/editor/graph/folding.ts @@ -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 ''; diff --git a/tests/core/collapsed-node.test.ts b/tests/core/collapsed-node.test.ts index 7c320211..179c892a 100644 --- a/tests/core/collapsed-node.test.ts +++ b/tests/core/collapsed-node.test.ts @@ -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); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index bf3f501a..3d5d043b 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -150,6 +150,22 @@ const CASES: Record = { 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 diff --git a/tests/core/variable-node.test.ts b/tests/core/variable-node.test.ts new file mode 100644 index 00000000..65dcab8a --- /dev/null +++ b/tests/core/variable-node.test.ts @@ -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, 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'); + }); +}); diff --git a/todo-notes/done/graph-node-collapse-width.md b/todo-notes/done/graph-node-collapse-width.md new file mode 100644 index 00000000..415c6396 --- /dev/null +++ b/todo-notes/done/graph-node-collapse-width.md @@ -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") diff --git a/todo-notes/done/merge-get-set-variable.md b/todo-notes/done/merge-get-set-variable.md new file mode 100644 index 00000000..f546bc10 --- /dev/null +++ b/todo-notes/done/merge-get-set-variable.md @@ -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). diff --git a/todo-notes/graph-node-collapse-width.md b/todo-notes/graph-node-collapse-width.md deleted file mode 100644 index 19f83ab1..00000000 --- a/todo-notes/graph-node-collapse-width.md +++ /dev/null @@ -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? diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 87899a31..d0582a73 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -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')`); diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs index ac9ff326..617ff2ef 100644 --- a/tools/e2e/23-variable-nodes.mjs +++ b/tools/e2e/23-variable-nodes.mjs @@ -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');