diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 25d3f97..4e1ce7e 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -51,6 +51,7 @@ import { HealthBar } from '../systems/combat/HealthBar'; import { PLAYER_COMBAT } from '../systems/combat/stats'; import { Interactables } from '../systems/Interactables'; +import { AudioSystem } from '../systems/AudioSystem'; import { SceneObjects } from '../systems/SceneObjects'; import type { InteractableDef } from '../data/interactables'; @@ -92,6 +93,10 @@ private fog: Graphics; /** Пейзажная фауна (безгласные олени). */ private fauna: FaunaSystem; + /** Позиционный звук мира + события боя (звук — подпиской). */ + private worldAudio: AudioSystem; + /** off() подписок worldAudio (снять в exit). */ + private audioOff: () => void; /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */ private cutscene = new CutsceneRunner(); /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */ @@ -136,8 +141,15 @@ // (иначе вошёл в дверь — и немедленно вылетел обратно). const disarmTile = entry ? { ...startTile } : null; if (save?.state) this.game.state.load(save.state); + // Позиционный звук: слушатель — герой. + this.worldAudio = new AudioSystem({ + audio: this.game.audio, + events: this.game.engine.events, + getListener: () => this.player.position + }); + this.audioOff = this.worldAudio.attach(); this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { - void this.game.audio.play('sfx/step', 0.35); + this.worldAudio.playStep('sfx/step', this.player.position, 0.35); // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. this.combat.noise(this.player.position, 0.35); }); @@ -162,7 +174,7 @@ this.interactables = new Interactables(area.interactables ?? [], this.game.state, { hasItem: (id) => this.game.inventory.has(id), give: (id, n) => this.game.inventory.add(id, n), - playSound: (k) => void this.game.audio.play(k, 0.6), + playSound: (k) => this.worldAudio.playAt(k, this.player.position, 0.6), showToast: (t) => this.showToast(t) }); for (const def of area.interactables ?? []) { @@ -176,7 +188,6 @@ { map: this.map, events: this.game.engine.events, - audio: this.game.audio, getPlayerPos: () => this.player.position, damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from), registry: this.objects.registry @@ -395,6 +406,8 @@ } exit(): void { + this.audioOff(); + this.worldAudio.setLayer(null); this.fauna.exit(); this.combatViews.exit(); this.ash?.clear(); @@ -412,6 +425,7 @@ update(dt: number): void { this.map.update(dt); // анимированные тайлы (вода) — no-op без анимаций + this.worldAudio.update(dt); this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { @@ -604,7 +618,6 @@ const applied = this.playerCombat.takeDamage(damage); if (applied <= 0) return; this.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp }); - void this.game.audio.play('sfx/hurt', 0.8); this.game.camera.addShake(2.5, 0.3); // Отброс от источника урона (направление — по метрике проекции) const dx = this.player.position.x - from.x; diff --git a/apps/game/src/systems/AudioSystem.ts b/apps/game/src/systems/AudioSystem.ts new file mode 100644 index 0000000..6e2be28 --- /dev/null +++ b/apps/game/src/systems/AudioSystem.ts @@ -0,0 +1,139 @@ +import { + createRng, + worldToScreen, + EventBus, + type AudioManager, + type MusicHandle, + type Vec2 +} from '@rpg/engine'; +import { Game } from '../Game'; + +/** + * Позиционный звук мира: громкость и панорама от расстояния до героя, + * звуки боя — подпиской на события шины (симуляция чиста), локальные + * амбиент-слои — лупами в шине ambience с тик-фейдом. + */ + +/** Минимальный интервал между запусками одного ключа (полифония). */ +const MIN_INTERVAL = 0.08; + +export interface AudioSystemDeps { + audio: AudioManager; + events: EventBus; + /** Позиция слушателя (герой), мировые юниты. */ + getListener: () => Vec2; +} + +export interface AudioLayerDef { + key: string; + /** Целевая громкость (по умолчанию 0.5). */ + volume?: number; + /** Сек до целевой громкости. */ + fade?: number; +} + +export class AudioSystem { + private clock = 0; + private lastAt = new Map(); + private offs: (() => void)[] = []; + private layer: { def: AudioLayerDef; handle: MusicHandle; volume: number } | null = null; + /** Ключ запрошенного слоя (и до асинхронного старта лупа) — дедуп setLayer. */ + private layerKey: string | null = null; + private layerSeq = 0; + private rng = createRng(20260905); + + constructor(private deps: AudioSystemDeps) {} + + /** Тик: часы полифонии + доводка громкости слоя к цели (fade сек до target). */ + update(dt: number): void { + this.clock += dt; + const layer = this.layer; + if (!layer) return; + const target = layer.def.volume ?? 0.5; + const rate = Math.abs(target) / Math.max(0.001, layer.def.fade ?? 1); + layer.volume = + layer.volume < target + ? Math.min(target, layer.volume + rate * dt) + : Math.max(target, layer.volume - rate * dt); + layer.handle.setVolume(layer.volume); + } + + /** + * Звук в точке мира: затухание по дистанции (степень 1.5) и панорама + * по экранному смещению от слушателя. За радиусом — тишина. + */ + playAt(key: string, pos: Vec2, base = 1, radius = 12): void { + const listener = this.deps.getListener(); + const d = Math.hypot(pos.x - listener.x, pos.y - listener.y); + const falloff = Math.max(0, 1 - d / radius); + const volume = base * Math.pow(falloff, 1.5); + if (volume < 0.02) return; + // Панорама — по разности экранных X источника и слушателя (камера не нужна). + const dx = worldToScreen(pos.x, pos.y).x - worldToScreen(listener.x, listener.y).x; + const pan = Math.max(-0.8, Math.min(0.8, dx / (Game.VIRTUAL_W / 2))); + if (Math.abs(pan) >= 0.01) this.play(key, { volume, pan }); + else this.play(key, { volume }); + } + + /** Шаг: позиционный звук с лёгким джиттером скорости (поверхность живая). */ + playStep(key: string, pos: Vec2, base = 1): void { + const listener = this.deps.getListener(); + const d = Math.hypot(pos.x - listener.x, pos.y - listener.y); + const volume = base * Math.max(0, 1 - d / 4); + if (volume < 0.02) return; + this.play(key, { volume, rate: 0.92 + this.rng.next() * 0.16 }); + } + + /** Подписки на события боя; возвращает off() для exit() сцены. */ + attach(): () => void { + const on = this.deps.events; + this.offs = [ + on.on<{ mode: string; origin: Vec2 }>('combat:attack', ({ mode, origin }) => { + if (mode === 'cone') this.playAt('sfx/bell_hit', origin, 0.8); + else this.playAt('sfx/bell_low', origin, 0.9); + }), + on.on<{ pos: Vec2 }>('combat:spit', ({ pos }) => this.playAt('sfx/spit', pos, 0.6)), + on.on<{ pos: Vec2 }>('combat:awake', ({ pos }) => this.playAt('sfx/ash_hiss', pos, 0.5)), + on.on<{ tile: Vec2 }>('combat:kill', ({ tile }) => this.playAt('sfx/ash_die', tile, 0.8)), + on.on('combat:playerHit', () => this.play('sfx/hurt', { volume: 0.8 })) + ]; + return () => this.off(); + } + + /** Отписаться от событий (сцена сменяется). */ + off(): void { + for (const off of this.offs) off(); + this.offs = []; + } + + /** + * Локальный амбиент-слой (вода, гул): луп в шине ambience поверх + * областного. Смена ключа — кроссфейд; громкость доводится в update. + */ + setLayer(def: AudioLayerDef | null): void { + if ((def?.key ?? null) === this.layerKey) return; + this.layerKey = def?.key ?? null; + const seq = ++this.layerSeq; + if (this.layer) { + this.layer.handle.stop(1); + this.layer = null; + } + if (!def) return; + void this.deps.audio + .playLoop(def.key, { bus: 'ambience', fade: def.fade ?? 1, volume: 0.001 }) + .then((handle) => { + // Слой сменили, пока луп грузился — не оставлять хвост. + if (seq !== this.layerSeq) { + handle?.stop(0.5); + return; + } + if (handle) this.layer = { def, handle, volume: 0.001 }; + }); + } + + private play(key: string, opts: { volume: number; rate?: number; pan?: number }): void { + if ((this.lastAt.get(key) ?? -1) + MIN_INTERVAL > this.clock) return; + this.lastAt.set(key, this.clock); + void this.deps.audio.play(key, opts); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/AudioSystem.test.ts b/apps/game/src/systems/__tests__/AudioSystem.test.ts new file mode 100644 index 0000000..dd3759d --- /dev/null +++ b/apps/game/src/systems/__tests__/AudioSystem.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventBus, type AudioManager, type MusicHandle } from '@rpg/engine'; +import { AudioSystem, type AudioSystemDeps } from '../AudioSystem'; + +/** Фейковый AudioManager: записывает вызовы play/playLoop. */ +function fakeAudio() { + const played: { key: string; opts?: { volume?: number; rate?: number; pan?: number } }[] = []; + const loops: { key: string; opts?: { bus?: string; volume?: number; fade?: number } }[] = []; + const audio = { + play: vi.fn(async (key: string, opts?: { volume?: number; rate?: number; pan?: number }) => { + played.push({ key, opts }); + }), + playLoop: vi.fn(async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => { + loops.push({ key, opts }); + return { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; + }) + } as unknown as AudioManager; + return { audio, played, loops }; +} + +function makeDeps(over: Partial = {}) { + const calls = fakeAudio(); + const events = new EventBus(); + const deps: AudioSystemDeps = { + audio: calls.audio, + events, + getListener: () => ({ x: 10, y: 10 }), + ...over + }; + return { deps, events, calls }; +} + +describe('AudioSystem.playAt — затухание и панорама', () => { + it('рядом с героем — полная громкость, без pan (герой в центре)', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/bell_hit', { x: 10, y: 10 }, 0.8); + + expect(calls.played).toHaveLength(1); + expect(calls.played[0]!.key).toBe('sfx/bell_hit'); + expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.8); + expect(calls.played[0]!.opts!.pan).toBeUndefined(); // |pan| < 0.01 — без узла + }); + + it('за радиусом — тишина (вызова нет)', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/spit', { x: 30, y: 30 }, 0.6, 12); + + expect(calls.played).toHaveLength(0); + }); + + it('сбоку от слушателя — панорама со знаком, клэмп ±0.8', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/bell_low', { x: 12, y: 10 }, 0.9); + + const pan = calls.played[0]!.opts!.pan!; + expect(pan).toBeGreaterThan(0); + expect(pan).toBeLessThanOrEqual(0.8); + }); + + it('далеко сбоку — панорама клэмпится к 0.8', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/bell_low', { x: 18, y: 2 }, 2); // далеко и сбоку, громкий + + expect(calls.played[0]!.opts!.pan).toBe(0.8); + }); +}); + +describe('AudioSystem.playStep — джиттер и полифония', () => { + it('шагу задаётся rate 0.92..1.08', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playStep('sfx/step', { x: 10, y: 10 }, 0.35); + + const rate = calls.played[0]!.opts!.rate!; + expect(rate).toBeGreaterThanOrEqual(0.92); + expect(rate).toBeLessThanOrEqual(1.08); + }); + + it('один ключ чаще 0.08 с не запускается, другой — проходит', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.playAt('sfx/spit', { x: 10, y: 10 }); + sys.playAt('sfx/spit', { x: 10, y: 10 }); // слишком рано — глушится + sys.update(0.05); + sys.playAt('sfx/spit', { x: 10, y: 10 }); // всё ещё рано + sys.update(0.05); + sys.playAt('sfx/bell_hit', { x: 10, y: 10 }); // другой ключ — ок + + expect(calls.played.map((p) => p.key)).toEqual(['sfx/spit', 'sfx/bell_hit']); + }); +}); + +describe('AudioSystem.attach — звук мира подпиской', () => { + it('combat:attack cone → bell_hit, resonance → bell_low; spit/awake/kill/playerHit', () => { + const { deps, events, calls } = makeDeps(); + const sys = new AudioSystem(deps); + sys.attach(); + + events.emit('combat:attack', { mode: 'cone', origin: { x: 10, y: 10 } }); + events.emit('combat:attack', { mode: 'resonance', origin: { x: 10, y: 10 } }); + events.emit('combat:spit', { pos: { x: 10, y: 10 } }); + events.emit('combat:awake', { kind: 'crawler', pos: { x: 10, y: 10 } }); + events.emit('combat:kill', { kind: 'crawler', tile: { x: 10, y: 10 } }); + events.emit('combat:playerHit', { hp: 5 }); + + expect(calls.played.map((p) => p.key)).toEqual([ + 'sfx/bell_hit', + 'sfx/bell_low', + 'sfx/spit', + 'sfx/ash_hiss', + 'sfx/ash_die', + 'sfx/hurt' + ]); + // playerHit — урон герою, без панорамы + expect(calls.played[5]!.opts!.pan).toBeUndefined(); + }); + + it('off() снимает подписки', () => { + const { deps, events, calls } = makeDeps(); + const sys = new AudioSystem(deps); + const off = sys.attach(); + off(); + + events.emit('combat:spit', { pos: { x: 10, y: 10 } }); + expect(calls.played).toHaveLength(0); + }); +}); + +describe('AudioSystem.setLayer — локальный амбиент', () => { + it('слой — playLoop в шине ambience; повтор с тем же ключом не перезапускает', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.setLayer({ key: 'ambience/ponds_water' }); + sys.setLayer({ key: 'ambience/ponds_water' }); // тот же ключ — no-op + + expect(calls.loops).toHaveLength(1); + expect(calls.loops[0]!.key).toBe('ambience/ponds_water'); + expect(calls.loops[0]!.opts!.bus).toBe('ambience'); + }); + + it('setLayer(null) останавливает слой, смена ключа — стоп прежнего', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + + sys.setLayer({ key: 'ambience/tower_hum' }); + sys.setLayer({ key: 'ambience/ponds_water' }); + expect(calls.loops).toHaveLength(2); + // прежний хендлер получил stop + const firstHandle = (calls.audio.playLoop as ReturnType).mock.results[0]!.value as Promise; + void firstHandle; // хендлер внутри then — проверяем через setLayer(null) + + sys.setLayer(null); + // после null слой снят: следующий вызов снова стартует луп + sys.setLayer({ key: 'ambience/tower_hum' }); + expect(calls.loops).toHaveLength(3); + }); + + it('update доводит громкость слоя к цели', async () => { + const { deps } = makeDeps(); + const sys = new AudioSystem(deps); + let handle: MusicHandle | null = null; + (deps.audio.playLoop as ReturnType).mockImplementation(async () => { + handle = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; + return handle; + }); + + sys.setLayer({ key: 'ambience/tower_hum', volume: 0.5, fade: 1 }); + await Promise.resolve(); // луп стартовал + sys.update(0.5); + expect(handle!.setVolume).toHaveBeenCalledWith(expect.closeTo(0.25)); + sys.update(0.5); + expect(handle!.setVolume).toHaveBeenCalledWith(0.5); + }); +}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts index 92c5732..a941b7f 100644 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -1,6 +1,5 @@ import { EventBus, - AudioManager, IsometricTileMap, World, worldToTile, @@ -59,7 +58,6 @@ export interface CombatDeps { map: IsometricTileMap; events: EventBus; - audio: AudioManager; /** Позиция героя (ноги), мировые юниты (1 юнит = 1 тайл). */ getPlayerPos: () => Vec2; /** Герой получил урон (сцена отбросит/шейкнет/проиграет звук). */ @@ -161,8 +159,8 @@ break; case 'shoot': this.combat.spawnProjectile(en.pos, intent.dir, en.kind.attackDamage); - void this.combat.deps.audio.play('sfx/spit', 0.6); - // Плевок слышно, но спящих не будит (< 0.7) + // Плевок слышно (звук — подпиской на combat:spit), но спящих не будит (< 0.7) + this.combat.deps.events.emit('combat:spit', { pos: { ...en.pos } }); this.combat.noise(en.pos, 0.6); break; default: @@ -286,7 +284,6 @@ * Побочный эффект: гулкий звон (уровень 1.0) будит спящих в wakeRadius. */ playerConeAttack(from: Vec2, dir: Vec2): number { - void this.deps.audio.play('sfx/bell_hit', 0.8); this.noise(from, 1.0); this.deps.events.emit('combat:attack', { mode: 'cone', origin: { ...from } }); @@ -304,7 +301,6 @@ /** Низкий резонанс: усыпляет сгустков в радиусе. Возвращает число усыпленных. */ resonancePulse(from: Vec2): number { - void this.deps.audio.play('sfx/bell_low', 0.9); this.deps.events.emit('combat:attack', { mode: 'resonance', origin: { ...from } }); let slept = 0; @@ -329,8 +325,7 @@ for (const [, en] of this.enemies) { if (inCircleW(pos, PLAYER_COMBAT.wakeRadius, en.pos) && en.brain.asleep) { en.brain.hearLoud(); - void this.deps.audio.play('sfx/ash_hiss', 0.5); - this.deps.events.emit('combat:awake', { kind: en.kind.id }); + this.deps.events.emit('combat:awake', { kind: en.kind.id, pos: { ...en.pos } }); } } } @@ -352,7 +347,6 @@ this.deps.events.emit('combat:hurt', { entity: e, died: en.hp <= 0 }); if (en.hp <= 0) { if (en.brain.die()) { - void this.deps.audio.play('sfx/ash_die', 0.8); this.noise(en.pos, 0.6); this.deps.events.emit('combat:kill', { kind: en.kind.id, tile: { ...en.pos } }); this.onEnemyKilled?.(en.kind); diff --git a/apps/game/src/testing/combatWorld.ts b/apps/game/src/testing/combatWorld.ts index 07ad708..d5ec8d1 100644 --- a/apps/game/src/testing/combatWorld.ts +++ b/apps/game/src/testing/combatWorld.ts @@ -1,4 +1,4 @@ -import type { AudioManager, EventBus, IsometricTileMap } from '@rpg/engine'; +import type { EventBus, IsometricTileMap } from '@rpg/engine'; import { CombatWorld } from '../systems/combat/CombatWorld'; import { type EnemyBrain, type EnemySenses } from '../systems/combat/EnemyBrain'; @@ -25,7 +25,6 @@ return new CombatWorld({ map: fakeMap(), events: { emit: () => {} } as unknown as EventBus, - audio: { play: () => Promise.resolve() } as unknown as AudioManager, getPlayerPos: () => playerPos, damagePlayer: () => {} }); diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index d3e1b10..ddc0304 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -86,7 +86,9 @@ (повторный вызов вытесняет прежний трек своего слота; негодный ключ не глушит текущий). `playLoop` — луп **без слота** (занимать нечего, сколько вызвали — столько играет, `MusicHandle.stop` останавливает конкретный): база для -локальных амбиент-слоёв (вода, гул) — игра поверх амбиента области: +локальных амбиент-слоёв (вода, гул) — игра поверх амбиента области. +У `MusicHandle` есть `setVolume(v)` — мгновенная громкость для медленных +тик-фейдов поверх (сам движок фейд не тикает): ```ts const layer = await audio.playLoop('ambience/ponds_water', { bus: 'ambience', fade: 2 }); diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index 27db6bb..a17e929 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -16,6 +16,8 @@ export interface MusicHandle { stop(fadeSeconds?: number): void; + /** Задать громкость сразу (для медленных тик-фейдов поверх — локальные слои). */ + setVolume(volume: number): void; } /** Опции разового sfx. */ @@ -141,7 +143,20 @@ async playLoop(key: string, { loop = true, fade = DEFAULT_FADE, volume = 1, bus = 'music' }: LoopOptions = {}): Promise { const slot = await this.startLoop(key, { loop, fade, volume, bus }); if (!slot) return null; - return { stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds) }; + return this.makeHandle(slot, fade); + } + + /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */ + private makeHandle(slot: Slot, fade: number): MusicHandle { + return { + stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds), + setVolume: (volume: number) => { + if (!this.ctx) return; + const t = this.ctx.currentTime; + slot.gain.gain.cancelScheduledValues(t); + slot.gain.gain.setValueAtTime(Math.max(0, volume), t); + } + }; } /** @@ -157,7 +172,7 @@ const slot = await this.startLoop(key, { loop, fade, volume, bus: 'music' }); if (!slot) return null; this.currentMusic = slot; - return { stop: (fadeSeconds = fade) => this.stopMusic(fadeSeconds) }; + return this.makeHandle(slot, fade); } /** Остановить музыку (с затуханием). */ @@ -180,7 +195,7 @@ const slot = await this.startLoop(key, { loop: true, fade, volume, bus: 'ambience' }); if (!slot) return null; this.currentAmbience = slot; - return { stop: (fadeSeconds = fade) => this.stopAmbience(fadeSeconds) }; + return this.makeHandle(slot, fade); } /** Остановить амбиент (с затуханием). */ diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts index dbcb2f3..dae2eb2 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -1,14 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AudioManager, type PlayOptions } from '../AudioManager'; -/** Параметр WebAudio: значение + планировщик огибающей. */ +/** Параметр WebAudio: значение + планировщик огибающей (мгновенно применяет). */ function makeParam(value = 1) { - return { + const p = { value, - setValueAtTime: vi.fn(), - linearRampToValueAtTime: vi.fn(), + setValueAtTime: vi.fn((v: number) => { + p.value = v; + }), + linearRampToValueAtTime: vi.fn((v: number) => { + p.value = v; + }), cancelScheduledValues: vi.fn() }; + return p; } /** Минимальный граф WebAudio: считает узлы, запоминает соединения. */ @@ -165,6 +170,17 @@ expect(graph.sources).toHaveLength(2); }); + it('setVolume хендла сразу задаёт громкость слота', async () => { + const { audio, graph } = makeManager(); + await audio.unlock(); + await audio.load('m'); + + const handle = await audio.playLoop('m', { fade: 0, volume: 1 }); + handle!.setVolume(0.3); + const slotGain = (graph.sources[0]!.dests[0] as { gain: { value: number } }).gain; + expect(slotGain.value).toBeCloseTo(0.3); + }); + it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => { const { audio, graph } = makeManager(); await audio.unlock();