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
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:
1 parent
7babf88636
commit
5671a38044
47 files changed
+2066
-42
No files matched your search
@@ -19,7 +19,11 @@ import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { soundFileOf } from '../../core/model/sound';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
||||
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import { bodyOf, type CollisionPair, CollisionSystem } from './CollisionSystem';
|
||||
@@ -80,10 +84,11 @@ export function addGameSystems(
|
||||
project: Project,
|
||||
input: Input,
|
||||
shouldRun?: (entity: Entity) => boolean,
|
||||
audio: AudioSink = SILENT_AUDIO,
|
||||
): BehaviorSystem {
|
||||
const collisions = new CollisionSystem();
|
||||
const animations = new AnimationSystem();
|
||||
const behaviors = new BehaviorSystem(project, input, { collisions, animations }, shouldRun);
|
||||
const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio }, shouldRun);
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(animations);
|
||||
world.addSystem(behaviors);
|
||||
@@ -94,6 +99,8 @@ export function addGameSystems(
|
||||
export interface EventSources {
|
||||
collisions?: CollisionSystem | null;
|
||||
animations?: AnimationSystem | null;
|
||||
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
|
||||
audio?: AudioSink | null;
|
||||
}
|
||||
|
||||
/** The behavior failure that stopped a run, if that is why it stopped. */
|
||||
@@ -111,6 +118,7 @@ export class BehaviorSystem implements System {
|
||||
private declared: Record<string, PropertyBag> = {};
|
||||
private readonly collisions: CollisionSystem | null;
|
||||
private readonly animations: AnimationSystem | null;
|
||||
private readonly audio: AudioSink;
|
||||
|
||||
constructor(
|
||||
private readonly project: Project,
|
||||
@@ -124,6 +132,7 @@ export class BehaviorSystem implements System {
|
||||
) {
|
||||
this.collisions = sources.collisions ?? null;
|
||||
this.animations = sources.animations ?? null;
|
||||
this.audio = sources.audio ?? SILENT_AUDIO;
|
||||
}
|
||||
|
||||
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
|
||||
@@ -440,6 +449,19 @@ export class BehaviorSystem implements System {
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* *is* – a node that says nothing therefore plays it as the project describes it.
|
||||
*/
|
||||
private playSound(assetId: string, volume: number, loop: boolean | undefined): void {
|
||||
const asset = findAssetOf(this.project, assetId, 'sound');
|
||||
if (!asset?.file) return;
|
||||
const props = resolveAssetProps(this.project, asset);
|
||||
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
|
||||
this.audio.play(asset.file, level, loop ?? loopsOf(props));
|
||||
}
|
||||
|
||||
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */
|
||||
private run(
|
||||
instance: Instance,
|
||||
@@ -499,6 +521,13 @@ export class BehaviorSystem implements System {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[behavior ${entity.name}]`, message);
|
||||
},
|
||||
playSound: (assetId, volume, loop) => this.playSound(assetId, volume, loop),
|
||||
// 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.
|
||||
stopSound: (assetId) => {
|
||||
const file = assetId === '' ? '' : soundFileOf(this.project, assetId);
|
||||
if (assetId === '' || file) this.audio.stop(file);
|
||||
},
|
||||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||||
destroy: (target) => {
|
||||
const found = resolve(target);
|
||||
|
||||
Reference in new issue
Block a user