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 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);
}
}