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>
This commit is contained in:
1 parent
de14f0f9e2
commit
183f356c99
25 files changed
+780
-94
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BEHAVIOR_PARAM,
|
BEHAVIOR_PARAM,
|
||||||
declaresPropertyPorts,
|
declaresPropertyPorts,
|
||||||
|
declaresSoundFxPorts,
|
||||||
declaresVariablePorts,
|
declaresVariablePorts,
|
||||||
type DeclaredPort,
|
type DeclaredPort,
|
||||||
findNode,
|
findNode,
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
OBJECT_BEHAVIOR_PARAM,
|
OBJECT_BEHAVIOR_PARAM,
|
||||||
propertyTargetOf,
|
propertyTargetOf,
|
||||||
SHADER_CALL,
|
SHADER_CALL,
|
||||||
|
SOUND_FX_INPUT,
|
||||||
|
soundFxSourceOf,
|
||||||
variablesSourceOf,
|
variablesSourceOf,
|
||||||
} from '../graph';
|
} from '../graph';
|
||||||
import { isGraphAsset } from '../model/Asset';
|
import { isGraphAsset } from '../model/Asset';
|
||||||
@@ -342,6 +345,54 @@ export class RenamePortCommand extends RenameCommand {
|
|||||||
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
|
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
|
||||||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||||||
for (const site of sites) carryPort(site, from, to);
|
for (const site of sites) carryPort(site, from, to);
|
||||||
|
if (node.type === SOUND_FX_INPUT) renameSoundFxParamPorts(project, this.assetId, from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default of a sound FX parameter (#311): what it is wherever no `Play sound` hands a value in. */
|
||||||
|
export class SetPortDefaultCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
private was: number | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly nodeId: string,
|
||||||
|
private readonly key: string,
|
||||||
|
private readonly value: number,
|
||||||
|
) {
|
||||||
|
this.label = `Set default of ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const node = requireNode(project, this.assetId, this.nodeId);
|
||||||
|
if (node.type !== SOUND_FX_INPUT) throw new CommandError('Only a sound FX Input has defaults');
|
||||||
|
const port = (node.ports ?? []).find((p) => p.key === this.key);
|
||||||
|
if (!port) throw new CommandError(`Port "${this.key}" not found`);
|
||||||
|
if (!Number.isFinite(this.value)) throw new CommandError('A default is a number');
|
||||||
|
this.was = port.default;
|
||||||
|
this.mark(node, this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
this.mark(requireNode(project, this.assetId, this.nodeId), this.was);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mark(node: GraphNode, value: number | undefined): void {
|
||||||
|
node.ports = (node.ports ?? []).map((port) => {
|
||||||
|
if (port.key !== this.key) return port;
|
||||||
|
const { default: _, ...plain } = port;
|
||||||
|
return value === undefined ? plain : { ...plain, default: value };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A renamed parameter of a sound FX, followed into every node that sets it. */
|
||||||
|
function renameSoundFxParamPorts(project: Project, fxId: string, from: string, to: string): void {
|
||||||
|
for (const asset of project.assets) {
|
||||||
|
if (!isGraphAsset(asset)) continue;
|
||||||
|
for (const node of asset.graph.nodes)
|
||||||
|
if (declaresSoundFxPorts(node.type) && soundFxSourceOf(node.params) === fxId)
|
||||||
|
renamePortOn(asset.graph, node, from, to);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export type { DeclaredPortKind } from '../properties/kinds';
|
|||||||
/**
|
/**
|
||||||
* `exec` carries control flow, the others values; `any` accepts every value type. `vec3` is a
|
* `exec` carries control flow, the others values; `any` accepts every value type. `vec3` is a
|
||||||
* colour inside a shader graph, where the values are GLSL and no behavior node ever sees one;
|
* colour inside a shader graph, where the values are GLSL and no behavior node ever sees one;
|
||||||
* `picture` is what a shader's Pass draws (#301), for the nodes that read a picture.
|
* `picture` is what a shader's Pass draws (#301), for the nodes that read a picture. `fxparams` is
|
||||||
|
* the values a `Play sound` hands to the parameters of its sound FX (#311).
|
||||||
*/
|
*/
|
||||||
export type PortType =
|
export type PortType =
|
||||||
| 'exec'
|
| 'exec'
|
||||||
@@ -19,6 +20,7 @@ export type PortType =
|
|||||||
| 'vec3'
|
| 'vec3'
|
||||||
| 'picture'
|
| 'picture'
|
||||||
| 'audio'
|
| 'audio'
|
||||||
|
| 'fxparams'
|
||||||
| 'any';
|
| 'any';
|
||||||
|
|
||||||
export interface PortRef {
|
export interface PortRef {
|
||||||
@@ -70,6 +72,8 @@ export interface DeclaredPort {
|
|||||||
* from it. On a start node, a node running the behavior has it picked in advance as well.
|
* from it. On a start node, a node running the behavior has it picked in advance as well.
|
||||||
*/
|
*/
|
||||||
machine?: string;
|
machine?: string;
|
||||||
|
/** On a parameter of a sound FX Input: its value wherever nobody hands one in (#311). */
|
||||||
|
default?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
||||||
@@ -183,12 +187,14 @@ export function graphFingerprint(graph: Graph): string {
|
|||||||
/**
|
/**
|
||||||
* A number may feed a colour: the shader compiler spreads it over the three channels. A picture is
|
* A number may feed a colour: the shader compiler spreads it over the three channels. A picture is
|
||||||
* no value a calculation could take, so like `exec` it goes only where a picture is read – and a
|
* no value a calculation could take, so like `exec` it goes only where a picture is read – and a
|
||||||
* sound on its way through effects (#311) only where a sound goes in.
|
* sound on its way through effects (#311) only where a sound goes in, and its parameters only to a
|
||||||
|
* `Play sound`.
|
||||||
*/
|
*/
|
||||||
export function portsCompatible(from: PortType, to: PortType): boolean {
|
export function portsCompatible(from: PortType, to: PortType): boolean {
|
||||||
if (from === 'exec' || to === 'exec') return from === to;
|
if (from === 'exec' || to === 'exec') return from === to;
|
||||||
if (from === 'picture' || to === 'picture') return from === to;
|
if (from === 'picture' || to === 'picture') return from === to;
|
||||||
if (from === 'audio' || to === 'audio') return from === to;
|
if (from === 'audio' || to === 'audio') return from === to;
|
||||||
|
if (from === 'fxparams' || to === 'fxparams') return from === to;
|
||||||
if (from === 'number' && to === 'vec3') return true;
|
if (from === 'number' && to === 'vec3') return true;
|
||||||
return from === to || from === 'any' || to === 'any';
|
return from === to || from === 'any' || to === 'any';
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AnimationPlayer } from '../model/animation';
|
import type { AnimationPlayer } from '../model/animation';
|
||||||
import type { Mat } from '../model/matrix';
|
import type { Mat } from '../model/matrix';
|
||||||
import type { Position, PropertyBag } from '../model/Properties';
|
import type { Position, PropertyBag } from '../model/Properties';
|
||||||
|
import type { FxParams } from '../soundfx/nodes';
|
||||||
import { type ChainTransform, composeAlongChain } from '../model/walk';
|
import { type ChainTransform, composeAlongChain } from '../model/walk';
|
||||||
import { enabledAlongChain } from '../properties/enabled';
|
import { enabledAlongChain } from '../properties/enabled';
|
||||||
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
|
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
|
||||||
@@ -238,8 +239,16 @@ export interface NodeContext {
|
|||||||
* a sound is something a thing in the world is doing. `detached` gives that up on purpose, for
|
* a sound is something a thing in the world is doing. `detached` gives that up on purpose, for
|
||||||
* the one case where the rule is in the way: the death sound of an object that destroys itself.
|
* the one case where the rule is in the way: the death sound of an object that destroys itself.
|
||||||
* `fx` names a sound FX asset the voice is played through (#311); `''` plays it as it is.
|
* `fx` names a sound FX asset the voice is played through (#311); `''` plays it as it is.
|
||||||
|
* `fxParams` are values for its parameters; one not given is the default the FX declares.
|
||||||
*/
|
*/
|
||||||
playSound(assetId: string, volume: number, loop: boolean | undefined, detached: boolean, fx: string): void;
|
playSound(
|
||||||
|
assetId: string,
|
||||||
|
volume: number,
|
||||||
|
loop: boolean | undefined,
|
||||||
|
detached: boolean,
|
||||||
|
fx: string,
|
||||||
|
fxParams: FxParams,
|
||||||
|
): void;
|
||||||
/** Silences every voice of that sound; `''` silences everything this run started. */
|
/** Silences every voice of that sound; `''` silences everything this run started. */
|
||||||
stopSound(assetId: string): void;
|
stopSound(assetId: string): void;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -213,7 +213,8 @@ function isInlinePort(port: PortDef): boolean {
|
|||||||
port.type !== 'object' &&
|
port.type !== 'object' &&
|
||||||
port.type !== 'struct' &&
|
port.type !== 'struct' &&
|
||||||
port.type !== 'picture' &&
|
port.type !== 'picture' &&
|
||||||
port.type !== 'audio'
|
port.type !== 'audio' &&
|
||||||
|
port.type !== 'fxparams'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AnimationClip } from '../model/animation';
|
import type { AnimationClip } from '../model/animation';
|
||||||
import type { VectorPoint } from '../model/Asset';
|
import type { VectorPoint } from '../model/Asset';
|
||||||
import type { PropertyBag } from '../model/Properties';
|
import type { PropertyBag } from '../model/Properties';
|
||||||
import type { Graph } from './Graph';
|
import type { DeclaredPort, Graph } from './Graph';
|
||||||
|
|
||||||
/** What running another behavior's graph in place needs: its nodes, its variable defaults, its name. */
|
/** What running another behavior's graph in place needs: its nodes, its variable defaults, its name. */
|
||||||
export interface BehaviorProgram {
|
export interface BehaviorProgram {
|
||||||
@@ -17,6 +17,12 @@ export interface ShaderFunction {
|
|||||||
graph: Graph;
|
graph: Graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A sound FX as a node naming it sees it: the parameters its Input declares (#311). */
|
||||||
|
export interface SoundFxShape {
|
||||||
|
name: string;
|
||||||
|
params: readonly DeclaredPort[];
|
||||||
|
}
|
||||||
|
|
||||||
/** One state of a machine: the node id is what a port and the runtime hold, the name is the label. */
|
/** One state of a machine: the node id is what a port and the runtime hold, the name is the label. */
|
||||||
export interface MachineState {
|
export interface MachineState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,6 +58,7 @@ export interface GraphLookup {
|
|||||||
behavior(assetId: string): BehaviorProgram | null;
|
behavior(assetId: string): BehaviorProgram | null;
|
||||||
/** A shader function's graph, which a call node takes its ports from and the compiler sets in (#303). */
|
/** A shader function's graph, which a call node takes its ports from and the compiler sets in (#303). */
|
||||||
shaderFunction(assetId: string): ShaderFunction | null;
|
shaderFunction(assetId: string): ShaderFunction | null;
|
||||||
|
soundFx(assetId: string): SoundFxShape | null;
|
||||||
machine(assetId: string): StateMachine | null;
|
machine(assetId: string): StateMachine | null;
|
||||||
curve(assetId: string): readonly VectorPoint[] | null;
|
curve(assetId: string): readonly VectorPoint[] | null;
|
||||||
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
|
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
|
||||||
@@ -90,6 +97,7 @@ export interface ValueTyping {
|
|||||||
export const NO_LOOKUP: GraphLookup = {
|
export const NO_LOOKUP: GraphLookup = {
|
||||||
behavior: () => null,
|
behavior: () => null,
|
||||||
shaderFunction: () => null,
|
shaderFunction: () => null,
|
||||||
|
soundFx: () => null,
|
||||||
machine: () => null,
|
machine: () => null,
|
||||||
curve: () => null,
|
curve: () => null,
|
||||||
structType: () => null,
|
structType: () => null,
|
||||||
|
|||||||
@@ -28,10 +28,29 @@ const ON_START = 'OnStart';
|
|||||||
export const SHADER_FUNCTION_INPUT = 'ShaderFunctionInput';
|
export const SHADER_FUNCTION_INPUT = 'ShaderFunctionInput';
|
||||||
export const SHADER_FUNCTION_OUTPUT = 'ShaderFunctionOutput';
|
export const SHADER_FUNCTION_OUTPUT = 'ShaderFunctionOutput';
|
||||||
export const SHADER_CALL = 'ShaderCall';
|
export const SHADER_CALL = 'ShaderCall';
|
||||||
|
/** The two ends of a sound FX graph and the behavior node naming its parameters (#311). */
|
||||||
|
export const SOUND_FX_INPUT = 'SoundFxInput';
|
||||||
|
export const SOUND_FX_OUTPUT = 'SoundFxOutput';
|
||||||
|
export const SOUND_FX_PARAMETERS = 'SoundFxParameters';
|
||||||
|
const PLAY_SOUND = 'PlaySound';
|
||||||
|
|
||||||
export const BEHAVIOR_PARAM = 'behavior';
|
export const BEHAVIOR_PARAM = 'behavior';
|
||||||
export const FUNCTION_PARAM = 'function';
|
export const FUNCTION_PARAM = 'function';
|
||||||
export const VARIABLES_PARAM = 'variablesAsset';
|
export const VARIABLES_PARAM = 'variablesAsset';
|
||||||
|
export const SOUND_FX_PARAM = 'soundFxAsset';
|
||||||
|
|
||||||
|
/** Whose parameters a `Sound FX parameters` node sets. */
|
||||||
|
export const SOUND_FX_SETTING: PropertyDef = {
|
||||||
|
key: SOUND_FX_PARAM,
|
||||||
|
label: 'Sound FX',
|
||||||
|
kind: 'asset',
|
||||||
|
default: '',
|
||||||
|
assetTypes: ['soundfx'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function soundFxSourceOf(params: PropertyBag): string {
|
||||||
|
return textOf(params[SOUND_FX_PARAM] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
|
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
|
||||||
export const VARIABLES_SETTING: PropertyDef = {
|
export const VARIABLES_SETTING: PropertyDef = {
|
||||||
@@ -71,6 +90,11 @@ export function declaresPropertyPorts(type: string): boolean {
|
|||||||
return type === PROPERTIES;
|
return type === PROPERTIES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nodes whose declared ports are parameters of the sound FX they name. */
|
||||||
|
export function declaresSoundFxPorts(type: string): boolean {
|
||||||
|
return type === SOUND_FX_PARAMETERS;
|
||||||
|
}
|
||||||
|
|
||||||
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
|
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
|
||||||
export function isValidPortName(name: string): boolean {
|
export function isValidPortName(name: string): boolean {
|
||||||
return isValidFieldName(name) && name !== 'exec';
|
return isValidFieldName(name) && name !== 'exec';
|
||||||
@@ -127,13 +151,15 @@ export function isTerminalType(type: string): boolean {
|
|||||||
type === BEHAVIOR_START ||
|
type === BEHAVIOR_START ||
|
||||||
type === BEHAVIOR_END ||
|
type === BEHAVIOR_END ||
|
||||||
type === SHADER_FUNCTION_INPUT ||
|
type === SHADER_FUNCTION_INPUT ||
|
||||||
type === SHADER_FUNCTION_OUTPUT
|
type === SHADER_FUNCTION_OUTPUT ||
|
||||||
|
type === SOUND_FX_INPUT ||
|
||||||
|
type === SOUND_FX_OUTPUT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The terminal whose ports a caller passes in, rather than takes out. */
|
/** The terminal whose ports a caller passes in, rather than takes out. */
|
||||||
export function isEntryTerminal(type: string): boolean {
|
export function isEntryTerminal(type: string): boolean {
|
||||||
return type === BEHAVIOR_START || type === SHADER_FUNCTION_INPUT;
|
return type === BEHAVIOR_START || type === SHADER_FUNCTION_INPUT || type === SOUND_FX_INPUT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTerminalNode(node: GraphNode): boolean {
|
export function isTerminalNode(node: GraphNode): boolean {
|
||||||
@@ -354,6 +380,7 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
|
|||||||
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
||||||
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
|
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
|
||||||
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
|
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
|
||||||
|
if (node.type === SOUND_FX_PARAMETERS) return soundFxParametersProblem(node, lookup);
|
||||||
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
|
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
|
||||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||||
if (!id) return 'No behavior picked';
|
if (!id) return 'No behavior picked';
|
||||||
@@ -398,6 +425,27 @@ function functionCallProblem(node: GraphNode, lookup: GraphLookup, ownerId: stri
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function soundFxParametersProblem(node: GraphNode, lookup: GraphLookup): string | null {
|
||||||
|
const id = soundFxSourceOf(node.params);
|
||||||
|
if (!id) return 'No sound FX picked';
|
||||||
|
return lookup.soundFx(id) ? null : 'That sound FX is gone';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `Play sound` whose parameters come from a node naming another sound FX than the one it plays:
|
||||||
|
* the names that FX does not know are left out, so the values would go nowhere. Only said where both
|
||||||
|
* are picked in place – one coming over an edge may be either.
|
||||||
|
*/
|
||||||
|
export function soundFxParamsMismatch(node: GraphNode, graph: Graph): string | null {
|
||||||
|
if (node.type !== PLAY_SOUND || edgesInto(graph, node.id, 'fx').length > 0) return null;
|
||||||
|
const played = textOf(node.params.fx ?? '');
|
||||||
|
const edge = edgesInto(graph, node.id, 'fxParams')[0];
|
||||||
|
const from = edge && findNode(graph, edge.from.node);
|
||||||
|
if (!played || from?.type !== SOUND_FX_PARAMETERS) return null;
|
||||||
|
const named = soundFxSourceOf(from.params);
|
||||||
|
return named && named !== played ? 'The parameters are for another sound FX' : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The ids the nodes of one type in that graph name in `param`, picked or not found alike. */
|
/** The ids the nodes of one type in that graph name in `param`, picked or not found alike. */
|
||||||
function namedBy(graph: Graph, type: string, param: string): string[] {
|
function namedBy(graph: Graph, type: string, param: string): string[] {
|
||||||
return graph.nodes
|
return graph.nodes
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isStruct } from '../../../model/Properties';
|
||||||
|
import type { FxParams } from '../../../soundfx/nodes';
|
||||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||||
import { asBoolean, asNumber, asString, type Value } from '../../values';
|
import { asBoolean, asNumber, asString, type Value } from '../../values';
|
||||||
import { exec, num, soundFxPort, soundPort, withDefault } from '../shared';
|
import { exec, num, soundFxPort, soundPort, withDefault } from '../shared';
|
||||||
@@ -56,8 +58,17 @@ export const PlaySound: NodeDef = {
|
|||||||
"leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " +
|
"leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " +
|
||||||
'the node only colors it. Each play is a voice of its own: firing this twice in a row is heard ' +
|
'the node only colors it. Each play is a voice of its own: firing this twice in a row is heard ' +
|
||||||
'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' +
|
'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' +
|
||||||
'unless "detached" is on. "fx" sends it through a sound FX asset; left empty, it plays as it is.',
|
'unless "detached" is on. "fx" sends it through a sound FX asset; left empty, it plays as it is. ' +
|
||||||
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort(), soundFxPort()],
|
'"fx params" takes values for its parameters from a Sound FX parameters node.',
|
||||||
|
inputs: [
|
||||||
|
exec(),
|
||||||
|
soundPort(),
|
||||||
|
withDefault(num('volume'), 1),
|
||||||
|
loopPort(),
|
||||||
|
detachedPort(),
|
||||||
|
soundFxPort(),
|
||||||
|
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
|
||||||
|
],
|
||||||
outputs: [exec()],
|
outputs: [exec()],
|
||||||
params: [],
|
params: [],
|
||||||
run: (ctx, inputs) => {
|
run: (ctx, inputs) => {
|
||||||
@@ -69,7 +80,16 @@ export const PlaySound: NodeDef = {
|
|||||||
loopChoice(inputs.loop),
|
loopChoice(inputs.loop),
|
||||||
asBoolean(inputs.detached),
|
asBoolean(inputs.detached),
|
||||||
asString(inputs.fx),
|
asString(inputs.fx),
|
||||||
|
fxParamsOf(inputs.fxParams),
|
||||||
);
|
);
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The numbers of a parameter bundle; anything else hands in none. */
|
||||||
|
function fxParamsOf(value: Value | undefined): FxParams {
|
||||||
|
if (!isStruct(value)) return {};
|
||||||
|
const params: Record<string, number> = {};
|
||||||
|
for (const [key, item] of Object.entries(value)) if (typeof item === 'number') params[key] = item;
|
||||||
|
return params;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { StructValue } from '../../../model/Properties';
|
||||||
|
import { SOUND_FX_PARAMETERS, SOUND_FX_SETTING, soundFxSourceOf } from '../../nested';
|
||||||
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
import { asNumber } from '../../values';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values for the parameters of one sound FX (#311), picked from those its Input declares the way a
|
||||||
|
* Variables node picks variables. Each picked one is a number input starting at the FX's default;
|
||||||
|
* the bundle goes into the `fx params` input of a `Play sound`.
|
||||||
|
*/
|
||||||
|
export const SoundFxParameters: NodeDef = {
|
||||||
|
type: SOUND_FX_PARAMETERS,
|
||||||
|
label: 'Sound FX parameters',
|
||||||
|
category: 'data',
|
||||||
|
description:
|
||||||
|
'Values for the parameters of a sound FX, for the "fx params" input of a Play sound. Pick the parameters to set; one not picked keeps the default its Input gives it.',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
|
||||||
|
params: [SOUND_FX_SETTING],
|
||||||
|
declaresPorts: 'inputs',
|
||||||
|
portKinds: ['number'],
|
||||||
|
declaredPort: (port, node, _graph, lookup) => {
|
||||||
|
const declared = lookup.soundFx(soundFxSourceOf(node.params))?.params.find((p) => p.key === port.key);
|
||||||
|
const value = declared?.default ?? 0;
|
||||||
|
return {
|
||||||
|
key: port.key,
|
||||||
|
label: port.key,
|
||||||
|
type: 'number',
|
||||||
|
default: value,
|
||||||
|
field: { key: port.key, label: port.key, kind: 'number', default: value },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
run: (_ctx, inputs, _params, def) => {
|
||||||
|
const values: StructValue = Object.fromEntries(def.inputs.map((p) => [p.key, asNumber(inputs[p.key])]));
|
||||||
|
return { outputs: { params: values } };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -89,6 +89,7 @@ import { Or } from './data/Or';
|
|||||||
import { Owner } from './data/Owner';
|
import { Owner } from './data/Owner';
|
||||||
import { PackStruct } from './data/PackStruct';
|
import { PackStruct } from './data/PackStruct';
|
||||||
import { Properties } from './data/Properties';
|
import { Properties } from './data/Properties';
|
||||||
|
import { SoundFxParameters } from './data/SoundFxParameters';
|
||||||
import { Parent } from './data/Parent';
|
import { Parent } from './data/Parent';
|
||||||
import { Random } from './data/Random';
|
import { Random } from './data/Random';
|
||||||
import { RenderLayerOf } from './data/RenderLayerOf';
|
import { RenderLayerOf } from './data/RenderLayerOf';
|
||||||
@@ -297,6 +298,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
StopAnimation,
|
StopAnimation,
|
||||||
PlaySound,
|
PlaySound,
|
||||||
StopSound,
|
StopSound,
|
||||||
|
SoundFxParameters,
|
||||||
LoadScene,
|
LoadScene,
|
||||||
Log,
|
Log,
|
||||||
StopGame,
|
StopGame,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GraphLookup } from '../graph/lookup';
|
import type { GraphLookup } from '../graph/lookup';
|
||||||
|
import { SOUND_FX_INPUT, terminalNode } from '../graph/nested';
|
||||||
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
|
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
|
||||||
import { pictureSize } from '../properties/aspect';
|
import { pictureSize } from '../properties/aspect';
|
||||||
import { resolveAssetProps } from '../properties/resolve';
|
import { resolveAssetProps } from '../properties/resolve';
|
||||||
@@ -34,6 +35,12 @@ export function graphLookup(project: Project): GraphLookup {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
shaderFunction: shaderFunctionsOf(project),
|
shaderFunction: shaderFunctionsOf(project),
|
||||||
|
soundFx: (assetId) => {
|
||||||
|
const asset = findAssetOf(project, assetId, 'soundfx');
|
||||||
|
return asset
|
||||||
|
? { name: asset.name, params: terminalNode(asset.graph, SOUND_FX_INPUT)?.ports ?? [] }
|
||||||
|
: null;
|
||||||
|
},
|
||||||
machine: (assetId) => {
|
machine: (assetId) => {
|
||||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||||
if (!asset) return null;
|
if (!asset) return null;
|
||||||
|
|||||||
+47
-13
@@ -1,10 +1,13 @@
|
|||||||
import { graphEmitter } from '../graph/emitGraph';
|
import { graphEmitter } from '../graph/emitGraph';
|
||||||
import { type Graph, graphFingerprint } from '../graph/Graph';
|
import { type DeclaredPort, type Graph, graphFingerprint, type GraphNode } from '../graph/Graph';
|
||||||
|
import { terminalNode } from '../graph/nested';
|
||||||
|
import { inlineValue, nodeDef } from '../graph/NodeRegistry';
|
||||||
|
import { asNumber } from '../graph/values';
|
||||||
import type { SoundFxAsset } from '../model/Asset';
|
import type { SoundFxAsset } from '../model/Asset';
|
||||||
import { nodeDef } from '../graph/NodeRegistry';
|
|
||||||
import {
|
import {
|
||||||
DRY,
|
EMPTY_PLAN,
|
||||||
type FxChain,
|
type FxAmount,
|
||||||
|
type FxPlan,
|
||||||
isSoundFxNodeDef,
|
isSoundFxNodeDef,
|
||||||
SOUND_FX_INPUT,
|
SOUND_FX_INPUT,
|
||||||
SOUND_FX_OUTPUT,
|
SOUND_FX_OUTPUT,
|
||||||
@@ -13,7 +16,7 @@ import {
|
|||||||
|
|
||||||
export interface SoundFxBuild {
|
export interface SoundFxBuild {
|
||||||
/** Null while the graph has a problem – a sound played through it is then played dry. */
|
/** Null while the graph has a problem – a sound played through it is then played dry. */
|
||||||
compiled: FxChain | null;
|
compiled: FxPlan | null;
|
||||||
/** How many nodes between Input and Output the sound passes. */
|
/** How many nodes between Input and Output the sound passes. */
|
||||||
effects: number;
|
effects: number;
|
||||||
/** Per node id, what keeps the graph from compiling. */
|
/** Per node id, what keeps the graph from compiling. */
|
||||||
@@ -22,33 +25,64 @@ export interface SoundFxBuild {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What travels along an edge of a sound FX graph: the sound so far, or a number. */
|
||||||
|
type FxValue = { plan: FxPlan } | { amount: FxAmount };
|
||||||
|
|
||||||
|
/** The parameters the Input declares, in their order, each with its default. */
|
||||||
|
export function soundFxParams(graph: Graph): readonly DeclaredPort[] {
|
||||||
|
return terminalNode(graph, SOUND_FX_INPUT)?.ports ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputOutputs(node: GraphNode): Record<string, FxValue> {
|
||||||
|
const outputs: Record<string, FxValue> = { out: { plan: EMPTY_PLAN } };
|
||||||
|
for (const port of node.ports ?? [])
|
||||||
|
outputs[port.key] = { amount: { value: port.default ?? 0, param: port.key } };
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sound FX graph to the line a voice is sent along: walked back from the Output to the Input, every
|
* A sound FX graph to the line a voice is sent along: walked back from the Output to the Input, every
|
||||||
* node on the way adding what it does. An input takes one edge, so the line never forks on its way
|
* node on the way adding what it does. An input takes one edge, so the line never forks on its way
|
||||||
* to the Output; a node fed from nowhere is a problem rather than silence.
|
* to the Output; a node fed from nowhere is a problem rather than silence. A number fed from a
|
||||||
|
* parameter stays open in the plan until a voice is started (#311).
|
||||||
*/
|
*/
|
||||||
export function compileSoundFx(graph: Graph): SoundFxBuild {
|
export function compileSoundFx(graph: Graph): SoundFxBuild {
|
||||||
const walk = graphEmitter<SoundFxNodeDef, FxChain>(graph, {
|
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
|
||||||
isDef: isSoundFxNodeDef,
|
isDef: isSoundFxNodeDef,
|
||||||
noun: 'sound FX',
|
noun: 'sound FX',
|
||||||
// Only reached with every input fed, so whatever is not the Input has its sound arriving.
|
emit: (def, inputs, node) => {
|
||||||
emit: (def, inputs, node) => ({ out: def.emit(inputs.in ?? DRY, node.params) }),
|
if (node.type === SOUND_FX_INPUT) return inputOutputs(node);
|
||||||
unfed: (node) => {
|
const amounts: Record<string, FxAmount> = {};
|
||||||
|
for (const [key, value] of Object.entries(inputs)) if ('amount' in value) amounts[key] = value.amount;
|
||||||
|
const arriving = inputs.in;
|
||||||
|
const plan = arriving && 'plan' in arriving ? arriving.plan : EMPTY_PLAN;
|
||||||
|
return { out: { plan: def.emit(plan, amounts) } };
|
||||||
|
},
|
||||||
|
unfed: (node, port) => {
|
||||||
|
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
|
||||||
walk.problem(node, 'Nothing comes in: connect it to the Input');
|
walk.problem(node, 'Nothing comes in: connect it to the Input');
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
conform: (node, port, value) => {
|
||||||
|
if ((port.type === 'audio') === 'plan' in value) return value;
|
||||||
|
walk.problem(
|
||||||
|
node,
|
||||||
|
port.type === 'audio' ? `"${port.label}" takes a sound` : `"${port.label}" takes a number`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const output = graph.nodes.find((n) => n.type === SOUND_FX_OUTPUT);
|
const output = graph.nodes.find((n) => n.type === SOUND_FX_OUTPUT);
|
||||||
if (!output) return { compiled: null, effects: 0, problems: walk.problems, reason: 'No Output node' };
|
if (!output) return { compiled: null, effects: 0, problems: walk.problems, reason: 'No Output node' };
|
||||||
const chain = walk.input(output, nodeDef(SOUND_FX_OUTPUT)!.inputs[0]!);
|
const arrived = walk.input(output, nodeDef(SOUND_FX_OUTPUT)!.inputs[0]!);
|
||||||
const failed = walk.firstProblem();
|
const failed = walk.firstProblem();
|
||||||
if (failed !== null || !chain)
|
if (failed !== null || !arrived || !('plan' in arrived))
|
||||||
return { compiled: null, effects: 0, problems: walk.problems, reason: failed ?? 'Cannot compile' };
|
return { compiled: null, effects: 0, problems: walk.problems, reason: failed ?? 'Cannot compile' };
|
||||||
const effects = graph.nodes.filter(
|
const effects = graph.nodes.filter(
|
||||||
(n) => walk.reached(n) && n.type !== SOUND_FX_INPUT && n.type !== SOUND_FX_OUTPUT,
|
(n) => walk.reached(n) && n.type !== SOUND_FX_INPUT && n.type !== SOUND_FX_OUTPUT,
|
||||||
).length;
|
).length;
|
||||||
return { compiled: chain, effects, problems: walk.problems, reason: '' };
|
return { compiled: arrived.plan, effects, problems: walk.problems, reason: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const builds = new Map<string, { fingerprint: string; build: SoundFxBuild }>();
|
const builds = new Map<string, { fingerprint: string; build: SoundFxBuild }>();
|
||||||
|
|||||||
+109
-34
@@ -1,7 +1,10 @@
|
|||||||
|
import type { DeclaredPort } from '../graph/Graph';
|
||||||
|
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
|
||||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||||
import type { PropertyBag } from '../model/Properties';
|
|
||||||
import type { FlatDef } from '../properties/PropertySchema';
|
import type { FlatDef } from '../properties/PropertySchema';
|
||||||
|
|
||||||
|
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||||||
|
|
||||||
/** The filter shapes the EQ uses – Web Audio's names, so the player hands them on as they are. */
|
/** The filter shapes the EQ uses – Web Audio's names, so the player hands them on as they are. */
|
||||||
export type FxFilter = 'lowshelf' | 'peaking' | 'highshelf';
|
export type FxFilter = 'lowshelf' | 'peaking' | 'highshelf';
|
||||||
|
|
||||||
@@ -21,45 +24,92 @@ export interface FxChain {
|
|||||||
|
|
||||||
export const DRY: FxChain = { pitch: 0, stages: [] };
|
export const DRY: FxChain = { pitch: 0, stages: [] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number of the graph as the compiler knows it: typed in, or a parameter of the Input that a
|
||||||
|
* `Play sound` may hand a value for (#311). `value` is what it is otherwise, and a parameter is held
|
||||||
|
* to the row it ends in only once its value is known.
|
||||||
|
*/
|
||||||
|
export interface FxAmount {
|
||||||
|
value: number;
|
||||||
|
param?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FxStagePlan =
|
||||||
|
| { kind: 'gain'; db: FxAmount }
|
||||||
|
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number };
|
||||||
|
|
||||||
|
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
||||||
|
export interface FxPlan {
|
||||||
|
pitch: readonly FxAmount[];
|
||||||
|
stages: readonly FxStagePlan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
|
||||||
|
|
||||||
|
/** Values for the parameters, by name; a name the plan does not have is left out. */
|
||||||
|
export type FxParams = Readonly<Record<string, number>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sound arriving
|
* A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sound arriving
|
||||||
* at its one input. Input and Output have a side less and no effect of their own.
|
* at its one audio input, given the numbers at its other inputs. Input and Output have no effect.
|
||||||
*/
|
*/
|
||||||
export interface SoundFxNodeDef extends NodeDef {
|
export interface SoundFxNodeDef extends NodeDef {
|
||||||
category: 'sound';
|
category: 'sound';
|
||||||
emit(chain: FxChain, params: PropertyBag): FxChain;
|
emit(plan: FxPlan, amounts: Readonly<Record<string, FxAmount>>): FxPlan;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef {
|
export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef {
|
||||||
return def.category === 'sound' && 'emit' in def;
|
return def.category === 'sound' && 'emit' in def;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SOUND_FX_INPUT = 'SoundFxInput';
|
|
||||||
export const SOUND_FX_OUTPUT = 'SoundFxOutput';
|
|
||||||
|
|
||||||
const audioIn: PortDef = { key: 'in', label: 'in', type: 'audio' };
|
const audioIn: PortDef = { key: 'in', label: 'in', type: 'audio' };
|
||||||
const audioOut: PortDef = { key: 'out', label: 'out', type: 'audio' };
|
const audioOut: PortDef = { key: 'out', label: 'out', type: 'audio' };
|
||||||
|
|
||||||
function numberParam(key: string, label: string, min: number, max: number, step: number): FlatDef<'number'> {
|
function numberRow(key: string, label: string, min: number, max: number, step: number): FlatDef<'number'> {
|
||||||
return { key, label, kind: 'number', default: 0, min, max, step };
|
return { key, label, kind: 'number', default: 0, min, max, step };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A param's value held to its row, so a hand-edited project cannot blow a speaker. */
|
/** An input taking a number: typed in under the key the setting had, or fed from a parameter. */
|
||||||
function read(params: PropertyBag, def: FlatDef<'number'>): number {
|
function numberIn(row: FlatDef<'number'>): PortDef {
|
||||||
const value = Number(params[def.key] ?? def.default);
|
return { key: row.key, label: row.label, type: 'number', field: row };
|
||||||
if (!Number.isFinite(value)) return def.default;
|
}
|
||||||
return Math.max(def.min ?? -Infinity, Math.min(def.max ?? Infinity, value));
|
|
||||||
|
function holdTo(value: number, row: FlatDef<'number'>): number {
|
||||||
|
if (!Number.isFinite(value)) return row.default;
|
||||||
|
return Math.max(row.min ?? -Infinity, Math.min(row.max ?? Infinity, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The amount at an input, held to its row, so a hand-edited project cannot blow a speaker. */
|
||||||
|
function held(amount: FxAmount | undefined, row: FlatDef<'number'>): FxAmount {
|
||||||
|
if (!amount) return { value: row.default };
|
||||||
|
const value = holdTo(amount.value, row);
|
||||||
|
if (amount.param === undefined) return { value };
|
||||||
|
return { value, param: amount.param, min: row.min, max: row.max };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an amount does nothing however the sound is played: a typed-in 0. */
|
||||||
|
const isNone = (amount: FxAmount) => amount.param === undefined && amount.value === 0;
|
||||||
|
|
||||||
|
/** A parameter of the Input as the nodes it feeds see it. */
|
||||||
|
export function soundFxParamPort(port: DeclaredPort): PortDef {
|
||||||
|
return { key: port.key, label: port.key, type: 'number' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SoundFxInput: SoundFxNodeDef = {
|
export const SoundFxInput: SoundFxNodeDef = {
|
||||||
type: SOUND_FX_INPUT,
|
type: SOUND_FX_INPUT,
|
||||||
label: 'Input',
|
label: 'Input',
|
||||||
category: 'sound',
|
category: 'sound',
|
||||||
description: 'The sound as the Play sound node starts it, before any effect.',
|
description:
|
||||||
|
'The sound as the Play sound node starts it, before any effect. Every parameter declared here is a number output with a default; a Play sound can hand in other values through a Sound FX parameters node.',
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [],
|
||||||
emit: (chain) => chain,
|
declaresPorts: 'outputs',
|
||||||
|
portKinds: ['number'],
|
||||||
|
declaredPort: (port) => soundFxParamPort(port),
|
||||||
|
emit: (plan) => plan,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SoundFxOutput: SoundFxNodeDef = {
|
export const SoundFxOutput: SoundFxNodeDef = {
|
||||||
@@ -70,10 +120,10 @@ export const SoundFxOutput: SoundFxNodeDef = {
|
|||||||
inputs: [audioIn],
|
inputs: [audioIn],
|
||||||
outputs: [],
|
outputs: [],
|
||||||
params: [],
|
params: [],
|
||||||
emit: (chain) => chain,
|
emit: (plan) => plan,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GAIN = numberParam('gain', 'Gain (dB)', -60, 24, 0.5);
|
const GAIN = numberRow('gain', 'Gain (dB)', -60, 24, 0.5);
|
||||||
|
|
||||||
export const SoundFxGain: SoundFxNodeDef = {
|
export const SoundFxGain: SoundFxNodeDef = {
|
||||||
type: 'SoundFxGain',
|
type: 'SoundFxGain',
|
||||||
@@ -81,17 +131,16 @@ export const SoundFxGain: SoundFxNodeDef = {
|
|||||||
category: 'sound',
|
category: 'sound',
|
||||||
description:
|
description:
|
||||||
'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.',
|
'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.',
|
||||||
inputs: [audioIn],
|
inputs: [audioIn, numberIn(GAIN)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [GAIN],
|
params: [],
|
||||||
emit: (chain, params) => {
|
emit: (plan, amounts) => {
|
||||||
const db = read(params, GAIN);
|
const db = held(amounts.gain, GAIN);
|
||||||
if (db === 0) return chain;
|
return isNone(db) ? plan : { ...plan, stages: [...plan.stages, { kind: 'gain', db }] };
|
||||||
return { ...chain, stages: [...chain.stages, { kind: 'gain', gain: 10 ** (db / 20) }] };
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEMITONES = numberParam('semitones', 'Semitones', -24, 24, 1);
|
const SEMITONES = numberRow('semitones', 'Semitones', -24, 24, 1);
|
||||||
|
|
||||||
export const SoundFxPitch: SoundFxNodeDef = {
|
export const SoundFxPitch: SoundFxNodeDef = {
|
||||||
type: 'SoundFxPitch',
|
type: 'SoundFxPitch',
|
||||||
@@ -99,10 +148,13 @@ export const SoundFxPitch: SoundFxNodeDef = {
|
|||||||
category: 'sound',
|
category: 'sound',
|
||||||
description:
|
description:
|
||||||
'Plays the sound higher or lower, in semitones: 12 is an octave up. It is the speed the sound is played at, so a higher one is also shorter, and it applies to the whole sound wherever the node sits in the line.',
|
'Plays the sound higher or lower, in semitones: 12 is an octave up. It is the speed the sound is played at, so a higher one is also shorter, and it applies to the whole sound wherever the node sits in the line.',
|
||||||
inputs: [audioIn],
|
inputs: [audioIn, numberIn(SEMITONES)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [SEMITONES],
|
params: [],
|
||||||
emit: (chain, params) => ({ ...chain, pitch: chain.pitch + read(params, SEMITONES) }),
|
emit: (plan, amounts) => {
|
||||||
|
const semitones = held(amounts.semitones, SEMITONES);
|
||||||
|
return isNone(semitones) ? plan : { ...plan, pitch: [...plan.pitch, semitones] };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The five bands of the EQ: a shelf at either end and three peaks between them. */
|
/** The five bands of the EQ: a shelf at either end and three peaks between them. */
|
||||||
@@ -116,7 +168,7 @@ export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter;
|
|||||||
|
|
||||||
/** About an octave and a half wide: neighbouring peaks overlap, so no frequency falls between them. */
|
/** About an octave and a half wide: neighbouring peaks overlap, so no frequency falls between them. */
|
||||||
const PEAK_Q = 1;
|
const PEAK_Q = 1;
|
||||||
const EQ_PARAMS = EQ_BANDS.map((band) => numberParam(band.key, band.label, -24, 24, 0.5));
|
const EQ_ROWS = EQ_BANDS.map((band) => numberRow(band.key, band.label, -24, 24, 0.5));
|
||||||
|
|
||||||
export const SoundFxEq: SoundFxNodeDef = {
|
export const SoundFxEq: SoundFxNodeDef = {
|
||||||
type: 'SoundFxEq',
|
type: 'SoundFxEq',
|
||||||
@@ -124,17 +176,17 @@ export const SoundFxEq: SoundFxNodeDef = {
|
|||||||
category: 'sound',
|
category: 'sound',
|
||||||
description:
|
description:
|
||||||
'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.',
|
'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.',
|
||||||
inputs: [audioIn],
|
inputs: [audioIn, ...EQ_ROWS.map(numberIn)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: EQ_PARAMS,
|
params: [],
|
||||||
emit: (chain, params) => {
|
emit: (plan, amounts) => {
|
||||||
const stages: FxStage[] = [];
|
const stages: FxStagePlan[] = [];
|
||||||
EQ_BANDS.forEach((band, i) => {
|
EQ_BANDS.forEach((band, i) => {
|
||||||
const gain = read(params, EQ_PARAMS[i]!);
|
const gain = held(amounts[band.key], EQ_ROWS[i]!);
|
||||||
if (gain !== 0)
|
if (!isNone(gain))
|
||||||
stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q });
|
stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q });
|
||||||
});
|
});
|
||||||
return stages.length === 0 ? chain : { ...chain, stages: [...chain.stages, ...stages] };
|
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,3 +197,26 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
|
|||||||
SoundFxPitch,
|
SoundFxPitch,
|
||||||
SoundFxEq,
|
SoundFxEq,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
|
||||||
|
function amountOf(amount: FxAmount, params: FxParams): number {
|
||||||
|
const given = amount.param !== undefined ? params[amount.param] : undefined;
|
||||||
|
if (given === undefined || !Number.isFinite(given)) return amount.value;
|
||||||
|
return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chain one voice is sent along: the plan with every parameter filled in. */
|
||||||
|
export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
|
||||||
|
const stages: FxStage[] = [];
|
||||||
|
for (const stage of plan.stages) {
|
||||||
|
if (stage.kind === 'gain') {
|
||||||
|
const db = amountOf(stage.db, params);
|
||||||
|
if (db !== 0) stages.push({ kind: 'gain', gain: 10 ** (db / 20) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const gain = amountOf(stage.gain, params);
|
||||||
|
if (gain !== 0) stages.push({ ...stage, gain });
|
||||||
|
}
|
||||||
|
const pitch = plan.pitch.reduce((sum, amount) => sum + amountOf(amount, params), 0);
|
||||||
|
return { pitch, stages };
|
||||||
|
}
|
||||||
@@ -487,6 +487,8 @@ function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolea
|
|||||||
// A machine that is gone is kept, as a struct type is: the port then offers no states.
|
// A machine that is gone is kept, as a struct type is: the port then offers no states.
|
||||||
if (kind === 'statemachine' && typeof p.machine === 'string' && p.machine !== '')
|
if (kind === 'statemachine' && typeof p.machine === 'string' && p.machine !== '')
|
||||||
port.machine = p.machine;
|
port.machine = p.machine;
|
||||||
|
if (kind === 'number' && typeof p.default === 'number' && Number.isFinite(p.default))
|
||||||
|
port.default = p.default;
|
||||||
ports.push(port);
|
ports.push(port);
|
||||||
}
|
}
|
||||||
return ports;
|
return ports;
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
}),
|
}),
|
||||||
playAt: () => centreOf(editor.soundFxEditor.playButton),
|
playAt: () => centreOf(editor.soundFxEditor.playButton),
|
||||||
stopAt: () => centreOf(editor.soundFxEditor.stopButton),
|
stopAt: () => centreOf(editor.soundFxEditor.stopButton),
|
||||||
|
/** The header's parameter values, and setting one as its field would. */
|
||||||
|
params: () => editor.soundFxEditor.previewValues,
|
||||||
|
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
|
||||||
},
|
},
|
||||||
particles: {
|
particles: {
|
||||||
current: () => editor.particlesEditor.currentAssetId,
|
current: () => editor.particlesEditor.currentAssetId,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
RUN_BEHAVIOR,
|
RUN_BEHAVIOR,
|
||||||
SHADER_CALL,
|
SHADER_CALL,
|
||||||
callsFunction,
|
callsFunction,
|
||||||
|
soundFxParamsMismatch,
|
||||||
startStateOf,
|
startStateOf,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import type { GraphAsset } from '../../core/model/Asset';
|
import type { GraphAsset } from '../../core/model/Asset';
|
||||||
@@ -325,6 +326,7 @@ export class GraphCanvas extends Widget {
|
|||||||
const problem =
|
const problem =
|
||||||
compilerProblems?.[node.id] ??
|
compilerProblems?.[node.id] ??
|
||||||
(!compilerProblems || node.type === SHADER_CALL ? callProblem(node, lookup, asset.id) : null) ??
|
(!compilerProblems || node.type === SHADER_CALL ? callProblem(node, lookup, asset.id) : null) ??
|
||||||
|
soundFxParamsMismatch(node, asset.graph) ??
|
||||||
ghostProblem(node, asset.graph, resolved) ??
|
ghostProblem(node, asset.graph, resolved) ??
|
||||||
blockProblem(asset.graph, node);
|
blockProblem(asset.graph, node);
|
||||||
view.sync(node, asset.graph, this.state.project, def, problem);
|
view.sync(node, asset.graph, this.state.project, def, problem);
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export function portColor(type: PortType): number {
|
|||||||
return c.portPicture;
|
return c.portPicture;
|
||||||
case 'audio':
|
case 'audio':
|
||||||
return c.portAudio;
|
return c.portAudio;
|
||||||
|
case 'fxparams':
|
||||||
|
return c.portFxParams;
|
||||||
case 'any':
|
case 'any':
|
||||||
return c.portAny;
|
return c.portAny;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
callProblem,
|
callProblem,
|
||||||
declaresPropertyPorts,
|
declaresPropertyPorts,
|
||||||
|
declaresSoundFxPorts,
|
||||||
declaresVariablePorts,
|
declaresVariablePorts,
|
||||||
edgesInto,
|
edgesInto,
|
||||||
edgesOfNode,
|
edgesOfNode,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
OBJECT_TYPE_PARAM,
|
OBJECT_TYPE_PARAM,
|
||||||
portSignature,
|
portSignature,
|
||||||
resolveDef,
|
resolveDef,
|
||||||
|
soundFxParamsMismatch,
|
||||||
|
soundFxSourceOf,
|
||||||
STATE_TRANSITION,
|
STATE_TRANSITION,
|
||||||
stateName,
|
stateName,
|
||||||
STRUCT_TYPE_PARAM,
|
STRUCT_TYPE_PARAM,
|
||||||
@@ -42,6 +45,7 @@ import { addNote, addTitle, type InspectorHost } from './host';
|
|||||||
import {
|
import {
|
||||||
buildPortSection,
|
buildPortSection,
|
||||||
buildPropertyPortSection,
|
buildPropertyPortSection,
|
||||||
|
buildSoundFxPortSection,
|
||||||
buildVariablePortSection,
|
buildVariablePortSection,
|
||||||
variableChoices,
|
variableChoices,
|
||||||
} from './portSection';
|
} from './portSection';
|
||||||
@@ -67,7 +71,9 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
|
|||||||
const declared = (node.ports ?? [])
|
const declared = (node.ports ?? [])
|
||||||
.map(
|
.map(
|
||||||
(p) =>
|
(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(',');
|
.join(',');
|
||||||
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
|
// 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.
|
// The source names which declarations the port menu offers, so switching it has to rebuild.
|
||||||
const typeId = nodeStructType(node) || listNodeItemType(node);
|
const typeId = nodeStructType(node) || listNodeItemType(node);
|
||||||
const typeName = structAssetOf(project, typeId)?.name ?? '';
|
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)
|
const choices = variableChoices(project, assetId, node)
|
||||||
.map((v) => v.key)
|
.map((v) => v.key)
|
||||||
.join(',');
|
.join(',');
|
||||||
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.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. */
|
/** 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`);
|
addTitle(host, `${def.category.toUpperCase()} NODE`);
|
||||||
host.body.add(new Label(def.label, { style: 'bodyBold' }));
|
host.body.add(new Label(def.label, { style: 'bodyBold' }));
|
||||||
addNote(host, def.description);
|
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');
|
if (problem) addNote(host, problem, 'warning');
|
||||||
const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => {
|
const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => {
|
||||||
const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers);
|
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);
|
host.body.add(row.row);
|
||||||
};
|
};
|
||||||
// Both draw their settings inside the port section: a setting decides what the menu offers.
|
// 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
|
// 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.
|
// rewrites the ports, which a plain param edit would not.
|
||||||
const typed = isTypedNode(node);
|
const typed = isTypedNode(node);
|
||||||
@@ -134,6 +142,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
|||||||
const pickItemType = (value: PropertyValue) =>
|
const pickItemType = (value: PropertyValue) =>
|
||||||
host.state.execute(new SetListNodeTypeCommand(assetId, nodeId, textOf(value)));
|
host.state.execute(new SetListNodeTypeCommand(assetId, nodeId, textOf(value)));
|
||||||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting);
|
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 (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
|
||||||
else if (def.declaresPorts) {
|
else if (def.declaresPorts) {
|
||||||
const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null;
|
const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null;
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
RemovePortCommand,
|
RemovePortCommand,
|
||||||
RenamePortCommand,
|
RenamePortCommand,
|
||||||
RepointPortCommand,
|
RepointPortCommand,
|
||||||
|
SetPortDefaultCommand,
|
||||||
SetPortMachineCommand,
|
SetPortMachineCommand,
|
||||||
SetPortWriteCommand,
|
SetPortWriteCommand,
|
||||||
} from '../../core/commands/portCommands';
|
} from '../../core/commands/portCommands';
|
||||||
import {
|
import {
|
||||||
type DeclaredPort,
|
type DeclaredPort,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
|
declaresSoundFxPorts,
|
||||||
isEntryTerminal,
|
isEntryTerminal,
|
||||||
isPortOf,
|
isPortOf,
|
||||||
isTerminalNode,
|
isTerminalNode,
|
||||||
@@ -23,6 +25,10 @@ import {
|
|||||||
portKindOf,
|
portKindOf,
|
||||||
SHADER_FUNCTION_INPUT,
|
SHADER_FUNCTION_INPUT,
|
||||||
SHADER_FUNCTION_OUTPUT,
|
SHADER_FUNCTION_OUTPUT,
|
||||||
|
SOUND_FX_INPUT,
|
||||||
|
SOUND_FX_SETTING,
|
||||||
|
soundFxSourceOf,
|
||||||
|
terminalNode,
|
||||||
VARIABLES_SETTING,
|
VARIABLES_SETTING,
|
||||||
variablesSourceOf,
|
variablesSourceOf,
|
||||||
propertyTargetOf,
|
propertyTargetOf,
|
||||||
@@ -44,6 +50,7 @@ import {
|
|||||||
Label,
|
Label,
|
||||||
Menu,
|
Menu,
|
||||||
type MenuItem,
|
type MenuItem,
|
||||||
|
NumberField,
|
||||||
type SectionHeader,
|
type SectionHeader,
|
||||||
type SortBlock,
|
type SortBlock,
|
||||||
sortBlocks,
|
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. */
|
/** 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[] {
|
export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] {
|
||||||
|
if (declaresSoundFxPorts(node.type)) return soundFxChoices(project, node);
|
||||||
const source = variablesSourceOf(node.params);
|
const source = variablesSourceOf(node.params);
|
||||||
if (source) return findAssetOf(project, source, 'variables')?.variables ?? [];
|
if (source) return findAssetOf(project, source, 'variables')?.variables ?? [];
|
||||||
const asset = findGraphAsset(project, assetId);
|
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. */
|
/** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */
|
||||||
export function buildPropertyPortSection(
|
export function buildPropertyPortSection(
|
||||||
host: InspectorHost,
|
host: InspectorHost,
|
||||||
@@ -133,7 +170,7 @@ export function buildPropertyPortSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PickedPorts {
|
interface PickedPorts {
|
||||||
noun: 'Variable' | 'Property';
|
noun: 'Variable' | 'Property' | 'Parameter';
|
||||||
choices: readonly PropertyDef[];
|
choices: readonly PropertyDef[];
|
||||||
/** Whose ports these are, for the warning on one it does not have. */
|
/** Whose ports these are, for the warning on one it does not have. */
|
||||||
owner: string;
|
owner: string;
|
||||||
@@ -153,7 +190,7 @@ function buildPickedPortSection(
|
|||||||
{ noun, choices, owner, empty, custom = false, note, hidden }: PickedPorts,
|
{ noun, choices, owner, empty, custom = false, note, hidden }: PickedPorts,
|
||||||
): void {
|
): void {
|
||||||
const offered = hidden ? choices.filter((def) => !hidden(def)) : choices;
|
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 ports = node.ports ?? [];
|
||||||
const taken = new Set(ports.map((p) => p.key));
|
const taken = new Set(ports.map((p) => p.key));
|
||||||
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
|
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
|
||||||
@@ -191,9 +228,10 @@ function buildPickedPortSection(
|
|||||||
const pickedRow = (port: DeclaredPort): Grip => {
|
const pickedRow = (port: DeclaredPort): Grip => {
|
||||||
// A machine a variable holds is a running value; which one its states come from is picked here.
|
// 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';
|
const carries = noun === 'Variable' && port.kind === 'statemachine';
|
||||||
|
// A parameter is only ever handed in, never written back.
|
||||||
const toggle = [
|
const toggle = [
|
||||||
...(carries ? [machinePick(host, assetId, node, port)] : []),
|
...(carries ? [machinePick(host, assetId, node, port)] : []),
|
||||||
...writeToggle(host, assetId, node, port),
|
...(noun === 'Parameter' ? [] : writeToggle(host, assetId, node, port)),
|
||||||
];
|
];
|
||||||
if (custom)
|
if (custom)
|
||||||
return portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle).grip;
|
return portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle).grip;
|
||||||
@@ -238,9 +276,10 @@ export function buildPortSection(
|
|||||||
typeName: string | null = null,
|
typeName: string | null = null,
|
||||||
): void {
|
): void {
|
||||||
const onStart = isEntryTerminal(node.type);
|
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 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`);
|
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.
|
// With a Struct asset as its type the fields are that asset's (#253): changed there, not here.
|
||||||
@@ -277,6 +316,8 @@ export function buildPortSection(
|
|||||||
host,
|
host,
|
||||||
noun === 'Field'
|
noun === 'Field'
|
||||||
? 'No fields yet. Every field added here becomes a port of the struct.'
|
? 'No fields yet. Every field added here becomes a port of the struct.'
|
||||||
|
: 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
|
: ofFunction
|
||||||
? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.`
|
? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.`
|
||||||
: onStart
|
: onStart
|
||||||
@@ -291,13 +332,18 @@ export function buildPortSection(
|
|||||||
node,
|
node,
|
||||||
port,
|
port,
|
||||||
portNameField(host, assetId, 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)));
|
sortBlocks(blocks, (from, to) => host.state.execute(new MovePortCommand(assetId, node.id, from, to)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const plural = (noun: string) => `${noun.toLowerCase()}s`;
|
const plural = (noun: string) => `${noun.toLowerCase()}s`;
|
||||||
|
const DEFAULT_WIDTH = 56;
|
||||||
|
|
||||||
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
|
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
|
||||||
return new TextField(port.key, {
|
return new TextField(port.key, {
|
||||||
@@ -332,6 +378,19 @@ function machinePick(host: InspectorHost, assetId: string, node: GraphNode, port
|
|||||||
return pick;
|
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[] {
|
function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
|
||||||
const box = new Checkbox(port.write === true);
|
const box = new Checkbox(port.write === true);
|
||||||
box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on));
|
box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on));
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import type { Asset, SoundFxAsset } from '../../core/model/Asset';
|
|||||||
import { findAssetOf } from '../../core/model/Project';
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||||
import { volumeOf } from '../../core/properties/sounds';
|
import { volumeOf } from '../../core/properties/sounds';
|
||||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile';
|
||||||
import { Button, Dropdown, Menu } from '../../ui';
|
import { fxChainOf } from '../../core/soundfx/nodes';
|
||||||
|
import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import { AssetTabEditor } from '../AssetTabEditor';
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||||
@@ -14,6 +15,7 @@ import type { EditorState } from '../state';
|
|||||||
/** Room kept for "N effects", or for the reason the graph does not compile. */
|
/** Room kept for "N effects", or for the reason the graph does not compile. */
|
||||||
const INFO_WIDTH = 220;
|
const INFO_WIDTH = 220;
|
||||||
const SOUND_WIDTH = 140;
|
const SOUND_WIDTH = 140;
|
||||||
|
const PARAM_WIDTH = 48;
|
||||||
/** Whose voices the tab's Stop silences: its own, never a running game's. */
|
/** Whose voices the tab's Stop silences: its own, never a running game's. */
|
||||||
const PREVIEW_OWNER = 'soundfx-preview';
|
const PREVIEW_OWNER = 'soundfx-preview';
|
||||||
|
|
||||||
@@ -29,6 +31,15 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
|
|||||||
readonly playButton: Button;
|
readonly playButton: Button;
|
||||||
readonly stopButton: Button;
|
readonly stopButton: Button;
|
||||||
private soundId = '';
|
private soundId = '';
|
||||||
|
/** One name and number per parameter of the Input: what the header's Play hands in. */
|
||||||
|
private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
private readonly paramFields = new Map<string, NumberField>();
|
||||||
|
/** The values the tab plays with – its own, never written into the asset. */
|
||||||
|
private values: Record<string, number> = {};
|
||||||
|
/** The defaults the values were taken from, so an edited default comes through and a moved value stays. */
|
||||||
|
private defaults: Record<string, number> = {};
|
||||||
|
private paramsKey = '';
|
||||||
|
private valuesFor = '';
|
||||||
|
|
||||||
constructor(state: EditorState) {
|
constructor(state: EditorState) {
|
||||||
super(state, { infoWidth: INFO_WIDTH });
|
super(state, { infoWidth: INFO_WIDTH });
|
||||||
@@ -43,9 +54,11 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
|
|||||||
this.soundPicker,
|
this.soundPicker,
|
||||||
this.playButton,
|
this.playButton,
|
||||||
this.stopButton,
|
this.stopButton,
|
||||||
|
this.paramBar,
|
||||||
]);
|
]);
|
||||||
state.on('load', () => {
|
state.on('load', () => {
|
||||||
this.soundId = '';
|
this.soundId = '';
|
||||||
|
this.valuesFor = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,14 +81,26 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
|
|||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the header's parameter fields are set to – the check scripts read this. */
|
||||||
|
get previewValues(): Record<string, number> {
|
||||||
|
return { ...this.values };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets one parameter the header plays with, as typing into its field does. */
|
||||||
|
setPreviewValue(key: string, value: number): void {
|
||||||
|
if (!(key in this.values)) return;
|
||||||
|
this.values[key] = value;
|
||||||
|
this.paramFields.get(key)?.setNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
/** Plays the picked sound through the graph; a graph that does not compile is not played at all. */
|
/** Plays the picked sound through the graph; a graph that does not compile is not played at all. */
|
||||||
play(): void {
|
play(): void {
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
|
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
|
||||||
const chain = asset && soundFxBuildFor(asset).compiled;
|
const plan = asset && soundFxBuildFor(asset).compiled;
|
||||||
if (!sound?.file || !chain) return;
|
if (!sound?.file || !plan) return;
|
||||||
const volume = volumeOf(resolveAssetProps(this.state.project, sound));
|
const volume = volumeOf(resolveAssetProps(this.state.project, sound));
|
||||||
soundLibrary.play(sound.file, volume, false, PREVIEW_OWNER, chain);
|
soundLibrary.play(sound.file, volume, false, PREVIEW_OWNER, fxChainOf(plan, this.values));
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
@@ -95,6 +120,48 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
|
|||||||
this.soundPicker.setEnabled(Boolean(asset && sound));
|
this.soundPicker.setEnabled(Boolean(asset && sound));
|
||||||
this.playButton.setEnabled(Boolean(asset && sound?.file && soundFxBuildFor(asset).compiled));
|
this.playButton.setEnabled(Boolean(asset && sound?.file && soundFxBuildFor(asset).compiled));
|
||||||
this.stopButton.setEnabled(Boolean(asset));
|
this.stopButton.setEnabled(Boolean(asset));
|
||||||
|
this.syncParams(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Another asset starts from its defaults; an edit of the graph keeps what a field was moved to,
|
||||||
|
* unless that parameter's default changed with it.
|
||||||
|
*/
|
||||||
|
private syncParams(asset: SoundFxAsset | null): void {
|
||||||
|
const params = asset ? soundFxParams(asset.graph) : [];
|
||||||
|
const same = (asset?.id ?? '') === this.valuesFor;
|
||||||
|
this.valuesFor = asset?.id ?? '';
|
||||||
|
const values: Record<string, number> = {};
|
||||||
|
const defaults: Record<string, number> = {};
|
||||||
|
for (const port of params) {
|
||||||
|
const fallback = port.default ?? 0;
|
||||||
|
const kept = same && this.defaults[port.key] === fallback ? this.values[port.key] : undefined;
|
||||||
|
values[port.key] = kept ?? fallback;
|
||||||
|
defaults[port.key] = fallback;
|
||||||
|
}
|
||||||
|
this.values = values;
|
||||||
|
this.defaults = defaults;
|
||||||
|
|
||||||
|
const key = params.map((p) => p.key).join('\u0000');
|
||||||
|
if (key !== this.paramsKey) {
|
||||||
|
this.paramsKey = key;
|
||||||
|
for (const child of this.paramBar.removeChildren()) child.destroy({ children: true });
|
||||||
|
this.paramFields.clear();
|
||||||
|
for (const port of params) {
|
||||||
|
this.paramBar.add(new Label(port.key, { style: 'small' }));
|
||||||
|
const field = new NumberField(values[port.key], {
|
||||||
|
width: PARAM_WIDTH,
|
||||||
|
step: 0.1,
|
||||||
|
onInput: (value) => (this.values[port.key] = value),
|
||||||
|
onChange: (value) => (this.values[port.key] = value),
|
||||||
|
});
|
||||||
|
this.paramFields.set(port.key, field);
|
||||||
|
this.paramBar.add(field);
|
||||||
|
}
|
||||||
|
if (this.paramBar.parent instanceof Stack) this.paramBar.parent.relayout();
|
||||||
|
this.paramBar.relayout();
|
||||||
|
}
|
||||||
|
for (const [name, field] of this.paramFields) field.setNumber(this.values[name] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private openSoundPicker(): void {
|
private openSoundPicker(): void {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { hasRenderLayer } from '../../core/properties/PropertySchema';
|
|||||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||||
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
||||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||||
import { DRY } from '../../core/soundfx/nodes';
|
import { DRY, fxChainOf, type FxParams } from '../../core/soundfx/nodes';
|
||||||
import type { Entity } from '../Entity';
|
import type { Entity } from '../Entity';
|
||||||
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||||||
import type { World } from '../World';
|
import type { World } from '../World';
|
||||||
@@ -164,8 +164,8 @@ export function behaviorContext(
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[behavior ${entity.name}]`, message);
|
console.log(`[behavior ${entity.name}]`, message);
|
||||||
},
|
},
|
||||||
playSound: (assetId, volume, loop, detached, fx) =>
|
playSound: (assetId, volume, loop, detached, fx, fxParams) =>
|
||||||
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx),
|
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams),
|
||||||
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
|
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
|
||||||
// only the empty id means "everything", which is what the node promises.
|
// only the empty id means "everything", which is what the node promises.
|
||||||
stopSound: (assetId) => {
|
stopSound: (assetId) => {
|
||||||
@@ -217,7 +217,8 @@ export function behaviorContext(
|
|||||||
* What the graph asked for, as the sink understands it: the asset's own volume scaled by the
|
* What the graph asked for, as the sink understands it: the asset's own volume scaled by the
|
||||||
* node's, and its loop flag unless the node overrules it either way. The asset is what a sound
|
* node's, and its loop flag unless the node overrules it either way. The asset is what a sound
|
||||||
* *is* – a node that says nothing therefore plays it as the project describes it. An FX asset that
|
* *is* – a node that says nothing therefore plays it as the project describes it. An FX asset that
|
||||||
* does not compile plays the sound dry: a broken effect should not silence the game.
|
* does not compile plays the sound dry: a broken effect should not silence the game. Its parameters
|
||||||
|
* take the values handed in, the others their defaults.
|
||||||
*/
|
*/
|
||||||
function playSound(
|
function playSound(
|
||||||
{ project, audio }: ContextSources,
|
{ project, audio }: ContextSources,
|
||||||
@@ -226,13 +227,15 @@ function playSound(
|
|||||||
loop: boolean | undefined,
|
loop: boolean | undefined,
|
||||||
owner: string,
|
owner: string,
|
||||||
fxId: string,
|
fxId: string,
|
||||||
|
fxParams: FxParams,
|
||||||
): void {
|
): void {
|
||||||
const asset = findAssetOf(project, assetId, 'sound');
|
const asset = findAssetOf(project, assetId, 'sound');
|
||||||
if (!asset?.file) return;
|
if (!asset?.file) return;
|
||||||
const props = resolveAssetProps(project, asset);
|
const props = resolveAssetProps(project, asset);
|
||||||
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
|
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
|
||||||
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
|
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
|
||||||
audio.play(asset.file, level, loop ?? loopsOf(props), owner, (fx && soundFxBuildFor(fx).compiled) || DRY);
|
const plan = fx && soundFxBuildFor(fx).compiled;
|
||||||
|
audio.play(asset.file, level, loop ?? loopsOf(props), owner, plan ? fxChainOf(plan, fxParams) : DRY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a ray of that length ends; where it has no direction, where it starts. */
|
/** Where a ray of that length ends; where it has no direction, where it starts. */
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const Theme = {
|
|||||||
portVec3: 0xff9ad5,
|
portVec3: 0xff9ad5,
|
||||||
portPicture: 0x9b8cff,
|
portPicture: 0x9b8cff,
|
||||||
portAudio: 0xf0c060,
|
portAudio: 0xf0c060,
|
||||||
|
portFxParams: 0xd0629b,
|
||||||
portAny: 0xbdbdbd,
|
portAny: 0xbdbdbd,
|
||||||
/** Ring around the port a running edge drag would connect to. */
|
/** Ring around the port a running edge drag would connect to. */
|
||||||
portTarget: 0xffffff,
|
portTarget: 0xffffff,
|
||||||
|
|||||||
@@ -1950,7 +1950,9 @@ const CASES: Record<string, Case[]> = {
|
|||||||
// flag – is the BehaviorSystem's to resolve, since only it holds the project (see
|
// flag – is the BehaviorSystem's to resolve, since only it holds the project (see
|
||||||
// `sounds.test.ts`).
|
// `sounds.test.ts`).
|
||||||
after: (ctx) =>
|
after: (ctx) =>
|
||||||
expect(ctx.played).toEqual([{ assetId: 'snd-1', volume: 0.5, loop: true, detached: false, fx: '' }]),
|
expect(ctx.played).toEqual([
|
||||||
|
{ assetId: 'snd-1', volume: 0.5, loop: true, detached: false, fx: '', fxParams: {} },
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'detached says the voice is nobody’s, so destroying this object leaves it playing',
|
name: 'detached says the voice is nobody’s, so destroying this object leaves it playing',
|
||||||
@@ -1976,6 +1978,24 @@ const CASES: Record<string, Case[]> = {
|
|||||||
next: 'exec',
|
next: 'exec',
|
||||||
after: (ctx) => expect(ctx.played[0]?.fx).toBe('fx-1'),
|
after: (ctx) => expect(ctx.played[0]?.fx).toBe('fx-1'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'hands on the numbers of its fx params, and nothing for what is no bundle',
|
||||||
|
inputs: { sound: 'snd-1', volume: 1, loop: '', fx: 'fx-1', fxParams: { depth: -12, name: 'x' } },
|
||||||
|
next: 'exec',
|
||||||
|
after: (ctx) => expect(ctx.played[0]?.fxParams).toEqual({ depth: -12 }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
SoundFxParameters: [
|
||||||
|
{
|
||||||
|
name: 'bundles its picked parameters as numbers',
|
||||||
|
ports: [
|
||||||
|
{ key: 'depth', kind: 'number' },
|
||||||
|
{ key: 'loud', kind: 'number' },
|
||||||
|
],
|
||||||
|
inputs: { depth: '-12', loud: 3 },
|
||||||
|
outputs: { params: { depth: -12, loud: 3 } },
|
||||||
|
},
|
||||||
|
{ name: 'with none picked the bundle is empty', outputs: { params: {} } },
|
||||||
],
|
],
|
||||||
LoadScene: [
|
LoadScene: [
|
||||||
{
|
{
|
||||||
|
|||||||
+186
-7
@@ -4,12 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildAsset, CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands';
|
import { buildAsset, CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands';
|
||||||
import { portsCompatible } from '../../src/core/graph/Graph';
|
import { RenamePortCommand, SetPortDefaultCommand } from '../../src/core/commands/portCommands';
|
||||||
|
import { callProblem, resolveDef, soundFxParamsMismatch } from '../../src/core/graph';
|
||||||
|
import { type DeclaredPort, portsCompatible } from '../../src/core/graph/Graph';
|
||||||
|
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||||
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||||
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
||||||
import { compileSoundFx, soundFxBuildFor } from '../../src/core/soundfx/compile';
|
import { compileSoundFx, soundFxBuildFor } from '../../src/core/soundfx/compile';
|
||||||
import { DRY, type FxChain } from '../../src/core/soundfx/nodes';
|
import { DRY, EMPTY_PLAN, type FxChain, fxChainOf } from '../../src/core/soundfx/nodes';
|
||||||
import { deserializeProject } from '../../src/core/store/serialize';
|
import { deserializeProject } from '../../src/core/store/serialize';
|
||||||
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
||||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||||
@@ -116,7 +119,8 @@ describe('compiling a sound FX graph', () => {
|
|||||||
|
|
||||||
it('passes the sound on untouched through Input and Output alone', () => {
|
it('passes the sound on untouched through Input and Output alone', () => {
|
||||||
const build = compileSoundFx(line([]).fx.graph);
|
const build = compileSoundFx(line([]).fx.graph);
|
||||||
expect(build).toMatchObject({ compiled: DRY, effects: 0, reason: '' });
|
expect(build).toMatchObject({ compiled: EMPTY_PLAN, effects: 0, reason: '' });
|
||||||
|
expect(fxChainOf(build.compiled!)).toEqual(DRY);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('turns every node on the line into its stages, in order', () => {
|
it('turns every node on the line into its stages, in order', () => {
|
||||||
@@ -128,7 +132,7 @@ describe('compiling a sound FX graph', () => {
|
|||||||
]);
|
]);
|
||||||
const build = compileSoundFx(f.fx.graph);
|
const build = compileSoundFx(f.fx.graph);
|
||||||
expect(build.effects).toBe(4);
|
expect(build.effects).toBe(4);
|
||||||
const chain = build.compiled!;
|
const chain = fxChainOf(build.compiled!);
|
||||||
expect(chain.pitch).toBe(7);
|
expect(chain.pitch).toBe(7);
|
||||||
expect(chain.stages).toHaveLength(3);
|
expect(chain.stages).toHaveLength(3);
|
||||||
expect(chain.stages[0]!.kind).toBe('gain');
|
expect(chain.stages[0]!.kind).toBe('gain');
|
||||||
@@ -141,10 +145,12 @@ describe('compiling a sound FX graph', () => {
|
|||||||
|
|
||||||
it('leaves out what does nothing and holds a value to its row', () => {
|
it('leaves out what does nothing and holds a value to its row', () => {
|
||||||
const quiet = compileSoundFx(line([{ type: 'SoundFxGain' }, { type: 'SoundFxEq' }]).fx.graph);
|
const quiet = compileSoundFx(line([{ type: 'SoundFxGain' }, { type: 'SoundFxEq' }]).fx.graph);
|
||||||
expect(quiet.compiled).toEqual(DRY);
|
expect(quiet.compiled).toEqual(EMPTY_PLAN);
|
||||||
expect(quiet.effects).toBe(2);
|
expect(quiet.effects).toBe(2);
|
||||||
|
|
||||||
const loud = compileSoundFx(line([{ type: 'SoundFxGain', params: { gain: 200 } }]).fx.graph).compiled!;
|
const loud = fxChainOf(
|
||||||
|
compileSoundFx(line([{ type: 'SoundFxGain', params: { gain: 200 } }]).fx.graph).compiled!,
|
||||||
|
);
|
||||||
expect(loud.stages[0]!.gain).toBeCloseTo(10 ** (24 / 20));
|
expect(loud.stages[0]!.gain).toBeCloseTo(10 ** (24 / 20));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,7 +159,7 @@ describe('compiling a sound FX graph', () => {
|
|||||||
const aside = f.edit.addNode('SoundFxGain', { gain: -20 });
|
const aside = f.edit.addNode('SoundFxGain', { gain: -20 });
|
||||||
f.edit.connect(f.input, 'out', aside, 'in');
|
f.edit.connect(f.input, 'out', aside, 'in');
|
||||||
const build = compileSoundFx(f.fx.graph);
|
const build = compileSoundFx(f.fx.graph);
|
||||||
expect(build.compiled).toEqual({ pitch: 3, stages: [] });
|
expect(fxChainOf(build.compiled!)).toEqual({ pitch: 3, stages: [] });
|
||||||
expect(build.effects).toBe(1);
|
expect(build.effects).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -217,3 +223,176 @@ describe('Play sound with fx', () => {
|
|||||||
for (const audio of [none, gone, brokenAudio]) expect(audio.played).toEqual([{ file: FILE, fx: DRY }]);
|
for (const audio of [none, gone, brokenAudio]) expect(audio.played).toEqual([{ file: FILE, fx: DRY }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Input with the given parameters, its `param` fed into the effect's `into`, then Output. */
|
||||||
|
function withParam(type: string, into: string, ports: DeclaredPort[], param = ports[0]!.key) {
|
||||||
|
const f = line([{ type }]);
|
||||||
|
f.input.ports = ports;
|
||||||
|
const effect = f.fx.graph.nodes.find((n) => n.type === type)!;
|
||||||
|
f.edit.connect(f.input, param, effect, into);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the parameters of a sound FX', () => {
|
||||||
|
it('stay open in the plan and take the value handed in, held to the row', () => {
|
||||||
|
const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number', default: -12 }]);
|
||||||
|
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||||
|
expect(plan.pitch).toEqual([{ value: -12, param: 'depth', min: -24, max: 24 }]);
|
||||||
|
expect(fxChainOf(plan).pitch).toBe(-12);
|
||||||
|
expect(fxChainOf(plan, { depth: 5 }).pitch).toBe(5);
|
||||||
|
expect(fxChainOf(plan, { depth: 100 }).pitch).toBe(24);
|
||||||
|
expect(fxChainOf(plan, { other: 3 }).pitch).toBe(-12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keep a stage that is 0 by default, so a value handed in can open it', () => {
|
||||||
|
const f = withParam('SoundFxGain', 'gain', [{ key: 'loud', kind: 'number' }]);
|
||||||
|
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||||
|
expect(plan.stages).toHaveLength(1);
|
||||||
|
expect(fxChainOf(plan)).toEqual(DRY);
|
||||||
|
expect(fxChainOf(plan, { loud: 6 }).stages[0]).toMatchObject({ kind: 'gain' });
|
||||||
|
expect((fxChainOf(plan, { loud: 6 }).stages[0] as { gain: number }).gain).toBeCloseTo(10 ** (6 / 20));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feed an EQ band as well', () => {
|
||||||
|
const f = withParam('SoundFxEq', 'mid', [{ key: 'body', kind: 'number', default: 3 }]);
|
||||||
|
const chain = fxChainOf(soundFxBuildFor(f.fx).compiled!, { body: -4 });
|
||||||
|
expect(chain.stages).toEqual([{ kind: 'filter', filter: 'peaking', frequency: 1000, gain: -4, q: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are kept with their defaults through a save, a default only on a number', () => {
|
||||||
|
const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number', default: -7 }]);
|
||||||
|
expect(roundTrip(f.project)).toEqual(f.project);
|
||||||
|
const loaded = deserializeProject({
|
||||||
|
name: 'P',
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
id: 'fx',
|
||||||
|
type: 'soundfx',
|
||||||
|
name: 'FX',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
type: 'SoundFxInput',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
params: {},
|
||||||
|
ports: [
|
||||||
|
{ key: 'p', kind: 'number', default: 'loud' },
|
||||||
|
{ key: 'q', kind: 'number', default: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect((loaded.assets[0] as SoundFxAsset).graph.nodes[0]!.ports).toEqual([
|
||||||
|
{ key: 'p', kind: 'number' },
|
||||||
|
{ key: 'q', kind: 'number', default: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('do not go where a sound goes, and a sound does not go where a number does', () => {
|
||||||
|
const f = line([{ type: 'SoundFxGain' }]);
|
||||||
|
f.input.ports = [{ key: 'level', kind: 'number' }];
|
||||||
|
const gain = f.fx.graph.nodes.find((n) => n.type === 'SoundFxGain')!;
|
||||||
|
f.fx.graph.edges.find((e) => e.to.node === gain.id)!.from.port = 'level';
|
||||||
|
expect(compileSoundFx(f.fx.graph).problems[gain.id]).toBe('"in" takes a sound');
|
||||||
|
expect(portsCompatible('fxparams', 'fxparams')).toBe(true);
|
||||||
|
expect(portsCompatible('fxparams', 'any')).toBe(false);
|
||||||
|
expect(portsCompatible('struct', 'fxparams')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('have a default set by a command that undoes', () => {
|
||||||
|
const f = withParam('SoundFxPitch', 'semitones', [{ key: 'depth', kind: 'number' }]);
|
||||||
|
f.stack.execute(new SetPortDefaultCommand(f.fx.id, f.input.id, 'depth', 4));
|
||||||
|
expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number', default: 4 }]);
|
||||||
|
expect(fxChainOf(soundFxBuildFor(f.fx).compiled!).pitch).toBe(4);
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.input.ports).toEqual([{ key: 'depth', kind: 'number' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sound FX parameters and Play sound', () => {
|
||||||
|
function scene(options: { pick?: string[]; values?: Record<string, number>; other?: boolean } = {}) {
|
||||||
|
const f = makeSpriteScene('Box');
|
||||||
|
const withFx = makeFx(f);
|
||||||
|
const { fx, edit: fxEdit } = withFx;
|
||||||
|
const input = fxEdit.addNode('SoundFxInput');
|
||||||
|
input.ports = [
|
||||||
|
{ key: 'depth', kind: 'number', default: -12 },
|
||||||
|
{ key: 'loud', kind: 'number', default: 0 },
|
||||||
|
];
|
||||||
|
const pitch = fxEdit.addNode('SoundFxPitch');
|
||||||
|
const gain = fxEdit.addNode('SoundFxGain');
|
||||||
|
const output = fxEdit.addNode('SoundFxOutput');
|
||||||
|
fxEdit.connect(input, 'out', pitch, 'in');
|
||||||
|
fxEdit.connect(pitch, 'out', gain, 'in');
|
||||||
|
fxEdit.connect(gain, 'out', output, 'in');
|
||||||
|
fxEdit.connect(input, 'depth', pitch, 'semitones');
|
||||||
|
fxEdit.connect(input, 'loud', gain, 'gain');
|
||||||
|
const sound = new CreateSoundCommand('Jump', FILE);
|
||||||
|
f.stack.execute(sound);
|
||||||
|
const behavior = makeBehavior(f, 'Logic');
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 }, place(f, f.spriteId, { x: 0, y: 0 }));
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const start = edit.addNode('OnStart');
|
||||||
|
const play = edit.addNode('PlaySound', { sound: sound.asset.id, fx: fx.id });
|
||||||
|
const named = options.other ? 'another-fx' : fx.id;
|
||||||
|
const params = edit.addNode('SoundFxParameters', { soundFxAsset: named, ...options.values });
|
||||||
|
params.ports = (options.pick ?? []).map((key) => ({ key, kind: 'number' as const }));
|
||||||
|
edit.connect(start, 'exec', play, 'exec');
|
||||||
|
edit.connect(params, 'params', play, 'fxParams');
|
||||||
|
return { ...f, fx, input, behavior, play, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hands the picked values to the voice, the others keep their defaults', () => {
|
||||||
|
const f = scene({ pick: ['depth'], values: { depth: 7 } });
|
||||||
|
const audio = fakeAudio();
|
||||||
|
startWorld(f, { behaviors: true, audio }).frame();
|
||||||
|
expect(audio.played).toEqual([{ file: FILE, fx: { pitch: 7, stages: [] } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a picked parameter at the default of the FX', () => {
|
||||||
|
const f = scene({ pick: ['depth', 'loud'] });
|
||||||
|
const def = resolveDef(f.params, f.behavior.graph, graphLookup(f.project))!;
|
||||||
|
expect(def.inputs.map((p) => [p.key, p.default])).toEqual([
|
||||||
|
['depth', -12],
|
||||||
|
['loud', 0],
|
||||||
|
]);
|
||||||
|
const audio = fakeAudio();
|
||||||
|
startWorld(f, { behaviors: true, audio }).frame();
|
||||||
|
expect(audio.played[0]!.fx).toEqual({ pitch: -12, stages: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so at the node when no FX is picked or it is gone', () => {
|
||||||
|
const f = scene();
|
||||||
|
const lookup = graphLookup(f.project);
|
||||||
|
expect(callProblem(f.params, lookup)).toBeNull();
|
||||||
|
f.params.params.soundFxAsset = '';
|
||||||
|
expect(callProblem(f.params, lookup)).toBe('No sound FX picked');
|
||||||
|
f.params.params.soundFxAsset = 'gone';
|
||||||
|
expect(callProblem(f.params, lookup)).toBe('That sound FX is gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns at Play sound when the parameters are for another FX', () => {
|
||||||
|
expect(soundFxParamsMismatch(scene().play, scene().behavior.graph)).toBeNull();
|
||||||
|
const f = scene({ other: true });
|
||||||
|
expect(soundFxParamsMismatch(f.play, f.behavior.graph)).toBe('The parameters are for another sound FX');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follow a parameter renamed at the Input, edges and value included, and back on undo', () => {
|
||||||
|
const f = scene({ pick: ['depth'], values: { depth: 7 } });
|
||||||
|
f.stack.execute(new RenamePortCommand(f.fx.id, f.input.id, 'depth', 'drop'));
|
||||||
|
expect(f.params.ports).toEqual([{ key: 'drop', kind: 'number' }]);
|
||||||
|
expect(f.params.params.drop).toBe(7);
|
||||||
|
const audio = fakeAudio();
|
||||||
|
startWorld(f, { behaviors: true, audio }).frame();
|
||||||
|
expect(audio.played[0]!.fx).toEqual({ pitch: 7, stages: [] });
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.params.ports).toEqual([{ key: 'depth', kind: 'number' }]);
|
||||||
|
expect(f.params.params.depth).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-3
@@ -205,7 +205,14 @@ export type FakeContext = NodeContext & {
|
|||||||
stopped: boolean;
|
stopped: boolean;
|
||||||
changes: { machineId: string; from: string; to: string }[];
|
changes: { machineId: string; from: string; to: string }[];
|
||||||
/** What the context was asked to play and to silence, in order – nothing here makes a noise. */
|
/** What the context was asked to play and to silence, in order – nothing here makes a noise. */
|
||||||
played: { assetId: string; volume: number; loop: boolean | undefined; detached: boolean; fx: string }[];
|
played: {
|
||||||
|
assetId: string;
|
||||||
|
volume: number;
|
||||||
|
loop: boolean | undefined;
|
||||||
|
detached: boolean;
|
||||||
|
fx: string;
|
||||||
|
fxParams: Readonly<Record<string, number>>;
|
||||||
|
}[];
|
||||||
silenced: string[];
|
silenced: string[];
|
||||||
/** The scenes the context was asked to load, in order. */
|
/** The scenes the context was asked to load, in order. */
|
||||||
loaded: string[];
|
loaded: string[];
|
||||||
@@ -258,8 +265,8 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
|||||||
played: [],
|
played: [],
|
||||||
silenced: [],
|
silenced: [],
|
||||||
loaded: [],
|
loaded: [],
|
||||||
playSound: (assetId, volume, loop, detached, fx) =>
|
playSound: (assetId, volume, loop, detached, fx, fxParams) =>
|
||||||
ctx.played.push({ assetId, volume, loop, detached, fx }),
|
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams }),
|
||||||
stopSound: (assetId) => ctx.silenced.push(assetId),
|
stopSound: (assetId) => ctx.silenced.push(assetId),
|
||||||
loadScene: (sceneId) => ctx.loaded.push(sceneId),
|
loadScene: (sceneId) => ctx.loaded.push(sceneId),
|
||||||
spawn: () => null,
|
spawn: () => null,
|
||||||
|
|||||||
+40
-6
@@ -272,7 +272,9 @@ export async function sound(t) {
|
|||||||
|
|
||||||
// 34g-3. A sound FX asset (#311): "+ → Sound FX" opens its tab, which says what keeps the graph
|
// 34g-3. A sound FX asset (#311): "+ → Sound FX" opens its tab, which says what keeps the graph
|
||||||
// from compiling; built into Input → Pitch → Gain → Output, the header plays the sound
|
// from compiling; built into Input → Pitch → Gain → Output, the header plays the sound
|
||||||
// through it, and a `Play sound` naming it sends the game's voice the same way.
|
// through it, and a `Play sound` naming it sends the game's voice the same way. The pitch
|
||||||
|
// comes from a parameter `depth` of the Input: the header has a field for it, and a
|
||||||
|
// `Sound FX parameters` node hands the game's value in.
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
await clickText('+');
|
await clickText('+');
|
||||||
await pickAssetType(t, 'Sound FX');
|
await pickAssetType(t, 'Sound FX');
|
||||||
@@ -309,8 +311,8 @@ export async function sound(t) {
|
|||||||
fx.name = 'Low';
|
fx.name = 'Low';
|
||||||
fx.graph = ${JSON.stringify({
|
fx.graph = ${JSON.stringify({
|
||||||
nodes: [
|
nodes: [
|
||||||
node('fx-in', 'SoundFxInput', 0, 0),
|
node('fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'depth', kind: 'number', default: -12 }]),
|
||||||
node('fx-pitch', 'SoundFxPitch', 220, 0, { semitones: -12 }),
|
node('fx-pitch', 'SoundFxPitch', 220, 0),
|
||||||
node('fx-gain', 'SoundFxGain', 440, 0, { gain: -6 }),
|
node('fx-gain', 'SoundFxGain', 440, 0, { gain: -6 }),
|
||||||
node('fx-out', 'SoundFxOutput', 660, 0),
|
node('fx-out', 'SoundFxOutput', 660, 0),
|
||||||
],
|
],
|
||||||
@@ -318,12 +320,15 @@ export async function sound(t) {
|
|||||||
edge('fx-e1', 'fx-in', 'out', 'fx-pitch', 'in'),
|
edge('fx-e1', 'fx-in', 'out', 'fx-pitch', 'in'),
|
||||||
edge('fx-e2', 'fx-pitch', 'out', 'fx-gain', 'in'),
|
edge('fx-e2', 'fx-pitch', 'out', 'fx-gain', 'in'),
|
||||||
edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'),
|
edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'),
|
||||||
|
edge('fx-e4', 'fx-in', 'depth', 'fx-pitch', 'semitones'),
|
||||||
],
|
],
|
||||||
})};`);
|
})};`);
|
||||||
const fxBuilt = await json('{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview() }');
|
const fxBuilt = await json(
|
||||||
|
'{ info: __gm.soundfx.info(), preview: __gm.soundfx.preview(), params: __gm.soundfx.params() }',
|
||||||
|
);
|
||||||
check(
|
check(
|
||||||
'Input → Pitch → Gain → Output compiles to two effects, and Play is offered',
|
'Input → Pitch → Gain → Output compiles to two effects, Play is offered, depth starts at its default',
|
||||||
fxBuilt.info === '2 effects' && fxBuilt.preview.play === true,
|
fxBuilt.info === '2 effects' && fxBuilt.preview.play === true && fxBuilt.params.depth === -12,
|
||||||
JSON.stringify(fxBuilt),
|
JSON.stringify(fxBuilt),
|
||||||
);
|
);
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
@@ -337,6 +342,16 @@ export async function sound(t) {
|
|||||||
JSON.stringify(heardFx),
|
JSON.stringify(heardFx),
|
||||||
);
|
);
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
|
await evaluate('__gm.soundfx.setParam("depth", 5)');
|
||||||
|
const beforeParam = await stats();
|
||||||
|
await click(fxPlayAt.x, fxPlayAt.y);
|
||||||
|
const heardParam = await waitFor(stats, (s) => s.started > beforeParam.started);
|
||||||
|
check(
|
||||||
|
'the header plays with the value its depth field is set to',
|
||||||
|
heardParam.last?.pitch === 5 && heardParam.last.stages === 1,
|
||||||
|
JSON.stringify(heardParam),
|
||||||
|
);
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||||
@@ -352,6 +367,25 @@ export async function sound(t) {
|
|||||||
await stop();
|
await stop();
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||||
|
graph.nodes.push(${JSON.stringify(
|
||||||
|
node('sn-fxp', 'SoundFxParameters', 0, 120, { soundFxAsset: fxId, depth: 7 }, [
|
||||||
|
{ key: 'depth', kind: 'number' },
|
||||||
|
]),
|
||||||
|
)});
|
||||||
|
graph.edges.push(${JSON.stringify(edge('sn-e-fxp', 'sn-fxp', 'params', 'sn-play', 'fxParams'))});`);
|
||||||
|
const beforeGameParam = await stats();
|
||||||
|
await play();
|
||||||
|
const gameParam = await waitFor(stats, (s) => s.started > beforeGameParam.started);
|
||||||
|
check(
|
||||||
|
'a Sound FX parameters node hands depth 7 to the game’s voice',
|
||||||
|
gameParam.last?.pitch === 7 && gameParam.last.stages === 1,
|
||||||
|
JSON.stringify(gameParam),
|
||||||
|
);
|
||||||
|
await stop();
|
||||||
|
await mutate(`
|
||||||
|
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||||
|
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-fxp');
|
||||||
|
graph.edges = graph.edges.filter((e) => e.id !== 'sn-e-fxp');
|
||||||
Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: '', fx: '' });
|
Object.assign(graph.nodes.find((n) => n.id === 'sn-play').params, { loop: '', fx: '' });
|
||||||
const project = __gm.state.project;
|
const project = __gm.state.project;
|
||||||
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(fxId)});`);
|
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(fxId)});`);
|
||||||
|
|||||||
Reference in new issue
Block a user