Behaviors with Blueprint-style node graph, graph editor tab and BehaviorSystem

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 12:44:43 +02:00
1 parent d4b1ffa051
commit 922774778b
36 files changed
+2969 -147

No files matched your search

+396
View File
@@ -0,0 +1,396 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands';
import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import type { Trace } from '../../runtime/systems/BehaviorSystem';
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { Menu, type Size, Theme, textStyle, Widget } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
import { NodeView, portColor } from './NodeView';
const GRID_STEP = 24;
interface PortAnchor {
nodeId: string;
port: string;
isOutput: boolean;
}
/**
* The node graph editor for one behavior asset. Shows the graph of whatever behavior the
* selection refers to (asset, placed object or node) and keeps showing it while unrelated
* things are selected, so the user can flip between Scene and Graph tabs freely.
*
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
* the empty canvas to pan. All edits go through commands (undoable).
*/
export class GraphEditor extends Widget {
private readonly bg = new Graphics();
private readonly grid = new Graphics();
private readonly clip = new Graphics();
private readonly canvas = new Container();
private readonly edges = new Graphics();
private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly views = new Map<string, NodeView>();
private assetId: string | null = null;
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
/** Supplies live execution data while playing (null otherwise). */
traceProvider: (assetId: string) => Trace | null = () => null;
timeProvider: () => number = () => 0;
constructor(private readonly state: EditorState) {
super();
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
this.canvas.mask = this.clip;
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('wheel', (e: FederatedWheelEvent) => {
const local = this.toLocal(e.global);
const before = this.screenToGraph(local.x, local.y);
this.zoom = Math.max(0.25, Math.min(2.5, this.zoom * Math.exp(-e.deltaY * 0.0015)));
const after = this.screenToGraph(local.x, local.y);
this.pan.x += (after.x - before.x) * this.zoom;
this.pan.y += (after.y - before.y) * this.zoom;
this.applyView();
e.preventDefault();
});
state.on('selection', () => this.followSelection());
state.on('load', () => { this.assetId = null; this.followSelection(); });
state.on('project', () => this.sync());
state.on('mode', () => this.redraw());
}
/** The behavior whose graph is shown, if any. */
get currentAssetId(): string | null {
return this.assetId;
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
// --- model binding ----------------------------------------------------------------------
private asset(): BehaviorAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'behavior' ? asset : null;
}
private followSelection(): void {
const wanted = this.state.selectedBehaviorId;
if (wanted && wanted !== this.assetId) {
this.assetId = wanted;
this.sync();
this.needsFit = true;
this.fitView();
} else if (!this.asset()) {
this.assetId = null;
this.sync();
} else {
this.redraw();
}
}
/** Rebuilds node views from the model (cheap: views are reused by node id). */
private sync(): void {
const asset = this.asset();
if (!asset) {
this.assetId = null;
for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear();
this.redraw();
return;
}
const seen = new Set<string>();
for (const node of asset.graph.nodes) {
const def = nodeDef(node.type);
if (!def) continue;
seen.add(node.id);
let view = this.views.get(node.id);
if (!view || view.node !== node) {
view?.destroy({ children: true });
view = new NodeView(node, def);
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
this.views.set(node.id, view);
this.nodeLayer.addChild(view);
}
view.sync(node, asset.graph, asset);
}
for (const [id, view] of this.views) {
if (!seen.has(id)) {
view.destroy({ children: true });
this.views.delete(id);
}
}
this.redraw();
}
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
private fitView(): void {
if (this.w === 0 || this.h === 0) return;
this.needsFit = false;
const asset = this.asset();
if (!asset || asset.graph.nodes.length === 0) {
this.pan = { x: this.w / 2 - 80, y: this.h / 3 };
this.zoom = 1;
} else {
const xs = asset.graph.nodes.map((n) => n.x);
const ys = asset.graph.nodes.map((n) => n.y);
const cx = (Math.min(...xs) + Math.max(...xs) + 176) / 2;
const cy = (Math.min(...ys) + Math.max(...ys) + 80) / 2;
this.zoom = 1;
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
}
this.applyView();
}
private applyView(): void {
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
this.canvas.scale.set(this.zoom);
this.redraw();
}
private screenToGraph(sx: number, sy: number): { x: number; y: number } {
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
}
// --- pointer -----------------------------------------------------------------------------
private onCanvasPointerDown(e: FederatedPointerEvent): void {
if (e.target !== this) return;
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}
}
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
if (!this.assetId || this.state.mode !== 'edit') return;
e.stopPropagation();
const local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y);
if (e.button === 2) {
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
else this.openNodeMenu(view, e.global.x, e.global.y);
return;
}
if (e.button !== 0) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) };
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
const p = this.toLocal(e.global);
this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false };
this.nodeLayer.addChild(view); // raise
}
private onPointerMove(e: FederatedPointerEvent): void {
if (this.panning) {
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
this.applyView();
return;
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
if (this.dragging) {
const dx = g.x - this.dragging.pointer.x;
const dy = g.y - this.dragging.pointer.y;
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
this.dragging.moved = true;
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
if (!node) return;
node.x = Math.round(this.dragging.start.x + dx);
node.y = Math.round(this.dragging.start.y + dy);
this.state.preview();
} else if (this.connecting) {
this.connecting.pointer = g;
this.redraw();
}
}
private onPointerUp(e: FederatedPointerEvent): void {
this.panning = null;
const drag = this.dragging;
this.dragging = null;
if (drag && this.assetId) {
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
if (node && drag.moved) {
const next = { x: node.x, y: node.y };
node.x = drag.start.x;
node.y = drag.start.y;
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y, drag.start));
}
}
const connecting = this.connecting;
this.connecting = null;
if (connecting && this.assetId) {
const target = this.portUnder(e.global.x, e.global.y);
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }));
}
}
this.redraw();
}
private portUnder(globalX: number, globalY: number): PortAnchor | null {
for (const view of this.views.values()) {
const local = view.toLocal({ x: globalX, y: globalY });
const hit = view.portAt(local.x, local.y);
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
}
return null;
}
// --- menus -------------------------------------------------------------------------------
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
new NodePalette((def: NodeDef) => {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}).open(globalX, globalY);
}
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
const assetId = this.assetId;
if (!assetId) return;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY);
}
private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
override layout(): void {
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
if (this.needsFit) this.fitView();
}
override redraw(): void {
const c = Theme.colors;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
this.drawGrid();
const asset = this.asset();
this.canvas.visible = asset !== null;
if (!asset) {
this.hint.text = 'Select a behavior asset (or a placed behavior) to edit its graph.';
return;
}
const playing = this.state.mode === 'play';
this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`;
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
const isHot = (map: Map<string, number> | undefined, id: string) => {
const t = map?.get(id);
return t !== undefined && now - t <= TRACE_FADE;
};
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
for (const view of this.views.values()) {
view.selected = view.node.id === selectedNode;
view.hot = isHot(trace?.nodes, view.node.id);
view.redraw();
}
this.edges.clear();
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
this.tempEdge.clear();
if (this.connecting) {
const view = this.views.get(this.connecting.from.nodeId);
const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
if (view && p) {
const a = { x: view.x + p.x, y: view.y + p.y };
const b = this.connecting.pointer;
const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a];
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
}
}
}
private drawEdge(edge: GraphEdge, hot: boolean): void {
const from = this.views.get(edge.from.node);
const to = this.views.get(edge.to.node);
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
}
private drawGrid(): void {
this.grid.clear();
const step = GRID_STEP * this.zoom;
if (step < 8) return;
const startX = ((this.pan.x % step) + step) % step;
const startY = ((this.pan.y % step) + step) % step;
for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h);
for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y);
this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.04 });
}
// --- test hooks -----------------------------------------------------------------------------
/** Global screen position of a port (tests drive connections with it). */
portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null {
const view = this.views.get(nodeId);
const p = view?.portPosition(port, isOutput);
if (!view || !p) return null;
const g = view.toGlobal({ x: p.x, y: p.y });
return { x: g.x, y: g.y };
}
/** Global screen position of a node's title bar centre. */
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);
if (!view) return null;
const g = view.toGlobal({ x: view.w / 2, y: 12 });
return { x: g.x, y: g.y };
}
}
/** Horizontal S-curve between two points, as used by every node editor. */
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
}