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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user