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,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 + '…';
|
||||
}
|
||||
Reference in new issue
Block a user