diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 32ff004..588cdd9 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -81,6 +81,15 @@ // Хук для смоук-тестов (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). + 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)); } @@ -125,9 +134,16 @@ 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/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index e53c01e..b32f81b 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -104,7 +104,8 @@ { name: 'interact', run: () => runScenario('interact', 'apps/game/tools/checks/interact.mjs') }, { name: 'interact-world', run: () => runScenario('interact-world', 'apps/game/tools/checks/interact-world.mjs') }, { name: 'collision', run: () => runScenario('collision', 'apps/game/tools/checks/collision.mjs') }, - { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') } + { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') }, + { name: 'audio', run: () => runScenario('audio', 'apps/game/tools/checks/audio.mjs') } ]; const only = args.only ?? all.map((p) => p.name); const skip = args.skip ?? []; diff --git a/apps/game/tools/checks/audio.mjs b/apps/game/tools/checks/audio.mjs new file mode 100644 index 0000000..f4e004c --- /dev/null +++ b/apps/game/tools/checks/audio.mjs @@ -0,0 +1,90 @@ +/** + * Сценарий audio — звук мира через DEV-шпион window.__gameAudioLog + * (на слух в headless не проверить: читаем фактические запуски sfx/амбиента). + * 1) новая игра: амбиент лугов запущен ровно один раз; + * 2) шаги: step_* в логе, боевых звуков нет; + * 3) атака: bell_hit; + * 4) guard амбиента: повторный playAmbience того же ключа не перезапускает; + * 5) лог ограничен 24 записями. + * Запуск: node tools/agent.mjs run tools/checks/audio.mjs + */ +import { startDevServer, openGame, Checks } from '../lib.mjs'; + +/** Прочитать DEV-лог аудио из страницы. */ +const readLog = (page) => page.evaluate(() => window.__gameAudioLog ?? null); + +/** + * Ждать условие на логе: декод WAV в headless занимает заметное время, + * поэтому после действия шагаем и опрашиваем лог, а не читаем его сразу. + */ +async function waitAudio({ page, agent }, pred, tries = 12) { + let log = await readLog(page); + for (let i = 0; i < tries && !pred(log); i++) { + await agent.step(30); + log = await readLog(page); + } + return log; +} + +export default async function ({ pretty }) { + const c = new Checks('audio'); + const server = await startDevServer(); + let ctx; + try { + await c.run('новая игра: амбиент лугов запущен ровно один раз', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const log = await readLog(ctx.page); + c.expect(Array.isArray(log), 'DEV-лог аудио недоступен (прод-сборка?)', log); + const amb = log.filter((e) => e.key === 'ambience/meadows'); + c.expect(amb.length === 1, 'амбиент лугов должен стартовать ровно один раз', amb); + return null; + }); + await c.run('шаги: step_* в логе, боевых звуков нет', async () => { + await ctx.agent.command('scene:sleepAll'); + const before = (await readLog(ctx.page)).filter((e) => e.key.startsWith('sfx/step_')).length; + await ctx.agent.walkTo(16, 14, { timeoutTicks: 1500 }); + const log = await waitAudio( + ctx, + (l) => l.filter((e) => e.key.startsWith('sfx/step_')).length > before + ); + const steps = log.filter((e) => e.key.startsWith('sfx/step_')); + c.expect(steps.length > before, 'шагов в логе нет', { steps: steps.length }); + const combat = log.filter((e) => ['sfx/hurt', 'sfx/ash_die', 'sfx/spit'].includes(e.key)); + c.expect(combat.length === 0, 'боевые звуки без боя', combat); + return null; + }); + await c.run('атака: bell_hit в логе', async () => { + await ctx.agent.press('attack'); + const log = await waitAudio(ctx, (l) => l.some((e) => e.key === 'sfx/bell_hit')); + c.expect(log.some((e) => e.key === 'sfx/bell_hit'), 'атака не сыграла bell_hit', log); + return null; + }); + await c.run('guard амбиента: тот же ключ не перезапускается', async () => { + const count = () => + readLog(ctx.page).then((log) => log.filter((e) => e.key === 'ambience/meadows').length); + const before = await count(); + await ctx.page.evaluate(() => window.__game.playAmbience('ambience/meadows')); + await ctx.agent.step(60); + c.expect((await count()) === before, 'повторный playAmbience перезапустил трек', { + before, + after: await count() + }); + return null; + }); + await c.run('лог ограничен 24 записями', async () => { + // Прогулка туда-обратно — шагов больше, чем вмещает кольцевой буфер. + for (const [x, y] of [[17, 14], [15, 14], [17, 14], [15, 14], [17, 14], [15, 14]]) { + await ctx.agent.walkTo(x, y, { timeoutTicks: 1500 }); + } + const log = await readLog(ctx.page); + c.expect(log.length <= 24, 'лог не обрезается до 24', log.length); + c.expect(log.length > 0, 'лог пуст после прогулки'); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index ddc0304..3a64f58 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -97,7 +97,9 @@ ``` Шпионский хук `onPlayed?: (key, opts) => void` вызывается при каждом -фактическом запуске sfx — для тестов и DEV-лога игры. +фактическом запуске sfx — для тестов и DEV-лога игры (кольцо +`window.__gameAudioLog`, им пользуется агентная проба `checks/audio.mjs`: +на слух в headless не проверить — смотрят фактические запуски). Связка с Settings (громкости применяются при изменении): diff --git a/docs/engine/practices.md b/docs/engine/practices.md index b5617c6..762a12d 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -166,6 +166,31 @@ `AGENT_SNAPSHOT_KEYS`). Если ждёшь `ok: false` без `error` в деталях — смотрим `error`, а не таймаут. +## Ситуация: добавляю звук + +1. Генерация: функция-синтез в `apps/game/tools/audio/gen.mjs` (примитивы + движкового `@rpg/engine/tools/wav.mjs` — bandNoise, bellPartial, synth, + decay, loopify; сиды фиксированы — звук детерминирован) + запись в словарь + `SFX`; `npm run audio` перегенерирует WAV в `apps/game/assets/audio/`. +2. Ключ — в реестр `apps/game/src/data/audio.ts` (`SFX_KEYS` / `AMBIENCE_KEYS` + / `AMBIENCE_LAYER_KEYS`): BootScene префетчит оттуда, validate-тест ловит + «ключ в контенте — файла нет» и «файл есть — ключа нет». +3. Как играть: разовый sfx — `audio.play(key, {volume, rate, pan})` (число = + громкость); позиционный — `worldAudio.playAt(key, pos, base, radius)` + (затухание + панорама от героя, тишина за радиусом); шаги — + `worldAudio.playStep(stepKey(tile), ...)`. Событийные звуки боя/фауны — + подпиской в `AudioSystem.attach()` на `combat:*` / `fauna:startle` + (симуляция чиста от аудио). Лупы областей — `AreaDef.ambience`, локальные + слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`. +4. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион + `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из + страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия + опрашивай лог с паузами (`waitAudio`): декод WAV занимает заметное время, + шаг(60) может не хватить. +5. Грабли: `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required` + в headless не резолвится (см. «Грабли среды»); амбиент не должен + перезапускаться на том же ключе — guard в `Game.playAmbience`. + ## Ситуация: добавляю врага / меняю ИИ 1. Вид — `data/enemies.ts` (`ENEMY_KINDS`): базовые статы + поля ИИ