import { describe, expect, it, vi } from 'vitest';
import { EventBus, type AudioManager, type MusicHandle } from '@rpg/engine';
import { AudioSystem, type AudioSystemDeps } from '../AudioSystem';
/** Фейковый AudioManager: записывает вызовы play/playBuffer/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 });
}),
playBuffer: vi.fn(async (key: string, _buf: AudioBuffer, opts?: { volume?: number; rate?: number; pan?: number }) => {
played.push({ key, opts });
}),
playSpec: vi.fn(async (key: string, _spec: unknown, opts?: { volume?: number; rate?: number; pan?: number }) => {
played.push({ key, opts });
}),
// По умолчанию контекст «не разблокирован» — буферы не создать.
createBuffer: vi.fn((): AudioBuffer | null => null),
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<AudioSystemDeps> = {}) {
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('без буферов (контекст не разблокирован) — файловый шаг по поверхности', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playStep('grass', { x: 10, y: 10 }, 0.35);
expect(calls.played).toHaveLength(1);
expect(calls.played[0]!.key).toBe('sfx/step_grass');
expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.35);
});
it('буферы готовы — playBuffer вариантов по кругу', () => {
const { deps, calls } = makeDeps();
vi.mocked(calls.audio.createBuffer).mockImplementation(() => ({ duration: 0.12 }) as AudioBuffer);
const sys = new AudioSystem(deps);
sys.playStep('ash', { x: 10, y: 10 }, 0.35);
sys.update(0.1);
sys.playStep('ash', { x: 10, y: 10 }, 0.35);
sys.update(0.1);
sys.playStep('ash', { x: 10, y: 10 }, 0.35);
expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_ash#0', 'sfx/step_ash#1', 'sfx/step_ash#2']);
});
it('дедуп: одна поверхность чаще 0.08 с не звучит', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playStep('grass', { x: 10, y: 10 }, 0.35);
sys.playStep('grass', { x: 10, y: 10 }, 0.35); // слишком рано — глушится
sys.update(0.05);
sys.playStep('grass', { x: 10, y: 10 }, 0.35); // всё ещё рано
sys.update(0.05);
sys.playStep('water', { x: 10, y: 10 }, 0.35); // другая поверхность — ок
expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_grass', 'sfx/step_water']);
});
it('далеко от слушателя — тишина', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playStep('floor', { x: 20, y: 20 }, 0.35);
expect(calls.played).toHaveLength(0);
});
});
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.setLayers — локальные амбиенты', () => {
it('слой — playLoop в шине ambience; повтор с тем же ключом не перезапускает', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.setLayers([{ key: 'ambience/ponds_water' }]);
sys.setLayers([{ 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('два слоя — два независимых лупа', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.setLayers([{ key: 'ambience/tower_hum' }, { key: 'ambience/houses' }]);
expect(calls.loops).toHaveLength(2);
expect(calls.loops[0]!.key).toBe('ambience/tower_hum');
expect(calls.loops[1]!.key).toBe('ambience/houses');
});
it('пустой набор останавливает слои; повторный старт — новый луп', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
const handles: MusicHandle[] = [];
(deps.audio.playLoop as ReturnType<typeof vi.fn>).mockImplementation(
async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => {
calls.loops.push({ key, opts });
const h = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle;
handles.push(h);
return h;
}
);
sys.setLayers([{ key: 'ambience/tower_hum' }]);
sys.setLayers([]);
sys.setLayers([{ key: 'ambience/tower_hum' }]);
expect(handles).toHaveLength(2); // второй старт после снятия
expect(calls.loops).toHaveLength(2);
});
it('update доводит громкость слоя к цели', async () => {
const { deps } = makeDeps();
const sys = new AudioSystem(deps);
let handle: MusicHandle | null = null;
(deps.audio.playLoop as ReturnType<typeof vi.fn>).mockImplementation(async () => {
handle = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle;
return handle;
});
sys.setLayers([{ 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);
});
});