An instrument could only play sound files so far. The Chip makes the sound of a note itself, the way old game consoles and sfxr did: a raw wave and what its frequency does over the note. A laser, a jump or a pickup is now an instrument and ends by itself. - Chip node with wave (square, triangle, saw, sine, noise and the periodic noise of the NES), follow note, frequency, duty, duty sweep, slide, delta slide, min frequency, vibrato, arpeggio and repeat. It counts in Hz, semitones and seconds, not sfxr's 0 to 1. - It runs as a third processor in the FX worklet. The math is a plain class, ChipPlayer, tested in Node. Every sample is worked out 8 times and averaged, as sfxr does. - Frequency and duty are AudioParams, so an Envelope can move them and a parameter can tune them while the note plays. A Pitch behind a Chip moves its frequency directly, without the phase vocoder. - Below its min frequency a Chip ends and tells the page. Without the worklet it is silent and its note ends at once. - An Envelope with sustain 0 now ends its note once the decay is over, for every source under it, Samples too. Before, a note with duration 0 ran on silently until a Stop sound. - A note made of Chips alone needs no file. playNote and the wait for the bytes no longer drop it for having none. - Inputs that do nothing as things stand are grey in the inspector. idleParams is told which inputs are connected, so a connected one always counts. The credit names sfxr (Tomas Pettersson, MIT) and jsfxr, which turned out to be public domain, not MIT as the plan in #329 said. Tests: - Unit chip (new) 12, sound-library +4. All of tests/core, tests/viewport, tests/editor/graph and tests/editor/inspector: 178 files, 2953 green. - Counter-checks: semitones read as /24 turn 4 chip tests red. Without the sustain rule and the file check 4 library tests fail. - e2e 34 (melektaus-atx): ALL PASSED, 60 checks (2 new). Offline in Chrome a square at 220 Hz plus 12 semitones and duty 0.25 gave 329 periods in 0.75 s, up 0.249. 64 noise voices for 2 s rendered in 2.0 s, about 63 at once. Live a 0.2 s note with sustain 0 was gone after 0.26 s. - Gate (melektaus-atx): CHECK EXIT 0, 255 files, 3962 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
617 lines
23 KiB
TypeScript
617 lines
23 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 '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<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, (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<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);
|
||
}
|