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:
1 parent
d4b1ffa051
commit
922774778b
36 files changed
+2969
-147
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user