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