Replace the five variable nodes with one Variables node

A single node now lists any number of variables. Every entry is an output
that reads the current value whenever it is pulled; an entry switched to
Set is also an input and is written when the flow arrives. Exec pins only
exist while at least one entry writes, and all inputs are read before any
write, so one node can swap two variables.

Get/Set variable, Get/Set variables and Variable are gone. Old project
files are converted on load: each old node becomes a Variables node with
the same variables, edges and typed values.

A written port without a typed value now shows the zero of its kind on
the node body, matching what the inspector shows and what the run writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 16:56:24 +02:00
1 parent 854ae2e756
commit 7c63c884ef
40 files changed
+895 -590

No files matched your search

+2
View File
@@ -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;
+13 -3
View File
@@ -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 {
+22 -8
View File
@@ -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')],
};
}
+2 -10
View File
@@ -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,
+3 -38
View File
@@ -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<string, Value> => ({
value: ctx.variables[variableKey(params)] ?? null,
});
export function writeVariable(
ctx: NodeContext,
inputs: Record<string, Value>,
params: PropertyBag,
): RunResult {
const key = variableKey(params);
if (key) writeTyped(ctx.variables, key, inputs.value);
return { next: 'exec' };
}
/** Once a variable is picked, the title names it: "Get speed", or just "speed". */
export const variableTitle =
(verb: string) =>
({ node }: PortSource): DerivedShape => {
const name = variableKey(node.params);
if (!name) return {};
return { label: verb ? `${verb} ${name}` : name };
};
/** No `assetTypes`: any placeable asset is a valid answer here. */
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
@@ -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) }),
};
@@ -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<string, Value> = {};
for (const port of def.outputs) outputs[port.key] = ctx.variables[port.key] ?? null;
return { outputs };
},
};
@@ -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,
};
@@ -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' };
},
};
-18
View File
@@ -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,
};
@@ -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' };
},
};
+2 -2
View File
@@ -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 };