import { CanvasTextMetrics, Graphics, Rectangle, type 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 { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; 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, crispText, } from '../../ui'; import { keyLabel, referenceLabel } from '../inspector/fieldFactory'; import { collapsedNodeWidth } from './folding'; import { inputTextRoom, valueSpan } from './portRow'; /** 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; case 'sound': return c.categorySound; } } 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 'picture': return c.portPicture; case 'audio': return c.portAudio; case 'fxparams': return c.portFxParams; case 'trigger': return c.portTrigger; case 'envelope': return c.portEnvelope; case 'any': return c.portAny; } } function rowStyle(port: PortDef): TextStyleToken { if (port.ghost) return 'warning'; return port.type === 'exec' ? 'body' : 'muted'; } export interface TextSpan { text: string; left: number; right: number; } 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[] = []; /** The input and output text of each port row, among `texts`. */ private readonly rowTexts: { input: Text | null; output: Text | null }[] = []; 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(); /** * What the texts and the box were last made from. A graph is redrawn on every model event and on * every frame of a run, and for nearly every node nothing has changed: a text made anew is a * picture uploaded anew, and a box cleared is a shape built anew (#292). */ private textsOf = ''; private drawnOf = ''; /** How often the texts and the box were really made, for the checks. */ readonly made = { texts: 0, box: 0 }; /** * 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'; // Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred // nodes were a few hundred draw calls. In the batch they share one. this.box.context.batchMode = 'batch'; this.ports.context.batchMode = 'batch'; 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) ?? []), ...(this.def.idleParams?.(node.params, (key) => edgesInto(graph, node.id, key).length > 0) ?? []), ]); 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(); // Every free input shows what it carries, its default too: an untouched `And` reads "false", // not nothing, empty text reads "(empty)" – the same value the node computes with. A free // object or struct has nothing to type in and carries null, which it says as well. for (const port of inlineInputs(this.def)) { if (this.connected.has(port.key)) continue; const text = formatField(inlineInputDef(port), inlineValue(node, port), project); this.inlineTexts.set(port.key, text === '' ? EMPTY_TEXT : text); } for (const port of this.def.inputs) { if ( (port.type === 'object' || port.type === 'struct') && edgesInto(graph, node.id, port.key).length === 0 ) this.inlineTexts.set(port.key, NULL_TEXT); } 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); const texts = this.textSignature(); if (texts !== this.textsOf) { this.textsOf = texts; this.made.texts += 1; this.rebuildTexts(); } this.position.set(node.x, node.y); } /** Everything `rebuildTexts` reads. */ private textSignature(): string { const rows: unknown[] = []; for (let i = 0; i < this.rowCount(); i++) { const input = this.def.inputs[i]; const output = this.def.outputs[i]; rows.push( input ? [this.inputText(input), rowStyle(input)] : null, output ? [output.label, rowStyle(output), output.type !== 'exec' && !output.ghost, output.key] : null, ); } return JSON.stringify([this.w, this.collapsed, this.def.label, this.paramLines, this.problem, rows]); } /** Everything `redraw` reads. */ private drawSignature(): string { const port = (p: PortDef) => `${p.key}:${p.paintType ?? p.type}:${p.ghost ? 1 : 0}`; return JSON.stringify([ this.w, this.h, this.collapsed, this.def.category, this.def.transitionEdges === true, this.isStart, this.hot, this.selected, this.problem !== null, this.stateMark, this.linked, this.targetPort, this.headerHeight(), this.def.inputs.map(port), this.def.outputs.map(port), ]); } 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)); } /** Where each port row's input and output text really landed, in node units, as cut to fit. */ get rowSpans(): { input: TextSpan | null; output: TextSpan | null }[] { const span = (t: Text | null): TextSpan | null => t && { text: t.text, left: t.x, right: t.x + t.width }; return this.rowTexts.map((row) => ({ input: span(row.input), output: span(row.output) })); } 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 = crispText({ 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.rowTexts.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 = crispText({ 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; } const inputX = PAD + PORT_RADIUS + 4; 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; const outputText = output ? add( output.label, rowStyle(output), this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10, ) : null; const room = inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null); const inputText = input ? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room) : null; this.rowTexts.push({ input: inputText, output: outputText }); if (output && outputText && output.type !== 'exec' && !output.ghost) { const inputEnd = inputText ? inputText.x + inputText.width : PAD; this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 }); } } this.placeValues(); } override redraw(): void { const drawn = this.drawSignature(); if (drawn === this.drawnOf) return; this.drawnOf = drawn; this.made.box += 1; 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 === 'tileLayer') return asString(value) || FIRST_TILE_LAYER; if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value); return referenceLabel(def, project, asString(value)) ?? formatValue(value); } /** How a node says "no value" and "empty value" – for inputs and live outputs alike. */ const NULL_TEXT = '(null)'; const EMPTY_TEXT = '(empty)'; function formatLive(output: PortDef, value: Value, project: Project | null): string { if (value === null) return NULL_TEXT; if (value === '') return EMPTY_TEXT; 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_TEXT; const head = value.slice(0, 4).map(formatValue).join(', '); return value.length > 4 ? `${head}, … (${value.length})` : head; } return asString(value); }