An animation key shows Direction only for one of the ten curves and the Curve row only for Curve. Lerp and Set sound FX parameters hid them on the node body, but the inspector still offered both rows for every easing, so a linear Lerp had a Direction to pick that did nothing. - A node definition can name `idleParams`: settings that do nothing at the current values. The node body and the inspector both leave them out. - `hiddenParams` stays as it was. It only trims the node body, the inspector keeps those rows (the Order of an event, the mode of Find), so it could not take this job. - Lerp and Set sound FX parameters use `idleEasing` (formerly `hiddenEasing`) for it. - The idle settings are part of the node signature of the inspector. Picking an easing that needs Direction or Curve rebuilds the section, and the row appears. Tests: - easing-curves: 14 tests (+1), node-catalog: 381, green - e2e 06 (149 checks, green) checks that a new linear Lerp has no Direction and no Curve row in the inspector. Picking Cubic brings the Direction row, and Out is picked there as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
613 lines
22 KiB
TypeScript
613 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) ?? []),
|
||
...(this.def.idleParams?.(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);
|
||
}
|