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:
1 parent
854ae2e756
commit
7c63c884ef
40 files changed
+895
-590
No files matched your search
@@ -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<GraphEdge>[] };
|
||||
|
||||
/** 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<Graph, EdgeTest['test'][]>();
|
||||
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<DeclaredPort>;
|
||||
private edges: { graph: Graph; entries: Removed<GraphEdge>[] }[] = [];
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string, boolean> = { GetVariable: false, SetVariable: true, Variable: true };
|
||||
const PLURAL: Record<string, boolean> = { GetVariables: false, SetVariables: true };
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
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<string, string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, unknown>): 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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user