Files
feuerwerk/src/editor/graph/NodeView.ts
T
adminandClaude Opus 5.5 183f356c99
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 14m15s
Let a game hand values to the parameters of a sound FX #311
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>
2026-10-05 17:26:20 +02:00

610 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}