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()), ],