A sound FX played the same every time: its numbers were settings of the nodes. Now the Input declares parameters with a default, every number of Gain, Pitch and EQ is an input they can feed, and a Play sound hands in values for them. - Input declares number parameters in the inspector, each with a Default (DeclaredPort.default, SetPortDefaultCommand). They are outputs of the Input. Input and Output count as terminals now, one of each per graph. - Gain, Pitch and EQ take their numbers at inputs under the keys the settings had, so saved graphs sound as before without a migration. Each value is held to its range however it arrives. - The compiler gives an FxPlan with the parameters still open. fxChainOf fills them in when a voice starts. The player still only sees an FxChain. - New behavior node Sound FX parameters: names an FX and picks its parameters like a Variables node picks variables. Each picked one is a number input that starts at the FX's default. Its output has the new port type fxparams, which only fits Play sound's fx params. - Play sound warns when the parameters name another FX than the one it plays. Names the FX does not know are left out. - Renaming a parameter at the Input renames it in every Sound FX parameters node naming that FX, edges and values included. - The Sound FX tab has a number field per parameter in its header. Play uses those values, they are never written into the asset. - A value counts from the start of a voice. Changing a playing voice is #315, a Mix node is #316. Tests: - soundfx.test.ts 22 (+11): parameters in the plan, held values, a 0-by-default stage, EQ band, save with defaults, wrong port types, default command with undo, Sound FX parameters into Play sound, defaults of picked ones, node problems, mismatch, rename with undo - node-catalog 368 (+3): Play sound fx params, Sound FX parameters - e2e 34: ALL PASSED - 28 checks (+2: header field plays depth 5, a Sound FX parameters node gives the game's voice depth 7) - Gate on melektaus-atx: typecheck, lint, prettier clean, 240 test files, 3636 tests green - Counter-check: with fxChainOf ignoring the values and the rename not followed, 5 of the new tests fail. - Browser probe: header fields, inspector Default and the picked parameter looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
610 lines
22 KiB
TypeScript
610 lines
22 KiB
TypeScript
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 '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<string>();
|
||
private inlineTexts = new Map<string, string>();
|
||
/** Which sides carry a link – a collapsed node marks those with one dot each. */
|
||
private linked = { inputs: false, outputs: false };
|
||
private liveTexts = new Map<string, string>();
|
||
private readonly valueTexts = new Map<string, Text>();
|
||
/** Where an output's value may be drawn: right-aligned before its label, clear of the input text. */
|
||
private valueSlots = new Map<string, { left: number; right: number; top: number }>();
|
||
/**
|
||
* 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) ?? []);
|
||
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<string, Value> | null, project: Project | null): void {
|
||
const next = new Map<string, string>();
|
||
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);
|
||
}
|