import { Graphics, Rectangle, Text } from 'pixi.js'; import { asString, edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType, type Value, } from '../../core/graph'; import type { BehaviorAsset } from '../../core/model/Asset'; import type { Project } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; import { assetRefLabel } from '../../core/properties/assetRef'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui'; import { keyLabel } from '../inspector/fieldFactory'; /** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */ 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 entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */ 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; /** Data inputs that currently have a connection (their inline value is hidden). */ private connected = new Set(); constructor( readonly node: GraphNode, readonly def: NodeDef, ) { super(); this.cursor = 'grab'; this.addChild(this.box, this.ports); this.sync(node, { nodes: [node], edges: [] }, null, null); } /** 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); } /** `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)}`, ); this.connected = new Set( inlineInputs(this.def) .filter((p) => edgesInto(graph, node.id, p.key).length > 0) .map((p) => p.key), ); const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * 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; } /** Every port with its local centre – inputs on the left edge, outputs on the right. */ private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> { for (const [list, isOutput] of [ [this.def.inputs, false], [this.def.outputs, true], ] as const) { for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) }; } } portAt(localX: number, localY: number): PortHit | null { for (const { port, isOutput, x, y } of this.portCentres()) { if (Math.hypot(localX - x, localY - y) <= PORT_HIT_RADIUS) return { port, isOutput }; } return null; } private rebuildTexts(): void { for (const t of this.texts) t.destroy(); this.texts.length = 0; const add = ( text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2, ) => { const node = new Text({ text: truncate(text, textStyle(style), maxWidth), style: textStyle(style) }); node.position.set(align === 'left' ? x : x - node.width, y); this.addChild(node); this.texts.push(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; } for (let i = 0; i < this.rowCount(); 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 = inlineValue(this.node, input); if (value !== null && 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(); const r = PORT_RADIUS; for (const { port, x, y } of this.portCentres()) { if (port.type === 'exec') { // A pentagon pointing right: the Blueprint-style exec pin. this.ports .moveTo(x - r, y - r) .lineTo(x + 1, y - r) .lineTo(x + r + 1, y) .lineTo(x + 1, y + r) .lineTo(x - r, y + r) .closePath() .fill(portColor('exec')); } else { this.ports.circle(x, y, r).fill(portColor(port.type)); this.ports.circle(x, y, r).stroke({ width: 1, color: c.node }); } } } } /** The value of a setting as shown on the node; references that no longer resolve get a "?". */ function formatParam( param: PropertyDef, value: PropertyValue | undefined, asset: BehaviorAsset | null, project: Project | null, ): string { if (value === undefined || value === '') return '—'; if (param.kind === 'key') return keyLabel(String(value)); if (param.kind === 'asset') return assetRefLabel(project, String(value)); if ( param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value) ) return `${value}?`; return formatValue(value); } function formatValue(value: Value): string { // Whole numbers stay bare to keep nodes narrow; fractions match the field that edits them. if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value); if (typeof value === 'boolean') return value ? 'true' : 'false'; return asString(value); }