A game that stays silent is only half a game, so a sound is an asset like any other (#45)
game-maker / suite (push) Successful in 12m1s

Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
This commit is contained in:
admin committed 2026-09-15 07:54:28 +02:00
1 parent 7babf88636
commit 5671a38044
47 files changed
+2066 -42

No files matched your search

+10
View File
@@ -119,6 +119,16 @@ export interface NodeContext {
*/
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
log(message: string): void;
/**
* Starts a sound asset, named by its id. `volume` multiplies the asset's own (1 leaves it alone);
* `loop` is the caller's answer to whether it repeats, and `undefined` leaves that to the asset,
* because the asset says what a sound *is* and the node only colours it. Every call is a voice of
* its own: two plays overlap, whether they loop or not, which is what makes one sound effect
* fired twice in a row audible twice.
*/
playSound(assetId: string, volume: number, loop: boolean | undefined): void;
/** Silences every voice of that sound; `''` silences everything this run started. */
stopSound(assetId: string): void;
/**
* The position is local to `parent` (world when none); null when the asset does not exist or
* cannot be placed. A spawned behavior runs its `On start` on the next tick.
+54
View File
@@ -0,0 +1,54 @@
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asNumber, asString, type Value } from '../../values';
import { exec, num, soundPort, withDefault } from '../shared';
/**
* Three answers, not two: a checkbox cannot say "leave it to the asset", and without that a sound
* whose own `Loop` is on could never be played as a one-shot.
*/
const LOOP_OPTIONS = [
{ value: '', label: 'From the asset' },
{ value: 'true', label: 'On' },
{ value: 'false', label: 'Off' },
];
/** `any`, not `boolean`, so a graph can still drive the choice – see `loopChoice`. */
const loopPort = (): PortDef => ({
key: 'loop',
label: 'loop',
type: 'any',
default: '',
field: { key: 'loop', label: 'Loop', kind: 'enum', default: '', options: LOOP_OPTIONS },
});
/**
* What the node says about looping: nothing (the asset decides) or an answer that overrules it.
* A boolean arriving over an edge is taken literally, which is what wiring a comparison or a
* variable into this input is for.
*/
export function loopChoice(value: Value | undefined): boolean | undefined {
if (typeof value === 'boolean') return value;
const text = asString(value);
if (text === 'true') return true;
if (text === 'false') return false;
return undefined;
}
export const PlaySound: NodeDef = {
type: 'PlaySound',
label: 'Play sound',
category: 'action',
description:
'Plays a sound asset. "volume" multiplies the volume the asset itself carries and "loop" either ' +
"leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " +
'the node only colours it. Each play is a voice of its own: firing this twice in a row is heard ' +
'twice, overlapping.',
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort()],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
const sound = asString(inputs.sound);
if (sound) ctx.playSound(sound, asNumber(inputs.volume), loopChoice(inputs.loop));
return { next: 'exec' };
},
};
+19
View File
@@ -0,0 +1,19 @@
import type { NodeDef } from '../../NodeRegistry';
import { asString } from '../../values';
import { exec, soundPort } from '../shared';
export const StopSound: NodeDef = {
type: 'StopSound',
label: 'Stop sound',
category: 'action',
description:
'Silences every voice of a sound, however many plays started it – including a looping one. ' +
'With no sound picked it silences everything the run is playing.',
inputs: [exec(), soundPort()],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
ctx.stopSound(asString(inputs.sound));
return { next: 'exec' };
},
};
+5
View File
@@ -35,6 +35,11 @@ export const StateMachineLiteral = literal(
'statemachine',
'A constant state machine, for a "State branch" or a "Set state" to work on.',
);
export const SoundLiteral = literal(
'Sound',
'sound',
'A constant sound, for a "Play sound" or a "Stop sound" to work on.',
);
export const ListLiteral = literal(
'List',
'list',
+6
View File
@@ -3,10 +3,12 @@ import { Destroy } from './action/Destroy';
import { Emit } from './action/Emit';
import { Log } from './action/Log';
import { Move } from './action/Move';
import { PlaySound } from './action/PlaySound';
import { RestartGame } from './action/RestartGame';
import { SetPosition } from './action/SetPosition';
import { Spawn } from './action/Spawn';
import { StopGame } from './action/StopGame';
import { StopSound } from './action/StopSound';
import { Abs } from './data/Abs';
import { Add } from './data/Add';
import { And } from './data/And';
@@ -32,6 +34,7 @@ import {
BooleanLiteral,
ListLiteral,
NumberLiteral,
SoundLiteral,
StateMachineLiteral,
StringLiteral,
TextureLiteral,
@@ -146,6 +149,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
StringLiteral,
TextureLiteral,
StateMachineLiteral,
SoundLiteral,
Add,
Subtract,
Multiply,
@@ -195,6 +199,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
Spawn,
Destroy,
Emit,
PlaySound,
StopSound,
Log,
StopGame,
RestartGame,
+11
View File
@@ -36,6 +36,17 @@ export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', de
/** No `assetTypes`: any placeable asset is a valid answer here. */
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
/**
* The `sound` input of the two sound nodes; unconnected, it shows the sound picker – the way
* `machinePort` shows the machine picker. A `Sound` literal is what feeds it through an edge.
*/
export const soundPort = (): PortDef => ({
key: 'sound',
label: 'sound',
type: 'string',
field: { key: 'sound', label: 'Sound', kind: 'sound', default: '' },
});
export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef {
return {
type,