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);