diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index c240cf8..f4e49b3 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -209,6 +209,9 @@ if (!spec) return; let buf = this.themeBuffers.get(key); if (!buf) { + // Тема компилируется синхронно, вне async-цепочки play* — контекст + // заводим на месте (prepare), иначе до первого жеста будет тишина. + this.audio.prepare(); const created = this.audio.createBuffer(renderMusicStereo(spec), RATE, 2); if (!created) { this.themeKey = null; // до unlock — компиляция при следующем входе diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 22080d6..b45b16d 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -229,8 +229,7 @@ ] }, interactables: INTERACTABLES.meadows, - // Глобальный ветер: порывы свистят выше базового амбиента лугов. - ambienceLayers: [{ key: 'ambience/wind', volume: 0.3 }], + // Амбиента нет (по фидбеку): на лугах тишина — только музыка и шаги. // Безгласные олени у берегов прудов — подходят к звону. fauna: [ { x: 18, y: 36 }, diff --git a/apps/game/tools/checks/audio.mjs b/apps/game/tools/checks/audio.mjs index 3a18449..f8696bd 100644 --- a/apps/game/tools/checks/audio.mjs +++ b/apps/game/tools/checks/audio.mjs @@ -46,12 +46,14 @@ // Луга без амбиента — гвард проверяем ключом прудов. const count = async () => (await t.readAudioLog()).filter((e) => e.key === 'ambience/ponds').length; - const before = await count(); await t.ctx.page.evaluate(() => window.__game.playAmbience('ambience/ponds')); + // Лог пишется при фактическом старте (после декода) — ждём его. + await t.waitAudioLog((l) => l.some((e) => e.key === 'ambience/ponds')); + const before = await count(); await t.ctx.page.evaluate(() => window.__game.playAmbience('ambience/ponds')); await t.ctx.agent.step(60); const after = await count(); - c.expect(after === before + 1, 'повторный playAmbience перезапустил трек', { + c.expect(after === before, 'повторный playAmbience перезапустил трек', { before, after }); diff --git a/apps/game/tools/checks/music.mjs b/apps/game/tools/checks/music.mjs index 6d0079a..2e1f86c 100644 --- a/apps/game/tools/checks/music.mjs +++ b/apps/game/tools/checks/music.mjs @@ -27,6 +27,7 @@ }); await c.run('новая игра в лугах: тема скомпилирована и играет', async () => { await t.boot(); + // Луга без амбиента (тишина по фидбеку) — тема играет одна. const log = await t.waitAudioLog((l) => l.some((e) => e.key === 'music/meadows')); c.expect( log.some((e) => e.key === 'music/meadows'), @@ -34,13 +35,15 @@ log ); c.expect( - log.some((e) => e.key === 'ambience/meadows'), - 'амбиент лугов пропал при старте темы' + !log.some((e) => e.key.startsWith('ambience/')), + 'на лугах не должно быть амбиента (тишина)', + log.filter((e) => e.key.startsWith('ambience/')) ); return null; }); await c.run('возврат в ту же область: тема не перезапускается (guard)', async () => { const before = await countKey('music/meadows'); + c.expect(before > 0, 'тема лугов не играла перед проверкой guard', before); // Шагнуть туда-обратно мимо переходов, сцена не меняется — guard ключа. await t.ctx.agent.walkTo(15, 28, { timeoutTicks: 1500 }); await t.ctx.agent.walkTo(17, 28, { timeoutTicks: 1500 }); diff --git a/apps/game/tools/checks/synth.mjs b/apps/game/tools/checks/synth.mjs index d927aff..2d6192c 100644 --- a/apps/game/tools/checks/synth.mjs +++ b/apps/game/tools/checks/synth.mjs @@ -79,14 +79,15 @@ c.expect(noSpec === null, 'вызов без спека должен вернуть null', noSpec); return null; }); - await c.run('гул (hum) не глушит амбиент области', async () => { + await c.run('гул (hum) не глушит музыку области', async () => { const { res, log } = await synthAndWait( { kind: 'hum', dur: 2, tone: 55, loop: true }, { key: 'agent/hum-test' } ); c.expect(res === true, 'scene:synthesize не принят мостом', res); c.expect(log.some((e) => e.key === 'agent/hum-test'), 'гул по спеку не сыграл', log); - c.expect(log.some((e) => e.key === 'ambience/meadows'), 'амбиент области пропал', log); + // Луга без амбиента — проверяем, что гул не глушит тему лугов. + c.expect(log.some((e) => e.key === 'music/meadows'), 'тема лугов пропала', log); return null; }); }, diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 6fea618..590846e 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -105,10 +105,10 @@ (процедурные звуки, синтезированные в рантайме): ключ — логическое имя голоса (дедуп, restart, полифония, шпион `onPlayed`), не файл. Пара к нему — `createBuffer(data: Float32Array, sampleRate = 22050)`: моно-сэмплы → -`AudioBuffer` (null, пока контекст не разблокирован — после первого -`play`/`unlock` работает). Чистые примитивы синтеза (bandNoise, synth, decay, -normalize, makeRng) идут с движком в `@rpg/engine/tools/synth.mjs` — без -node-импортов, готовы для браузера: +`AudioBuffer` (null, пока контекст не создан — после первого +`play`/`unlock`/`prepare` работает). Чистые примитивы синтеза (bandNoise, +synth, decay, normalize, makeRng) идут с движком в +`@rpg/engine/tools/synth.mjs` — без node-импортов, готовы для браузера: ```ts import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs'; @@ -235,6 +235,9 @@ `play`/`playMusic`/`playAmbience` не падают, если звук не загружен или AudioContext не разблокирован — тихо ничего не делают. До `unlock` шин ещё нет — `setBusVolume` игнорируется, громкости применяются после него. +`prepare()` — синхронное создание контекта без `resume`: для генеративных +лупов, компилирующихся вне async-цепочки `play*` (темы через `createBuffer`), +чтобы не ждать первого жеста пользователя. ## SaveManager diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index 6577058..031e585 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -61,21 +61,34 @@ } } + /** Создать контекст и шины синхронно (без resume) — для prepare(). */ + private createCtx(): void { + if (this.ctx) return; + this.ctx = this.ctxFactory(); + const master = this.ctx.createGain(); + const music = this.ctx.createGain(); + const sfx = this.ctx.createGain(); + const ambience = this.ctx.createGain(); + music.connect(master); + sfx.connect(master); + ambience.connect(master); + master.connect(this.ctx.destination); + this.buses = { master, music, sfx, ambience }; + } + + /** + * Синхронно завести контекст без resume: после него createBuffer работает, + * а первый play доведёт разблокировку до конца (resume — асинхронный). + * Нужно генеративным лупам (темы), которые компилируются вне async-цепочки. + */ + prepare(): void { + this.createCtx(); + } + /** Расблокировать аудио — вызвать из обработчика пользовательского ввода. */ async unlock(): Promise { - if (!this.ctx) { - this.ctx = this.ctxFactory(); - const master = this.ctx.createGain(); - const music = this.ctx.createGain(); - const sfx = this.ctx.createGain(); - const ambience = this.ctx.createGain(); - music.connect(master); - sfx.connect(master); - ambience.connect(master); - master.connect(this.ctx.destination); - this.buses = { master, music, sfx, ambience }; - } - if (this.ctx.state === 'suspended') { + this.createCtx(); + if (this.ctx?.state === 'suspended') { await this.ctx.resume(); } }