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<string, number>();
private offs: (() => void)[] = [];
private layers = new Map<string, { def: AudioLayerDef; handle: MusicHandle | null; volume: number }>();
/** Ключи запрошенных слоёв (и до асинхронного старта лупов). */
private wanted = new Set<string>();
private rng = createRng(20260905);
constructor(private deps: AudioSystemDeps) {}
/** Тик: часы полифонии + доводка громкости слоёв к целям (fade сек до target). */
update(dt: number): void {
this.clock += dt;
for (const layer of this.layers.values()) {
if (!layer.handle) continue; // луп ещё грузится
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 })),
on.on<{ pos: Vec2 }>('fauna:startle', ({ pos }) => this.playAt('sfx/deer_startle', pos, 0.7))
];
return () => this.off();
}
/** Отписаться от событий (сцена сменяется). */
off(): void {
for (const off of this.offs) off();
this.offs = [];
}
/**
* Локальные амбиент-слои (вода, гул, дома) — лупы в шине ambience поверх
* областного. Вызывать каждый тик с текущим набором: лишние стопятся,
* громкости доводятся в update.
*/
setLayers(defs: AudioLayerDef[]): void {
this.wanted = new Set(defs.map((d) => d.key));
for (const [key, layer] of this.layers) {
if (!this.wanted.has(key)) {
layer.handle?.stop(1); // pending-луп остановит .then (слой уже не нужен)
this.layers.delete(key);
}
}
for (const def of defs) {
const existing = this.layers.get(def.key);
if (existing) {
existing.def = def;
continue;
}
// Синхронная пометка «слой занят»: повторный вызов до резолва лупа
// не перезапускает его (playLoop асинхронен).
const pending: { def: AudioLayerDef; handle: MusicHandle | null; volume: number } = {
def,
handle: null,
volume: 0.001
};
this.layers.set(def.key, pending);
void this.deps.audio
.playLoop(def.key, { bus: 'ambience', fade: def.fade ?? 1, volume: 0.001 })
.then((handle) => {
// Слой уже не нужен или луп не декодировался — без хвостов.
if (!handle || !this.wanted.has(def.key)) {
if (this.layers.get(def.key) === pending) this.layers.delete(def.key);
handle?.stop(0.5);
return;
}
pending.handle = handle;
});
}
}
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);
}
}