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

+191
View File
@@ -0,0 +1,191 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import { edgesInto, type Graph, type GraphNode, inlineInputs, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { Theme, textStyle, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
export const NODE_WIDTH = 176;
export const TITLE_HEIGHT = 24;
export const ROW_HEIGHT = 20;
export const PORT_RADIUS = 5;
/** Distance (graph units) within which a press counts as hitting a port. */
export const PORT_HIT_RADIUS = 9;
const PAD = 6;
export function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
switch (category) {
case 'event': return c.categoryEvent;
case 'flow': return c.categoryFlow;
case 'data': return c.categoryData;
case 'variable': return c.categoryVariable;
case 'action': return c.categoryAction;
}
}
export function portColor(type: PortType): number {
const c = Theme.colors;
switch (type) {
case 'exec': return c.portExec;
case 'number': return c.portNumber;
case 'boolean': return c.portBoolean;
case 'string': return c.portString;
case 'object': return c.portObject;
case 'any': return c.portAny;
}
}
export interface PortHit {
port: PortDef;
isOutput: boolean;
}
/**
* Draws one graph node: a title bar coloured by category, a line per setting and a row per
* port pair (inputs left, outputs right). Everything is derived from the NodeDef; the view
* never interprets what a node does. Positioned in graph coordinates by the GraphEditor.
*/
export class NodeView extends Widget {
private readonly box = new Graphics();
private readonly ports = new Graphics();
private readonly texts: Text[] = [];
private paramLines: string[] = [];
selected = false;
hot = false;
/** Whether a data input currently has a connection (inline value hidden). */
private connected = new Set<string>();
constructor(readonly node: GraphNode, readonly def: NodeDef) {
super();
this.cursor = 'grab';
this.addChild(this.box, this.ports);
this.sync(node, { nodes: [node], edges: [] }, null);
}
get titleText(): string {
return this.def.label;
}
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
override resize(w: number, h: number): void {
super.resize(w, h);
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
}
/** Re-reads params and connections; rebuilds the text lines. */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null): void {
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p.kind, node.params[p.key], asset)}`);
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
const rows = this.rowCount();
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + rows * ROW_HEIGHT + PAD;
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
}
private rowCount(): number {
return Math.max(this.def.inputs.length, this.def.outputs.length);
}
/** Local position of a port's centre. */
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
const list = isOutput ? this.def.outputs : this.def.inputs;
const index = list.findIndex((p) => p.key === key);
if (index < 0) return null;
return { x: isOutput ? this.w : 0, y: this.rowY(index) };
}
private rowY(index: number): number {
return TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/** Which port (if any) sits under a local point. */
portAt(localX: number, localY: number): PortHit | null {
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
for (let i = 0; i < list.length; i++) {
const px = isOutput ? this.w : 0;
if (Math.hypot(localX - px, localY - this.rowY(i)) <= PORT_HIT_RADIUS) return { port: list[i]!, isOutput };
}
}
return null;
}
private rebuildTexts(): void {
for (const t of this.texts) t.destroy();
this.texts.length = 0;
const add = (text: string, style: Parameters<typeof textStyle>[0], x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
node.position.set(align === 'left' ? x : x - node.width, y);
this.addChild(node);
this.texts.push(node);
return node;
};
add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2);
let y = TITLE_HEIGHT + PAD;
for (const line of this.paramLines) {
add(line, 'small', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
const rows = this.rowCount();
for (let i = 0; i < rows; i++) {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2;
if (input) {
let label = input.label;
if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) {
const value = this.node.params[input.key];
if (value !== undefined && value !== '') label += ` ${formatValue(value)}`;
}
add(label || '', input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
}
if (output) add(output.label || '', output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
}
}
override redraw(): void {
const c = Theme.colors;
this.box.clear();
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category));
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.ports.clear();
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
list.forEach((port, i) => {
const x = isOutput ? this.w : 0;
const y = this.rowY(i);
if (port.type === 'exec') {
this.ports.moveTo(x - 5, y - 5).lineTo(x + 1, y - 5).lineTo(x + 6, y).lineTo(x + 1, y + 5).lineTo(x - 5, y + 5).closePath().fill(portColor('exec'));
} else {
this.ports.circle(x, y, PORT_RADIUS).fill(portColor(port.type));
this.ports.circle(x, y, PORT_RADIUS).stroke({ width: 1, color: c.node });
}
});
}
}
}
function formatParam(kind: string, value: unknown, asset: BehaviorAsset | null): string {
if (value === undefined || value === '') return '—';
if (kind === 'key') return keyLabel(String(value));
if (kind === 'enum' && asset && !asset.variables.some((v) => v.key === value) && asset.variables.length > 0 && String(value).length > 3) return `${value}?`;
return formatValue(value);
}
function formatValue(value: unknown): string {
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
}
function fit(text: string, style: Parameters<typeof textStyle>[0], maxWidth: number): string {
const s = textStyle(style);
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
let cut = text;
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1);
return cut + '…';
}