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

+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;
}