import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js'; import { asNumber, asString, edgesInto, type Graph, type GraphNode, inlineInputDef, inlineInputs, inlineValue, type NodeCategory, type NodeDef, nodeDef, type PortDef, type PortType, type Value, } from '../../core/graph'; import { layerIndexOf, layerMaskOf, layerName, maskLabel } from '../../core/model/collisionLayers'; import type { Project } from '../../core/model/Project'; import { isList, type PropertyValue } from '../../core/model/Properties'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { formatNumber, Theme, textStyle, type TextStyleToken, toHex, truncate, Widget } from '../../ui'; import { keyLabel, referenceLabel } from '../inspector/fieldFactory'; import { collapsedNodeWidth } from './folding'; /** 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; const ROW_HEIGHT = 20; const PORT_RADIUS = 5; /** Distance (graph units) within which a press counts as hitting a port. */ const PORT_HIT_RADIUS = 9; const PAD = 6; /** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */ const CHEVRON_SLOT = 14; const TITLE_X = PAD + CHEVRON_SLOT; /** Right of a collapsed title: the side's link dot sits centred on the edge. */ const COLLAPSED_TITLE_END = PAD + PORT_RADIUS; 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; case 'state': return c.categoryState; case 'shader': return c.categoryShader; case 'particles': return c.categoryParticles; } } 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 'list': return c.portList; case 'struct': return c.portStruct; case 'vec3': return c.portVec3; case 'any': return c.portAny; } } function rowStyle(port: PortDef): TextStyleToken { if (port.ghost) return 'warning'; return port.type === 'exec' ? 'body' : 'muted'; } 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[] = []; /** Why this node can do nothing; see `callProblem`. */ private problem: string | null = null; selected = false; hot = false; /** The state a machine begins in wears a ring, so the entry point is visible at a glance. */ isStart = false; /** * While something is running: `held` for a state some instance of the machine is in, `current` * for the one the selected object's instance holds. Nothing outside a run. */ stateMark: 'none' | 'held' | 'current' = 'none'; /** Port a running edge drag would connect to, drawn with a ring so the drop is predictable. */ targetPort: { port: string; isOutput: boolean } | null = null; /** Data inputs that currently have a connection (their inline value is hidden). */ private connected = new Set(); private inlineTexts = new Map(); /** Which sides carry a link – a collapsed node marks those with one dot each. */ private linked = { inputs: false, outputs: false }; private liveTexts = new Map(); private readonly valueTexts = new Map(); /** Where an output's value may be drawn: right-aligned before its label, clear of the input text. */ private valueSlots = new Map(); /** * Not fixed at construction: a nested-behavior node's ports come from another graph, so the * definition that applies to this node can change without the node itself changing. */ def: NodeDef; constructor( readonly node: GraphNode, def: NodeDef, ) { super(); this.def = def; this.cursor = 'grab'; this.addChild(this.box, this.ports); this.sync(node, { nodes: [node], edges: [] }, null, def); } /** 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); } /** * Whether a local point is on this node, by the same area its pointer events use. An input port * sits at exactly x = 0, and rounding a global point back can land a hair outside it – which made * a drop on such a port count as a drop on empty canvas and opened the palette. */ containsLocal(x: number, y: number): boolean { return this.hitArea?.contains(x, y) ?? false; } /** `project` resolves asset references (Spawn, Is asset). */ sync( node: GraphNode, graph: Graph, project: Project | null, def: NodeDef, problem: string | null = null, ): void { this.def = def; this.problem = problem; const hidden = new Set(this.def.hiddenParams?.(node.params) ?? []); const title = this.def.label; const derived = title !== nodeDef(node.type)?.label; this.paramLines = this.def.params .filter((p) => !hidden.has(p.key)) .map((p) => ({ label: p.label, value: formatParam(p, node.params[p.key] ?? p.default, project), })) // A title made from a setting states it ("Behavior: Double" under "Double"). A dangling // reference keeps its row: "Double?" is not in the title. .filter((p) => !derived || (p.value !== title && !title.endsWith(` ${p.value}`))) .map((p) => `${p.label}: ${p.value}`); this.connected = new Set( inlineInputs(this.def) .filter((p) => edgesInto(graph, node.id, p.key).length > 0) .map((p) => p.key), ); this.inlineTexts = new Map(); for (const port of inlineInputs(this.def)) { const value = inlineValue(node, port); if (!this.connected.has(port.key) && value !== null && value !== '') this.inlineTexts.set(port.key, formatField(inlineInputDef(port), value, project)); } this.linked = { inputs: graph.edges.some((e) => e.to.node === node.id), outputs: graph.edges.some((e) => e.from.node === node.id), }; const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD; // Sized before the texts: output labels are placed against the right edge. this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height); this.rebuildTexts(); this.position.set(node.x, node.y); } private collapsedWidth(): number { const title = CanvasTextMetrics.measureText(this.def.label, textStyle('accent')).width; return collapsedNodeWidth(title, TITLE_X + COLLAPSED_TITLE_END, NODE_WIDTH); } get collapsed(): boolean { return this.node.collapsed === true; } chevronAt(localX: number, localY: number): boolean { return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < TITLE_X; } chevronGlobal(): { x: number; y: number } { return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 }); } /** Rows of ports, which set the node's height – none on a state, whose ports are never drawn. */ private rowCount(): number { if (this.def.transitionEdges === true) return 0; return Math.max(this.def.inputs.length, this.def.outputs.length); } /** The warning line as drawn – checks read what the node really shows, not a recomputation. */ get problemText(): string | null { return this.problem; } /** The setting rows as drawn, for the same reason. */ get settingLines(): string[] { return [...this.paramLines]; } get inputLines(): string[] { return this.def.inputs.map((input) => this.inputText(input)); } get outputLines(): string[] { return this.def.outputs.map((output) => { const value = this.valueTexts.get(output.key)?.text; return value === undefined ? output.label : `${value} ${output.label}`; }); } /** Nothing clears them when play ends: the views are rebuilt then. */ setLiveValues(values: Record | null, project: Project | null): void { const next = new Map(); for (const output of this.def.outputs) { const value = values?.[output.key]; if (output.type === 'exec' || value === undefined) continue; next.set(output.key, formatLive(output, value, project)); } if (next.size === this.liveTexts.size && [...next].every(([k, v]) => this.liveTexts.get(k) === v)) return; this.liveTexts = next; this.placeValues(); } private placeValues(): void { for (const text of this.valueTexts.values()) text.destroy(); this.valueTexts.clear(); if (this.collapsed) return; const style = textStyle('live'); for (const [key, value] of this.liveTexts) { const slot = this.valueSlots.get(key); if (!slot || slot.right - slot.left < 12) continue; const text = new Text({ text: truncate(value, style, slot.right - slot.left), style }); text.position.set(slot.right - text.width, slot.top); this.addChild(text); this.valueTexts.set(key, text); } } private inputText(input: PortDef): string { const inline = this.inlineTexts.get(input.key); return inline === undefined ? input.label : `${input.label} ${inline}`; } /** Everything above the first port row. The problem line sits in here, so ports move down with it. */ private headerHeight(): number { const lines = this.paramLines.length + (this.problem ? 1 : 0); return TITLE_HEIGHT + PAD + lines * ROW_HEIGHT; } /** 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.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) }; } private rowY(index: number): number { return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2; } /** * Every port with its local centre – none while collapsed, and none on a state, so neither can * be drawn or grabbed. `portPosition` stays open either way: the edges still need their anchor. */ private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> { if (this.collapsed || this.def.transitionEdges === true) return; 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; this.valueSlots = new Map(); 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); return node; }; add( this.def.label, 'accent', TITLE_X, (TITLE_HEIGHT - 16) / 2, 'left', this.w - TITLE_X - (this.collapsed ? COLLAPSED_TITLE_END : PAD), ); if (this.collapsed) { this.placeValues(); return; } let y = TITLE_HEIGHT + PAD; for (const line of this.paramLines) { add(line, 'small', PAD + 2, y + 2); y += ROW_HEIGHT; } if (this.problem) { add(this.problem, 'warning', 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; let inputEnd = PAD; if (input) { const text = add( this.inputText(input), rowStyle(input), PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20, ); inputEnd = text.x + text.width; } if (output) { const text = add( output.label, rowStyle(output), this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10, ); if (output.type !== 'exec' && !output.ghost) this.valueSlots.set(output.key, { left: inputEnd + 6, right: text.x - 6, top: rowTop + 1 }); } } this.placeValues(); } 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)); if (!this.collapsed) this.box .rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius) .fill(categoryColor(this.def.category)); this.drawChevron(PAD + 4, TITLE_HEIGHT / 2); // The ring sits where a state's ports never are: a state draws none (see `portCentres`). if (this.isStart) this.box .circle(this.w - PAD - PORT_RADIUS, TITLE_HEIGHT / 2, PORT_RADIUS) .stroke({ width: 2, color: c.text }); const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder; this.box .roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius) .stroke({ width: this.selected || this.hot || this.problem ? 2 : 1, color: border }); // Inside the border, so a selected state still shows both: what is picked, and what is live. if (this.stateMark !== 'none') this.box .roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius) .stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 }); this.ports.clear(); const r = PORT_RADIUS; if (this.collapsed) { for (const [linked, x] of [ [this.linked.inputs, 0], [this.linked.outputs, this.w], ] as const) { if (!linked) continue; this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).fill(c.textMuted); this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).stroke({ width: 1, color: c.node }); } } for (const { port, isOutput, x, y } of this.portCentres()) { if (this.targetPort?.port === port.key && this.targetPort.isOutput === isOutput) { // A gap in the node colour first, so the ring stays visible on the white exec pin too. this.ports.circle(x, y, r + 2).stroke({ width: 2, color: c.node }); this.ports.circle(x, y, r + 4).stroke({ width: 2, color: c.portTarget }); } if (port.ghost) { // Hollow: a pin is drawn where a port used to be, and nothing stands behind it any more. this.ports.circle(x, y, r).stroke({ width: 2, color: c.danger }); } else 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 { // `paintType`: an `any` input takes the colour of the edge feeding it, its type stays `any`. this.ports.circle(x, y, r).fill(portColor(port.paintType ?? port.type)); this.ports.circle(x, y, r).stroke({ width: 1, color: c.node }); } } } /** The hierarchy's shape, so both folds read the same. */ private drawChevron(x: number, y: number): void { const color = Theme.colors.text; if (this.collapsed) this.box .moveTo(x - 1, y - 4) .lineTo(x + 4, y) .lineTo(x - 1, y + 4) .fill(color); else this.box .moveTo(x - 3, y - 2) .lineTo(x + 5, y - 2) .lineTo(x + 1, y + 3) .fill(color); } } function formatParam(param: PropertyDef, value: PropertyValue | undefined, project: Project | null): string { if (value === undefined || value === '') return '—'; return formatField(param, value, project); } /** A value in the words of the field that edits it: a key's name, a color's hex, a texture's name. */ function formatField(def: PropertyDef, value: Value, project: Project | null): string { if (def.kind === 'key') return keyLabel(asString(value)); if (def.kind === 'color') return toHex(asNumber(value)); // Layers read as names, not as the bits they are stored in (#152, #158). const layers = project?.collisionLayers ?? []; if (def.kind === 'layer') return layerName(layers, layerIndexOf(value)); if (def.kind === 'layers') return maskLabel(layerMaskOf(value), layers); if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value); return referenceLabel(def, project, asString(value)) ?? formatValue(value); } function formatLive(output: PortDef, value: Value, project: Project | null): string { if (value === null) return '—'; if (value === '') return '""'; return output.field ? formatField(output.field, value, project) : formatValue(value); } function formatValue(value: Value): string { if (typeof value === 'number') return formatNumber(value); if (typeof value === 'boolean') return value ? 'true' : 'false'; // A list can hold hundreds of items – shown whole it would stretch the node across the graph. if (isList(value)) { if (value.length === 0) return 'empty'; const head = value.slice(0, 4).map(formatValue).join(', '); return value.length > 4 ? `${head}, … (${value.length})` : head; } return asString(value); }