From 7c63c884ef7cb291be75614cb7a4f605e43ffdc2 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 11 Sep 2026 16:56:24 +0200 Subject: [PATCH] Replace the five variable nodes with one Variables node A single node now lists any number of variables. Every entry is an output that reads the current value whenever it is pulled; an entry switched to Set is also an input and is written when the flow arrives. Exec pins only exist while at least one entry writes, and all inputs are read before any write, so one node can swap two variables. Get/Set variable, Get/Set variables and Variable are gone. Old project files are converted on load: each old node becomes a Variables node with the same variables, edges and typed values. A written port without a typed value now shows the zero of its kind on the node body, matching what the inspector shows and what the run writes. Co-Authored-By: Claude Opus 5 --- README.md | 41 +-- src/core/commands/portCommands.ts | 110 +++++++- src/core/commands/variableCommands.ts | 2 - src/core/graph/Graph.ts | 2 + src/core/graph/NodeRegistry.ts | 16 +- src/core/graph/nested.ts | 30 ++- src/core/graph/nodes/index.ts | 12 +- src/core/graph/nodes/shared.ts | 41 +-- src/core/graph/nodes/variable/GetVariable.ts | 14 - src/core/graph/nodes/variable/GetVariables.ts | 24 -- src/core/graph/nodes/variable/SetVariable.ts | 14 - src/core/graph/nodes/variable/SetVariables.ts | 25 -- src/core/graph/nodes/variable/Variable.ts | 18 -- src/core/graph/nodes/variable/Variables.ts | 34 +++ src/core/graph/propertyPorts.ts | 4 +- src/core/store/legacyVariableNodes.ts | 56 ++++ src/core/store/serialize.ts | 9 +- src/editor/debugHook.ts | 2 + src/editor/graph/GraphCanvas.ts | 6 +- src/editor/graph/NodeView.ts | 38 ++- src/editor/inspector/nodeSection.ts | 19 +- src/editor/inspector/portSection.ts | 23 +- tests/core/dynamic-ports.test.ts | 4 +- tests/core/enabled.test.ts | 8 +- tests/core/graph.test.ts | 50 ++-- tests/core/legacy-variable-nodes.test.ts | 132 ++++++++++ tests/core/nested-behavior.test.ts | 22 +- tests/core/node-catalog.test.ts | 58 +--- tests/core/nodes.test.ts | 8 +- tests/core/property-ports.test.ts | 9 +- tests/core/variable-node.test.ts | 247 ++++++++++++++---- tests/core/variable-ports.test.ts | 109 ++------ tests/helpers.ts | 21 ++ tools/e2e/06-graph.mjs | 2 +- tools/e2e/23-variable-nodes.mjs | 116 ++++---- tools/e2e/24-property-nodes.mjs | 8 +- tools/lib/builders.mjs | 5 +- tools/seed-e2e.mjs | 6 +- tools/seed-jumprun.mjs | 78 +++--- tools/seed-tetris.mjs | 62 +++-- 40 files changed, 895 insertions(+), 590 deletions(-) delete mode 100644 src/core/graph/nodes/variable/GetVariable.ts delete mode 100644 src/core/graph/nodes/variable/GetVariables.ts delete mode 100644 src/core/graph/nodes/variable/SetVariable.ts delete mode 100644 src/core/graph/nodes/variable/SetVariables.ts delete mode 100644 src/core/graph/nodes/variable/Variable.ts create mode 100644 src/core/graph/nodes/variable/Variables.ts create mode 100644 src/core/store/legacyVariableNodes.ts create mode 100644 tests/core/legacy-variable-nodes.test.ts diff --git a/README.md b/README.md index 8eb20542..6b07f255 100644 --- a/README.md +++ b/README.md @@ -99,16 +99,19 @@ 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 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. `Get properties` / - `Set properties` do the same for the object they are handed: a built-in property (`width`, + gets its own mutable copy. One node reads and writes them, `Variables`: its settings are a *list* + of variables, and each one is an output that 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. A variable + switched to **Set** in the inspector is an input too, written when the flow arrives; the node has + its flow pins only while at least one is. Unconnected and with nothing typed, it writes the zero + of its kind (0, false, empty), and the node shows that value beside the name. All of them are written together, their inputs read + first, so one node swaps two variables. `count = count + 1` is one `Variables` node feeding its + own output through an `Add` back into its input. With a single variable the node is titled by it + (*speed*). The 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. Files + from before this node still load: their `Get/Set variable(s)` and `Variable` nodes become + `Variables` nodes that read or write the same variables. `Get properties` / + `Set properties` pick their ports the same way, for the object they are handed: a built-in property (`width`, `color`, `align`, …) is picked from one list covering every object type, each entry named with its type (*Sprite width*, *Camera zoom* – a key several types share is there once per type), and keeps its own field and default – an unconnected `color` is a colour field starting white, shown @@ -160,8 +163,8 @@ shown on the node and editable in the inspector. rows stay off the node body while the delay is 0, so a plain key node keeps its single line. - **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`, `Variable`, `Get/Set variables`, `Get/Set property`, `Get/Set properties`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`, + `Is asset` (is this object an instance of asset X – how you tell what you collided with), + `Variables`, `Get/Set property`, `Get/Set properties`, `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`, @@ -244,7 +247,7 @@ shown on the node and editable in the inspector. shape that could be hit, so it takes every click inside the camera frame instead (the `world x`/`world y` outputs say where) – that is the "click anywhere" listener. - **Idiomatic movement** (the demo's "Mover", five nodes): `On update → Move(Owner, dx = Axis - input.x, dy = Axis input.y, scale = Get variable speed, dt = On update.dt)`. + input.x, dy = Axis input.y, scale = speed, dt = On update.dt)`, `speed` being a `Variables` node. - **Idiomatic platformer** (the example game's "Player"): `On update` adds `gravity · dt` to a `vy` variable and moves the owner by `(axis.x · speed, vy)` with *Collide with solids* on; `blocked y` while falling means standing, so `vy` goes back to 0 and `onGround` becomes true; @@ -659,7 +662,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 # 569 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 572 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 @@ -764,15 +767,13 @@ parallel variables copied back and forth, and each strand packs only the field i (`mergeStruct` keeps the rest). Reading a cell is `((shape · 4 + rot) · 4 + corner)` into that list instead of the same index doubled and read twice. -Where a step needs several variables at once, one `Get variables` / `Set variables` stands for the +Where a step needs several variables at once, one `Variables` node stands for the lot: the call to `Next move` has **one node at each side** of it rather than five, and the master graph is **44 nodes / 64 edges** where the same game in single nodes was 69 / 76. Inside `Next move` each strand writes its whole answer – the candidate, what kind of move it was and the wish -it used up – in one node rather than three to five, which is **30 nodes** against 40. Two things -bound how far such a node may reach: a data node answers once per pulse and keeps that answer, so -a `Get variables` must not straddle a write of one of its variables (which is why the board is -read a second time after the lines were cleared), and a `Set variables` reads all its inputs -before it writes any, so none of them may come from a variable it writes. +it used up – in one node rather than three to five, which is **30 nodes** against 40. A writing +node reads all its inputs before it writes any, so an input fed from a variable the same node +writes sees the value from before. It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`, diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index ab678a52..c3e57e88 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -32,9 +32,7 @@ interface PortSite { * the behavior – facing the other way, because what a start node hands out the caller passes in. */ function portSites(project: Project, assetId: string, node: GraphNode): PortSite[] { - const asset = requireBehavior(project, assetId); - const asInput = requireNodeDef(node.type).declaresPorts === 'inputs'; - const sites: PortSite[] = [{ graph: asset.graph, nodeId: node.id, asInput }]; + const sites = ownSites(requireBehavior(project, assetId).graph, node); if (!isTerminalNode(node)) return sites; for (const other of project.assets) { if (other.type !== 'behavior') continue; @@ -46,6 +44,37 @@ function portSites(project: Project, assetId: string, node: GraphNode): PortSite return sites; } +function ownSites(graph: Graph, node: GraphNode): PortSite[] { + const side = requireNodeDef(node.type).declaresPorts; + const site = (asInput: boolean): PortSite => ({ graph, nodeId: node.id, asInput }); + return side === 'both' ? [site(false), site(true)] : [site(side === 'inputs')]; +} + +type EdgeTest = { graph: Graph; test: (edge: GraphEdge) => boolean }; +type DroppedEdges = { graph: Graph; entries: Removed[] }; + +/** One pass per edge list: a second pass over the same list would be restored at shifted indices. */ +function dropEdges(tests: readonly EdgeTest[]): DroppedEdges[] { + const byGraph = new Map(); + for (const { graph, test } of tests) byGraph.set(graph, [...(byGraph.get(graph) ?? []), test]); + return [...byGraph].map(([graph, all]) => ({ + graph, + entries: removeWhere(graph.edges, (edge) => all.some((test) => test(edge))), + })); +} + +function restoreEdges(dropped: readonly DroppedEdges[]): void { + for (const { graph, entries } of dropped) restoreAt(graph.edges, entries); +} + +/** A node declaring both sides has its flow pins only while one of its ports writes. */ +function flowEdgesOnceSilent(graph: Graph, node: GraphNode): EdgeTest[] { + if (requireNodeDef(node.type).declaresPorts !== 'both' || (node.ports ?? []).some((p) => p.write)) + return []; + const onFlow = (end: { node: string; port: string }) => end.node === node.id && end.port === 'exec'; + return [{ graph, test: (edge) => onFlow(edge.to) || onFlow(edge.from) }]; +} + function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean { return (edge) => site.asInput @@ -122,7 +151,7 @@ export class AddPortCommand extends AddPortsCommand { export class RemovePortCommand implements Command { readonly label: string; private removed?: Removed; - private edges: { graph: Graph; entries: Removed[] }[] = []; + private edges: DroppedEdges[] = []; private values: { site: PortSite; value: PropertyValue }[] = []; constructor( @@ -141,10 +170,11 @@ export class RemovePortCommand implements Command { if (!removed) throw new CommandError(`Port "${this.key}" not found`); this.removed = removed; node.ports = ports; - this.edges = sites.map((site) => ({ - graph: site.graph, - entries: removeWhere(site.graph.edges, edgesOnPort(site, this.key)), - })); + const graph = requireBehavior(project, this.assetId).graph; + this.edges = dropEdges([ + ...sites.map((site) => ({ graph: site.graph, test: edgesOnPort(site, this.key) })), + ...flowEdgesOnceSilent(graph, node), + ]); this.values = []; for (const site of sites) { const target = site.asInput ? nodeAt(site) : undefined; @@ -161,7 +191,7 @@ export class RemovePortCommand implements Command { const ports = [...(node.ports ?? [])]; restoreAt(ports, [this.removed]); node.ports = ports; - for (const { graph, entries } of this.edges) restoreAt(graph.edges, entries); + restoreEdges(this.edges); for (const { site, value } of this.values) { const target = nodeAt(site); if (target) target.params[this.key] = value; @@ -169,6 +199,62 @@ export class RemovePortCommand implements Command { } } +/** + * Switches whether a port of a node declaring both sides is written. Switched off, its input goes + * with its edge and typed-in value, and the last one off takes the flow pins and their edges along. + */ +export class SetPortWriteCommand implements Command { + readonly label: string; + private edges: DroppedEdges[] = []; + private value: PropertyValue | undefined; + private was = false; + + constructor( + private readonly assetId: string, + private readonly nodeId: string, + private readonly key: string, + private readonly write: boolean, + ) { + this.label = write ? `Set ${key}` : `Stop setting ${key}`; + } + + execute(project: Project): void { + const node = requireNode(project, this.assetId, this.nodeId); + if (requireNodeDef(node.type).declaresPorts !== 'both') + throw new CommandError('This node does not write its ports'); + const port = (node.ports ?? []).find((p) => p.key === this.key); + if (!port) throw new CommandError(`Port "${this.key}" not found`); + this.was = port.write === true; + this.mark(node, this.write); + this.edges = []; + this.value = undefined; + if (this.write) return; + const graph = requireBehavior(project, this.assetId).graph; + const input: PortSite = { graph, nodeId: node.id, asInput: true }; + this.edges = dropEdges([ + { graph, test: edgesOnPort(input, this.key) }, + ...flowEdgesOnceSilent(graph, node), + ]); + this.value = node.params[this.key]; + delete node.params[this.key]; + } + + undo(project: Project): void { + const node = requireNode(project, this.assetId, this.nodeId); + this.mark(node, this.was); + restoreEdges(this.edges); + if (this.value !== undefined) node.params[this.key] = this.value; + } + + private mark(node: GraphNode, write: boolean): void { + node.ports = (node.ports ?? []).map((port) => { + if (port.key !== this.key) return port; + const { write: _, ...plain } = port; + return write ? { ...plain, write: true } : plain; + }); + } +} + /** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */ export class RenamePortCommand implements Command { readonly label: string; @@ -208,11 +294,7 @@ function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): const ports = node.ports ?? []; if (from === to || !ports.some((p) => p.key === from)) return; node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p)); - carryPort( - { graph, nodeId: node.id, asInput: requireNodeDef(node.type).declaresPorts === 'inputs' }, - from, - to, - ); + for (const site of ownSites(graph, node)) carryPort(site, from, to); } /** diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index da7b207b..a37f60a5 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -138,8 +138,6 @@ export class RenameVariableCommand implements Command { bag[to] = bag[from]!; delete bag[from]; } - // Every node that picks a variable stores it under the param key `variable`. - for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to; renameVariablePorts(asset.graph, from, to); } } diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 9cf844c9..52806806 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -32,6 +32,8 @@ export interface GraphNode { export interface DeclaredPort { key: string; kind: DeclaredPortKind; + /** On a node declaring `both` sides: the port is an input too, so the node writes it. */ + write?: true; } export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list', 'struct'] as const; diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 9c732f90..21ed1ced 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -75,9 +75,10 @@ export interface NodeDef { hiddenParams?(params: PropertyBag): string[]; /** * Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it - * is what gives a node the port editor in the inspector. + * is what gives a node the port editor in the inspector. `both`: every port is an output, and an + * input as well where it is marked `write`. */ - declaresPorts?: 'inputs' | 'outputs'; + declaresPorts?: 'inputs' | 'outputs' | 'both'; /** How a declared port becomes a port; without it, named after its key and typed by its kind. */ declaredPort?(port: DeclaredPort): PortDef; /** @@ -132,7 +133,16 @@ export function hasExecInput(def: NodeDef): boolean { * struct is assembled from its parts, so neither has a value that could be typed on one line. */ export function inlineInputs(def: NodeDef): PortDef[] { - return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object' && p.type !== 'struct'); + return def.inputs.filter(isInlinePort); +} + +function isInlinePort(port: PortDef): boolean { + return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct'; +} + +/** Untyped, an input writes the zero of its kind; as its default the node body shows it, not just the name. */ +export function withInlineDefault(port: PortDef): PortDef { + return isInlinePort(port) ? { ...port, default: inlineInputDef(port).default } : port; } export function inlineInputDef(port: PortDef): PropertyDef { diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 12aab306..2e836eef 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -7,8 +7,7 @@ import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; export const BEHAVIOR_START = 'BehaviorStart'; export const BEHAVIOR_END = 'BehaviorEnd'; export const RUN_BEHAVIOR = 'RunBehavior'; -export const GET_VARIABLES = 'GetVariables'; -export const SET_VARIABLES = 'SetVariables'; +export const VARIABLES = 'Variables'; export const GET_PROPERTIES = 'GetProperties'; export const SET_PROPERTIES = 'SetProperties'; @@ -20,7 +19,7 @@ export const BEHAVIOR_PARAM = 'behavior'; * instead of letting them be typed. */ export function declaresVariablePorts(type: string): boolean { - return type === GET_VARIABLES || type === SET_VARIABLES; + return type === VARIABLES; } /** Nodes whose declared ports are properties of the object they are handed. */ @@ -35,7 +34,19 @@ export function isValidPortName(name: string): boolean { /** Keys a declared port cannot take: the node's fixed ports on the side it declares (`object`). */ export function fixedPortKeys(def: NodeDef): string[] { - return (def.declaresPorts === 'inputs' ? def.inputs : def.outputs).map((p) => p.key); + const side = def.declaresPorts; + const fixed = + side === 'inputs' ? def.inputs : side === 'outputs' ? def.outputs : [...def.inputs, ...def.outputs]; + return fixed.map((p) => p.key); +} + +export function declaredOn( + def: NodeDef, + ports: readonly DeclaredPort[], + side: 'inputs' | 'outputs', +): DeclaredPort[] { + if (def.declaresPorts === 'both') return side === 'outputs' ? [...ports] : ports.filter((p) => p.write); + return def.declaresPorts === side ? [...ports] : []; } /** The single start (or end) node of a graph; a second one is ignored. */ @@ -68,14 +79,17 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[] export function resolveDef(node: GraphNode, graph: Graph, lookup: BehaviorLookup): NodeDef | undefined { const def = nodeDef(node.type); if (!def || (!def.declaresPorts && !def.derivePorts)) return def; - const own = def.declaresPorts ? (node.ports ?? []).map((p) => (def.declaredPort ?? portDefOf)(p)) : []; - const side = (which: 'inputs' | 'outputs') => (def.declaresPorts === which ? own : []); + const own = (side: 'inputs' | 'outputs') => + declaredOn(def, node.ports ?? [], side).map((p) => + def.declaredPort ? def.declaredPort(p) : portDefOf(p), + ); const grown = def.derivePorts?.({ node, graph, behaviors: lookup }) ?? {}; + // Derived before declared: a derived exec pin belongs at the top. return { ...def, label: grown.label ?? def.label, - inputs: [...def.inputs, ...side('inputs'), ...(grown.inputs ?? [])], - outputs: [...def.outputs, ...side('outputs'), ...(grown.outputs ?? [])], + inputs: [...def.inputs, ...(grown.inputs ?? []), ...own('inputs')], + outputs: [...def.outputs, ...(grown.outputs ?? []), ...own('outputs')], }; } diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 052530c6..40b19ef0 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -83,11 +83,7 @@ import { Branch } from './flow/Branch'; import { Repeat } from './flow/Repeat'; import { RunBehavior } from './flow/RunBehavior'; import { Sequence } from './flow/Sequence'; -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'; +import { Variables } from './variable/Variables'; /** * Importing this module registers the built-in nodes. The palette sorts by category and label, so @@ -113,11 +109,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ RunBehavior, BehaviorEnd, // variables - Variable, - GetVariable, - GetVariables, - SetVariable, - SetVariables, + Variables, // data: references and properties Self, Owner, diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 2215c54e..0b817278 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -1,8 +1,7 @@ -import { type PropertyBag, type PropertyValue, textOf } from '../../model/Properties'; +import type { PropertyValue } from '../../model/Properties'; import { OBJECT_PROPERTY_SUGGESTIONS, type PropertyDef } from '../../properties/PropertySchema'; -import type { NodeContext } from '../NodeContext'; -import type { DerivedShape, NodeDef, PortDef, PortSource, RunResult } from '../NodeRegistry'; -import { asBoolean, asNumber, type Value, writeTyped } from '../values'; +import type { NodeDef, PortDef } from '../NodeRegistry'; +import { asBoolean, asNumber } from '../values'; /** The property a Get/Set property node names: typed freely, or picked from the built-in ones. */ export const PROPERTY_PARAM: PropertyDef = { @@ -34,40 +33,6 @@ export const collisionOutputs = (): PortDef[] => [ export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' }; -/** Options are empty on purpose: the graph inspector fills in the behavior's variables. */ -export const variableParam: PropertyDef = { - key: 'variable', - label: 'Variable', - kind: 'enum', - default: '', - 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 deleted file mode 100644 index 2e72e33e..00000000 --- a/src/core/graph/nodes/variable/GetVariable.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NodeDef } from '../../NodeRegistry'; -import { any, readVariable, variableParam, variableTitle } from '../shared'; - -export const GetVariable: NodeDef = { - type: 'GetVariable', - label: 'Get variable', - category: 'variable', - description: "Reads one of this behavior's variables.", - inputs: [], - outputs: [any('value')], - params: [variableParam], - derivePorts: variableTitle('Get'), - run: (ctx, _inputs, params) => ({ outputs: readVariable(ctx, params) }), -}; diff --git a/src/core/graph/nodes/variable/GetVariables.ts b/src/core/graph/nodes/variable/GetVariables.ts deleted file mode 100644 index 535f42bf..00000000 --- a/src/core/graph/nodes/variable/GetVariables.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { NodeDef } from '../../NodeRegistry'; -import { GET_VARIABLES } from '../../nested'; -import type { Value } from '../../values'; - -/** - * The plural of Get variable: one output per variable picked, so a graph reading a handful of them - * is one node instead of a column of five. The ports are declared on the node, the way Expand - * declares the fields of a struct. - */ -export const GetVariables: NodeDef = { - type: GET_VARIABLES, - label: 'Get variables', - category: 'variable', - description: "Reads several of this behavior's variables at once, one output per variable.", - inputs: [], - outputs: [], - params: [], - declaresPorts: 'outputs', - run: (ctx, _inputs, _params, def) => { - const outputs: Record = {}; - for (const port of def.outputs) outputs[port.key] = ctx.variables[port.key] ?? null; - return { outputs }; - }, -}; diff --git a/src/core/graph/nodes/variable/SetVariable.ts b/src/core/graph/nodes/variable/SetVariable.ts deleted file mode 100644 index a8f72b65..00000000 --- a/src/core/graph/nodes/variable/SetVariable.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NodeDef } from '../../NodeRegistry'; -import { any, exec, variableParam, variableTitle, writeVariable } from '../shared'; - -export const SetVariable: NodeDef = { - type: 'SetVariable', - label: 'Set variable', - category: 'variable', - description: "Writes one of this behavior's variables.", - inputs: [exec(), any('value')], - outputs: [exec()], - params: [variableParam], - derivePorts: variableTitle('Set'), - run: writeVariable, -}; diff --git a/src/core/graph/nodes/variable/SetVariables.ts b/src/core/graph/nodes/variable/SetVariables.ts deleted file mode 100644 index 365b4f78..00000000 --- a/src/core/graph/nodes/variable/SetVariables.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { NodeDef } from '../../NodeRegistry'; -import { SET_VARIABLES } from '../../nested'; -import { writeTyped } from '../../values'; -import { exec } from '../shared'; - -/** - * The plural of Set variable: one input per variable picked. An input left unconnected writes the - * value typed on the node, exactly as it does on the singular node – the node writes every variable - * it lists, every time it runs. - */ -export const SetVariables: NodeDef = { - type: SET_VARIABLES, - label: 'Set variables', - category: 'variable', - description: "Writes several of this behavior's variables at once, one input per variable.", - inputs: [exec()], - outputs: [exec()], - params: [], - declaresPorts: 'inputs', - run: (ctx, inputs, _params, def) => { - for (const port of def.inputs) - if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]); - return { next: 'exec' }; - }, -}; diff --git a/src/core/graph/nodes/variable/Variable.ts b/src/core/graph/nodes/variable/Variable.ts deleted file mode 100644 index b7881137..00000000 --- a/src/core/graph/nodes/variable/Variable.ts +++ /dev/null @@ -1,18 +0,0 @@ -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/core/graph/nodes/variable/Variables.ts b/src/core/graph/nodes/variable/Variables.ts new file mode 100644 index 00000000..7435a887 --- /dev/null +++ b/src/core/graph/nodes/variable/Variables.ts @@ -0,0 +1,34 @@ +import { portDefOf, VARIABLES } from '../../nested'; +import { type NodeDef, withInlineDefault } from '../../NodeRegistry'; +import { writeTyped } from '../../values'; +import { exec } from '../shared'; + +/** + * Every variable picked is an output, read at the moment it is asked for. One switched to Set is an + * input too; the flow pins exist only while at least one is. + */ +export const Variables: NodeDef = { + type: VARIABLES, + label: 'Variables', + category: 'variable', + description: + "Reads this behavior's variables at any time. Those switched to Set are written together when " + + 'the flow arrives, and a read after that sees the new value.', + inputs: [], + outputs: [], + params: [], + declaresPorts: 'both', + declaredPort: (port) => withInlineDefault(portDefOf(port)), + derivePorts: ({ node }) => { + const ports = node.ports ?? []; + const only = ports.length === 1 ? ports[0]?.key : undefined; + const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {}; + return only ? { ...shape, label: only } : shape; + }, + read: (ctx) => ctx.variables, + run: (ctx, inputs, _params, def) => { + for (const port of def.inputs) + if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/propertyPorts.ts b/src/core/graph/propertyPorts.ts index 045c76d7..67f6ea13 100644 --- a/src/core/graph/propertyPorts.ts +++ b/src/core/graph/propertyPorts.ts @@ -1,7 +1,7 @@ import { OBJECT_PROPERTIES, type PropertyDef } from '../properties/PropertySchema'; import type { DeclaredPort } from './Graph'; import { portDefOf } from './nested'; -import type { PortDef } from './NodeRegistry'; +import { type PortDef, withInlineDefault } from './NodeRegistry'; import { portKindOf } from './variablePorts'; export function objectPropertyDef(key: string): PropertyDef | undefined { @@ -17,7 +17,7 @@ export function propertyPortOf(def: PropertyDef): DeclaredPort { * edited with its field. A custom name, or a kind the property does not travel as, stays plain. */ export function propertyPort(port: DeclaredPort): PortDef { - const plain = portDefOf(port); + const plain = withInlineDefault(portDefOf(port)); const field = objectPropertyDef(port.key); if (!field || portKindOf(field) !== port.kind) return plain; return { ...plain, field, default: field.default }; diff --git a/src/core/store/legacyVariableNodes.ts b/src/core/store/legacyVariableNodes.ts new file mode 100644 index 00000000..1b3c19a1 --- /dev/null +++ b/src/core/store/legacyVariableNodes.ts @@ -0,0 +1,56 @@ +import { VARIABLES } from '../graph/nested'; +import { portKindOf } from '../graph/variablePorts'; +import type { PropertyDef } from '../properties/PropertySchema'; + +/** The nodes `Variables` replaced, and whether they wrote. */ +const SINGLE: Record = { GetVariable: false, SetVariable: true, Variable: true }; +const PLURAL: Record = { GetVariables: false, SetVariables: true }; + +type Raw = Record; +const record = (value: unknown): Raw => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {}; + +/** + * Rewrites the variable nodes of an older file into `Variables`, on the raw graph before it is + * normalized. A single node's `value` port is renamed to the variable, which is the key there; one + * without a variable has no pins left and loses its edges, flow included. + */ +export function upgradeVariableNodes(raw: unknown, variables: readonly PropertyDef[]): unknown { + const graph = record(raw); + if (!Array.isArray(graph.nodes)) return raw; + const renamed = new Map(); + const nodes = (graph.nodes as unknown[]).map((item) => { + const node = record(item); + const type = String(node.type); + if (type in PLURAL) { + const write = PLURAL[type]; + const ports = Array.isArray(node.ports) + ? (node.ports as unknown[]).map((p) => (write ? { ...record(p), write } : p)) + : []; + return { ...node, type: VARIABLES, ports }; + } + if (!(type in SINGLE)) return item; + const write = SINGLE[type]; + const { variable, value, ...params } = record(node.params); + const key = typeof variable === 'string' ? variable : ''; + renamed.set(String(node.id), key); + if (!key) return { ...node, type: VARIABLES, params, ports: [] }; + const declared = variables.find((v) => v.key === key); + const kind = declared ? portKindOf(declared) : 'text'; + if (write && value !== undefined) params[key] = value; + return { ...node, type: VARIABLES, params, ports: [write ? { key, kind, write } : { key, kind }] }; + }); + const edges = (Array.isArray(graph.edges) ? graph.edges : []).flatMap((item) => { + const edge = record(item); + const end = (side: unknown) => { + const at = record(side); + const key = renamed.get(String(at.node)); + if (key === '') return null; + return key !== undefined && at.port === 'value' ? { ...at, port: key } : at; + }; + const from = end(edge.from); + const to = end(edge.to); + return from && to ? [{ ...edge, from, to }] : []; + }); + return { ...graph, nodes, edges }; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 39fd8d0b..fa6b1682 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -19,6 +19,7 @@ import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile, normalizeFrameRect } from '../model/texture'; import { hasParentCycle } from '../model/walk'; +import { upgradeVariableNodes } from './legacyVariableNodes'; import { coerceValue, COMMON_PROPS, @@ -79,7 +80,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { name, props, variables: variables.get(id) ?? [], - graph: normalizeGraph(a.graph), + graph: normalizeGraph(upgradeVariableNodes(a.graph, variables.get(id) ?? [])), }); } else if (type === 'texture') { assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) }); @@ -211,7 +212,7 @@ function readField(key: string, f: Record): StructFieldDef | nu return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null; } -function normalizePorts(raw: unknown, fixed: readonly string[]): DeclaredPort[] { +function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] { const ports: DeclaredPort[] = []; const seen = new Set(fixed); for (const item of Array.isArray(raw) ? raw : []) { @@ -222,7 +223,7 @@ function normalizePorts(raw: unknown, fixed: readonly string[]): DeclaredPort[] const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind)) ? (p.kind as DeclaredPortKind) : 'number'; - ports.push({ key, kind }); + ports.push(writable && p.write === true ? { key, kind, write: true } : { key, kind }); } return ports; } @@ -245,7 +246,7 @@ function normalizeGraph(raw: unknown): Graph { if (stored !== undefined) params[key] = stored; } const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params }; - const ports = normalizePorts(n.ports, fixedPortKeys(def)); + const ports = normalizePorts(n.ports, fixedPortKeys(def), def.declaresPorts === 'both'); if (ports.length > 0) node.ports = ports; if (n.collapsed === true) node.collapsed = true; graph.nodes.push(node); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index ea955711..dfd09965 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -196,6 +196,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId), nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId), nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId), + /** Each input row as drawn: its name, and the inline value beside it while unconnected. */ + nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId), /** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */ nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId), canvasRect: () => graphEditor.canvas.canvasRect(), diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index cae186bb..1fe95f13 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -213,7 +213,7 @@ export class GraphCanvas extends Widget { this.views.set(node.id, view); this.nodeLayer.addChild(view); } - view.sync(node, asset.graph, asset, this.state.project, def, callProblem(node, lookup, asset.id)); + view.sync(node, asset.graph, this.state.project, def, callProblem(node, lookup, asset.id)); } for (const [id, view] of this.views) { if (!seen.has(id)) { @@ -790,6 +790,10 @@ export class GraphCanvas extends Widget { return this.views.get(nodeId)?.settingLines ?? null; } + nodeInputs(nodeId: string): string[] | null { + return this.views.get(nodeId)?.inputLines ?? null; + } + /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 09d374ad..5841701e 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -15,7 +15,6 @@ import { type PortType, type Value, } from '../../core/graph'; -import type { BehaviorAsset } from '../../core/model/Asset'; import type { Project } from '../../core/model/Project'; import { isList, type PropertyValue } from '../../core/model/Properties'; import { assetRefLabel } from '../../core/properties/assetRef'; @@ -114,7 +113,7 @@ export class NodeView extends Widget { this.def = def; this.cursor = 'grab'; this.addChild(this.box, this.ports); - this.sync(node, { nodes: [node], edges: [] }, null, null, def); + this.sync(node, { nodes: [node], edges: [] }, null, def); } /** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */ @@ -127,7 +126,6 @@ export class NodeView extends Widget { sync( node: GraphNode, graph: Graph, - asset: BehaviorAsset | null, project: Project | null, def: NodeDef, problem: string | null = null, @@ -141,10 +139,10 @@ export class NodeView extends Widget { .filter((p) => !hidden.has(p.key)) .map((p) => ({ label: p.label, - value: formatParam(p, node.params[p.key] ?? p.default, asset, project), + value: formatParam(p, node.params[p.key] ?? p.default, project), })) - // 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. + // A title made from a setting states it ("Behavior: Double" under "Double"). A dangling + // reference keeps its row: "Double?" 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( @@ -200,6 +198,15 @@ export class NodeView extends Widget { return [...this.paramLines]; } + get inputLines(): string[] { + return this.def.inputs.map((input) => this.inputText(input)); + } + + private inputText(input: PortDef): string { + const inline = this.inlineTexts.get(input.key); + return inline === undefined ? input.label : `${input.label} ${inline}`; + } + /** Everything above the first port row. The problem line sits in here, so ports move down with it. */ private headerHeight(): number { const lines = this.paramLines.length + (this.problem ? 1 : 0); @@ -276,10 +283,8 @@ export class NodeView extends Widget { const output = this.def.outputs[i]; const rowTop = y + i * ROW_HEIGHT + 2; if (input) { - const inline = this.inlineTexts.get(input.key); - const label = inline === undefined ? input.label : `${input.label} ${inline}`; add( - label, + this.inputText(input), input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, @@ -368,21 +373,8 @@ export class NodeView extends Widget { } } -/** The value of a setting as shown on the node; references that no longer resolve get a "?". */ -function formatParam( - param: PropertyDef, - value: PropertyValue | undefined, - asset: BehaviorAsset | null, - project: Project | null, -): string { +function formatParam(param: PropertyDef, value: PropertyValue | undefined, project: Project | null): string { if (value === undefined || value === '') return '—'; - if ( - param.kind === 'enum' && - param.key === 'variable' && - asset && - !asset.variables.some((v) => v.key === value) - ) - return `${asString(value)}?`; return formatField(param, value, project); } diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index f55458cd..c83db224 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -9,7 +9,6 @@ import { portSignature, resolveDef, } from '../../core/graph'; -import type { Asset } from '../../core/model/Asset'; import { behaviorLookup } from '../../core/model/behaviorPrograms'; import { findAssetOf, type Project } from '../../core/model/Project'; import type { PropertyDef } from '../../core/properties/PropertySchema'; @@ -33,7 +32,7 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string) .filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0) .map((p) => p.key) : []; - const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}`).join(','); + const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(','); return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables .map((v) => v.key) .join(',')}`; @@ -58,12 +57,11 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s if (def.params.length > 0) addTitle(host, 'SETTINGS'); for (const param of def.params) { - const effective = resolveParamDef(param, asset); const row = createFieldRow( host, - effective, - node.params[param.key] ?? effective.default, - paramHandlers(host, assetId, nodeId, effective), + param, + node.params[param.key] ?? param.default, + paramHandlers(host, assetId, nodeId, param), ); host.fields.set(`param:${param.key}`, row); host.body.add(row.row); @@ -101,15 +99,6 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: return true; } -/** - * Variable params are the one setting a node cannot declare on its own: their options are the - * variables of the behavior it sits in. Everything else is used exactly as declared. - */ -function resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef { - if (param.kind !== 'enum' || param.key !== 'variable' || asset.type !== 'behavior') return param; - return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) }; -} - /** Previews straight into the node's params; the command records the value from before the preview. */ function paramHandlers( host: InspectorHost, diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 256fab2a..45716767 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -5,6 +5,7 @@ import { RemovePortCommand, RenamePortCommand, RepointPortCommand, + SetPortWriteCommand, } from '../../core/commands/portCommands'; import { BEHAVIOR_START, @@ -24,6 +25,7 @@ import { VARIABLE_KINDS, } from '../../core/properties/PropertySchema'; import { + Checkbox, Dropdown, HStack, Label, @@ -39,8 +41,8 @@ import { addNote, type InspectorHost } from './host'; import { KIND_LABELS, kindMark, markLabel } from './variableRows'; /** - * The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked - * from that list rather than named, and its kind follows the variable's declaration. + * The ports of a Variables node: each one *is* a variable of the behavior, so it is picked from that + * list rather than named, and its kind follows the variable's declaration. Set makes it written too. */ export function buildVariablePortSection( host: InspectorHost, @@ -122,7 +124,14 @@ function buildPickedPortSection( ); new Menu(items, { searchable: custom }).openBelow(choose); }; - portRow(host, assetId, node, port, choose); + portRow( + host, + assetId, + node, + port, + choose, + noun === 'Variable' ? writeToggle(host, assetId, node, port) : [], + ); if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning'); } } @@ -193,17 +202,25 @@ export function buildPortSection( } } +function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] { + const box = new Checkbox(port.write === true); + box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on)); + return [new Label('Set', { style: 'muted' }), box]; +} + function portRow( host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort, name: Widget, + extras: readonly Widget[] = [], ): void { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; row.add(markLabel(kindMark(port.kind))); row.add(name, 1); + for (const extra of extras) row.add(extra); row.add(new Spacer()); row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)))); host.body.add(row); diff --git a/tests/core/dynamic-ports.test.ts b/tests/core/dynamic-ports.test.ts index d575e78c..84463acc 100644 --- a/tests/core/dynamic-ports.test.ts +++ b/tests/core/dynamic-ports.test.ts @@ -111,12 +111,12 @@ describe('a struct travelling through a nested behavior', () => { fixture.stack.execute(new AddPortCommand(caller.id, pack.id, { key, kind: 'number' })); const onStart = outer.addNode('OnStart'); const call = outer.addNode('RunBehavior', { behavior: callee.id }); - const set = outer.addNode('SetVariable', { variable: 'result' }); + const set = outer.variables(['result'], { write: true }); outer.wire([ [onStart, 'exec', call, 'exec'], [pack, 'value', call, 'piece'], [call, 'exec', set, 'exec'], - [call, 'total', set, 'value'], + [call, 'total', set, 'result'], ]); // One edge into the call, where two numbers would need two. diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts index b9814717..a5862153 100644 --- a/tests/core/enabled.test.ts +++ b/tests/core/enabled.test.ts @@ -93,13 +93,13 @@ describe('the enabled switch', () => { const update = edit.addNode('OnUpdate'); const one = edit.addNode('Number', { value: 1 }); const add = edit.addNode('Add'); - const get = edit.addNode('GetVariable', { variable: 'ticks' }); - const set = edit.addNode('SetVariable', { variable: 'ticks' }); + const get = edit.variables(['ticks']); + const set = edit.variables(['ticks'], { write: true }); edit.wire([ [update, 'exec', set, 'exec'], - [get, 'value', add, 'a'], + [get, 'ticks', add, 'a'], [one, 'value', add, 'b'], - [add, 'result', set, 'value'], + [add, 'result', set, 'ticks'], [start, 'exec', set, 'exec'], ]); const counter = place(f, behavior.id, { x: 0, y: 0 }); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index c1dcbab8..55654102 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -44,13 +44,13 @@ function withMover(fixture: F) { const edit = graphEditor(fixture, behavior); const held = edit.addNode('OnKeyHeld', { key: 'ArrowRight' }); const owner = edit.addNode('Owner'); - const speed = edit.addNode('GetVariable', { variable: 'speed' }); + const speed = edit.variables(['speed']); const mul = edit.addNode('Multiply'); const move = edit.addNode('Move'); edit.wire([ [held, 'exec', move, 'exec'], [owner, 'object', move, 'object'], - [speed, 'value', mul, 'a'], + [speed, 'speed', mul, 'a'], [held, 'dt', mul, 'b'], [mul, 'result', move, 'dx'], ]); @@ -137,7 +137,7 @@ describe('GraphInterpreter', () => { const update = edit.addNode('OnUpdate'); const owner = edit.addNode('Owner'); const axis = edit.addNode('AxisInput'); - const speed = edit.addNode('GetVariable', { variable: 'speed' }); + const speed = edit.variables(['speed']); const move = edit.addNode('Move'); edit.wire([ [update, 'exec', move, 'exec'], @@ -145,7 +145,7 @@ describe('GraphInterpreter', () => { [owner, 'object', move, 'object'], [axis, 'x', move, 'dx'], [axis, 'y', move, 'dy'], - [speed, 'value', move, 'scale'], + [speed, 'speed', move, 'scale'], ]); expect(behavior.graph.nodes).toHaveLength(5); @@ -196,20 +196,20 @@ describe('GraphInterpreter', () => { it('follows Branch, writes variables and logs', () => { const { behavior, edit } = emptyBehavior(['count']); const start = edit.addNode('OnStart'); - const get = edit.addNode('GetVariable', { variable: 'count' }); + const get = edit.variables(['count']); const cmp = edit.addNode('Compare', { op: '<', b: 3 }); const branch = edit.addNode('Branch'); const add = edit.addNode('Add', { b: 1 }); - const set = edit.addNode('SetVariable', { variable: 'count' }); + const set = edit.variables(['count'], { write: true }); const logThen = edit.addNode('Log', { message: 'then' }); const logElse = edit.addNode('Log', { message: 'else' }); edit.wire([ [start, 'exec', branch, 'exec'], - [get, 'value', cmp, 'a'], + [get, 'count', cmp, 'a'], [cmp, 'result', branch, 'condition'], [branch, 'then', set, 'exec'], - [get, 'value', add, 'a'], - [add, 'result', set, 'value'], + [get, 'count', add, 'a'], + [add, 'result', set, 'count'], [set, 'exec', logThen, 'exec'], [branch, 'else', logElse, 'exec'], ]); @@ -301,15 +301,15 @@ describe('Repeat', () => { const { edit } = fixture; const start = edit.addNode('OnStart'); const repeat = edit.addNode('Repeat', { times }); - const get = edit.addNode('GetVariable', { variable: 'count' }); + const get = edit.variables(['count']); const add = edit.addNode('Add'); - const set = edit.addNode('SetVariable', { variable: 'count' }); + const set = edit.variables(['count'], { write: true }); edit.wire([ [start, 'exec', repeat, 'exec'], [repeat, 'body', set, 'exec'], - [get, 'value', add, 'a'], + [get, 'count', add, 'a'], [repeat, 'index', add, 'b'], - [add, 'result', set, 'value'], + [add, 'result', set, 'count'], ]); return { ...fixture, nodes: { repeat, set } }; } @@ -343,15 +343,15 @@ describe('Repeat', () => { const start = edit.addNode('OnStart'); const outer = edit.addNode('Repeat', { times: 3 }); const inner = edit.addNode('Repeat', { times: 4 }); - const get = edit.addNode('GetVariable', { variable: 'count' }); + const get = edit.variables(['count']); const add = edit.addNode('Add', { b: 1 }); - const set = edit.addNode('SetVariable', { variable: 'count' }); + const set = edit.variables(['count'], { write: true }); edit.wire([ [start, 'exec', outer, 'exec'], [outer, 'body', inner, 'exec'], [inner, 'body', set, 'exec'], - [get, 'value', add, 'a'], - [add, 'result', set, 'value'], + [get, 'count', add, 'a'], + [add, 'result', set, 'count'], ]); const ctx = fakeContext({ variables: { count: 0 } }); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); @@ -362,19 +362,19 @@ describe('Repeat', () => { const { behavior, edit } = emptyBehavior(['count']); const start = edit.addNode('OnStart'); const repeat = edit.addNode('Repeat', { times: 3 }); - const get = edit.addNode('GetVariable', { variable: 'count' }); + const get = edit.variables(['count']); const add = edit.addNode('Add', { b: 1 }); - const set = edit.addNode('SetVariable', { variable: 'count' }); - const after = edit.addNode('SetVariable', { variable: 'count' }); + const set = edit.variables(['count'], { write: true }); + const after = edit.variables(['count'], { write: true }); const afterAdd = edit.addNode('Multiply', { b: 10 }); edit.wire([ [start, 'exec', repeat, 'exec'], [repeat, 'body', set, 'exec'], - [get, 'value', add, 'a'], - [add, 'result', set, 'value'], + [get, 'count', add, 'a'], + [add, 'result', set, 'count'], [repeat, 'done', after, 'exec'], - [get, 'value', afterAdd, 'a'], - [afterAdd, 'result', after, 'value'], + [get, 'count', afterAdd, 'a'], + [afterAdd, 'result', after, 'count'], ]); const ctx = fakeContext({ variables: { count: 0 } }); new GraphInterpreter(behavior.graph).fire('OnStart', ctx); @@ -447,7 +447,7 @@ describe('variable commands', () => { expect(behavior.variables[0]!.key).toBe('velocity'); expect(behavior.props).toEqual({ enabled: true, runInEditor: false, velocity: 0 }); expect(object(logic).overrides).toEqual({ velocity: 5 }); - expect(findNode(behavior.graph, nodes.speed.id)!.params.variable).toBe('velocity'); + expect(findNode(behavior.graph, nodes.speed.id)!.ports).toEqual([{ key: 'velocity', kind: 'number' }]); stack.undo(); expect(behavior.variables[0]!.key).toBe('speed'); expect(object(logic).overrides).toEqual({ speed: 5 }); diff --git a/tests/core/legacy-variable-nodes.test.ts b/tests/core/legacy-variable-nodes.test.ts new file mode 100644 index 00000000..cced5268 --- /dev/null +++ b/tests/core/legacy-variable-nodes.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { GraphInterpreter } from '../../src/core/graph'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { behaviorLookup } from '../../src/core/model/behaviorPrograms'; +import { findAsset } from '../../src/core/model/Project'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { fakeContext, makeBehavior, makeProject } from '../helpers'; + +type RawNode = { id: string; type: string; params?: Record; ports?: unknown[] }; +type RawEdge = [from: string, fromPort: string, to: string, toPort: string]; + +/** A saved project whose behavior holds `nodes` and `edges` exactly as an older editor wrote them. */ +function loadOld(nodes: RawNode[], edges: RawEdge[], variables = ['px', 'py', 'sum']) { + const fixture = makeProject(); + const behavior = makeBehavior(fixture, 'B', variables); + fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct')); + fixture.stack.execute(new AddVariableCommand(behavior.id, 'flag', 'boolean')); + const raw = serializeProject(fixture.project) as { assets: Record[] }; + raw.assets.find((a) => a.id === behavior.id)!.graph = { + nodes: nodes.map((n) => ({ x: 0, y: 0, params: {}, ...n })), + edges: edges.map(([from, fromPort, to, toPort], i) => ({ + id: `e${i}`, + from: { node: from, port: fromPort }, + to: { node: to, port: toPort }, + })), + }; + const project = deserializeProject(raw); + return { project, behavior: findAsset(project, behavior.id) as BehaviorAsset }; +} + +const node = (behavior: BehaviorAsset, id: string) => behavior.graph.nodes.find((n) => n.id === id)!; +const edges = (behavior: BehaviorAsset) => + behavior.graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`); + +describe('variable nodes of an older file', () => { + it('load as Variables nodes that compute what the old graph computed', () => { + const { project, behavior } = loadOld( + [ + { id: 'start', type: 'OnStart' }, + { id: 'set', type: 'SetVariables', params: { px: 7 }, ports: [{ key: 'px', kind: 'number' }] }, + { id: 'y', type: 'SetVariable', params: { variable: 'py', value: 9 } }, + { id: 'get', type: 'GetVariable', params: { variable: 'px' } }, + { id: 'both', type: 'GetVariables', ports: [{ key: 'py', kind: 'number' }] }, + { id: 'add', type: 'Add' }, + { id: 'sum', type: 'Variable', params: { variable: 'sum' } }, + ], + [ + ['start', 'exec', 'set', 'exec'], + ['set', 'exec', 'y', 'exec'], + ['y', 'exec', 'sum', 'exec'], + ['get', 'value', 'add', 'a'], + ['both', 'py', 'add', 'b'], + ['add', 'result', 'sum', 'value'], + ], + ); + expect(behavior.graph.nodes.map((n) => n.type)).toEqual([ + 'OnStart', + 'Variables', + 'Variables', + 'Variables', + 'Variables', + 'Add', + 'Variables', + ]); + expect(edges(behavior)).toEqual([ + 'start.exec>set.exec', + 'set.exec>y.exec', + 'y.exec>sum.exec', + 'get.px>add.a', + 'both.py>add.b', + 'add.result>sum.sum', + ]); + const ctx = fakeContext({ variables: { px: 0, py: 0, sum: 0 } }); + new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx); + expect(ctx.variables).toEqual({ px: 7, py: 9, sum: 16 }); + }); + + it('keep whether they wrote, their typed-in value, and the kind of the variable', () => { + const { behavior } = loadOld( + [ + { id: 'get', type: 'GetVariable', params: { variable: 'flag' } }, + { id: 'set', type: 'SetVariable', params: { variable: 'px', value: 3 } }, + { id: 'plural', type: 'SetVariables', params: { py: 4 }, ports: [{ key: 'py', kind: 'number' }] }, + { id: 'gone', type: 'Variable', params: { variable: 'nope' } }, + ], + [], + ); + expect(node(behavior, 'get').ports).toEqual([{ key: 'flag', kind: 'boolean' }]); + expect(node(behavior, 'set')).toMatchObject({ + params: { px: 3 }, + ports: [{ key: 'px', kind: 'number', write: true }], + }); + expect(node(behavior, 'plural')).toMatchObject({ + params: { py: 4 }, + ports: [{ key: 'py', kind: 'number', write: true }], + }); + // A variable the behavior does not declare keeps its port, as text; the inspector marks it. + expect(node(behavior, 'gone').ports).toEqual([{ key: 'nope', kind: 'text', write: true }]); + expect(node(behavior, 'get').params).toEqual({}); + }); + + it('give a struct variable a struct port, so an Expand can still take it apart', () => { + const { behavior } = loadOld([{ id: 'get', type: 'GetVariable', params: { variable: 'piece' } }], []); + expect(node(behavior, 'get').ports).toEqual([{ key: 'piece', kind: 'struct' }]); + }); + + it('drop every edge of a node that never had a variable picked, flow included', () => { + const { behavior } = loadOld( + [ + { id: 'start', type: 'OnStart' }, + { id: 'empty', type: 'SetVariable' }, + { id: 'add', type: 'Add' }, + ], + [ + ['start', 'exec', 'empty', 'exec'], + ['add', 'result', 'empty', 'value'], + ], + ); + expect(node(behavior, 'empty').ports).toBeUndefined(); + expect(edges(behavior)).toEqual([]); + }); + + it('are left alone once they are Variables nodes', () => { + const { project, behavior } = loadOld( + [{ id: 'get', type: 'GetVariable', params: { variable: 'px' } }], + [], + ); + const again = findAsset(deserializeProject(serializeProject(project)), behavior.id) as BehaviorAsset; + expect(again.graph).toEqual(behavior.graph); + }); +}); diff --git a/tests/core/nested-behavior.test.ts b/tests/core/nested-behavior.test.ts index 842100ea..5361f354 100644 --- a/tests/core/nested-behavior.test.ts +++ b/tests/core/nested-behavior.test.ts @@ -45,11 +45,11 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number) const edit = graphEditor(fixture, asset); const start = edit.addNode('OnStart'); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: input }); - const set = edit.addNode('SetVariable', { variable: 'result' }); + const set = edit.variables(['result'], { write: true }); edit.wire([ [start, 'exec', call, 'exec'], [call, 'exec', set, 'exec'], - [call, 'out1', set, 'value'], + [call, 'out1', set, 'result'], ]); return asset; } @@ -87,13 +87,13 @@ describe('a behavior used as a node', () => { const start = edit.addNode(BEHAVIOR_START); const end = edit.addNode(BEHAVIOR_END); fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); - const get = edit.addNode('GetVariable', { variable: 'count' }); + const get = edit.variables(['count']); const plus = edit.addNode('Add', { b: 1 }); - const set = edit.addNode('SetVariable', { variable: 'count' }); + const set = edit.variables(['count'], { write: true }); edit.wire([ [start, 'exec', set, 'exec'], - [get, 'value', plus, 'a'], - [plus, 'result', set, 'value'], + [get, 'count', plus, 'a'], + [plus, 'result', set, 'count'], [set, 'exec', end, 'exec'], [plus, 'result', end, 'out1'], ]); @@ -103,12 +103,12 @@ describe('a behavior used as a node', () => { const onStart = cEdit.addNode('OnStart'); const first = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); const second = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); - const store = cEdit.addNode('SetVariable', { variable: 'result' }); + const store = cEdit.variables(['result'], { write: true }); cEdit.wire([ [onStart, 'exec', first, 'exec'], [first, 'exec', second, 'exec'], [second, 'exec', store, 'exec'], - [second, 'out1', store, 'value'], + [second, 'out1', store, 'result'], ]); const { ctx } = runOnStart(fixture, caller, { result: 0, count: 500 }); @@ -135,13 +135,13 @@ describe('a behavior used as a node', () => { const edit = graphEditor(fixture, caller); const onStart = edit.addNode('OnStart'); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); - const set = edit.addNode('SetVariable', { variable: 'result' }); + const set = edit.variables(['result'], { write: true }); const mine = edit.addNode('Number', { value: 7 }); edit.wire([ [onStart, 'exec', call, 'exec'], // Read *after* the call, and from the caller's own node – the point of the test. [call, 'exec', set, 'exec'], - [mine, 'value', set, 'value'], + [mine, 'value', set, 'result'], ]); // Ids are unique in practice; forcing a collision is the only way to show the frames are real. @@ -171,7 +171,7 @@ describe('a behavior used as a node', () => { const edit = graphEditor(fixture, caller); const start = edit.addNode('OnStart'); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: target }); - const set = edit.addNode('SetVariable', { variable: 'result' }); + const set = edit.variables(['result'], { write: true }); edit.wire([ [start, 'exec', call, 'exec'], // The flow carries on past the call, which is what keeps a dangling reference harmless. diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 8de7bccd..162af8e8 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -106,29 +106,18 @@ const CASES: Record = { // variables - GetVariable: [ - { name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } }, - { name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } }, - { name: 'no variable chosen reads as null', outputs: { value: null } }, - ], - GetVariables: [ + // Reading needs the interpreter, which asks `read` at the moment: see variable-node.test.ts. + Variables: [ { - name: 'reads one output per variable it names', + name: 'writes the variables switched to Set, each keeping its declared type', ports: [ - { key: 'speed', kind: 'number' }, - { key: 'nope', kind: 'number' }, + { key: 'speed', kind: 'number', write: true }, + { key: 'lives', kind: 'number' }, ], - outputs: { speed: 100, nope: null }, - }, - { name: 'without a variable it reads nothing', outputs: {} }, - ], - SetVariables: [ - { - name: 'writes every variable it names, each keeping its declared type', - ports: [{ key: 'speed', kind: 'number' }], - inputs: { speed: '250' }, + inputs: { speed: '250', lives: 9 }, + context: { variables: { speed: 100, lives: 3 } }, next: 'exec', - after: (ctx) => expect(ctx.variables.speed).toBe(250), + after: (ctx) => expect(ctx.variables).toEqual({ speed: 250, lives: 3 }), }, { name: 'without a variable it writes nothing', @@ -136,37 +125,6 @@ const CASES: Record = { after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }), }, ], - SetVariable: [ - { - name: 'writes a variable, 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 }), - }, - ], - // 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/nodes.test.ts b/tests/core/nodes.test.ts index ce2e67fc..ff90925b 100644 --- a/tests/core/nodes.test.ts +++ b/tests/core/nodes.test.ts @@ -19,15 +19,15 @@ describe('game-flow nodes', () => { // On click → clicks = clicks + 1 const edit = graphEditor(f, behavior); const click = edit.addNode('OnClick'); - const get = edit.addNode('GetVariable', { variable: 'clicks' }); + const get = edit.variables(['clicks']); const one = edit.addNode('Number', { value: 1 }); const add = edit.addNode('Add'); - const set = edit.addNode('SetVariable', { variable: 'clicks' }); + const set = edit.variables(['clicks'], { write: true }); edit.wire([ [click, 'exec', set, 'exec'], - [get, 'value', add, 'a'], + [get, 'clicks', add, 'a'], [one, 'value', add, 'b'], - [add, 'result', set, 'value'], + [add, 'result', set, 'clicks'], ]); const button = place(f, f.spriteId, { x: 0, y: 0 }); const logic = place(f, behavior.id, { x: 0, y: 0 }, button); diff --git a/tests/core/property-ports.test.ts b/tests/core/property-ports.test.ts index e2596c14..ca8cf557 100644 --- a/tests/core/property-ports.test.ts +++ b/tests/core/property-ports.test.ts @@ -30,7 +30,7 @@ function typedIn(params: Record = {}) { const self = edit.addNode('Self'); const set = edit.addNode(SET_PROPERTIES, params); const get = edit.addNode(GET_PROPERTIES); - const store = edit.addNode('SetVariable', { variable: 'seen' }); + const store = edit.variables(['seen'], { write: true }); for (const key of ['width', 'color']) fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' })); fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'width', kind: 'number' })); @@ -39,7 +39,7 @@ function typedIn(params: Record = {}) { [self, 'object', set, 'object'], [set, 'exec', store, 'exec'], [self, 'object', get, 'object'], - [get, 'width', store, 'value'], + [get, 'width', store, 'seen'], ]); return { fixture, behavior, set, get }; } @@ -70,6 +70,11 @@ describe('a node that reads or writes several properties at once', () => { expect(propertyPort({ key: 'width', kind: 'text' }).field).toBeUndefined(); }); + it('gives a custom name the zero it writes untyped, so the node shows a value', () => { + expect(propertyPort({ key: 'speed', kind: 'number' }).default).toBe(0); + expect(propertyPort({ key: 'ready', kind: 'boolean' }).default).toBe(false); + }); + it('keeps the fixed object input its own, so no declared port can take its key', () => { const { fixture, behavior, set, get } = typedIn(); const add = (nodeId: string) => diff --git a/tests/core/variable-node.test.ts b/tests/core/variable-node.test.ts index 65dcab8a..8ad22a43 100644 --- a/tests/core/variable-node.test.ts +++ b/tests/core/variable-node.test.ts @@ -1,61 +1,69 @@ import { describe, expect, it } from 'vitest'; -import { RenameVariableCommand } from '../../src/core/commands/variableCommands'; -import { GraphInterpreter, resolveDef } from '../../src/core/graph'; +import { RemovePortCommand, SetPortWriteCommand } from '../../src/core/commands/portCommands'; +import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; +import { type GraphNode, GraphInterpreter, inlineValue, 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() { +function setup(variables = ['count', 'copy']) { const fixture = makeProject(); - const behavior = makeBehavior(fixture, 'B', ['count', 'copy']); + const behavior = makeBehavior(fixture, 'B', variables); return { fixture, behavior, edit: graphEditor(fixture, behavior) }; } -function run(behavior: BehaviorAsset, fixture: ReturnType, count = 5) { - const ctx = fakeContext({ variables: { count, copy: 0 } }); +function run( + behavior: BehaviorAsset, + fixture: ReturnType, + variables: Record = { count: 5, copy: 0 }, +) { + const ctx = fakeContext({ variables: { ...variables } }); 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; +const ports = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') => + resolveDef(node, behavior.graph, () => null)![side].map((p) => p.key); -describe('the Variable node', () => { +const edgeCount = (behavior: BehaviorAsset) => behavior.graph.edges.length; + +describe('the Variables 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' }); + const read = edit.variables(['count']); + const store = edit.variables(['copy'], { write: true }); edit.wire([ - [start, 'exec', copy, 'exec'], - [variable, 'value', copy, 'value'], + [start, 'exec', store, 'exec'], + [read, 'count', store, 'copy'], ]); 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(); + it('writes the variables switched to Set, typed in or through an edge, and leaves the rest', () => { + const { fixture, behavior, edit } = setup(['count', 'copy', 'kept']); 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' }); + const node = edit.variables(['count', 'copy', 'kept'], { + write: ['count', 'copy'], + params: { count: 7 }, + }); edit.wire([ - [start, 'exec', inline, 'exec'], - [inline, 'exec', wired, 'exec'], - [number, 'value', wired, 'value'], + [start, 'exec', node, 'exec'], + [number, 'value', node, 'copy'], ]); - expect(run(behavior, fixture)).toEqual({ count: 7, copy: 3 }); + expect(run(behavior, fixture, { count: 5, copy: 0, kept: 4 })).toEqual({ count: 7, copy: 3, kept: 4 }); }); 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 count = edit.variables(['count'], { write: true }); const add = edit.addNode('Add', { b: 1 }); edit.wire([ - [start, 'exec', variable, 'exec'], - [variable, 'value', add, 'a'], - [add, 'result', variable, 'value'], + [start, 'exec', count, 'exec'], + [count, 'count', add, 'a'], + [add, 'result', count, 'count'], ]); expect(run(behavior, fixture).count).toBe(6); }); @@ -63,40 +71,189 @@ describe('the Variable node', () => { 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' }); + const count = edit.variables(['count'], { write: true, params: { count: 7 } }); + const overwrite = edit.variables(['count'], { write: true, params: { count: 9 } }); + const copy = edit.variables(['copy'], { write: true }); edit.wire([ - [start, 'exec', variable, 'exec'], - [variable, 'exec', overwrite, 'exec'], + [start, 'exec', count, 'exec'], + [count, 'exec', overwrite, 'exec'], [overwrite, 'exec', copy, 'exec'], - [variable, 'value', copy, 'value'], + [count, 'count', copy, 'copy'], ]); expect(run(behavior, fixture)).toEqual({ count: 9, copy: 9 }); }); + + /** `Get variables` answered once per pulse; one node read on both sides of a write gave the old value twice. */ + it('reaches across a write of one of its variables', () => { + const { fixture, behavior, edit } = setup(['px', 'before', 'after']); + const start = edit.addNode('OnStart'); + const read = edit.variables(['px']); + const before = edit.variables(['before'], { write: true }); + const write = edit.variables(['px'], { write: true, params: { px: 7 } }); + const after = edit.variables(['after'], { write: true }); + edit.wire([ + [start, 'exec', before, 'exec'], + [before, 'exec', write, 'exec'], + [write, 'exec', after, 'exec'], + [read, 'px', before, 'before'], + [read, 'px', after, 'after'], + ]); + expect(run(behavior, fixture, { px: 1, before: 0, after: 0 })).toEqual({ px: 7, before: 1, after: 7 }); + }); + + it('reads every input before it writes any, so one node swaps two variables', () => { + const { fixture, behavior, edit } = setup(['a', 'b']); + const start = edit.addNode('OnStart'); + const read = edit.variables(['a', 'b']); + const swap = edit.variables(['a', 'b'], { write: true }); + edit.wire([ + [start, 'exec', swap, 'exec'], + [read, 'b', swap, 'a'], + [read, 'a', swap, 'b'], + ]); + expect(run(behavior, fixture, { a: 1, b: 2 })).toEqual({ a: 2, b: 1 }); + }); + + it('carries the value it writes when nothing is typed, so the node can show it', () => { + const { fixture, behavior, edit } = setup(); + for (const [key, kind] of [ + ['label', 'text'], + ['on', 'boolean'], + ['items', 'list'], + ] as const) + fixture.stack.execute(new AddVariableCommand(behavior.id, key, kind)); + const start = edit.addNode('OnStart'); + const node = edit.variables(['count', 'label', 'on', 'items'], { write: true, params: { label: 'hi' } }); + edit.connect(start, 'exec', node, 'exec'); + const carried = resolveDef(node, behavior.graph, () => null)! + .inputs.filter((p) => p.type !== 'exec') + .map((p) => [p.key, inlineValue(node, p)]); + expect(Object.fromEntries(carried)).toEqual({ count: 0, label: 'hi', on: false, items: [] }); + expect(run(behavior, fixture).count).toBe(0); + }); + + it('has flow pins only while one of its variables is set', () => { + const { behavior, edit } = setup(); + const read = edit.variables(['count', 'copy']); + const mixed = edit.variables(['count', 'copy'], { write: ['copy'] }); + expect([ports(behavior, read, 'inputs'), ports(behavior, read, 'outputs')]).toEqual([ + [], + ['count', 'copy'], + ]); + expect([ports(behavior, mixed, 'inputs'), ports(behavior, mixed, 'outputs')]).toEqual([ + ['exec', 'copy'], + ['exec', 'count', 'copy'], + ]); + }); }); -describe('variable node titles', () => { - it('name the picked variable, and keep the plain title until one is picked', () => { +describe('switching Set on a Variables node', () => { + function wired() { + const { fixture, behavior, edit } = setup(); + const start = edit.addNode('OnStart'); + const number = edit.addNode('Number', { value: 3 }); + const node = edit.variables(['count', 'copy'], { write: true, params: { count: 7 } }); + const next = edit.variables(['copy'], { write: true }); + edit.wire([ + [start, 'exec', node, 'exec'], + [node, 'exec', next, 'exec'], + [number, 'value', node, 'copy'], + [node, 'count', next, 'copy'], + ]); + return { fixture, behavior, node }; + } + + it('off takes the input with its edge and typed value, and undo brings both back', () => { + const { fixture, behavior, node } = wired(); + const edges = structuredClone(behavior.graph.edges); + fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'copy', false)); + expect(ports(behavior, node, 'inputs')).toEqual(['exec', 'count']); + expect(edgeCount(behavior)).toBe(3); + fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false)); + expect(node.params.count).toBeUndefined(); + + fixture.stack.undo(); + fixture.stack.undo(); + expect(node.params.count).toBe(7); + expect(behavior.graph.edges).toEqual(edges); + }); + + it('off on the last one takes the flow pins and their edges along, and undo restores the order', () => { + const { fixture, behavior, node } = wired(); + const edges = structuredClone(behavior.graph.edges); + fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'copy', false)); + fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false)); + expect(ports(behavior, node, 'inputs')).toEqual([]); + expect(ports(behavior, node, 'outputs')).toEqual(['count', 'copy']); + // Only the read of `count` is left: the flow in and out of the node went with its pins. + expect(behavior.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['count>copy']); + + fixture.stack.undo(); + fixture.stack.undo(); + expect(behavior.graph.edges).toEqual(edges); + }); + + /** Edges of both pins and of the flow, interleaved in the list: undo has to put each back in its place. */ + function interleaved() { + const { fixture, behavior, edit } = setup(); + const start = edit.addNode('OnStart'); + const number = edit.addNode('Number', { value: 3 }); + const node = edit.variables(['count'], { write: true }); + const log = edit.addNode('Log'); + edit.wire([ + [start, 'exec', node, 'exec'], + [node, 'count', log, 'message'], + [number, 'value', node, 'count'], + [node, 'exec', log, 'exec'], + ]); + return { fixture, behavior, node, edges: structuredClone(behavior.graph.edges) }; + } + + it('off on the only one takes its edge and the flow in one step, and undo restores the order', () => { + const { fixture, behavior, node, edges } = interleaved(); + fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false)); + expect(behavior.graph.edges.map((e) => e.to.port)).toEqual(['message']); + fixture.stack.undo(); + expect(behavior.graph.edges).toEqual(edges); + }); + + it('removing the last written variable takes both its pins and the flow, and undo restores the order', () => { + const { fixture, behavior, node, edges } = interleaved(); + fixture.stack.execute(new RemovePortCommand(behavior.id, node.id, 'count')); + expect(edgeCount(behavior)).toBe(0); + fixture.stack.undo(); + expect(behavior.graph.edges).toEqual(edges); + }); + + it('on is undone to off, not to whatever the opposite of the request is', () => { + const { fixture, behavior, edit } = setup(); + const node = edit.variables(['count'], { write: true }); + fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', true)); + fixture.stack.undo(); + expect(node.ports?.[0]?.write).toBe(true); + }); +}); + +describe('Variables node titles', () => { + const titleOf = (behavior: BehaviorAsset, node: GraphNode) => + resolveDef(node, behavior.graph, () => null)!.label; + + it('name a single variable, and stay plain for none or several', () => { 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', + const one = edit.variables(['count'], { write: true }); + const none = edit.variables([]); + const two = edit.variables(['count', 'copy']); + expect([one, none, two].map((node) => titleOf(behavior, node))).toEqual([ 'count', - 'Get variable', - 'Variable', + 'Variables', + 'Variables', ]); }); it('follow a renamed variable', () => { const { fixture, behavior, edit } = setup(); - edit.addNode('Variable', { variable: 'count' }); + const node = edit.variables(['count']); fixture.stack.execute(new RenameVariableCommand(behavior.id, 'count', 'score')); - expect(titleOf(behavior, 0)).toBe('score'); + expect(titleOf(behavior, node)).toBe('score'); }); }); diff --git a/tests/core/variable-ports.test.ts b/tests/core/variable-ports.test.ts index 608e149d..ee06350b 100644 --- a/tests/core/variable-ports.test.ts +++ b/tests/core/variable-ports.test.ts @@ -1,7 +1,7 @@ /** - * Get/Set variables: the two nodes whose declared ports are variables of the behavior around them, - * rather than names of their own. What that costs is the wiring – a port has to follow the variable - * it names when that is renamed or swapped, and take its edges along. + * The Variables node declares variables of the behavior around it as ports, rather than names of + * its own. What that costs is the wiring – a port has to follow the variable it names when that is + * renamed or swapped, and take its edges along, on the input side as well as on the output side. */ import { describe, expect, it } from 'vitest'; import { AddPortCommand, RepointPortCommand } from '../../src/core/commands/portCommands'; @@ -11,13 +11,7 @@ import { RemoveVariableCommand, RenameVariableCommand, } from '../../src/core/commands/variableCommands'; -import { - GET_VARIABLES, - type GraphNode, - GraphInterpreter, - resolveDef, - SET_VARIABLES, -} from '../../src/core/graph'; +import { type GraphNode, GraphInterpreter, resolveDef, VARIABLES } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { behaviorLookup } from '../../src/core/model/behaviorPrograms'; import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; @@ -28,21 +22,18 @@ function bothWays() { const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum']); const edit = graphEditor(fixture, behavior); const start = edit.addNode('OnStart'); - const set = edit.addNode(SET_VARIABLES, { px: 7, py: 9 }); - const get = edit.addNode(GET_VARIABLES); + const set = edit.variables(['px', 'py'], { write: true, params: { px: 7, py: 9 } }); + const get = edit.variables(['px', 'py']); const add = edit.addNode('Add'); - const store = edit.addNode('SetVariable', { variable: 'sum' }); - for (const node of [set, get]) - for (const key of ['px', 'py']) - fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key, kind: 'number' })); + const store = edit.variables(['sum'], { write: true }); edit.wire([ [start, 'exec', set, 'exec'], [set, 'exec', store, 'exec'], [get, 'px', add, 'a'], [get, 'py', add, 'b'], - [add, 'result', store, 'value'], + [add, 'result', store, 'sum'], ]); - return { fixture, behavior, set, get, edit }; + return { fixture, behavior, set, get, store, edit }; } function run( @@ -70,17 +61,20 @@ describe('a node that reads or writes several variables at once', () => { fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean')); fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'alive', kind: 'boolean' })); expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number', 'alive:boolean']); - // The exec port is the node's own; only the variables come from the declaration. expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']); }); - it('follows a renamed variable, edge and all', () => { - const { fixture, behavior, get } = bothWays(); + it('follows a renamed variable, edges and typed-in value on both sides', () => { + const { fixture, behavior, get, set, store } = bothWays(); fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column')); + fixture.stack.execute(new RenameVariableCommand(behavior.id, 'sum', 'total')); expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']); + expect(set.params).toEqual({ column: 7, py: 9 }); expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true); - expect(run(behavior, fixture, { column: 0, py: 0, sum: 0 })).toEqual({ column: 7, py: 9, sum: 16 }); + expect(behavior.graph.edges.some((e) => e.to.node === store.id && e.to.port === 'total')).toBe(true); + expect(run(behavior, fixture, { column: 0, py: 0, total: 0 })).toEqual({ column: 7, py: 9, total: 16 }); + fixture.stack.undo(); fixture.stack.undo(); expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']); expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 }); @@ -112,55 +106,21 @@ describe('a node that reads or writes several variables at once', () => { expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 }); }); - /** - * The rule that decides how far a `Get variables` may reach: a data node answers once per pulse - * and that answer is kept, so a single node cannot straddle a write of one of its variables – the - * later reader would be served the value from before it. A second node is the way across. - */ - it('answers once per pulse, so a reader past a write needs a node of its own', () => { - const fixture = makeProject(); - const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum', 'fresh']); - const edit = graphEditor(fixture, behavior); - const start = edit.addNode('OnStart'); - const early = edit.addNode(GET_VARIABLES); - const late = edit.addNode(GET_VARIABLES); - const write = edit.addNode(SET_VARIABLES, { px: 7 }); - const before = edit.addNode('SetVariable', { variable: 'sum' }); - const after = edit.addNode('SetVariable', { variable: 'py' }); - const again = edit.addNode('SetVariable', { variable: 'fresh' }); - for (const node of [early, late, write]) - fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key: 'px', kind: 'number' })); - edit.wire([ - [start, 'exec', before, 'exec'], - [before, 'exec', write, 'exec'], - [write, 'exec', after, 'exec'], - [after, 'exec', again, 'exec'], - [early, 'px', before, 'value'], - [early, 'px', after, 'value'], - [late, 'px', again, 'value'], - ]); - // `py` is the point: the same node read past the write still answers 1, while `late` sees 7. - expect(run(behavior, fixture, { px: 1, py: 0, sum: 0, fresh: 0 })).toEqual({ - px: 7, - py: 1, - sum: 1, - fresh: 7, - }); - }); - it('survives a save and load, which is what makes the pick more than a session', () => { const { fixture, behavior } = bothWays(); const loaded = roundTrip(fixture.project); const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset; - const set = asset.graph.nodes.find((n) => n.type === SET_VARIABLES)!; - expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']); + const set = asset.graph.nodes.find((n) => n.type === VARIABLES && n.params.px === 7)!; + expect(set.ports).toEqual([ + { key: 'px', kind: 'number', write: true }, + { key: 'py', kind: 'number', write: true }, + ]); expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 }); }); /** - * A struct port writes the way the singular node does: the bundle it is handed is merged in, so a - * `Pack` of one field leaves the rest standing. Tetris leans on this – each strand of `Next move` - * packs only the field it changes and lets the plural node keep the other three. + * The bundle a struct port is handed is merged in, so a `Pack` of one field leaves the rest + * standing. Tetris leans on this: each strand of `Next move` packs only the field it changes. */ it('merges a partial bundle into a struct variable instead of replacing it', () => { const fixture = makeProject(); @@ -170,9 +130,8 @@ describe('a node that reads or writes several variables at once', () => { fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key, kind: 'number' })); const edit = graphEditor(fixture, behavior); const start = edit.addNode('OnStart'); - const set = edit.addNode(SET_VARIABLES); + const set = edit.variables(['piece'], { write: true }); const pack = edit.addNode('PackStruct', { rot: 2 }); - fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'piece', kind: 'struct' })); fixture.stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'rot', kind: 'number' })); edit.wire([ [start, 'exec', set, 'exec'], @@ -183,24 +142,4 @@ describe('a node that reads or writes several variables at once', () => { new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx); expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 }); }); - - /** - * The other half of how far a plural node may reach: it collects every input before it writes any - * of them, so an input fed from a variable the same node writes sees the value from before. - */ - it('reads every input before it writes any, so a plural set cannot feed itself', () => { - const fixture = makeProject(); - const behavior = makeBehavior(fixture, 'B', ['px', 'py']); - const edit = graphEditor(fixture, behavior); - const start = edit.addNode('OnStart'); - const read = edit.addNode('GetVariable', { variable: 'px' }); - const set = edit.addNode(SET_VARIABLES, { px: 7 }); - for (const key of ['px', 'py']) - fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' })); - edit.wire([ - [start, 'exec', set, 'exec'], - [read, 'value', set, 'py'], - ]); - expect(run(behavior, fixture, { px: 1, py: 0 })).toEqual({ px: 7, py: 1 }); - }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 2b445674..9d3520bc 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,9 +1,11 @@ import { CreateAssetCommand } from '../src/core/commands/assetCommands'; import { CommandStack } from '../src/core/commands/CommandStack'; import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphCommands'; +import { AddPortsCommand } from '../src/core/commands/portCommands'; import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../src/core/commands/variableCommands'; import { + type DeclaredPort, ENTITY_HANDLE, type EntityHandle, type GraphNode, @@ -12,6 +14,8 @@ import { type Value, requireNodeDef, type RunResult, + portKindOf, + VARIABLES, } from '../src/core/graph'; import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset'; import { findObject, worldPosition } from '../src/core/model/hierarchy'; @@ -98,6 +102,11 @@ export type Edge = readonly [from: GraphNode, fromPort: string, to: GraphNode, t export interface GraphEditor { /** Adds a node with inline params (graph coordinates do not matter to tests). */ addNode(type: string, params?: PropertyBag): GraphNode; + /** A Variables node naming `keys`, typed by their declaration; `write` sets all of them or the listed. */ + variables( + keys: readonly string[], + options?: { write?: boolean | readonly string[]; params?: PropertyBag }, + ): GraphNode; connect(from: GraphNode, fromPort: string, to: GraphNode, toPort: string): void; wire(edges: readonly Edge[]): void; } @@ -116,6 +125,18 @@ export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): G stack.execute(command); return command.node; }, + variables(keys, { write = false, params = {} } = {}) { + const command = new AddNodeCommand(behavior.id, VARIABLES, 0, 0, params); + stack.execute(command); + const writes = (key: string) => write === true || (Array.isArray(write) && write.includes(key)); + const ports = keys.map((key): DeclaredPort => { + const declared = behavior.variables.find((v) => v.key === key); + const kind = declared ? portKindOf(declared) : 'number'; + return writes(key) ? { key, kind, write: true } : { key, kind }; + }); + if (ports.length > 0) stack.execute(new AddPortsCommand(behavior.id, command.node.id, ports)); + return command.node; + }, connect, wire(edges) { for (const [from, fromPort, to, toPort] of edges) connect(from, fromPort, to, toPort); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index bee5e9d5..e370bb68 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -820,7 +820,7 @@ export async function graph(t) { ); await shot('24b-node-collapsed'); - const speedOut = await json(`__gm.graph.portAt('n-speed', 'value', true)`); + 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 }); diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs index 617ff2ef..98f1a97c 100644 --- a/tools/e2e/23-variable-nodes.mjs +++ b/tools/e2e/23-variable-nodes.mjs @@ -1,10 +1,10 @@ -import { edge, node, port, variable } from '../lib/builders.mjs'; +import { edge, node, port, variable, written } from '../lib/builders.mjs'; import { MAKE_OBJECT } from './lib/scaffold.mjs'; /** - * Get variables / Set variables: one node for several of them. Their ports are the behavior's - * variables, so the step checks all three sides of that – the run, the picking of a port from the - * variable list, and pointing an existing port at another variable without losing its edge. + * 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. */ @@ -12,7 +12,7 @@ import { MAKE_OBJECT } from './lib/scaffold.mjs'; const SCALE = 100; /** - * `On update → Set variables{vx = 2, vy = 3} → Move(owner, dx = vx, dy = vy)`, where the two + * `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. */ @@ -20,8 +20,8 @@ const logicGraph = { nodes: [ node('vp-update', 'OnUpdate', 0, 0), node('vp-owner', 'Owner', 0, 300), - node('vp-set', 'SetVariables', 260, 0, { vx: 2, vy: 3 }, [port('vx', 'number'), port('vy', 'number')]), - node('vp-get', 'GetVariables', 260, 180, {}, [port('vx', 'number'), port('vy', 'number')]), + 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: [ @@ -50,6 +50,7 @@ export async function variableNodes(t) { stop, typeText, press, + has, } = t; const portAt = (nodeId, key, isOutput) => json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); @@ -67,6 +68,13 @@ export async function variableNodes(t) { await click(p.x, p.y); await sleep(200); }; + /** 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); + await sleep(300); + return box; + }; // 23a. A box wearing a behavior whose two speeds travel through its variables. await mutate(` @@ -104,18 +112,24 @@ export async function variableNodes(t) { await stop(); await sleep(300); - // 23c. The node draws one port per variable it names, on the side its type says. + // 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 vxIn = await portAt('vp-set', 'vx', false); - const vyIn = await portAt('vp-set', 'vy', false); - const vxOut = await portAt('vp-get', 'vx', true); + 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( - 'the picked variables are ports: inputs on Set, outputs on Get', - Boolean(vxIn) && Boolean(vyIn) && vyIn.y > vxIn.y && Boolean(vxOut), - JSON.stringify({ vxIn, vyIn, vxOut }), + '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. + // 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'); @@ -125,15 +139,32 @@ export async function variableNodes(t) { await sleep(300); const grown = await portsOf('vp-set'); check( - 'picking one adds it as a port, typed from the variable', - grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number', + '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 input', Boolean(await portAt('vp-set', 'vz', false))); - await shot('02-variable-added'); + 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 sleep(300); - check('undo takes it back', (await portsOf('vp-set')).length === 2); + await undo(); + await sleep(300); + 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. @@ -231,63 +262,54 @@ 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. + // 23i. From the palette the node comes empty; picking a variable titles it, 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(); 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'); + // Enter takes the first hit in category order, so search by a phrase only its description has. + await typeText('switched to Set'); 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)); + check('the palette offers the Variables node', added?.type === 'Variables', 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); + const bare = !(await portAt(variableId, 'exec', false)); + await clickLast('+ Variable'); await clickLast('vz'); await sleep(300); + await clickLastSet(); 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 }), + 'it starts without pins, and picking its variable makes that the title', + untitled === 'Variables' && bare && (await titleOf(variableId)) === 'vz', + 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-set-vx', 'SetVariable', 800, 400, { variable: 'vx' }))}, - ${JSON.stringify(node('vp-set-gone', 'SetVariable', 800, 500, { variable: 'gone' }))}, + ${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, 'value', 'vp-add', 'a'))}, - ${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'value'))}, + ${JSON.stringify(edge('vp-e8', variableId, 'vz', 'vp-add', 'a'))}, + ${JSON.stringify(edge('vp-e9', 'vp-add', 'result', variableId, 'vz'))}, ); `); 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') }; + await select('vp-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 }), + '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(); diff --git a/tools/e2e/24-property-nodes.mjs b/tools/e2e/24-property-nodes.mjs index ea413113..43536f2b 100644 --- a/tools/e2e/24-property-nodes.mjs +++ b/tools/e2e/24-property-nodes.mjs @@ -1,9 +1,9 @@ -import { edge, node, port, variable } from '../lib/builders.mjs'; +import { edge, node, port, variable, written } from '../lib/builders.mjs'; import { MAKE_OBJECT } from './lib/scaffold.mjs'; /** * Get properties / Set properties: several properties of an object in one node, picked the way - * Get/Set variables pick theirs. A built-in property keeps its own field and default; a name the + * the Variables node picks its variables. A built-in property keeps its own field and default; a name the * list does not know is typed. The step brings its own box and behavior and removes them again. */ @@ -17,7 +17,7 @@ const logicGraph = { port('color', 'number'), ]), node('pp-get', 'GetProperties', 260, 300, {}, [port('width', 'number')]), - node('pp-store', 'SetVariable', 560, 0, { variable: 'seen' }), + node('pp-store', 'Variables', 560, 0, {}, [written('seen', 'number')]), node('pp-single', 'SetProperty', 560, 300, { property: 'width' }), ], edges: [ @@ -25,7 +25,7 @@ const logicGraph = { edge('pp-e2', 'pp-owner', 'object', 'pp-set', 'object'), edge('pp-e3', 'pp-set', 'exec', 'pp-store', 'exec'), edge('pp-e4', 'pp-owner', 'object', 'pp-get', 'object'), - edge('pp-e5', 'pp-get', 'width', 'pp-store', 'value'), + edge('pp-e5', 'pp-get', 'width', 'pp-store', 'seen'), ], }; diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs index eccf554e..1d29d9b0 100644 --- a/tools/lib/builders.mjs +++ b/tools/lib/builders.mjs @@ -42,9 +42,12 @@ export const node = (id, type, x, y, params = {}, ports) => ({ ...(ports ? { ports } : {}), }); -/** A port a behavior's start or end node declares. */ +/** A port a node declares: an input of a start node, a field of a struct, a variable read by `Variables`. */ export const port = (key, kind) => ({ key, kind }); +/** A variable a `Variables` node writes as well as reads. */ +export const written = (key, kind) => ({ key, kind, write: true }); + /** * A behavior variable declaration. `items` is the item kind of a `list`, `fields` the shape of a * `struct` – or of the items of a `list` whose items are structs. diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index 7fdae7bd..ff6a1fdd 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -6,7 +6,7 @@ * * Usage: node tools/seed-e2e.mjs [projectId] [baseUrl] */ -import { edge, node, sceneObject, variable } from './lib/builders.mjs'; +import { edge, node, port, sceneObject, variable } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture); @@ -21,7 +21,7 @@ const moverGraph = { node('n-update', 'OnUpdate', 0, 0), node('n-owner', 'Owner', 0, 120), node('n-axis', 'AxisInput', 0, 200), - node('n-speed', 'GetVariable', 0, 400, { variable: 'speed' }), + node('n-speed', 'Variables', 0, 400, {}, [port('speed', 'number')]), node('n-move', 'Move', 340, 0, { collide: true }), ], edges: [ @@ -30,7 +30,7 @@ const moverGraph = { edge('e2', 'n-owner', 'object', 'n-move', 'object'), edge('e3', 'n-axis', 'x', 'n-move', 'dx'), edge('e4', 'n-axis', 'y', 'n-move', 'dy'), - edge('e5', 'n-speed', 'value', 'n-move', 'scale'), + edge('e5', 'n-speed', 'speed', 'n-move', 'scale'), ], }; diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs index 892b1117..e7e92d02 100644 --- a/tools/seed-jumprun.mjs +++ b/tools/seed-jumprun.mjs @@ -14,7 +14,7 @@ * * Usage: node tools/seed-jumprun.mjs [projectId] [baseUrl] */ -import { edge, node, sceneObject, variable } from './lib/builders.mjs'; +import { edge, node, port, sceneObject, variable, written } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun); @@ -132,29 +132,29 @@ const playerGraph = { node('p-upd', 'OnUpdate', 0, 0), node('p-owner', 'Owner', 0, 140), node('p-axis', 'AxisInput', 0, 220, { left: 'ArrowLeft', right: 'ArrowRight', up: '', down: '' }), - node('p-speed', 'GetVariable', 0, 360, { variable: 'speed' }), + node('p-speed', 'Variables', 0, 360, {}, [port('speed', 'number')]), node('p-mulx', 'Multiply', 220, 300), - node('p-vy', 'GetVariable', 0, 440, { variable: 'vy' }), - node('p-grav', 'GetVariable', 0, 520, { variable: 'gravity' }), + node('p-vy', 'Variables', 0, 440, {}, [port('vy', 'number')]), + node('p-grav', 'Variables', 0, 520, {}, [port('gravity', 'number')]), node('p-mulg', 'Multiply', 220, 500), node('p-addvy', 'Add', 420, 460), - node('p-setvy', 'SetVariable', 620, 0, { variable: 'vy' }), - node('p-vy2', 'GetVariable', 620, 340, { variable: 'vy' }), + node('p-setvy', 'Variables', 620, 0, {}, [written('vy', 'number')]), + node('p-vy2', 'Variables', 620, 340, {}, [port('vy', 'number')]), node('p-move', 'Move', 840, 0, { collide: true }), node('p-br', 'Branch', 1080, 0), node('p-seq', 'Sequence', 1260, 0), - node('p-vy3', 'GetVariable', 1080, 260, { variable: 'vy' }), + node('p-vy3', 'Variables', 1080, 260, {}, [port('vy', 'number')]), node('p-cmp', 'Compare', 1260, 260, { op: '>=', b: 0 }), - node('p-ground', 'SetVariable', 1460, 0, { variable: 'onGround' }), - node('p-zero', 'SetVariable', 1460, 140, { variable: 'vy', value: 0 }), - node('p-air', 'SetVariable', 1260, 400, { variable: 'onGround', value: false }), + node('p-ground', 'Variables', 1460, 0, {}, [written('onGround', 'boolean')]), + node('p-zero', 'Variables', 1460, 140, { vy: 0 }, [written('vy', 'number')]), + node('p-air', 'Variables', 1260, 400, { onGround: false }, [written('onGround', 'boolean')]), node('p-key', 'OnKeyDown', 0, 700, { key: ' ' }), - node('p-og', 'GetVariable', 0, 820, { variable: 'onGround' }), + node('p-og', 'Variables', 0, 820, {}, [port('onGround', 'boolean')]), node('p-brj', 'Branch', 240, 700), - node('p-jump', 'GetVariable', 240, 880, { variable: 'jumpSpeed' }), + node('p-jump', 'Variables', 240, 880, {}, [port('jumpSpeed', 'number')]), node('p-neg', 'Multiply', 440, 880, { b: -1 }), - node('p-setjump', 'SetVariable', 660, 700, { variable: 'vy' }), + node('p-setjump', 'Variables', 660, 700, {}, [written('vy', 'number')]), node('p-col', 'OnCollisionEnter', 0, 1060), node('p-iscoin', 'IsAsset', 260, 1180, { asset: 'coin-sprite' }), @@ -169,32 +169,32 @@ const playerGraph = { ], edges: [ edge('pe1', 'p-upd', 'exec', 'p-setvy', 'exec'), - edge('pe2', 'p-vy', 'value', 'p-addvy', 'a'), - edge('pe3', 'p-grav', 'value', 'p-mulg', 'a'), + edge('pe2', 'p-vy', 'vy', 'p-addvy', 'a'), + edge('pe3', 'p-grav', 'gravity', 'p-mulg', 'a'), edge('pe4', 'p-upd', 'dt', 'p-mulg', 'b'), edge('pe5', 'p-mulg', 'result', 'p-addvy', 'b'), - edge('pe6', 'p-addvy', 'result', 'p-setvy', 'value'), + edge('pe6', 'p-addvy', 'result', 'p-setvy', 'vy'), edge('pe7', 'p-setvy', 'exec', 'p-move', 'exec'), edge('pe8', 'p-owner', 'object', 'p-move', 'object'), edge('pe9', 'p-axis', 'x', 'p-mulx', 'a'), - edge('pe10', 'p-speed', 'value', 'p-mulx', 'b'), + edge('pe10', 'p-speed', 'speed', 'p-mulx', 'b'), edge('pe11', 'p-mulx', 'result', 'p-move', 'dx'), - edge('pe12', 'p-vy2', 'value', 'p-move', 'dy'), + edge('pe12', 'p-vy2', 'vy', 'p-move', 'dy'), edge('pe13', 'p-upd', 'dt', 'p-move', 'dt'), edge('pe14', 'p-move', 'exec', 'p-br', 'exec'), edge('pe15', 'p-move', 'blockedY', 'p-br', 'condition'), edge('pe16', 'p-br', 'then', 'p-seq', 'exec'), edge('pe17', 'p-seq', 'a', 'p-ground', 'exec'), - edge('pe18', 'p-vy3', 'value', 'p-cmp', 'a'), - edge('pe19', 'p-cmp', 'result', 'p-ground', 'value'), + edge('pe18', 'p-vy3', 'vy', 'p-cmp', 'a'), + edge('pe19', 'p-cmp', 'result', 'p-ground', 'onGround'), edge('pe20', 'p-seq', 'b', 'p-zero', 'exec'), edge('pe21', 'p-br', 'else', 'p-air', 'exec'), edge('pe22', 'p-key', 'exec', 'p-brj', 'exec'), - edge('pe23', 'p-og', 'value', 'p-brj', 'condition'), + edge('pe23', 'p-og', 'onGround', 'p-brj', 'condition'), edge('pe24', 'p-brj', 'then', 'p-setjump', 'exec'), - edge('pe25', 'p-jump', 'value', 'p-neg', 'a'), - edge('pe26', 'p-neg', 'result', 'p-setjump', 'value'), + edge('pe25', 'p-jump', 'jumpSpeed', 'p-neg', 'a'), + edge('pe26', 'p-neg', 'result', 'p-setjump', 'vy'), edge('pe27', 'p-col', 'exec', 'p-brc', 'exec'), edge('pe28', 'p-col', 'other', 'p-iscoin', 'object'), @@ -229,36 +229,36 @@ const gameGraph = { node('g-off3', 'SetEnabled', 920, 0, { enabled: false }), node('g-upd', 'OnUpdate', 0, 520), - node('g-running', 'GetVariable', 0, 640, { variable: 'running' }), + node('g-running', 'Variables', 0, 640, {}, [port('running', 'boolean')]), node('g-brrun', 'Branch', 220, 520), - node('g-time', 'GetVariable', 220, 700, { variable: 'timeLeft' }), + node('g-time', 'Variables', 220, 700, {}, [port('timeLeft', 'number')]), node('g-sub', 'Subtract', 420, 700), node('g-max', 'Max', 600, 700, { b: 0 }), - node('g-settime', 'SetVariable', 440, 520, { variable: 'timeLeft' }), + node('g-settime', 'Variables', 440, 520, {}, [written('timeLeft', 'number')]), node('g-timelabel', 'ObjectByName', 440, 860, { name: 'TimeLabel' }), - node('g-time2', 'GetVariable', 440, 940, { variable: 'timeLeft' }), + node('g-time2', 'Variables', 440, 940, {}, [port('timeLeft', 'number')]), node('g-ceil', 'Ceil', 640, 940), node('g-join', 'Join', 840, 940, { a: 'Time ' }), node('g-settext', 'SetText', 700, 520), node('g-coinlabel', 'ObjectByName', 700, 1080, { name: 'CoinLabel' }), - node('g-coins', 'GetVariable', 700, 1160, { variable: 'coins' }), + node('g-coins', 'Variables', 700, 1160, {}, [port('coins', 'number')]), node('g-totext2', 'ToText', 900, 1160, { decimals: 0 }), node('g-join2', 'Join', 1080, 1160, { a: 'Coins ' }), node('g-join3', 'Join', 1260, 1160, { b: '/10' }), node('g-settext2', 'SetText', 960, 520), - node('g-needed', 'GetVariable', 960, 700, { variable: 'needed' }), + node('g-needed', 'Variables', 960, 700, {}, [port('needed', 'number')]), node('g-cmpwin', 'Compare', 1160, 700, { op: '>=' }), node('g-brwin', 'Branch', 1220, 520), - node('g-stop1', 'SetVariable', 1420, 440, { variable: 'running', value: false }), + node('g-stop1', 'Variables', 1420, 440, { running: false }, [written('running', 'boolean')]), node('g-leveloff1', 'SetEnabled', 1620, 440, { enabled: false }), node('g-winon', 'SetEnabled', 1820, 440, { enabled: true }), node('g-cmplose', 'Compare', 1160, 860, { op: '<=', b: 0 }), node('g-brlose', 'Branch', 1420, 640), - node('g-stop2', 'SetVariable', 1620, 640, { variable: 'running', value: false }), + node('g-stop2', 'Variables', 1620, 640, { running: false }, [written('running', 'boolean')]), node('g-leveloff2', 'SetEnabled', 1820, 640, { enabled: false }), node('g-loseon', 'SetEnabled', 2020, 640, { enabled: true }), ], @@ -273,29 +273,29 @@ const gameGraph = { edge('ge8', 'g-lose', 'object', 'g-off3', 'object'), edge('ge9', 'g-upd', 'exec', 'g-brrun', 'exec'), - edge('ge10', 'g-running', 'value', 'g-brrun', 'condition'), + edge('ge10', 'g-running', 'running', 'g-brrun', 'condition'), edge('ge11', 'g-brrun', 'then', 'g-settime', 'exec'), - edge('ge12', 'g-time', 'value', 'g-sub', 'a'), + edge('ge12', 'g-time', 'timeLeft', 'g-sub', 'a'), edge('ge13', 'g-upd', 'dt', 'g-sub', 'b'), edge('ge14', 'g-sub', 'result', 'g-max', 'a'), - edge('ge15', 'g-max', 'result', 'g-settime', 'value'), + edge('ge15', 'g-max', 'result', 'g-settime', 'timeLeft'), edge('ge16', 'g-settime', 'exec', 'g-settext', 'exec'), edge('ge17', 'g-timelabel', 'object', 'g-settext', 'object'), - edge('ge18', 'g-time2', 'value', 'g-ceil', 'value'), + edge('ge18', 'g-time2', 'timeLeft', 'g-ceil', 'value'), edge('ge19', 'g-ceil', 'result', 'g-join', 'b'), edge('ge20', 'g-join', 'text', 'g-settext', 'text'), edge('ge21', 'g-settext', 'exec', 'g-settext2', 'exec'), edge('ge22', 'g-coinlabel', 'object', 'g-settext2', 'object'), - edge('ge23', 'g-coins', 'value', 'g-totext2', 'value'), + edge('ge23', 'g-coins', 'coins', 'g-totext2', 'value'), edge('ge24', 'g-totext2', 'text', 'g-join2', 'b'), edge('ge25', 'g-join2', 'text', 'g-join3', 'a'), edge('ge26', 'g-join3', 'text', 'g-settext2', 'text'), edge('ge27', 'g-settext2', 'exec', 'g-brwin', 'exec'), - edge('ge28', 'g-coins', 'value', 'g-cmpwin', 'a'), - edge('ge29', 'g-needed', 'value', 'g-cmpwin', 'b'), + edge('ge28', 'g-coins', 'coins', 'g-cmpwin', 'a'), + edge('ge29', 'g-needed', 'needed', 'g-cmpwin', 'b'), edge('ge30', 'g-cmpwin', 'result', 'g-brwin', 'condition'), edge('ge31', 'g-brwin', 'then', 'g-stop1', 'exec'), edge('ge32', 'g-stop1', 'exec', 'g-leveloff1', 'exec'), @@ -304,7 +304,7 @@ const gameGraph = { edge('ge35', 'g-win', 'object', 'g-winon', 'object'), edge('ge36', 'g-brwin', 'else', 'g-brlose', 'exec'), - edge('ge37', 'g-time2', 'value', 'g-cmplose', 'a'), + edge('ge37', 'g-time2', 'timeLeft', 'g-cmplose', 'a'), edge('ge38', 'g-cmplose', 'result', 'g-brlose', 'condition'), edge('ge39', 'g-brlose', 'then', 'g-stop2', 'exec'), edge('ge40', 'g-stop2', 'exec', 'g-leveloff2', 'exec'), diff --git a/tools/seed-tetris.mjs b/tools/seed-tetris.mjs index 40be7eee..25254536 100644 --- a/tools/seed-tetris.mjs +++ b/tools/seed-tetris.mjs @@ -7,7 +7,7 @@ * * Usage: node tools/seed-tetris.mjs [projectId] [baseUrl] */ -import { edge, field, node, port, sceneObject, variable } from './lib/builders.mjs'; +import { edge, field, node, port, sceneObject, variable, written } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris); @@ -163,10 +163,10 @@ function graphBuilder(variables = []) { const edges = []; let band = 0; const kinds = Object.fromEntries(variables.map((v) => [v.key, v.kind])); - const declare = (keys) => + const declare = (keys, write = false) => keys.map((key) => { if (!kinds[key]) throw new Error(`no variable "${key}" to declare a port for`); - return port(key, kinds[key]); + return write ? written(key, kinds[key]) : port(key, kinds[key]); }); const at = (id, type, column, row, params = {}, ports) => { @@ -186,22 +186,22 @@ function graphBuilder(variables = []) { flow: (...ids) => { for (let i = 0; i < ids.length - 1; i++) link(ids[i], 'exec', ids[i + 1], 'exec'); }, - get: (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key }), + /** A Variables node reading one variable; its output is named after it. */ + get: (id, key, column, row) => at(id, 'Variables', column, row, {}, declare([key])), + /** A Variables node writing one variable, typed in as `value` or through its input of that name. */ set: (id, key, column, row, value) => - at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value }), + at(id, 'Variables', column, row, value === undefined ? {} : { [key]: value }, declare([key], true)), /** - * The plural nodes: one node for a whole cluster, one port per variable. A data node is cached - * for the entire pulse, so a `gets` must not span a write of one of its variables – the second - * read would serve the value from before the write. Split it in two where it does. A `sets` - * reads all its inputs before it writes any, so none of them may come from a variable it writes. + * One node for a whole cluster. A `sets` reads all its inputs before it writes any, so an input + * fed from a variable it writes sees the value from before. */ - gets: (id, keys, column, row) => at(id, 'GetVariables', column, row, {}, declare(keys)), + gets: (id, keys, column, row) => at(id, 'Variables', column, row, {}, declare(keys)), sets: (id, spec, column, row) => { const keys = Array.isArray(spec) ? spec : Object.keys(spec); const params = Array.isArray(spec) ? {} : Object.fromEntries(Object.entries(spec).filter(([, v]) => v !== LINKED)); - return at(id, 'SetVariables', column, row, params, declare(keys)); + return at(id, 'Variables', column, row, params, declare(keys, true)); }, /** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */ done() { @@ -279,8 +279,10 @@ function pieceCellGraph() { * Whether the four cells of a piece may stand where it is asked to: inside the well, and on empty * board cells. Every attempt to turn, move or drop goes through this one answer. */ +const FITS_VARS = [variable('ok', 'boolean', true)]; + function fitsGraph() { - const g = graphBuilder(); + const g = graphBuilder(FITS_VARS); g.at('start', 'BehaviorStart', 0, 0, {}, [ port('shapes', 'list'), port('grid', 'list'), @@ -331,7 +333,7 @@ function fitsGraph() { g.at('end', 'BehaviorEnd', 11, 0, {}, [port('ok', 'boolean')]); g.get('result', 'ok', 10, 3); g.link('loop', 'done', 'end', 'exec'); - g.link('result', 'value', 'end', 'ok'); + g.link('result', 'ok', 'end', 'ok'); return g.done(); } @@ -402,7 +404,7 @@ function clearLinesGraph() { g.set('setrow', 'row', 10, 13); g.link('brfull', 'else', 'setrow', 'exec'); g.link('row-vars', 'row', 'back', 'a'); - g.link('back', 'result', 'setrow', 'value'); + g.link('back', 'result', 'setrow', 'row'); g.at('end', 'BehaviorEnd', 13, 0, {}, [port('grid', 'list'), port('lines', 'number')]); g.gets('out-vars', ['board', 'cleared'], 12, 6); @@ -554,7 +556,7 @@ function nextMoveGraph() { link('f-rate', 'result', 'f-step', 'b'); link('start', 'fall', 'f-add', 'a'); link('f-step', 'result', 'f-add', 'b'); - link('f-add', 'result', 'f-set', 'value'); + link('f-add', 'result', 'f-set', 'outFall'); get('f-fall', 'outFall', 5, 3); at('f-due', 'Compare', 6, 3, { op: '>=' }); @@ -564,11 +566,11 @@ function nextMoveGraph() { at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]); sets('f-vars', { outFall: LINKED, next: LINKED, kind: 'fall' }, 6, 0); flow('f-set', 'f-br'); - link('f-fall', 'value', 'f-due', 'a'); + link('f-fall', 'outFall', 'f-due', 'a'); link('start', 'stepTime', 'f-due', 'b'); link('f-due', 'result', 'f-br', 'condition'); link('f-br', 'then', 'f-vars', 'exec'); - link('f-fall', 'value', 'f-minus', 'a'); + link('f-fall', 'outFall', 'f-minus', 'a'); link('start', 'stepTime', 'f-minus', 'b'); link('f-minus', 'result', 'f-vars', 'outFall'); link('of', 'y', 'f-down1', 'a'); @@ -654,14 +656,16 @@ function drawPieceGraph() { } /** Writes the four cells of the landed piece into the board, read back from where they stand. */ +const LOCK_PIECE_VARS = [variable('board', 'list', [], 'number')]; + function lockPieceGraph() { - const g = graphBuilder(); + const g = graphBuilder(LOCK_PIECE_VARS); const { at, link, flow, get, set } = g; at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('shape', 'number')]); set('take', 'board', 1, 0); at('loop', 'Repeat', 2, 0, { times: 4 }); - link('start', 'grid', 'take', 'value'); + link('start', 'grid', 'take', 'board'); flow('start', 'take', 'loop'); const object = objectOfRound(g, 'p', 'loop', 'p', 3, 4); @@ -688,16 +692,16 @@ function lockPieceGraph() { link('rowr', 'result', 'row10', 'a'); link('row10', 'result', 'index', 'a'); link('colr', 'result', 'index', 'b'); - link('board', 'value', 'write', 'list'); + link('board', 'board', 'write', 'list'); link('index', 'result', 'write', 'index'); link('start', 'shape', 'write', 'value'); - link('write', 'result', 'keep', 'value'); + link('write', 'result', 'keep', 'board'); link('loop', 'body', 'keep', 'exec'); get('done', 'board', 4, 3); at('end', 'BehaviorEnd', 4, 0, {}, [port('grid', 'list')]); link('loop', 'done', 'end', 'exec'); - link('done', 'value', 'end', 'grid'); + link('done', 'board', 'end', 'grid'); return g.done(); } @@ -755,14 +759,14 @@ function tetrisGraph() { get('u-has', 'hasPiece', 0, 2); at('u-br', 'Branch', 1, 0); flow('u-update', 'u-br'); - link('u-has', 'value', 'u-br', 'condition'); + link('u-has', 'hasPiece', 'u-br', 'condition'); // 3a) No piece: ask for one and write down what came back. at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' }); get('n-columns', 'spawnX', 2, 8); sets('n-vars', { piece: LINKED, fall: 0, hasPiece: true }, 3, 5); link('u-br', 'else', 'n-spawn', 'exec'); - link('n-columns', 'value', 'n-spawn', 'spawnX'); + link('n-columns', 'spawnX', 'n-spawn', 'spawnX'); link('n-spawn', 'piece', 'n-vars', 'piece'); flow('n-spawn', 'n-vars'); @@ -799,7 +803,7 @@ function tetrisGraph() { flow('t-fits', 'm-br'); link('t-fits', 'ok', 'm-br', 'condition'); link('m-br', 'then', 'm-piece', 'exec'); - link('m-in', 'next', 'm-piece', 'value'); + link('m-in', 'next', 'm-piece', 'piece'); at('m-landed', 'Equals', 3, 7, { b: 'fall' }); at('m-brland', 'Branch', 3, 5); @@ -812,7 +816,7 @@ function tetrisGraph() { link('m-in', 'kind', 'm-landed', 'a'); link('m-landed', 'result', 'm-brland', 'condition'); link('m-brland', 'then', 'm-brover', 'exec'); - link('m-py2', 'value', 'm-pieceY', 'value'); + link('m-py2', 'piece', 'm-pieceY', 'value'); link('m-pieceY', 'y', 'm-attop', 'a'); link('m-attop', 'result', 'm-brover', 'condition'); link('m-brover', 'then', 'm-stop', 'exec'); @@ -836,7 +840,7 @@ function tetrisGraph() { link('l-in', 'grid', 'l-lock', 'grid'); link('l-in', 'piece', 'l-shape', 'value'); link('l-shape', 'shape', 'l-lock', 'shape'); - link('l-lock', 'grid', 'l-setgrid', 'value'); + link('l-lock', 'grid', 'l-setgrid', 'grid'); flow('l-lock', 'l-setgrid'); at('r-clear', 'RunBehavior', 2, 0, { behavior: 'clear-lines' }); @@ -926,13 +930,13 @@ const project = { }, { id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate }, behavior('piece-cell', 'Piece cell', [], pieceCellGraph()), - behavior('fits', 'Fits', [variable('ok', 'boolean', true)], fitsGraph()), + behavior('fits', 'Fits', FITS_VARS, fitsGraph()), behavior('clear-lines', 'Clear lines', CLEAR_LINES_VARS, clearLinesGraph()), behavior('draw-board', 'Draw board', [], drawBoardGraph()), behavior('spawn-piece', 'Spawn piece', [], spawnPieceGraph()), behavior('next-move', 'Next move', NEXT_MOVE_VARS, nextMoveGraph()), behavior('draw-piece', 'Draw piece', [], drawPieceGraph()), - behavior('lock-piece', 'Lock piece', [variable('board', 'list', [], 'number')], lockPieceGraph()), + behavior('lock-piece', 'Lock piece', LOCK_PIECE_VARS, lockPieceGraph()), behavior('show-lines', 'Show lines', [], showLinesGraph()), behavior('tetris', 'Tetris', tetrisVariables, tetrisGraph()), ],