From 0b85b5caf0ae3a3f2dfbd7547aac9cc7f258d589 Mon Sep 17 00:00:00 2001 From: arudaev Date: Fri, 23 Oct 2026 23:34:21 +0200 Subject: [PATCH 1/2] feat: synthesize the effects and loops with Web Audio Decision 14 keeps audio out of Git, so every sound is a recipe of oscillators and filtered noise played at runtime: the docs/07 effects, one ambient loop per slice region and Jenny's loop. Hits, defeats, the boss and region turns are wired to the game events; game code calls sound.play() for the rest. --- docs/07-ASSET-MANIFEST.md | 6 +- game/src/fx/sound/Audio.ts | 106 ++++++++++++++++++++++++++++++++ game/src/fx/sound/loops.ts | 85 +++++++++++++++++++++++++ game/src/fx/sound/recipes.ts | 92 +++++++++++++++++++++++++++ game/src/fx/sound/sound.test.ts | 58 +++++++++++++++++ game/src/fx/sound/synth.ts | 56 +++++++++++++++++ game/src/scenes/registry.ts | 3 +- game/src/story/Crossing.ts | 3 + game/src/ui/Title.ts | 2 + 9 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 game/src/fx/sound/Audio.ts create mode 100644 game/src/fx/sound/loops.ts create mode 100644 game/src/fx/sound/recipes.ts create mode 100644 game/src/fx/sound/sound.test.ts create mode 100644 game/src/fx/sound/synth.ts 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..7d25745 --- /dev/null +++ b/game/src/fx/sound/Audio.ts @@ -0,0 +1,106 @@ +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)); + 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(); } From 85cf3950b832a5e09dba6dff8a033d196ee33661 Mon Sep 17 00:00:00 2001 From: arudaev Date: Fri, 23 Oct 2026 23:43:27 +0200 Subject: [PATCH 2/2] feat: add dev keys to step through the sounds F7 plays the effects one by one and F6 cycles the loops in dev builds, so the sound can be checked with headphones before merging. --- game/src/fx/sound/Audio.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/game/src/fx/sound/Audio.ts b/game/src/fx/sound/Audio.ts index 7d25745..706a924 100644 --- a/game/src/fx/sound/Audio.ts +++ b/game/src/fx/sound/Audio.ts @@ -57,6 +57,23 @@ export class Audio extends Phaser.Scene { 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);