Let a game hand values to the parameters of a sound FX #311
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 14m15s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 17:26:20 +02:00
1 parent de14f0f9e2
commit 183f356c99
25 files changed
+785 -99

No files matched your search

+14 -5
View File
@@ -1,6 +1,7 @@
import {
callProblem,
declaresPropertyPorts,
declaresSoundFxPorts,
declaresVariablePorts,
edgesInto,
edgesOfNode,
@@ -16,6 +17,8 @@ import {
OBJECT_TYPE_PARAM,
portSignature,
resolveDef,
soundFxParamsMismatch,
soundFxSourceOf,
STATE_TRANSITION,
stateName,
STRUCT_TYPE_PARAM,
@@ -42,6 +45,7 @@ import { addNote, addTitle, type InspectorHost } from './host';
import {
buildPortSection,
buildPropertyPortSection,
buildSoundFxPortSection,
buildVariablePortSection,
variableChoices,
} from './portSection';
@@ -67,7 +71,9 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
const declared = (node.ports ?? [])
.map(
(p) =>
`${p.key}:${p.kind}${p.write ? ':set' : ''}${p.machine ? `:${p.machine}:${machineOf(p.machine)}` : ''}`,
`${p.key}:${p.kind}${p.write ? ':set' : ''}${p.machine ? `:${p.machine}:${machineOf(p.machine)}` : ''}${
p.default !== undefined ? `=${p.default}` : ''
}`,
)
.join(',');
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
@@ -82,13 +88,13 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
// The source names which declarations the port menu offers, so switching it has to rebuild.
const typeId = nodeStructType(node) || listNodeItemType(node);
const typeName = structAssetOf(project, typeId)?.name ?? '';
const source = `${variablesSourceOf(node.params)}:${typeId}:${typeName}`;
const source = `${variablesSourceOf(node.params)}:${soundFxSourceOf(node.params)}:${typeId}:${typeName}`;
const choices = variableChoices(project, assetId, node)
.map((v) => v.key)
.join(',');
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(
',',
)}|${source}:${choices}|${target}|${transitions}`;
)}|${source}:${choices}|${target}|${transitions}|${soundFxParamsMismatch(node, asset.graph) ?? ''}`;
}
/** The name of the state at the other end of an edge – what a transition row is labelled with. */
@@ -111,7 +117,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
addTitle(host, `${def.category.toUpperCase()} NODE`);
host.body.add(new Label(def.label, { style: 'bodyBold' }));
addNote(host, def.description);
const problem = callProblem(node, graphLookup(host.state.project), assetId);
const problem =
callProblem(node, graphLookup(host.state.project), assetId) ?? soundFxParamsMismatch(node, asset.graph);
if (problem) addNote(host, problem, 'warning');
const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => {
const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers);
@@ -119,7 +126,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
host.body.add(row.row);
};
// Both draw their settings inside the port section: a setting decides what the menu offers.
const ownSettings = declaresPropertyPorts(node.type) || declaresVariablePorts(node.type);
const ownSettings =
declaresPropertyPorts(node.type) || declaresVariablePorts(node.type) || declaresSoundFxPorts(node.type);
// The type of a Pack/Expand decides its fields (#253), so it stands above them – and picking one
// rewrites the ports, which a plain param edit would not.
const typed = isTypedNode(node);
@@ -134,6 +142,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
const pickItemType = (value: PropertyValue) =>
host.state.execute(new SetListNodeTypeCommand(assetId, nodeId, textOf(value)));
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting);
else if (declaresSoundFxPorts(node.type)) buildSoundFxPortSection(host, assetId, node, addSetting);
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
else if (def.declaresPorts) {
const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null;
+70 -11
View File
@@ -6,12 +6,14 @@ import {
RemovePortCommand,
RenamePortCommand,
RepointPortCommand,
SetPortDefaultCommand,
SetPortMachineCommand,
SetPortWriteCommand,
} from '../../core/commands/portCommands';
import {
type DeclaredPort,
type GraphNode,
declaresSoundFxPorts,
isEntryTerminal,
isPortOf,
isTerminalNode,
@@ -23,6 +25,10 @@ import {
portKindOf,
SHADER_FUNCTION_INPUT,
SHADER_FUNCTION_OUTPUT,
SOUND_FX_INPUT,
SOUND_FX_SETTING,
soundFxSourceOf,
terminalNode,
VARIABLES_SETTING,
variablesSourceOf,
propertyTargetOf,
@@ -44,6 +50,7 @@ import {
Label,
Menu,
type MenuItem,
NumberField,
type SectionHeader,
type SortBlock,
sortBlocks,
@@ -58,6 +65,7 @@ import { kindMenu, markLabel } from './variableRows';
/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */
export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] {
if (declaresSoundFxPorts(node.type)) return soundFxChoices(project, node);
const source = variablesSourceOf(node.params);
if (source) return findAssetOf(project, source, 'variables')?.variables ?? [];
const asset = findGraphAsset(project, assetId);
@@ -90,6 +98,35 @@ export function buildVariablePortSection(
});
}
/** The parameters the Input of the sound FX a `Sound FX parameters` node names declares (#311). */
function soundFxChoices(project: Project, node: GraphNode): PropertyDef[] {
const fx = findAssetOf(project, soundFxSourceOf(node.params), 'soundfx');
const ports = fx ? (terminalNode(fx.graph, SOUND_FX_INPUT)?.ports ?? []) : [];
return ports.map((p) => ({ key: p.key, label: p.key, kind: 'number', default: p.default ?? 0 }));
}
/** The ports of a `Sound FX parameters` node: picked from the parameters of the FX it names. */
export function buildSoundFxPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
addSetting: (def: PropertyDef) => void,
): void {
addSetting(SOUND_FX_SETTING);
const id = soundFxSourceOf(node.params);
const fx = id ? findAssetOf(host.state.project, id, 'soundfx') : undefined;
buildPickedPortSection(host, assetId, node, {
noun: 'Parameter',
choices: soundFxChoices(host.state.project, node),
owner: fx ? `"${fx.name}"` : 'a sound FX',
empty: id
? fx
? `"${fx.name}" declares no parameters yet. Add them at its Input.`
: 'That sound FX is gone.'
: 'Pick a sound FX first.',
});
}
/** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */
export function buildPropertyPortSection(
host: InspectorHost,
@@ -133,7 +170,7 @@ export function buildPropertyPortSection(
}
interface PickedPorts {
noun: 'Variable' | 'Property';
noun: 'Variable' | 'Property' | 'Parameter';
choices: readonly PropertyDef[];
/** Whose ports these are, for the warning on one it does not have. */
owner: string;
@@ -153,7 +190,7 @@ function buildPickedPortSection(
{ noun, choices, owner, empty, custom = false, note, hidden }: PickedPorts,
): void {
const offered = hidden ? choices.filter((def) => !hidden(def)) : choices;
const plural = noun === 'Property' ? 'properties' : 'variables';
const plural = noun === 'Property' ? 'properties' : noun === 'Parameter' ? 'parameters' : 'variables';
const ports = node.ports ?? [];
const taken = new Set(ports.map((p) => p.key));
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
@@ -191,9 +228,10 @@ function buildPickedPortSection(
const pickedRow = (port: DeclaredPort): Grip => {
// A machine a variable holds is a running value; which one its states come from is picked here.
const carries = noun === 'Variable' && port.kind === 'statemachine';
// A parameter is only ever handed in, never written back.
const toggle = [
...(carries ? [machinePick(host, assetId, node, port)] : []),
...writeToggle(host, assetId, node, port),
...(noun === 'Parameter' ? [] : writeToggle(host, assetId, node, port)),
];
if (custom)
return portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle).grip;
@@ -238,9 +276,10 @@ export function buildPortSection(
typeName: string | null = null,
): void {
const onStart = isEntryTerminal(node.type);
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
const ofSoundFx = node.type === SOUND_FX_INPUT;
const noun = ofSoundFx ? 'Parameter' : isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
const ofFunction = node.type === SHADER_FUNCTION_INPUT || node.type === SHADER_FUNCTION_OUTPUT;
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
const base = ofSoundFx ? 'param' : noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
const header = sectionHeader(host, `${noun.toUpperCase()}S`);
// With a Struct asset as its type the fields are that asset's (#253): changed there, not here.
@@ -277,11 +316,13 @@ export function buildPortSection(
host,
noun === 'Field'
? 'No fields yet. Every field added here becomes a port of the struct.'
: ofFunction
? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.`
: onStart
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.'
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.',
: ofSoundFx
? 'No parameters yet. Every parameter added here is a number with a default, and a Sound FX parameters node can set it when the sound is played.'
: ofFunction
? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.`
: onStart
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.'
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.',
);
const blocks = ports.map((port) =>
@@ -291,13 +332,18 @@ export function buildPortSection(
node,
port,
portNameField(host, assetId, node, port),
onStart && port.kind === 'statemachine' ? [machinePick(host, assetId, node, port)] : [],
ofSoundFx
? defaultField(host, assetId, node, port)
: onStart && port.kind === 'statemachine'
? [machinePick(host, assetId, node, port)]
: [],
),
);
sortBlocks(blocks, (from, to) => host.state.execute(new MovePortCommand(assetId, node.id, from, to)));
}
const plural = (noun: string) => `${noun.toLowerCase()}s`;
const DEFAULT_WIDTH = 56;
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
return new TextField(port.key, {
@@ -332,6 +378,19 @@ function machinePick(host: InspectorHost, assetId: string, node: GraphNode, port
return pick;
}
/** The value a sound FX parameter has where nobody hands one in. */
function defaultField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
const field = new NumberField(port.default ?? 0, {
width: DEFAULT_WIDTH,
step: 0.1,
onChange: (value) => {
if (value !== (port.default ?? 0))
host.state.execute(new SetPortDefaultCommand(assetId, node.id, port.key, value));
},
});
return [new Label('Default', { style: 'muted' }), field];
}
function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
const box = new Checkbox(port.write === true);
box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on));