Let a behavior be a node in another graph
A behavior that has a Start and an End node can now be dropped into any other graph as a node of its own. What is declared on the start node becomes the inputs of that node, what hangs on the end node its outputs; the port list is derived from the sub graph, not copied, so declaring a port in one graph grows the node in the other without touching it. The node carries the behavior's name and the palette lists behaviors by name, so a call is one grab instead of a node plus a dropdown. An embedded behavior has no object and therefore no place for variables: every call starts on the asset defaults and communicates only through its ports. That is the conservative direction - state can still be added later, and shared state already has a home in a behavior object in the scene. Node ids are unique per graph only, so each call gets its own pulse cache; without that, two graphs would collide. Renaming a port used to silently drop the value typed on the calling node. It is carried along now, project wide, like the edges.
This commit is contained in:
1 parent
64e034ffb9
commit
6702ff8abe
31 files changed
+1402
-107
No files matched your search
@@ -6,7 +6,9 @@ import {
|
||||
type GraphNode,
|
||||
portsCompatible,
|
||||
} from '../graph/Graph';
|
||||
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { behaviorLookup } from '../model/behaviorPrograms';
|
||||
import { newId } from '../ids';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
@@ -42,7 +44,12 @@ export class AddNodeCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
requireNodeDef(this.node.type);
|
||||
requireBehavior(project, this.assetId).graph.nodes.push(this.node);
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
// The two terminals give the behavior its signature; a second one of either would make
|
||||
// "which ports does this behavior have" ambiguous.
|
||||
if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type))
|
||||
throw new CommandError(`This behavior already has a ${nodeDef(this.node.type)?.label} node`);
|
||||
graph.nodes.push(this.node);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
@@ -153,8 +160,9 @@ export class ConnectCommand implements Command {
|
||||
const toNode = findNode(graph, this.edge.to.node);
|
||||
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
||||
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
|
||||
const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port);
|
||||
const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
|
||||
const lookup = behaviorLookup(project);
|
||||
const out = resolveDef(fromNode, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
|
||||
const inp = resolveDef(toNode, lookup)?.inputs.find((p) => p.key === this.edge.to.port);
|
||||
if (!out || !inp) throw new CommandError('Port not found');
|
||||
if (!portsCompatible(out.type, inp.type))
|
||||
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
BEHAVIOR_PARAM,
|
||||
BEHAVIOR_START,
|
||||
type DeclaredPort,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
isValidPortName,
|
||||
RUN_BEHAVIOR,
|
||||
terminalNode,
|
||||
} from '../graph';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior } from './require';
|
||||
|
||||
/** Where a declared port shows up: on the terminal node itself, and on every node calling it. */
|
||||
interface PortSite {
|
||||
graph: Graph;
|
||||
nodeId: string;
|
||||
/** Whether the port is an input there – the terminal and the call node face opposite ways. */
|
||||
asInput: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A start node's port is an output there and an input on the calling node; an end node's port the
|
||||
* other way round. Every site has to be kept in step, or an edge would hang on a port that is gone.
|
||||
*/
|
||||
function portSites(project: Project, assetId: string, terminalType: string): PortSite[] {
|
||||
const asset = requireBehavior(project, assetId);
|
||||
const sites: PortSite[] = [];
|
||||
const terminal = terminalNode(asset.graph, terminalType);
|
||||
const onStart = terminalType === BEHAVIOR_START;
|
||||
if (terminal) sites.push({ graph: asset.graph, nodeId: terminal.id, asInput: !onStart });
|
||||
for (const other of project.assets) {
|
||||
if (other.type !== 'behavior') continue;
|
||||
for (const node of other.graph.nodes) {
|
||||
if (node.type !== RUN_BEHAVIOR || node.params[BEHAVIOR_PARAM] !== assetId) continue;
|
||||
sites.push({ graph: other.graph, nodeId: node.id, asInput: onStart });
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean {
|
||||
return (edge) =>
|
||||
site.asInput
|
||||
? edge.to.node === site.nodeId && edge.to.port === key
|
||||
: edge.from.node === site.nodeId && edge.from.port === key;
|
||||
}
|
||||
|
||||
function requireTerminal(project: Project, assetId: string, terminalType: string): GraphNode {
|
||||
const node = terminalNode(requireBehavior(project, assetId).graph, terminalType);
|
||||
if (!node) throw new CommandError('The behavior has no start or end node');
|
||||
return node;
|
||||
}
|
||||
|
||||
function nodeAt(site: PortSite): GraphNode | undefined {
|
||||
return site.graph.nodes.find((n) => n.id === site.nodeId);
|
||||
}
|
||||
|
||||
function renameParam(site: PortSite, from: string, to: string): void {
|
||||
const node = nodeAt(site);
|
||||
const value = node?.params[from];
|
||||
if (!node || value === undefined) return;
|
||||
node.params[to] = value;
|
||||
delete node.params[from];
|
||||
}
|
||||
|
||||
const INVALID_NAME = 'Port names must start with a letter and contain only letters, digits or _';
|
||||
|
||||
export class AddPortCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly terminalType: string,
|
||||
private readonly port: DeclaredPort,
|
||||
) {
|
||||
this.label = `Add ${port.key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||
if (!isValidPortName(this.port.key)) throw new CommandError(INVALID_NAME);
|
||||
if ((node.ports ?? []).some((p) => p.key === this.port.key))
|
||||
throw new CommandError(`"${this.port.key}" already exists`);
|
||||
node.ports = [...(node.ports ?? []), { ...this.port }];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||
node.ports = (node.ports ?? []).filter((p) => p.key !== this.port.key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes the edges hanging on the port with it – in this graph and in every calling one. */
|
||||
export class RemovePortCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed?: Removed<DeclaredPort>;
|
||||
private edges: { graph: Graph; entries: Removed<GraphEdge>[] }[] = [];
|
||||
private values: { site: PortSite; value: PropertyValue }[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly terminalType: string,
|
||||
private readonly key: string,
|
||||
) {
|
||||
this.label = `Remove ${key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const sites = portSites(project, this.assetId, this.terminalType);
|
||||
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||
const ports = [...(node.ports ?? [])];
|
||||
const removed = removeFirst(ports, (p) => p.key === this.key);
|
||||
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)),
|
||||
}));
|
||||
this.values = [];
|
||||
for (const site of sites) {
|
||||
const target = site.asInput ? nodeAt(site) : undefined;
|
||||
const value = target?.params[this.key];
|
||||
if (!target || value === undefined) continue;
|
||||
this.values.push({ site, value });
|
||||
delete target.params[this.key];
|
||||
}
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||
const ports = [...(node.ports ?? [])];
|
||||
restoreAt(ports, [this.removed]);
|
||||
node.ports = ports;
|
||||
for (const { graph, entries } of this.edges) restoreAt(graph.edges, entries);
|
||||
for (const { site, value } of this.values) {
|
||||
const target = nodeAt(site);
|
||||
if (target) target.params[this.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly terminalType: string,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
) {
|
||||
this.label = `Rename ${from}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.rename(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rename(project, this.to, this.from);
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
if (from === to) return;
|
||||
const sites = portSites(project, this.assetId, this.terminalType);
|
||||
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||
if (!isValidPortName(to)) throw new CommandError(INVALID_NAME);
|
||||
const ports = node.ports ?? [];
|
||||
if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`);
|
||||
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
|
||||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||||
for (const site of sites) {
|
||||
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
|
||||
if (site.asInput) edge.to.port = to;
|
||||
else edge.from.port = to;
|
||||
}
|
||||
// An input keeps its typed-in value under the port key, so a rename has to carry it along
|
||||
// or the value silently falls back to the default.
|
||||
if (site.asInput) renameParam(site, from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
|
||||
export function nextPortName(node: GraphNode, base: string): string {
|
||||
const taken = new Set((node.ports ?? []).map((p) => p.key));
|
||||
for (let i = 1; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
|
||||
}
|
||||
@@ -18,6 +18,24 @@ export interface GraphNode {
|
||||
x: number;
|
||||
y: number;
|
||||
params: PropertyBag;
|
||||
/**
|
||||
* Ports the node declares itself, on top of the fixed ones of its type. Only the behavior
|
||||
* start/end nodes carry this – they are what gives a nested behavior its signature.
|
||||
*/
|
||||
ports?: DeclaredPort[];
|
||||
}
|
||||
|
||||
/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */
|
||||
export interface DeclaredPort {
|
||||
key: string;
|
||||
kind: DeclaredPortKind;
|
||||
}
|
||||
|
||||
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object'] as const;
|
||||
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
|
||||
|
||||
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
||||
return kind === 'text' ? 'string' : kind;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||||
import {
|
||||
BEHAVIOR_END,
|
||||
BEHAVIOR_START,
|
||||
type BehaviorLookup,
|
||||
NO_BEHAVIORS,
|
||||
resolveDef,
|
||||
terminalNode,
|
||||
} from './nested';
|
||||
import type { NodeContext } from './NodeContext';
|
||||
import { hasExecInput, inlineValue, isEventNode, type RepeatResult, requireNodeDef } from './NodeRegistry';
|
||||
import { type CallResult, hasExecInput, inlineValue, isEventNode, type RepeatResult } from './NodeRegistry';
|
||||
import type { Value } from './values';
|
||||
|
||||
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
|
||||
@@ -18,6 +26,17 @@ export interface TraceListener {
|
||||
onNode(nodeId: string): void;
|
||||
}
|
||||
|
||||
/** One graph's pulse state. A nested behavior runs in its own – node ids are unique per graph only. */
|
||||
interface Frame {
|
||||
graph: Graph;
|
||||
outputCache: Map<string, Record<string, Value>>;
|
||||
dataCache: Map<string, Record<string, Value>>;
|
||||
}
|
||||
|
||||
function emptyFrame(graph: Graph): Frame {
|
||||
return { graph, outputCache: new Map(), dataCache: new Map() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows
|
||||
* exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and
|
||||
@@ -26,13 +45,19 @@ export interface TraceListener {
|
||||
export class GraphInterpreter {
|
||||
/** Per-node state that persists across pulses (timer accumulators etc.). */
|
||||
readonly nodeState = new Map<string, Record<string, number>>();
|
||||
private outputCache = new Map<string, Record<string, Value>>();
|
||||
/** Kept apart from `outputCache` because a loop round has to forget exactly these again. */
|
||||
private dataCache = new Map<string, Record<string, Value>>();
|
||||
/** Assets run as a node so far – the graph editor highlights them from this instance's trace. */
|
||||
readonly calledBehaviors = new Set<string>();
|
||||
private frame: Frame;
|
||||
private callStack: string[] = [];
|
||||
private steps = 0;
|
||||
trace: TraceListener | null = null;
|
||||
|
||||
constructor(readonly graph: Graph) {}
|
||||
constructor(
|
||||
readonly graph: Graph,
|
||||
private readonly behaviors: BehaviorLookup = NO_BEHAVIORS,
|
||||
) {
|
||||
this.frame = emptyFrame(graph);
|
||||
}
|
||||
|
||||
eventNodes(type: string): GraphNode[] {
|
||||
return this.graph.nodes.filter((n) => n.type === type);
|
||||
@@ -48,10 +73,10 @@ export class GraphInterpreter {
|
||||
let fired = 0;
|
||||
for (const node of this.eventNodes(type)) {
|
||||
if (filter && !filter(node)) continue;
|
||||
this.outputCache = new Map();
|
||||
this.dataCache = new Map();
|
||||
this.frame = emptyFrame(this.graph);
|
||||
this.callStack = [];
|
||||
this.steps = 0;
|
||||
this.outputCache.set(node.id, { ...outputs });
|
||||
this.frame.outputCache.set(node.id, { ...outputs });
|
||||
this.trace?.onNode(node.id);
|
||||
this.follow(node, 'exec', ctx, 0);
|
||||
fired++;
|
||||
@@ -60,7 +85,7 @@ export class GraphInterpreter {
|
||||
}
|
||||
|
||||
private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void {
|
||||
for (const edge of edgesOutOf(this.graph, node.id, port)) {
|
||||
for (const edge of edgesOutOf(this.frame.graph, node.id, port)) {
|
||||
const target = this.node(edge.to.node);
|
||||
if (!target) continue;
|
||||
this.trace?.onEdge(edge.id);
|
||||
@@ -73,41 +98,68 @@ export class GraphInterpreter {
|
||||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||||
if (depth > MAX_DEPTH)
|
||||
throw new Error('Behavior graph nested too deeply (a flow feeding back on itself?)');
|
||||
const def = requireNodeDef(node.type);
|
||||
if (!def.run) return;
|
||||
const def = this.def(node);
|
||||
if (!def?.run) return;
|
||||
const inputs = this.collectInputs(node, ctx);
|
||||
const result = def.run(ctx, inputs, node.params);
|
||||
this.outputCache.set(node.id, result.outputs ?? {});
|
||||
const returned = result.call ? this.call(result.call, ctx, depth) : {};
|
||||
this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
|
||||
this.trace?.onNode(node.id);
|
||||
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
|
||||
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||||
for (const port of next) this.follow(node, port, ctx, depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs another behavior from its start node to its end node and returns what the end node was
|
||||
* handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is
|
||||
* a call and not an inlining: nothing of it survives the return but the values it hands back.
|
||||
*/
|
||||
private call(call: CallResult, ctx: NodeContext, depth: number): Record<string, Value> {
|
||||
const program = this.behaviors(call.behaviorId);
|
||||
if (!program) return {};
|
||||
if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`);
|
||||
const start = terminalNode(program.graph, BEHAVIOR_START);
|
||||
if (!start) return {};
|
||||
const outer = this.frame;
|
||||
this.frame = emptyFrame(program.graph);
|
||||
this.frame.outputCache.set(start.id, { ...call.inputs });
|
||||
this.callStack.push(call.behaviorId);
|
||||
this.calledBehaviors.add(call.behaviorId);
|
||||
try {
|
||||
this.trace?.onNode(start.id);
|
||||
this.follow(start, 'exec', { ...ctx, variables: { ...program.defaults } }, depth + 1);
|
||||
const end = terminalNode(program.graph, BEHAVIOR_END);
|
||||
return (end && this.frame.outputCache.get(end.id)) ?? {};
|
||||
} finally {
|
||||
this.callStack.pop();
|
||||
this.frame = outer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One round per count, the loop node's `index` output carrying the round number. Data nodes are
|
||||
* cached for the whole pulse, so the round has to drop that cache first – otherwise every round
|
||||
* would read the values the first one computed.
|
||||
*/
|
||||
private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void {
|
||||
const outputs = this.outputCache.get(node.id) ?? {};
|
||||
const outputs = this.frame.outputCache.get(node.id) ?? {};
|
||||
for (let round = 0; round < repeat.times; round++) {
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||||
this.dataCache.clear();
|
||||
this.outputCache.set(node.id, { ...outputs, [repeat.index]: round });
|
||||
this.frame.dataCache.clear();
|
||||
this.frame.outputCache.set(node.id, { ...outputs, [repeat.index]: round });
|
||||
this.follow(node, repeat.port, ctx, depth);
|
||||
}
|
||||
this.dataCache.clear();
|
||||
this.frame.dataCache.clear();
|
||||
}
|
||||
|
||||
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
||||
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
|
||||
const def = requireNodeDef(node.type);
|
||||
const inputs: Record<string, Value> = {};
|
||||
for (const port of def.inputs) {
|
||||
for (const port of this.def(node)?.inputs ?? []) {
|
||||
if (port.type === 'exec') continue;
|
||||
const edge = edgesInto(this.graph, node.id, port.key)[0];
|
||||
const edge = edgesInto(this.frame.graph, node.id, port.key)[0];
|
||||
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
|
||||
else inputs[port.key] = inlineValue(node, port);
|
||||
}
|
||||
@@ -116,24 +168,29 @@ export class GraphInterpreter {
|
||||
|
||||
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
|
||||
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
|
||||
const cached = this.outputCache.get(nodeId) ?? this.dataCache.get(nodeId);
|
||||
const cached = this.frame.outputCache.get(nodeId) ?? this.frame.dataCache.get(nodeId);
|
||||
if (cached) return cached[port] ?? null;
|
||||
const source = this.node(nodeId);
|
||||
if (!source) return null;
|
||||
const def = requireNodeDef(source.type);
|
||||
const def = this.def(source);
|
||||
if (!def) return null;
|
||||
// Exec nodes that have not run in this pulse have no value yet.
|
||||
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
|
||||
// Mark before evaluating so a data cycle reads null instead of recursing forever.
|
||||
this.dataCache.set(nodeId, {});
|
||||
this.frame.dataCache.set(nodeId, {});
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
|
||||
this.dataCache.set(nodeId, outputs);
|
||||
this.frame.dataCache.set(nodeId, outputs);
|
||||
this.trace?.onNode(nodeId);
|
||||
return outputs[port] ?? null;
|
||||
}
|
||||
|
||||
private def(node: GraphNode) {
|
||||
return resolveDef(node, this.behaviors);
|
||||
}
|
||||
|
||||
private node(id: string): GraphNode | undefined {
|
||||
return this.graph.nodes.find((n) => n.id === id);
|
||||
return this.frame.graph.nodes.find((n) => n.id === id);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ export interface RepeatResult {
|
||||
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>;
|
||||
@@ -31,6 +37,8 @@ export interface RunResult {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph';
|
||||
import { type NodeDef, nodeDef } from './NodeRegistry';
|
||||
import { type BehaviorLookup, resolveDef } from './nested';
|
||||
import type { NodeDef } from './NodeRegistry';
|
||||
|
||||
/** One end of an edge being dragged: a port on a node, seen from its own side. */
|
||||
export interface PortSide {
|
||||
@@ -8,18 +9,18 @@ export interface PortSide {
|
||||
isOutput: boolean;
|
||||
}
|
||||
|
||||
export function portTypeOf(graph: Graph, side: PortSide): PortType | undefined {
|
||||
export function portTypeOf(graph: Graph, side: PortSide, lookup: BehaviorLookup): PortType | undefined {
|
||||
const node = findNode(graph, side.nodeId);
|
||||
const def = node && nodeDef(node.type);
|
||||
const def = node && resolveDef(node, lookup);
|
||||
const list = side.isOutput ? def?.outputs : def?.inputs;
|
||||
return list?.find((p) => p.key === side.port)?.type;
|
||||
}
|
||||
|
||||
/** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */
|
||||
export function canConnect(graph: Graph, from: PortSide, to: PortSide): boolean {
|
||||
export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: BehaviorLookup): boolean {
|
||||
if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false;
|
||||
const fromType = portTypeOf(graph, from);
|
||||
const toType = portTypeOf(graph, to);
|
||||
const fromType = portTypeOf(graph, from, lookup);
|
||||
const toType = portTypeOf(graph, to, lookup);
|
||||
if (fromType === undefined || toType === undefined) return false;
|
||||
const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType];
|
||||
return portsCompatible(out, inp) && !linked(graph, from, to);
|
||||
@@ -30,11 +31,16 @@ export function canConnect(graph: Graph, from: PortSide, to: PortSide): boolean
|
||||
* the first free compatible one, or – when none is free – the last compatible one, whose edge is
|
||||
* then replaced. Returns null when the node offers nothing to connect to.
|
||||
*/
|
||||
export function dropTargetPort(graph: Graph, from: PortSide, nodeId: string): PortSide | null {
|
||||
export function dropTargetPort(
|
||||
graph: Graph,
|
||||
from: PortSide,
|
||||
nodeId: string,
|
||||
lookup: BehaviorLookup,
|
||||
): PortSide | null {
|
||||
if (nodeId === from.nodeId) return null;
|
||||
const node = findNode(graph, nodeId);
|
||||
const def = node && nodeDef(node.type);
|
||||
const fromType = portTypeOf(graph, from);
|
||||
const def = node && resolveDef(node, lookup);
|
||||
const fromType = portTypeOf(graph, from, lookup);
|
||||
if (!def || fromType === undefined) return null;
|
||||
const candidates = compatiblePorts(def, fromType, from.isOutput)
|
||||
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type }))
|
||||
|
||||
@@ -3,6 +3,7 @@ import './nodes';
|
||||
|
||||
export * from './dropTarget';
|
||||
export * from './Graph';
|
||||
export * from './nested';
|
||||
export * from './NodeContext';
|
||||
export * from './NodeRegistry';
|
||||
export * from './values';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
||||
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 BEHAVIOR_PARAM = 'behavior';
|
||||
|
||||
/** What running another behavior's graph in place needs: its nodes, its variable defaults, its name. */
|
||||
export interface BehaviorProgram {
|
||||
name: string;
|
||||
graph: Graph;
|
||||
defaults: PropertyBag;
|
||||
}
|
||||
|
||||
export type BehaviorLookup = (assetId: string) => BehaviorProgram | null;
|
||||
|
||||
export const NO_BEHAVIORS: BehaviorLookup = () => null;
|
||||
|
||||
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
|
||||
export function isValidPortName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec';
|
||||
}
|
||||
|
||||
/** The single start (or end) node of a graph; a second one is ignored. */
|
||||
export function terminalNode(graph: Graph, type: string): GraphNode | undefined {
|
||||
return graph.nodes.find((n) => n.type === type);
|
||||
}
|
||||
|
||||
export function isTerminalType(type: string): boolean {
|
||||
return type === BEHAVIOR_START || type === BEHAVIOR_END;
|
||||
}
|
||||
|
||||
export function isTerminalNode(node: GraphNode): boolean {
|
||||
return isTerminalType(node.type);
|
||||
}
|
||||
|
||||
export function portsOf(port: DeclaredPort): PortDef {
|
||||
return { key: port.key, label: port.key, type: declaredPortType(port.kind) };
|
||||
}
|
||||
|
||||
function declaredOf(graph: Graph | undefined, type: string): PortDef[] {
|
||||
const node = graph && terminalNode(graph, type);
|
||||
return (node?.ports ?? []).map(portsOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* The node definition as it applies to *this* node. Identical to the registered one for every fixed
|
||||
* type; the three nested-behavior types grow ports from a declaration instead – the start and end
|
||||
* node from their own, the call node from those of the behavior it names.
|
||||
*/
|
||||
export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | undefined {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def) return undefined;
|
||||
const own = (node.ports ?? []).map(portsOf);
|
||||
if (node.type === BEHAVIOR_START) return { ...def, outputs: [...def.outputs, ...own] };
|
||||
if (node.type === BEHAVIOR_END) return { ...def, inputs: [...def.inputs, ...own] };
|
||||
if (node.type !== RUN_BEHAVIOR) return def;
|
||||
const program = lookup(String(node.params[BEHAVIOR_PARAM] ?? ''));
|
||||
return {
|
||||
...def,
|
||||
// The graph reads as what it does, not as a row of identical "Run behavior" boxes.
|
||||
label: program ? program.name : def.label,
|
||||
inputs: [...def.inputs, ...declaredOf(program?.graph, BEHAVIOR_START)],
|
||||
outputs: [...def.outputs, ...declaredOf(program?.graph, BEHAVIOR_END)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that changes a node's ports, as one string. Views and the inspector rebuild when it
|
||||
* changes – a port list that is derived rather than fixed has no other way of being noticed.
|
||||
*/
|
||||
export function portSignature(node: GraphNode, lookup: BehaviorLookup): string {
|
||||
const def = resolveDef(node, lookup);
|
||||
if (!def) return 'missing';
|
||||
const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(',');
|
||||
return `${node.type}|${def.label}|${list(def.inputs)}|${list(def.outputs)}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { BEHAVIOR_END } from '../../nested';
|
||||
import { exec } from '../shared';
|
||||
|
||||
/**
|
||||
* The exit of a behavior used as a node: what arrives at its declared inputs leaves the calling
|
||||
* node as output. Handing the inputs straight back out is what parks the result in the pulse
|
||||
* cache, where the caller reads it once the flow has returned.
|
||||
*/
|
||||
export const BehaviorEnd: NodeDef = {
|
||||
type: BEHAVIOR_END,
|
||||
label: 'End',
|
||||
category: 'flow',
|
||||
description:
|
||||
'Ends this behavior and hands its results back to the graph that ran it. Every output declared ' +
|
||||
'here becomes an output port on that node.',
|
||||
inputs: [exec()],
|
||||
outputs: [],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({ outputs: inputs }),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { BEHAVIOR_START } from '../../nested';
|
||||
import { exec } from '../shared';
|
||||
|
||||
/**
|
||||
* The entry point of a behavior used as a node. Its declared outputs are the inputs the calling
|
||||
* graph passes in; the interpreter seeds them, so it has no `run` – like an event node.
|
||||
*/
|
||||
export const BehaviorStart: NodeDef = {
|
||||
type: BEHAVIOR_START,
|
||||
label: 'Start',
|
||||
category: 'event',
|
||||
description:
|
||||
'Where this behavior begins when another graph runs it as a node. Every input declared here ' +
|
||||
'becomes an input port on that node.',
|
||||
inputs: [],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { BEHAVIOR_PARAM, RUN_BEHAVIOR } from '../../nested';
|
||||
import { exec } from '../shared';
|
||||
|
||||
/**
|
||||
* Runs another behavior's graph in place, from its start node to its end node. The ports come from
|
||||
* those two nodes (see `nested.ts`); the interpreter does the call, because only it can hold a
|
||||
* second graph's pulse.
|
||||
*/
|
||||
export const RunBehavior: NodeDef = {
|
||||
type: RUN_BEHAVIOR,
|
||||
label: 'Run behavior',
|
||||
category: 'flow',
|
||||
description:
|
||||
'Runs another behavior as a step of this one, passing values in and taking results out. The ' +
|
||||
'behavior keeps no state between calls: its variables start at their defaults every time.',
|
||||
inputs: [exec()],
|
||||
outputs: [exec()],
|
||||
params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }],
|
||||
run: (_ctx, inputs, params) => ({
|
||||
call: { behaviorId: String(params[BEHAVIOR_PARAM] ?? ''), inputs },
|
||||
next: 'exec',
|
||||
}),
|
||||
};
|
||||
@@ -65,8 +65,11 @@ import { OnKeyUp } from './events/OnKeyUp';
|
||||
import { OnStart } from './events/OnStart';
|
||||
import { OnTimer } from './events/OnTimer';
|
||||
import { OnUpdate } from './events/OnUpdate';
|
||||
import { BehaviorEnd } from './flow/BehaviorEnd';
|
||||
import { BehaviorStart } from './flow/BehaviorStart';
|
||||
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 { SetVariable } from './variable/SetVariable';
|
||||
@@ -87,10 +90,13 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
OnCollisionStay,
|
||||
OnCollisionExit,
|
||||
OnClick,
|
||||
BehaviorStart,
|
||||
// flow
|
||||
Branch,
|
||||
Sequence,
|
||||
Repeat,
|
||||
RunBehavior,
|
||||
BehaviorEnd,
|
||||
// variables
|
||||
GetVariable,
|
||||
SetVariable,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { BehaviorLookup } from '../graph/nested';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { findAsset, type Project } from './Project';
|
||||
|
||||
/**
|
||||
* What a `Run behavior` node needs from the project. `defaults` are the asset's variable values;
|
||||
* every call copies them, so a nested behavior never carries state from one call to the next.
|
||||
*/
|
||||
export function behaviorLookup(project: Project): BehaviorLookup {
|
||||
return (assetId) => {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (!asset || asset.type !== 'behavior') return null;
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) };
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
// Importing the graph package index is what registers the built-in nodes.
|
||||
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
|
||||
import {
|
||||
createEmptyGraph,
|
||||
DECLARED_PORT_KINDS,
|
||||
type DeclaredPort,
|
||||
type DeclaredPortKind,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
isValidPortName,
|
||||
nodeDef,
|
||||
} from '../graph';
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||
@@ -166,6 +176,22 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
return defs;
|
||||
}
|
||||
|
||||
function normalizePorts(raw: unknown): DeclaredPort[] {
|
||||
const ports: DeclaredPort[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const p = asRecord(item);
|
||||
const key = typeof p.key === 'string' ? p.key : '';
|
||||
if (!isValidPortName(key) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind))
|
||||
? (p.kind as DeclaredPortKind)
|
||||
: 'number';
|
||||
ports.push({ key, kind });
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
function normalizeGraph(raw: unknown): Graph {
|
||||
const g = asRecord(raw);
|
||||
const graph = createEmptyGraph();
|
||||
@@ -182,6 +208,8 @@ function normalizeGraph(raw: unknown): Graph {
|
||||
params[key] = value;
|
||||
}
|
||||
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
||||
const ports = normalizePorts(n.ports);
|
||||
if (ports.length > 0) node.ports = ports;
|
||||
graph.nodes.push(node);
|
||||
}
|
||||
const edgeIds = new Set<string>();
|
||||
|
||||
Reference in new issue
Block a user