diff --git a/docs/07-ASSET-MANIFEST.md b/docs/07-ASSET-MANIFEST.md index cbeaa34..e75334f 100644 --- a/docs/07-ASSET-MANIFEST.md +++ b/docs/07-ASSET-MANIFEST.md @@ -61,9 +61,11 @@ Frame lists approved as they are (A14). | Hidden Garden (prologue) | 1 chunk `hg-0-0` ([art/stages/hidden-garden](../art/stages/hidden-garden/README.md)): bench, path, jetty, the wrong reflection; layers 03/04/05/07. A second chunk waits for seam repair | ๐ŸŸก in review ยท โฌœ collision | | Jenny's arena | right chunk of Rusty Creek (boss clearing) | โœ… art ยท โฌœ collision | -## Audio (to source during the jam) +## Audio (synthesized in code) -Footsteps (girder, grass), chair whoosh + hit, rover motor, crab servos, drone hum, Jenny engine + crash, fluid absorb, crash-dialog "ding", one ambient loop per region, one boss loop. Use CC0 sources only and record every source in `NOTICE.md`. +Footsteps (girder, grass), chair whoosh + hit, rover motor, crab servos, drone hum, Jenny engine + crash, fluid absorb, crash-dialog "ding", one ambient loop per region, one boss loop. + +Every sound is **synthesized at runtime with Web Audio** โ€“ no audio files, so nothing to license and nothing that breaks decision 14 (no audio in Git). Recipes are plain data in `game/src/fx/sound/recipes.ts` (effects) and `loops.ts` (Hidden Garden, Rusty Creek, Jenny); the `Audio` scene plays them. Game code calls `sound.play('chair-whoosh')` / `sound.loop('boss-jenny')`; hits, defeats, the boss and region turns are wired to the game events, and the anomaly state plays the region loop slightly flat. M mutes. [โ† 06 โ€“ Visual direction](06-VISUAL-DIRECTION.md) ยท [Design docs](README.md) ยท **7 / 14** ยท [08 โ€“ Tech stack and where things live โ†’](08-TECH-STACK.md) diff --git a/game/src/fx/sound/Audio.ts b/game/src/fx/sound/Audio.ts new file mode 100644 index 0000000..706a924 --- /dev/null +++ b/game/src/fx/sound/Audio.ts @@ -0,0 +1,123 @@ +import Phaser from 'phaser'; +import { gameEvents } from '../../core/events'; +import { LOOPS, LoopId, LoopState, loopVoices, regionLoop } from './loops'; +import { RECIPES, SoundId } from './recipes'; +import { noiseBuffer, playVoices } from './synth'; + +const MASTER = 0.6; +/** Bars are scheduled this far ahead of the audio clock, so a slow frame never gaps the loop. */ +const LOOKAHEAD = 0.5; + +let active: Audio | undefined; + +/** + * Game code plays effects through this, e.g. `sound.play('chair-whoosh')` (lane B: footsteps, swings, motors). + * Calls before the first input โ€“ when browsers keep audio locked โ€“ are silently dropped. + */ +export const sound = { + play(id: SoundId): void { active?.play(id); }, + loop(id: LoopId | null): void { active?.setLoop(id); }, + /** For debugging: whether audio is unlocked, which loop plays and how many bars it has scheduled. */ + state(): { context: AudioContextState | 'none'; loop: LoopId | null; bars: number } { + return { context: active?.ctx?.state ?? 'none', loop: active?.loopId ?? null, bars: active?.bar ?? 0 }; + }, +}; + +/** + * The sound of the slice (#71): synthesized effects and loops, no audio files. Listens to the game events + * for hits, defeats, the boss and region turns; M mutes. + */ +export class Audio extends Phaser.Scene { + ctx?: AudioContext; + private master?: GainNode; + private loopBus?: GainNode; + private bed?: { source: AudioBufferSourceNode; gain: GainNode }; + loopId: LoopId | null = null; + private loopState: LoopState = 'clean'; + bar = 0; + private nextBarAt = 0; + + constructor() { super({ key: 'Audio', active: true }); } + + create(): void { + const manager = this.sound as Phaser.Sound.WebAudioSoundManager; + if (!('context' in manager)) return; // no Web Audio: the game stays silent rather than failing + this.ctx = manager.context; + this.master = this.ctx.createGain(); + this.master.gain.value = MASTER; + this.master.connect(manager.destination); + this.loopBus = this.ctx.createGain(); + this.loopBus.connect(this.master); + active = this; + + const offs = [ + gameEvents.on('player-hit', () => this.play('player-hurt')), + gameEvents.on('enemy-defeated', () => this.play('enemy-defeated')), + gameEvents.on('boss-defeated', () => { this.play('jenny-crash'); this.setLoop(null); }), + gameEvents.on('region-turned', ({ region, state }) => { this.loopState = state; this.setLoop(regionLoop(region)); }), + ]; + this.input.keyboard?.on('keydown-M', () => manager.setMute(!manager.mute)); + // Dev builds: F6 steps through the loops, F7 through the effects โ€“ for listening with headphones (#71). + if (import.meta.env.DEV) { + const effects = Object.keys(RECIPES) as SoundId[]; + const loops: (LoopId | null)[] = [...(Object.keys(LOOPS) as LoopId[]), null]; + let effect = 0; + let loop = 0; + this.input.keyboard?.on('keydown-F7', () => { + const id = effects[effect++ % effects.length]; + console.info(`[sound] ${id}`); + this.play(id); + }); + this.input.keyboard?.on('keydown-F6', () => { + const id = loops[loop++ % loops.length]; + console.info(`[sound] loop ${id ?? 'off'}`); + this.setLoop(id); + }); + } + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + offs.forEach((off) => off()); + this.setLoop(null); + if (active === this) active = undefined; + }); + } + + play(id: SoundId): void { + if (this.ctx && this.master && this.ctx.state === 'running') playVoices(this.ctx, this.master, RECIPES[id]); + } + + /** Switches the background loop; null stops it. The same loop keeps playing (only its state may change). */ + setLoop(id: LoopId | null): void { + if (id === this.loopId) return; + this.bed?.source.stop(); + this.bed = undefined; + this.loopId = id; + this.bar = 0; + this.nextBarAt = 0; + const loop = id && LOOPS[id]; + if (!loop?.bed || !this.ctx || !this.loopBus) return; + const source = this.ctx.createBufferSource(); + source.buffer = noiseBuffer(this.ctx); + source.loop = true; + const filter = this.ctx.createBiquadFilter(); + filter.type = loop.bed.type; + filter.frequency.value = loop.bed.freq; + filter.Q.value = loop.bed.q ?? 1; + const gain = this.ctx.createGain(); + gain.gain.value = loop.bed.gain; + source.connect(filter).connect(gain).connect(this.loopBus); + source.start(); + this.bed = { source, gain }; + } + + update(): void { + const ctx = this.ctx; + if (!ctx || !this.loopBus || !this.loopId || ctx.state !== 'running') return; + const loop = LOOPS[this.loopId]; + if (this.nextBarAt < ctx.currentTime) this.nextBarAt = ctx.currentTime + 0.05; + while (this.nextBarAt < ctx.currentTime + LOOKAHEAD) { + playVoices(ctx, this.loopBus, loopVoices(this.loopId, this.bar, this.loopState), this.nextBarAt); + this.bar++; + this.nextBarAt += loop.bar; + } + } +} diff --git a/game/src/fx/sound/loops.ts b/game/src/fx/sound/loops.ts new file mode 100644 index 0000000..2ae5dfe --- /dev/null +++ b/game/src/fx/sound/loops.ts @@ -0,0 +1,85 @@ +import { seeded } from '../anomalyLayout'; +import { Voice } from './recipes'; + +/** + * One ambient loop per region and one boss loop (docs/07), as bars of synthesized voices over a steady + * noise bed. Bars are deterministic per index, so a loop sounds the same every time it plays. + */ + +export type LoopId = 'hidden-garden' | 'rusty-creek' | 'boss-jenny'; +export type LoopState = 'clean' | 'anomaly'; + +export interface Loop { + /** Seconds per bar. */ + bar: number; + /** A filtered noise bed that runs under the whole loop: wind, water. */ + bed?: { type: BiquadFilterType; freq: number; q?: number; gain: number }; + /** The voices of bar `index`; `start` is seconds into the bar. */ + voices(index: number): Voice[]; +} + +const chirp = (start: number, pitch: number): Voice => + ({ wave: 'sine', from: pitch, to: pitch * 1.6, start, dur: 0.09, gain: 0.05 }); + +export const LOOPS: Record = { + // Wind in the garden, a soft pad and a few birds. + 'hidden-garden': { + bar: 4, + bed: { type: 'lowpass', freq: 900, gain: 0.05 }, + voices(index) { + const rand = seeded(1000 + index); + const birds = Array.from({ length: 2 + Math.floor(rand() * 2) }, () => chirp(rand() * 3.6, 1900 + rand() * 900)); + const root = [196, 174.6, 220, 164.8][index % 4]; + return [ + { wave: 'sine', from: root, start: 0, dur: 4, attack: 1.2, gain: 0.05 }, + { wave: 'sine', from: root * 1.5, start: 0, dur: 4, attack: 1.6, gain: 0.03 }, + ...birds, + ]; + }, + }, + // Running water, a low hum from the steel, now and then a girder ticking. + 'rusty-creek': { + bar: 4, + bed: { type: 'bandpass', freq: 700, q: 0.8, gain: 0.07 }, + voices(index) { + const rand = seeded(2000 + index); + const ticks: Voice[] = rand() < 0.5 ? [{ + wave: 'noise', start: rand() * 3.5, dur: 0.05, gain: 0.06, filter: { type: 'bandpass', from: 3000, q: 8 }, + }] : []; + return [{ wave: 'triangle', from: 65.4, start: 0, dur: 4, attack: 1, gain: 0.08 }, ...ticks]; + }, + }, + // Jenny: 140 bpm, kick on 1 and 3, hats on the off-beats, a saw bass riff. + 'boss-jenny': { + bar: (60 / 140) * 4, + voices(index) { + const beat = 60 / 140; + const riff = index % 2 === 0 ? [55, 55, 65.4, 49] : [55, 55, 73.4, 61.7]; + const out: Voice[] = []; + for (let b = 0; b < 4; b++) { + if (b % 2 === 0) out.push({ wave: 'sine', from: 120, to: 40, start: b * beat, dur: 0.15, gain: 0.4 }); + out.push({ wave: 'noise', start: b * beat + beat / 2, dur: 0.04, gain: 0.07, filter: { type: 'highpass', from: 6000 } }); + out.push({ wave: 'sawtooth', from: riff[b], start: b * beat, dur: beat * 0.9, gain: 0.16, filter: { type: 'lowpass', from: 700 } }); + } + return out; + }, + }, +}; + +/** The anomaly plays the same loop slightly flat โ€“ the place sounds wrong without changing tune. */ +export const ANOMALY_DETUNE = 0.94; + +export function loopVoices(id: LoopId, index: number, state: LoopState = 'clean'): Voice[] { + const voices = LOOPS[id].voices(index); + if (state === 'clean') return voices; + return voices.map((v) => ({ + ...v, + from: v.from && v.from * ANOMALY_DETUNE, + to: v.to && v.to * ANOMALY_DETUNE, + })); +} + +/** Which ambient loop a region plays; regions without their own loop fall back to the creek. */ +export function regionLoop(region: string): LoopId { + return region === 'hidden-garden' ? 'hidden-garden' : 'rusty-creek'; +} diff --git a/game/src/fx/sound/recipes.ts b/game/src/fx/sound/recipes.ts new file mode 100644 index 0000000..329a4fe --- /dev/null +++ b/game/src/fx/sound/recipes.ts @@ -0,0 +1,92 @@ +/** + * Every sound in the slice, synthesized: no audio files (decision 14, docs/07). A recipe is a few voices โ€“ + * an oscillator or noise with a pitch glide, an envelope and an optional filter sweep โ€“ kept as plain data + * so the list and the loudness are tested without Web Audio. + */ + +export type Wave = 'sine' | 'square' | 'sawtooth' | 'triangle' | 'noise'; + +export interface Voice { + wave: Wave; + /** Pitch glide in Hz (ignored for noise). */ + from?: number; + to?: number; + /** Seconds after the sound starts. */ + start?: number; + dur: number; + /** Attack in seconds; the rest of `dur` decays to silence. */ + attack?: number; + /** Peak gain, 0โ€“1. */ + gain: number; + filter?: { type: BiquadFilterType; from: number; to?: number; q?: number }; +} + +export type SoundId = + | 'footstep-girder' | 'footstep-grass' + | 'chair-whoosh' | 'chair-hit' + | 'rover-motor' | 'crab-servo' | 'drone-hum' + | 'jenny-engine' | 'jenny-crash' + | 'fluid-absorb' | 'crash-ding' + | 'player-hurt' | 'enemy-defeated'; + +export const RECIPES: Record = { + 'footstep-girder': [ + { wave: 'noise', dur: 0.06, gain: 0.45, filter: { type: 'bandpass', from: 2600, q: 3 } }, + { wave: 'square', from: 180, to: 120, dur: 0.04, gain: 0.08 }, + ], + 'footstep-grass': [ + { wave: 'noise', dur: 0.09, attack: 0.01, gain: 0.16, filter: { type: 'highpass', from: 3200, to: 1600 } }, + ], + 'chair-whoosh': [ + { wave: 'noise', dur: 0.22, attack: 0.08, gain: 0.7, filter: { type: 'bandpass', from: 600, to: 2400, q: 1 } }, + ], + 'chair-hit': [ + { wave: 'square', from: 220, to: 60, dur: 0.12, gain: 0.3 }, + { wave: 'noise', dur: 0.08, gain: 0.3, filter: { type: 'lowpass', from: 1400, to: 400 } }, + ], + 'rover-motor': [ + { wave: 'sawtooth', from: 70, to: 92, dur: 0.5, attack: 0.05, gain: 0.18, filter: { type: 'lowpass', from: 420 } }, + ], + 'crab-servo': [ + { wave: 'square', from: 420, to: 900, dur: 0.12, gain: 0.3, filter: { type: 'bandpass', from: 1200, q: 1.5 } }, + { wave: 'square', from: 900, to: 520, start: 0.13, dur: 0.1, gain: 0.3, filter: { type: 'bandpass', from: 1200, q: 1.5 } }, + ], + 'drone-hum': [ + { wave: 'sine', from: 220, dur: 0.8, attack: 0.15, gain: 0.12 }, + { wave: 'sine', from: 223, dur: 0.8, attack: 0.15, gain: 0.12 }, + ], + 'jenny-engine': [ + { wave: 'sawtooth', from: 55, to: 140, dur: 0.9, attack: 0.05, gain: 0.3, filter: { type: 'lowpass', from: 300, to: 1200 } }, + ], + 'jenny-crash': [ + { wave: 'noise', dur: 1.2, gain: 0.5, filter: { type: 'lowpass', from: 3000, to: 200 } }, + { wave: 'square', from: 90, to: 30, dur: 0.4, gain: 0.3 }, + ], + 'fluid-absorb': [ + { wave: 'sine', from: 320, to: 80, dur: 0.6, attack: 0.05, gain: 0.3 }, + { wave: 'noise', dur: 0.5, attack: 0.1, gain: 0.12, filter: { type: 'lowpass', from: 500, to: 200 } }, + ], + // The old desktop's error chime: two soft bells a beat apart. + 'crash-ding': [ + { wave: 'sine', from: 880, dur: 0.9, gain: 0.22 }, + { wave: 'sine', from: 1318.5, start: 0.07, dur: 0.8, gain: 0.18 }, + { wave: 'triangle', from: 1760, dur: 0.25, gain: 0.05 }, + ], + 'player-hurt': [ + { wave: 'square', from: 300, to: 120, dur: 0.15, gain: 0.25 }, + ], + 'enemy-defeated': [ + { wave: 'square', from: 600, to: 150, dur: 0.18, gain: 0.2 }, + { wave: 'noise', dur: 0.1, gain: 0.18, filter: { type: 'lowpass', from: 2000 } }, + ], +}; + +/** The loudest a recipe can get: every voice's peak at once. Kept under 0.9 so nothing clips. */ +export function peakGain(voices: Voice[]): number { + return voices.reduce((sum, v) => sum + v.gain, 0); +} + +/** How long a recipe rings, in seconds. */ +export function length(voices: Voice[]): number { + return Math.max(...voices.map((v) => (v.start ?? 0) + v.dur)); +} diff --git a/game/src/fx/sound/sound.test.ts b/game/src/fx/sound/sound.test.ts new file mode 100644 index 0000000..c8ff3eb --- /dev/null +++ b/game/src/fx/sound/sound.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import manifest from '../../../../docs/07-ASSET-MANIFEST.md?raw'; +import { LOOPS, loopVoices, regionLoop, ANOMALY_DETUNE } from './loops'; +import { length, peakGain, RECIPES, SoundId } from './recipes'; + +/** docs/07's audio list, mapped to the recipes that cover it. */ +const MANIFEST: [string, SoundId[]][] = [ + ['Footsteps (girder, grass)', ['footstep-girder', 'footstep-grass']], + ['chair whoosh + hit', ['chair-whoosh', 'chair-hit']], + ['rover motor', ['rover-motor']], + ['crab servos', ['crab-servo']], + ['drone hum', ['drone-hum']], + ['Jenny engine + crash', ['jenny-engine', 'jenny-crash']], + ['fluid absorb', ['fluid-absorb']], + ['ding', ['crash-ding']], +]; + +describe('synthesized sound', () => { + it('covers every effect docs/07 lists', () => { + for (const [phrase, ids] of MANIFEST) { + expect(manifest).toContain(phrase); + for (const id of ids) expect(RECIPES[id]).toBeDefined(); + } + }); + + it('keeps every effect short and under the clipping limit', () => { + for (const [id, voices] of Object.entries(RECIPES)) { + expect(peakGain(voices), id).toBeLessThanOrEqual(0.9); + expect(length(voices), id).toBeLessThanOrEqual(1.5); + } + }); + + it('keeps every bar of every loop inside its bar and under the limit', () => { + for (const [id, loop] of Object.entries(LOOPS)) { + for (let bar = 0; bar < 8; bar++) { + const voices = loop.voices(bar); + for (const v of voices) expect((v.start ?? 0) + v.dur, id).toBeLessThanOrEqual(loop.bar + 1e-9); + // Voices that overlap in time: at worst all of them at once, plus the bed. + expect(peakGain(voices.filter((v) => (v.start ?? 0) === 0)) + (loop.bed?.gain ?? 0), id).toBeLessThanOrEqual(0.9); + } + } + }); + + it('plays the same bar the same way every time', () => { + expect(LOOPS['hidden-garden'].voices(3)).toEqual(LOOPS['hidden-garden'].voices(3)); + }); + + it('detunes the loop in the anomaly state', () => { + const clean = loopVoices('rusty-creek', 0, 'clean')[0]; + const wrong = loopVoices('rusty-creek', 0, 'anomaly')[0]; + expect(wrong.from).toBeCloseTo((clean.from ?? 0) * ANOMALY_DETUNE); + }); + + it('gives each slice region an ambient loop', () => { + expect(regionLoop('hidden-garden')).toBe('hidden-garden'); + expect(regionLoop('rusty-creek')).toBe('rusty-creek'); + }); +}); diff --git a/game/src/fx/sound/synth.ts b/game/src/fx/sound/synth.ts new file mode 100644 index 0000000..64022ac --- /dev/null +++ b/game/src/fx/sound/synth.ts @@ -0,0 +1,56 @@ +import { Voice } from './recipes'; + +/** Plays recipes on a Web Audio context: one short-lived node chain per voice, nothing loaded from files. */ + +const noiseBuffers = new WeakMap(); + +/** One second of white noise per context, reused by every noise voice. */ +export function noiseBuffer(ctx: BaseAudioContext): AudioBuffer { + let buffer = noiseBuffers.get(ctx); + if (!buffer) { + buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; + noiseBuffers.set(ctx, buffer); + } + return buffer; +} + +/** Schedules `voices` to start at context time `when`, into `out`. */ +export function playVoices(ctx: BaseAudioContext, out: AudioNode, voices: Voice[], when = ctx.currentTime): void { + for (const v of voices) { + const t0 = when + (v.start ?? 0); + const t1 = t0 + v.dur; + const attack = Math.min(v.attack ?? 0.005, v.dur / 2); + const env = ctx.createGain(); + env.gain.setValueAtTime(0, t0); + env.gain.linearRampToValueAtTime(v.gain, t0 + attack); + env.gain.exponentialRampToValueAtTime(0.0001, t1); + let source: AudioScheduledSourceNode; + if (v.wave === 'noise') { + const noise = ctx.createBufferSource(); + noise.buffer = noiseBuffer(ctx); + noise.loop = true; + source = noise; + } else { + const osc = ctx.createOscillator(); + osc.type = v.wave; + osc.frequency.setValueAtTime(v.from ?? 440, t0); + if (v.to) osc.frequency.exponentialRampToValueAtTime(v.to, t1); + source = osc; + } + let head: AudioNode = source; + if (v.filter) { + const filter = ctx.createBiquadFilter(); + filter.type = v.filter.type; + filter.Q.value = v.filter.q ?? 1; + filter.frequency.setValueAtTime(v.filter.from, t0); + if (v.filter.to) filter.frequency.exponentialRampToValueAtTime(v.filter.to, t1); + head.connect(filter); + head = filter; + } + head.connect(env).connect(out); + source.start(t0); + source.stop(t1 + 0.02); + } +} diff --git a/game/src/scenes/registry.ts b/game/src/scenes/registry.ts index fe32f04..069a124 100644 --- a/game/src/scenes/registry.ts +++ b/game/src/scenes/registry.ts @@ -1,6 +1,7 @@ import type Phaser from 'phaser'; import { AnomalyTest } from '../fx/AnomalyTest'; import { AtmosphereTest } from '../fx/AtmosphereTest'; +import { Audio } from '../fx/sound/Audio'; import { Crossing } from '../story/Crossing'; import { Hud } from '../ui/Hud'; import { Title } from '../ui/Title'; @@ -10,7 +11,7 @@ import { Title } from '../ui/Title'; * so a new lane A scene is added here and the shared main.ts stays untouched. */ export const LANE_A_SCENES: Phaser.Types.Scenes.SceneType[] = [ - Hud, Crossing, Title, + Audio, Hud, Crossing, Title, // Dev-only test scenes stay out of production builds. ...(import.meta.env.DEV ? [AnomalyTest, AtmosphereTest] : []), ]; diff --git a/game/src/story/Crossing.ts b/game/src/story/Crossing.ts index a27981a..c22ec76 100644 --- a/game/src/story/Crossing.ts +++ b/game/src/story/Crossing.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; +import { sound } from '../fx/sound/Audio'; import { crashWindow } from '../ui/crashWindow'; import { CrossingSequence } from './crossingSequence'; @@ -45,12 +46,14 @@ export class Crossing extends Phaser.Scene { }); this.run.start(); this.events.once('freeze', () => { if (this.scene.isActive(frozen)) this.scene.pause(frozen); }); + this.events.once('dialog', () => sound.play('crash-ding')); this.scene.bringToTop(); } update(_time: number, delta: number): void { const phase = this.run.update(delta); if (phase === 'freeze' || phase === 'dialog') this.events.emit('freeze'); + if (phase === 'dialog') this.events.emit('dialog'); this.flash.setVisible(phase === 'hit'); this.dim.setVisible(phase === 'freeze' || phase === 'dialog'); this.cracks.setVisible(phase === 'freeze' || phase === 'dialog'); diff --git a/game/src/ui/Title.ts b/game/src/ui/Title.ts index 158029f..8c636ea 100644 --- a/game/src/ui/Title.ts +++ b/game/src/ui/Title.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; +import { sound } from '../fx/sound/Audio'; import { CREDITS, MENU, moveSelection, SUBTITLE, TITLE } from './credits'; const C = { bg: 0x101820, text: '#e8e4dc', dim: '#7d8a90', pick: '#46d1b4' }; @@ -62,6 +63,7 @@ export class Title extends Phaser.Scene { if (MENU[this.choice] === 'Credits') return this.showCredits(true); if (this.scene.isPaused(STAGE)) this.scene.resume(STAGE); this.scene.setVisible(true, HUD); + sound.loop('hidden-garden'); // the slice opens in the prologue garden; the first key press unlocked audio this.scene.stop(); }