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:
root committed 2026-09-09 20:01:49 +02:00
1 parent 64e034ffb9
commit 6702ff8abe
31 files changed
+1402 -107

No files matched your search

+11 -3
View File
@@ -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}`);
+195
View File
@@ -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
View File
@@ -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 {
+81 -24
View File
@@ -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);
}
}
+8
View File
@@ -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. */
+15 -9
View File
@@ -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 }))
+1
View File
@@ -3,6 +3,7 @@ import './nodes';
export * from './dropTarget';
export * from './Graph';
export * from './nested';
export * from './NodeContext';
export * from './NodeRegistry';
export * from './values';
+80
View File
@@ -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)}`;
}
+21
View File
@@ -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: [],
};
+24
View File
@@ -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',
}),
};
+6
View File
@@ -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,
+15
View File
@@ -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) };
};
}
+29 -1
View File
@@ -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>();
+1
View File
@@ -184,6 +184,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
portAt: (nodeId: string, port: string, isOutput: boolean) =>
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
canvasRect: () => graphEditor.canvas.canvasRect(),
+50 -16
View File
@@ -16,14 +16,15 @@ import {
firstPortFor,
type GraphEdge,
type GraphNode,
type NodeDef,
nodeDef,
type BehaviorLookup,
type PortSide,
type PortType,
portsCompatible,
portTypeOf,
resolveDef,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
@@ -31,6 +32,7 @@ import { removeNodes } from '../actions';
import { type EditorState, nodeSelection } from '../state';
import { type Box, boxesInBand, frameBoxes } from './framing';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
const GRID_STEP = 24;
@@ -140,8 +142,8 @@ export class GraphCanvas extends Widget {
addNodeFrom(anchor: Widget): void {
if (!this.assetId || this.state.mode !== 'edit') return;
const centre = this.screenToGraph(this.w / 2, this.h / 2);
new NodePalette((def: NodeDef) =>
this.addNode(def, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
new NodePalette(this.entries(), (entry) =>
this.addNode(entry, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
).openBelow(anchor);
}
@@ -151,6 +153,10 @@ export class GraphCanvas extends Widget {
// --- model binding ----------------------------------------------------------------------
private behaviors(): BehaviorLookup {
return behaviorLookup(this.state.project);
}
private asset(): BehaviorAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'behavior' ? asset : null;
@@ -168,8 +174,9 @@ export class GraphCanvas extends Widget {
return;
}
const seen = new Set<string>();
const lookup = behaviorLookup(this.state.project);
for (const node of asset.graph.nodes) {
const def = nodeDef(node.type);
const def = resolveDef(node, lookup);
if (!def) continue;
seen.add(node.id);
let view = this.views.get(node.id);
@@ -180,7 +187,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);
view.sync(node, asset.graph, asset, this.state.project, def);
}
for (const [id, view] of this.views) {
if (!seen.has(id)) {
@@ -425,13 +432,14 @@ export class GraphCanvas extends Widget {
): void {
const graph = this.asset()?.graph;
const from = drag.from;
const fromType = graph && portTypeOf(graph, from);
const fromType = graph && portTypeOf(graph, from, this.behaviors());
if (!graph || fromType === undefined) return;
const local = this.toLocal({ x: globalX, y: globalY });
const at = this.screenToGraph(local.x, local.y);
this.pendingDrop = drag;
const palette = new NodePalette(
(def: NodeDef) => this.addConnectedNode(def, from, fromType, at),
this.entries(),
(entry) => this.addConnectedNode(entry, from, fromType, at),
(def) => defAcceptsFrom(def, fromType, from.isOutput),
);
palette.onClose = () => {
@@ -449,10 +457,11 @@ export class GraphCanvas extends Widget {
const graph = this.asset()?.graph;
const from = this.connecting?.from;
if (!graph || !from) return null;
const lookup = this.behaviors();
const hit = this.portUnder(globalX, globalY);
if (hit) return canConnect(graph, from, hit) ? hit : null;
if (hit) return canConnect(graph, from, hit, lookup) ? hit : null;
const node = this.nodeUnder(globalX, globalY);
return node ? dropTargetPort(graph, from, node) : null;
return node ? dropTargetPort(graph, from, node, lookup) : null;
}
private portUnder(globalX: number, globalY: number): PortSide | null {
@@ -483,25 +492,42 @@ export class GraphCanvas extends Widget {
// --- menus -------------------------------------------------------------------------------
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
new NodePalette((def: NodeDef) => this.addNode(def, at)).open(globalX, globalY);
new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY);
}
private addNode(def: NodeDef, at: { x: number; y: number }): void {
private entries(): PaletteEntry[] {
const asset = this.asset();
return asset ? paletteEntries(this.state.project, asset) : [];
}
private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
const command = new AddNodeCommand(
this.assetId,
entry.def.type,
Math.round(at.x),
Math.round(at.y),
entry.params,
);
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id]));
}
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
private addConnectedNode(
def: NodeDef,
entry: PaletteEntry,
from: PortSide,
fromType: PortType,
at: { x: number; y: number },
): void {
if (!this.assetId) return;
const add = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
const port = firstPortFor(def, fromType, from.isOutput);
const add = new AddNodeCommand(
this.assetId,
entry.def.type,
Math.round(at.x),
Math.round(at.y),
entry.params,
);
const port = firstPortFor(entry.def, fromType, from.isOutput);
const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput };
const [out, inp] = from.isOutput ? [from, to] : [to, from];
const commands: Command[] = [add];
@@ -712,6 +738,14 @@ export class GraphCanvas extends Widget {
};
}
/**
* The title a node is drawn with. Worth reaching for from outside because a nested-behavior node
* takes its title from the behavior it runs – "which node is this" is otherwise only a guess.
*/
nodeLabel(nodeId: string): string | null {
return this.views.get(nodeId)?.def.label ?? 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);
+18 -16
View File
@@ -1,5 +1,6 @@
import { allNodeDefs, type NodeCategory, type NodeDef } from '../../core/graph';
import type { NodeCategory, NodeDef } from '../../core/graph';
import { ListView, Popup, ScrollContainer, TextField, Theme, ui } from '../../ui';
import type { PaletteEntry } from './paletteEntries';
const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action'];
@@ -8,7 +9,7 @@ export class NodePalette extends Popup {
private readonly search: TextField;
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
private filtered: NodeDef[] = [];
private filtered: PaletteEntry[] = [];
/** Guards against a second pick per opening (e.g. Enter and a row click racing). */
private picked = false;
@@ -17,7 +18,8 @@ export class NodePalette extends Popup {
* could actually connect to.
*/
constructor(
private readonly onPick: (def: NodeDef) => void,
private readonly entries: PaletteEntry[],
private readonly onPick: (entry: PaletteEntry) => void,
private readonly accepts: (def: NodeDef) => boolean = () => true,
) {
super({ padding: 4, gap: 4 });
@@ -39,8 +41,8 @@ export class NodePalette extends Popup {
this.list.rowHeight = Theme.rowHeight - 2;
this.list.emptyText = 'No matching node';
this.list.onSelect = (id) => {
const def = this.filtered.find((d) => d.type === id);
if (def) this.pick(def);
const entry = this.filtered.find((e) => e.id === id);
if (entry) this.pick(entry);
};
this.refresh();
}
@@ -53,28 +55,28 @@ export class NodePalette extends Popup {
private refresh(): void {
const query = this.search.value.trim().toLowerCase();
this.filtered = allNodeDefs()
.filter(this.accepts)
this.filtered = this.entries
.filter((e) => this.accepts(e.def))
.filter(
(d) =>
({ def }) =>
!query ||
d.label.toLowerCase().includes(query) ||
d.type.toLowerCase().includes(query) ||
d.description.toLowerCase().includes(query),
def.label.toLowerCase().includes(query) ||
def.type.toLowerCase().includes(query) ||
def.description.toLowerCase().includes(query),
)
.sort(
(a, b) =>
CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) ||
a.label.localeCompare(b.label),
CATEGORY_ORDER.indexOf(a.def.category) - CATEGORY_ORDER.indexOf(b.def.category) ||
a.def.label.localeCompare(b.def.label),
);
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
this.list.setItems(this.filtered.map((e) => ({ id: e.id, label: e.def.label, hint: e.hint })));
this.scroll.relayoutContent();
}
private pick(def: NodeDef): void {
private pick(entry: PaletteEntry): void {
if (this.picked) return;
this.picked = true;
this.close();
this.onPick(def);
this.onPick(entry);
}
}
+26 -6
View File
@@ -81,14 +81,21 @@ export class NodeView extends Widget {
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
/**
* Not fixed at construction: a nested-behavior node's ports come from another graph, so the
* definition that applies to this node can change without the node itself changing.
*/
def: NodeDef;
constructor(
readonly node: GraphNode,
readonly def: NodeDef,
def: NodeDef,
) {
super();
this.def = def;
this.cursor = 'grab';
this.addChild(this.box, this.ports);
this.sync(node, { nodes: [node], edges: [] }, null, null);
this.sync(node, { nodes: [node], edges: [] }, null, null, def);
}
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
@@ -98,10 +105,23 @@ export class NodeView extends Widget {
}
/** `project` resolves asset references (Spawn, Is asset). */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
this.paramLines = this.def.params.map(
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
);
sync(
node: GraphNode,
graph: Graph,
asset: BehaviorAsset | null,
project: Project | null,
def: NodeDef,
): void {
this.def = def;
this.paramLines = this.def.params
.map((p) => ({
label: p.label,
value: formatParam(p, node.params[p.key] ?? p.default, asset, project),
}))
// A setting the title already states is not worth a row: a nested-behavior node is titled
// with the behavior it runs, so "Behavior: Double" under "Double" says nothing new.
.filter((p) => p.value !== this.def.label)
.map((p) => `${p.label}: ${p.value}`);
this.connected = new Set(
inlineInputs(this.def)
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
+46
View File
@@ -0,0 +1,46 @@
import {
allNodeDefs,
BEHAVIOR_PARAM,
isTerminalType,
type NodeDef,
resolveDef,
RUN_BEHAVIOR,
terminalNode,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import type { PropertyBag } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
/**
* One row of the palette. `def` is the definition the node would actually have once placed, so the
* drop filter and the port it lands on are right for a behavior entry too.
*/
export interface PaletteEntry {
id: string;
hint: string;
def: NodeDef;
params: PropertyBag;
}
/**
* The node types offerable in `asset`'s graph. Every other behavior appears under its own name
* instead of behind a generic "Run behavior"; the two terminals drop out once the graph has them.
*/
export function paletteEntries(project: Project, asset: BehaviorAsset): PaletteEntry[] {
const entries: PaletteEntry[] = [];
for (const def of allNodeDefs()) {
if (def.type === RUN_BEHAVIOR) continue;
if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue;
entries.push({ id: def.type, hint: def.category, def, params: {} });
}
const lookup = behaviorLookup(project);
for (const other of project.assets) {
// A behavior running itself is an endless loop, so it is not on offer.
if (other.type !== 'behavior' || other.id === asset.id) continue;
const params = { [BEHAVIOR_PARAM]: other.id };
const def = resolveDef({ id: '', type: RUN_BEHAVIOR, x: 0, y: 0, params }, lookup);
if (def) entries.push({ id: `${RUN_BEHAVIOR}:${other.id}`, hint: 'behavior', def, params });
}
return entries;
}
+23 -7
View File
@@ -1,26 +1,40 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import { edgesInto, type GraphNode, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
import {
edgesInto,
type GraphNode,
inlineInputDef,
inlineInputs,
isTerminalNode,
portSignature,
resolveDef,
} from '../../core/graph';
import type { Asset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection } from './portSection';
import { previewingHandlers } from './previewHandlers';
/** Structure-defining facts of a node selection: type, connected inputs, declared variables. */
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
const asset = findAsset(project, assetId);
if (!asset || asset.type !== 'behavior') return 'missing';
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const def = nodeDef(node.type);
const lookup = behaviorLookup(project);
const def = resolveDef(node, lookup);
const connected = def
? inlineInputs(def)
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
.map((p) => p.key)
: [];
return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}`).join(',');
return `${portSignature(node, lookup)}|${declared}|${connected.join(',')}|${asset.variables
.map((v) => v.key)
.join(',')}`;
}
/** Description, settings (params) and the inline values of unconnected data inputs. */
@@ -28,12 +42,13 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
const asset = findAsset(host.state.project, assetId);
if (!asset || asset.type !== 'behavior') return;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
const def = node && nodeDef(node.type);
const def = node && resolveDef(node, behaviorLookup(host.state.project));
if (!node || !def) return;
addTitle(host, `${def.category.toUpperCase()} NODE`);
host.body.add(new Label(def.label, { style: 'bodyBold' }));
addNote(host, def.description);
if (isTerminalNode(node)) buildPortSection(host, assetId, node);
if (def.params.length > 0) addTitle(host, 'SETTINGS');
for (const param of def.params) {
@@ -74,14 +89,15 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
const node = currentNode(host.state.project, assetId, nodeId);
const def = node && nodeDef(node.type);
const def = node && resolveDef(node, behaviorLookup(host.state.project));
if (!node || !def) return false;
for (const [key, row] of host.fields) {
const [scope, name] = key.split(':') as ['param' | 'input', string];
const port = def.inputs.find((p) => p.key === name);
const fallback =
scope === 'param'
? def.params.find((p) => p.key === name)?.default
: inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
: port && inlineInputDef(port).default;
row.setValue(node.params[name] ?? fallback ?? '');
}
return true;
+85
View File
@@ -0,0 +1,85 @@
import {
AddPortCommand,
nextPortName,
RemovePortCommand,
RenamePortCommand,
} from '../../core/commands/portCommands';
import { BEHAVIOR_START, DECLARED_PORT_KINDS, type DeclaredPortKind, type GraphNode } from '../../core/graph';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { addNote, type InspectorHost } from './host';
const KIND_LABELS: Record<DeclaredPortKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
object: 'Object',
};
const KIND_MARKS: Record<DeclaredPortKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
object: '◇',
};
/**
* The ports a start or end node declares – the signature of the behavior seen as a node. Named from
* the caller's side: what the start node hands into the graph is an *input* of the behavior.
*/
export function buildPortSection(host: InspectorHost, assetId: string, node: GraphNode): void {
const onStart = node.type === BEHAVIOR_START;
const noun = onStart ? 'Input' : 'Output';
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => {
const items = DECLARED_PORT_KINDS.map((kind) => ({
label: KIND_LABELS[kind],
onSelect: () =>
host.state.execute(
new AddPortCommand(assetId, node.type, {
key: nextPortName(node, onStart ? 'in' : 'out'),
kind,
}),
),
}));
new Menu(items).openBelow(add);
};
header.add(add);
host.body.add(header);
const ports = node.ports ?? [];
if (ports.length === 0)
addNote(
host,
onStart
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.'
: 'No outputs yet. Every output added here becomes a result of running this behavior.',
);
for (const port of ports) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
row.add(
new TextField(port.key, {
width: 90,
onChange: (next) =>
host.state.execute(new RenamePortCommand(assetId, node.type, port.key, next.trim())),
}),
1,
);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.type, port.key)),
}),
);
host.body.add(row);
}
}
+7 -3
View File
@@ -5,6 +5,7 @@ import {
type NodeContext,
type TraceListener,
} from '../../core/graph';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
@@ -78,7 +79,7 @@ export class BehaviorSystem implements System {
const asset = findAsset(this.project, entity.assetId);
if (!asset || asset.type !== 'behavior') continue;
const trace: Trace = { edges: new Map(), nodes: new Map() };
const interpreter = new GraphInterpreter(asset.graph);
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project));
interpreter.trace = {
onEdge: (id) => trace.edges.set(id, world.time),
onNode: (id) => trace.nodes.set(id, world.time),
@@ -153,14 +154,17 @@ export class BehaviorSystem implements System {
return this.instances.map((i) => i.entity.objectId);
}
/** Live highlight data for the graph editor: merged over all instances of the asset. */
/**
* Live highlight data for the graph editor: merged over all instances of the asset – and over
* those that ran it as a node, which is the only way a nested behavior's graph ever lights up.
*/
traceFor(assetId: string): Trace {
const merged: Trace = { edges: new Map(), nodes: new Map() };
const latest = (into: Map<string, number>, from: Map<string, number>) => {
for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity));
};
for (const instance of this.instances) {
if (instance.entity.assetId !== assetId) continue;
if (instance.entity.assetId !== assetId && !instance.interpreter.calledBehaviors.has(assetId)) continue;
latest(merged.edges, instance.trace.edges);
latest(merged.nodes, instance.trace.nodes);
}