game-maker / suite (push) Failing after 11m6s
Load scene sets a flag on the World like Stop and Restart do; the swap happens after the tick, and the last wish of a tick wins. Exactly one thing crosses over: the SharedState, so global variables and global machines keep what they know while everything local is built anew. Loading the scene that is already running rebuilds it, which is what "reset the level" means when the score must survive. A run starts either from the start scene (a new project field, set from the asset panel and marked with an arrow in the list) or from whatever scene is open. The toggle next to Play says which question is asked and remembers the answer in localStorage: it is a way of working, not part of the game. Sounds no longer end because a scene ends. A voice belongs to the behavior that started it and stops right after On destroy, so the scene swap is no longer a special case at all. For the enemy that plays its explosion and destroys itself in the same breath there is now a detached input on Play sound. The scene selector stays usable while a run plays, because a double click in the asset panel already did the same thing unguarded. It moves what the editor looks at, not what the run plays.
190 lines
7.5 KiB
TypeScript
190 lines
7.5 KiB
TypeScript
import type { AudioSink } from '../runtime/systems/Audio';
|
||
|
||
const DEFAULT_BASE = '/game-maker/data/sounds';
|
||
|
||
/** One sound in flight: its source, its own gain, the file it came from and who is making it. */
|
||
interface Voice {
|
||
file: string;
|
||
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
|
||
owner: string;
|
||
source: AudioBufferSourceNode;
|
||
gain: GainNode;
|
||
}
|
||
|
||
/**
|
||
* Plays uploaded sounds, keyed by file name – the texture library's twin on the audio side. Files
|
||
* are content addressed, so a decoded buffer can never go stale and nothing is evicted.
|
||
*
|
||
* Everything here is asynchronous on the way in and synchronous on the way out: `play` returns at
|
||
* once and starts the voice when the bytes have been fetched and decoded. A sound fired in the
|
||
* first frame of a run is therefore heard a moment later rather than not at all, and a graph never
|
||
* waits on the network.
|
||
*
|
||
* The context is created on the first play, not at start-up: a browser refuses one before the page
|
||
* has been interacted with, and an editor that was only ever looked at should not be holding an
|
||
* audio device open.
|
||
*/
|
||
export class SoundLibrary implements AudioSink {
|
||
private base = DEFAULT_BASE;
|
||
private context: AudioContext | null = null;
|
||
private readonly buffers = new Map<string, AudioBuffer>();
|
||
private readonly loading = new Map<string, Promise<AudioBuffer | null>>();
|
||
/** Never retried, so one broken reference cannot spam the network. */
|
||
private readonly failed = new Set<string>();
|
||
private voices: Voice[] = [];
|
||
private started = 0;
|
||
private stopped = 0;
|
||
/** The last voice actually started, with the values it was started at – what a check reads back. */
|
||
private last: { file: string; volume: number; loop: boolean } | null = null;
|
||
/**
|
||
* How often a stop has reached each file, and how often one has silenced everything. Counters
|
||
* rather than a "cancelled" flag: what a play has to know is whether a stop happened *after it
|
||
* was asked for*, and a flag cannot tell that from one that happened long before – which would
|
||
* make the first play of a sound disappear whenever anything had ever stopped it.
|
||
*/
|
||
private readonly stops = new Map<string, number>();
|
||
private allStops = 0;
|
||
/** The same counter per owner, for the same reason – a behavior destroyed while bytes were in
|
||
* flight must not be given its voice afterwards. */
|
||
private readonly ownerStops = new Map<string, number>();
|
||
|
||
/** Where the files are served from. Set once at start-up, before anything plays. */
|
||
configure(base: string): void {
|
||
this.base = base.replace(/\/+$/, '');
|
||
}
|
||
|
||
play(file: string, volume: number, loop: boolean, owner = ''): void {
|
||
if (file === '' || this.failed.has(file)) return;
|
||
const buffer = this.buffers.get(file);
|
||
if (buffer) {
|
||
this.start(file, buffer, volume, loop, owner);
|
||
return;
|
||
}
|
||
const askedAt = this.cancelsFor(file, owner);
|
||
void this.load(file).then((loaded) => {
|
||
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
|
||
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
|
||
if (loaded && this.cancelsFor(file, owner) === askedAt) this.start(file, loaded, volume, loop, owner);
|
||
});
|
||
}
|
||
|
||
stop(file: string): void {
|
||
this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file));
|
||
if (file === '') this.allStops += 1;
|
||
else this.stops.set(file, (this.stops.get(file) ?? 0) + 1);
|
||
}
|
||
|
||
/**
|
||
* Every voice of one behavior. An empty owner is deliberately no one: those are the voices that
|
||
* were started to outlive their behavior, so "stop the owner" must not reach them.
|
||
*/
|
||
stopOwner(owner: string): void {
|
||
if (owner === '') return;
|
||
this.silence(this.voices.filter((voice) => voice.owner === owner));
|
||
this.ownerStops.set(owner, (this.ownerStops.get(owner) ?? 0) + 1);
|
||
}
|
||
|
||
/** Every voice at once: what ending a run does, so nothing outlives the world that started it. */
|
||
stopAll(): void {
|
||
this.stop('');
|
||
}
|
||
|
||
/** A pause holds the sounds where they are; resuming carries them on mid-note. */
|
||
setSuspended(suspended: boolean): void {
|
||
const context = this.context;
|
||
if (!context) return;
|
||
if (suspended) void context.suspend();
|
||
else void context.resume();
|
||
}
|
||
|
||
/** What has been played and what is playing – the only witness a muted check script has. */
|
||
stats(): {
|
||
playing: number;
|
||
started: number;
|
||
stopped: number;
|
||
decoded: number;
|
||
failed: number;
|
||
last: { file: string; volume: number; loop: boolean } | null;
|
||
} {
|
||
return {
|
||
playing: this.voices.length,
|
||
started: this.started,
|
||
stopped: this.stopped,
|
||
decoded: this.buffers.size,
|
||
failed: this.failed.size,
|
||
last: this.last,
|
||
};
|
||
}
|
||
|
||
/** How long a decoded sound runs, in seconds; null while it has not been loaded (test hook). */
|
||
duration(file: string): number | null {
|
||
return this.buffers.get(file)?.duration ?? null;
|
||
}
|
||
|
||
/** Fetches and decodes once per file; a second play while it is in flight waits on the first. */
|
||
async load(file: string): Promise<AudioBuffer | null> {
|
||
const held = this.buffers.get(file);
|
||
if (held) return held;
|
||
if (this.failed.has(file)) return null;
|
||
const running = this.loading.get(file);
|
||
if (running) return running;
|
||
const work = (async () => {
|
||
try {
|
||
const response = await fetch(`${this.base}/${file}`);
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const bytes = await response.arrayBuffer();
|
||
const buffer = await this.audio().decodeAudioData(bytes);
|
||
this.buffers.set(file, buffer);
|
||
return buffer;
|
||
} catch (error: unknown) {
|
||
this.failed.add(file);
|
||
console.error(`[sounds] cannot load ${file}`, error);
|
||
return null;
|
||
} finally {
|
||
this.loading.delete(file);
|
||
}
|
||
})();
|
||
this.loading.set(file, work);
|
||
return work;
|
||
}
|
||
|
||
/** Ends these voices now: `onended` only fires next frame and the counts have to be right here. */
|
||
private silence(going: Voice[]): void {
|
||
for (const voice of going) voice.source.stop();
|
||
this.voices = this.voices.filter((voice) => !going.includes(voice));
|
||
this.stopped += going.length;
|
||
}
|
||
|
||
private start(file: string, buffer: AudioBuffer, volume: number, loop: boolean, owner: string): void {
|
||
const context = this.audio();
|
||
// A context starts suspended where the page has not been interacted with; without this the
|
||
// voice would run silently and end as if it had played.
|
||
if (context.state === 'suspended') void context.resume();
|
||
const source = context.createBufferSource();
|
||
source.buffer = buffer;
|
||
source.loop = loop;
|
||
const gain = context.createGain();
|
||
gain.gain.value = Math.max(0, Math.min(1, volume));
|
||
this.last = { file, volume: gain.gain.value, loop };
|
||
source.connect(gain).connect(context.destination);
|
||
const voice: Voice = { file, owner, source, gain };
|
||
source.onended = () => {
|
||
this.voices = this.voices.filter((other) => other !== voice);
|
||
};
|
||
this.voices.push(voice);
|
||
this.started += 1;
|
||
source.start();
|
||
}
|
||
|
||
private audio(): AudioContext {
|
||
return (this.context ??= new AudioContext());
|
||
}
|
||
|
||
/** Everything that would have silenced this file for this owner, counted rather than flagged. */
|
||
private cancelsFor(file: string, owner: string): number {
|
||
return (this.stops.get(file) ?? 0) + this.allStops + (this.ownerStops.get(owner) ?? 0);
|
||
}
|
||
}
|
||
|
||
export const soundLibrary = new SoundLibrary();
|