diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 588cdd9..00bef99 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -81,14 +81,13 @@ // Хук для смоук-тестов (apps/game/tools/smoke*.mjs): доступ к контексту из страницы. if (import.meta.env.DEV) { (window as unknown as { __game?: Game }).__game = this; - // Шпион аудио: последние 24 запуска sfx — для агентных проверок звука - // (на слух в headless не проверить, см. tools/checks/audio.mjs). + // Шпион аудио: последние 24 запуска (sfx + лупы) — для агентных + // проверок звука (на слух в headless не проверить, tools/checks/audio.mjs). const audioLog: { key: string; t: number; volume: number; pan: number }[] = []; this.audio.onPlayed = (key, opts) => { audioLog.push({ key, t: Math.round(performance.now()), volume: opts.volume ?? 1, pan: opts.pan ?? 0 }); if (audioLog.length > 24) audioLog.shift(); }; - this.devAudioLog = audioLog; (window as unknown as { __gameAudioLog?: typeof audioLog }).__gameAudioLog = audioLog; // Агентный мост: window.__agent (см. docs/engine/agent.md, apps/game/tools/agent.mjs). import('./agent/GameAgent').then(({ registerAgent }) => registerAgent(this)); @@ -134,16 +133,9 @@ this.audio.stopAmbience(1.5); return; } - // DEV-шпион видит и старты амбиента (sfx логируются через onPlayed). - if (import.meta.env.DEV && this.devAudioLog) { - this.devAudioLog.push({ key, t: Math.round(performance.now()), volume, pan: 0 }); - if (this.devAudioLog.length > 24) this.devAudioLog.shift(); - } void this.audio.playAmbience(key, { fade: 1.5, volume }); } /** Текущий амбиент (защита от перезапуска того же ключа; '' — тишина). */ private ambienceKey: string | null = null; - /** DEV-лог аудио (заполняется в DEV, см. конструктор). */ - private devAudioLog: { key: string; t: number; volume: number; pan: number }[] | null = null; } \ No newline at end of file diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 2169449..fd808ea 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -108,9 +108,14 @@ ``` Шпионский хук `onPlayed?: (key, opts) => void` вызывается при каждом -фактическом запуске sfx — для тестов и DEV-лога игры (кольцо +фактическом запуске звука — и sfx, и лупов (музыка/амбиент/слои; опции +нормализованы, rate 1/pan 0) — для тестов и DEV-лога игры (кольцо `window.__gameAudioLog`, им пользуется агентная проба `checks/audio.mjs`: на слух в headless не проверить — смотрят фактические запуски). +`onEnded?(key)` зовётся, когда звук доиграл до конца; заглушенные голосом +(stop, воровство, restart) окончания событие не поднимают. +`duration(key)` — длительность буфера в секундах (null, если ещё не +декодирован) — для катсцен и генераторов, синхронизирующихся со звуком. Связка с Settings (громкости применяются при изменении): diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index 3d01530..d906a48 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -59,6 +59,8 @@ source: AudioBufferSourceNode; gain: GainNode; panner: StereoPannerNode | null; + /** Заглушен принудительно (stop/вор/новый restart) — onEnded не зовётся. */ + killed: boolean; } const DEFAULT_FADE = 1; @@ -78,9 +80,16 @@ /** Лимит одновременных sfx-голосов (0 — без лимита); избыток — воруется. */ private maxVoices: number; - /** Шпионский хук: вызывается при каждом фактическом запуске sfx (тесты, DEV-мост игры). */ + /** + * Шпионский хук: вызывается при каждом фактическом запуске звука — + * и разового sfx, и лупов (музыка/амбиент/слои; rate 1, pan 0). + * Для тестов и DEV-лога игры. + */ onPlayed?: (key: string, opts: PlayOptions) => void; + /** Звук доиграл до конца (stop/вор/новый restart — не считаются). */ + onEnded?: (key: string) => void; + constructor( private resolveUrl: (key: string) => string, private ctxFactory: () => AudioContext = () => new AudioContext(), @@ -162,13 +171,24 @@ gain.connect(this.buses!.sfx); } source.start(); - const voice: SfxVoice = { key, source, gain, panner }; - source.onended = () => this.releaseVoice(voice); + const voice: SfxVoice = { key, source, gain, panner, killed: false }; + source.onended = () => { + this.releaseVoice(voice); + if (!voice.killed) this.onEnded?.(key); + }; this.sfxVoices.push(voice); this.onPlayed?.(key, { volume, rate, pan }); return this.makeSfxHandle(voice); } + /** + * Длительность буфера в секундах (после preload/декода); не декодирован — + * null. Для катсцен и генераторов, синхронизирующихся со звуком. + */ + duration(key: string): number | null { + return this.buffers.get(key)?.duration ?? null; + } + /** Хендл над голосом: после снятия с учёта (stop/конец) — no-op. */ private makeSfxHandle(voice: SfxVoice): SfxHandle { const alive = () => this.sfxVoices.includes(voice); @@ -223,6 +243,7 @@ /** Затухание голоса + снятие с учёта (handle сразу «мёртв»). */ private killVoice(voice: SfxVoice, fadeSeconds: number): void { + voice.killed = true; this.fadeOut(voice, fadeSeconds); this.releaseVoice(voice); } @@ -318,6 +339,7 @@ gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade); source.connect(gain).connect(this.buses![bus]); source.start(); + this.onPlayed?.(key, { volume, rate: 1, pan: 0 }); return { source, gain }; } diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts index 8b486a4..ab3211b 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -73,7 +73,7 @@ graph.panners.push(node); return node; }, - decodeAudioData: vi.fn(async () => ({ decoded: true })) + decodeAudioData: vi.fn(async () => ({ decoded: true, duration: 1.25 })) }; return { ctx, graph }; } @@ -265,6 +265,65 @@ expect(graph.sources[2]!.stop).not.toHaveBeenCalled(); }); + it('onPlayed зовётся и для лупов (музыка/амбиент), и для sfx', async () => { + const { audio } = makeManager(); + const played: { key: string; opts: PlayOptions }[] = []; + audio.onPlayed = (key, opts) => played.push({ key, opts }); + await audio.unlock(); + await audio.load('m'); + await audio.load('a'); + await audio.load('bell'); + + await audio.playMusic('m', { volume: 0.6, fade: 0 }); + await audio.playAmbience('a', { fade: 0 }); + await audio.play('bell', 0.5); + + expect(played).toEqual([ + { key: 'm', opts: { volume: 0.6, rate: 1, pan: 0 } }, + { key: 'a', opts: { volume: 1, rate: 1, pan: 0 } }, + { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } } + ]); + }); + + it('onEnded зовётся на естественном конце звука', async () => { + const { audio, graph } = makeManager(); + const ended: string[] = []; + audio.onEnded = (key) => ended.push(key); + await audio.unlock(); + await audio.load('bell'); + + await audio.play('bell'); + expect(ended).toHaveLength(0); + + // Фейковый источник не завершается сам — дёргаем onended руками. + (graph.sources[0] as unknown as { onended?: () => void }).onended?.(); + expect(ended).toEqual(['bell']); + }); + + it('заглушенный голос (stop) onEnded не зовёт', async () => { + const { audio, graph } = makeManager(); + const ended: string[] = []; + audio.onEnded = (key) => ended.push(key); + await audio.unlock(); + await audio.load('bell'); + + const h = await audio.play('bell'); + h!.stop(0); + // Источник «остановился» после затухания — голос убит, событие не для него. + (graph.sources[0] as unknown as { onended?: () => void }).onended?.(); + expect(ended).toEqual([]); + }); + + it('duration: после load+preload — секунды буфера, иначе null', async () => { + const { audio } = makeManager(); + await audio.unlock(); + + expect(audio.duration('m')).toBeNull(); + await audio.load('m'); + await audio.preload('m'); + expect(audio.duration('m')).toBeCloseTo(1.25); + }); + it('setVolume хендла сразу задаёт громкость слота', async () => { const { audio, graph } = makeManager(); await audio.unlock();