Ports that vary per node already existed, but only for the three nested-behavior
types, and the rule for them sat as a switch on type names in nested.ts – the
one place in the project that knew node types by name. It dissolves into two
optional contracts on NodeDef, so a type carries its own rule:
declaresPorts the node holds an editable list, shown as a section in the
inspector – behavior start/end, and now Pack/Expand
derivePorts the node computes them from its settings – Sequence from a
count, Run behavior from the behavior it names
Sequence gains an Outputs setting (2 to 26). Its keys stay a, b, c and count on
with d, e, so the edges of the sequences already out there survive.
A param can now take a port away, and the edge on it would stay: it draws as
nothing yet survives every save, and comes back to life if the port returns.
SetNodeParamCommand takes those edges with it, which also closes the same leak
that switching a Run behavior node's target has always had.
The port editor addresses its node by id rather than by type, so two Pack nodes
can live in one graph, and it calls the rows Fields when they are not a
behavior's signature.
150 lines
5.0 KiB
TypeScript
150 lines
5.0 KiB
TypeScript
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||
import type { PropertyDef } from '../properties/PropertySchema';
|
||
import type { PortType } from './Graph';
|
||
import type { BehaviorLookup } from './lookup';
|
||
import type { NodeContext } from './NodeContext';
|
||
import { asBoolean, asNumber, asString, type Value } from './values';
|
||
|
||
export interface PortDef {
|
||
key: string;
|
||
label: string;
|
||
type: PortType;
|
||
/** Inline value used while the input is unconnected and the node has no own value (default 0/''/false). */
|
||
default?: PropertyValue;
|
||
}
|
||
|
||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
|
||
|
||
/**
|
||
* How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body,
|
||
* `index` is the number output seeded with the round number.
|
||
*/
|
||
export interface RepeatResult {
|
||
port: string;
|
||
index: string;
|
||
times: number;
|
||
}
|
||
|
||
/** How a node asks for another behavior's graph to be run in place, `inputs` seeding its start node. */
|
||
export interface CallResult {
|
||
behaviorId: string;
|
||
inputs: Record<string, Value>;
|
||
}
|
||
|
||
export interface RunResult {
|
||
/** Cached for the rest of the pulse. */
|
||
outputs?: Record<string, Value>;
|
||
/** Exec output(s) to continue along; omitted = the node ends the flow. */
|
||
next?: string | string[];
|
||
/** Runs `port` that many times before continuing along `next`. */
|
||
repeat?: RepeatResult;
|
||
/** Runs another behavior before continuing along `next`; its results join `outputs`. */
|
||
call?: CallResult;
|
||
}
|
||
|
||
/** What a node may compute its ports from: its own settings, and the behaviors around it. */
|
||
export interface PortSource {
|
||
params: PropertyBag;
|
||
behaviors: BehaviorLookup;
|
||
}
|
||
|
||
/** Ports a single node has on top of the fixed ones of its type, and a label naming what it does. */
|
||
export interface DerivedShape {
|
||
label?: string;
|
||
inputs?: PortDef[];
|
||
outputs?: PortDef[];
|
||
}
|
||
|
||
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
||
export interface NodeDef {
|
||
type: string;
|
||
label: string;
|
||
category: NodeCategory;
|
||
description: string;
|
||
inputs: PortDef[];
|
||
outputs: PortDef[];
|
||
/** Node settings that are not ports (key name, operator, variable). */
|
||
params: PropertyDef[];
|
||
/**
|
||
* Settings that say nothing at this value set, so the node body leaves them off; the inspector
|
||
* still offers them. Without it a switched-off repeat is two rows of "0" on every key node.
|
||
*/
|
||
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.
|
||
*/
|
||
declaresPorts?: 'inputs' | 'outputs';
|
||
/**
|
||
* Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence
|
||
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
|
||
*/
|
||
derivePorts?(source: PortSource): DerivedShape;
|
||
/**
|
||
* Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes
|
||
* run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs.
|
||
*/
|
||
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
|
||
}
|
||
|
||
const defs = new Map<string, NodeDef>();
|
||
|
||
export function registerNode(def: NodeDef): NodeDef {
|
||
if (defs.has(def.type)) throw new Error(`Node type "${def.type}" registered twice`);
|
||
defs.set(def.type, def);
|
||
return def;
|
||
}
|
||
|
||
export function nodeDef(type: string): NodeDef | undefined {
|
||
return defs.get(type);
|
||
}
|
||
|
||
export function requireNodeDef(type: string): NodeDef {
|
||
const def = defs.get(type);
|
||
if (!def) throw new Error(`Unknown node type "${type}"`);
|
||
return def;
|
||
}
|
||
|
||
export function allNodeDefs(): NodeDef[] {
|
||
return [...defs.values()];
|
||
}
|
||
|
||
export function isEventNode(def: NodeDef): boolean {
|
||
return def.category === 'event';
|
||
}
|
||
|
||
export function hasExecInput(def: NodeDef): boolean {
|
||
return def.inputs.some((p) => p.type === 'exec');
|
||
}
|
||
|
||
/**
|
||
* Data inputs that may carry an inline value while unconnected. An object is a live handle and a
|
||
* 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');
|
||
}
|
||
|
||
export function inlineInputDef(port: PortDef): PropertyDef {
|
||
switch (port.type) {
|
||
case 'boolean':
|
||
return { key: port.key, label: port.label, kind: 'boolean', default: asBoolean(port.default ?? false) };
|
||
case 'string':
|
||
case 'any':
|
||
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
|
||
default:
|
||
return {
|
||
key: port.key,
|
||
label: port.label,
|
||
kind: 'number',
|
||
default: asNumber(port.default ?? 0),
|
||
step: 1,
|
||
};
|
||
}
|
||
}
|
||
|
||
/** The value an unconnected input carries: the node's own inline value, else the port default. */
|
||
export function inlineValue(node: { params: PropertyBag }, port: PortDef): Value {
|
||
return node.params[port.key] ?? port.default ?? null;
|
||
}
|