import { createRng, RATE, renderMusicStereo, renderSpec, type AudioManager, type MusicHandle, type Vec2 } from '@rpg/engine';
import { AudioSystem, type AudioLayerDef } from './AudioSystem';
import { playSfx, ROOM_TONES } from '../data/sfxSpecs';
import { TENSION_STEM } from '../data/music';
import type { AmbienceLayerDef, AreaDef } from '../data/locations';
/**
* Аудио-подсистема локации: позиционные слои (громкость от дистанции до
* источника), room tone интерьеров, редкие шорохи пепла и динамическая
* музыка угрозы (tension-стем под темой области). Сцена кормит её тиком
* и уровнем угрозы боевого мира; свои лупы гасит сама в exit().
*/
export interface LocationAudioDeps {
audio: AudioManager;
/** Позиционный звук мира (шаги, звуки боя) — тикается сценой отдельно. */
worldAudio: AudioSystem;
area: AreaDef;
/** Позиции тайлов-источников амбиент-слоёв по id (собирает сцена). */
layerTilePos: Map<number, Vec2[]>;
/** Слушатель — герой (мировые юниты). */
heroPos: () => Vec2;
/** Тема области/боя (Game.playTheme: кроссфейд, '' — тишина). */
playTheme: (key: string) => void;
/** Уровень угрозы боевого мира 0..1 (погоня — 1, wary — 0.4). */
threat: () => number;
}
export class LocationAudio {
/** Tension-стем (враги wary): буфер + хендл + сглаженная громкость. */
private stemHandle: MusicHandle | null = null;
private stemBuf: AudioBuffer | null = null;
private stemLevel = 0;
/** Стем заводится (промис не вернулся) — не заводить второй. */
private stemPending = false;
/** Room tone интерьера (тихий гул на шине ambience). */
private roomtoneHandle: MusicHandle | null = null;
/** Дикий мир: редкие дальние шорохи пепла (rng детерминирован). */
private ambientRng = createRng(20260908);
private rustleIn = 6;
/** exit() вызван: гасим хвосты асинхронных заводок (roomtone/стем). */
private exited = false;
constructor(private deps: LocationAudioDeps) {}
/** Room tone интерьера (см. ROOM_TONES): тихий луп на шине ambience. */
startRoomTone(): void {
const spec = ROOM_TONES[this.deps.area.id];
if (!spec) return;
const buf = this.deps.audio.createBuffer(renderSpec(spec));
if (!buf) return; // до unlock контекста нет — тон появится при следующем входе
const key = this.deps.area.id;
void this.deps.audio
.playLoopBuffer(`roomtone/${key}`, buf, { bus: 'ambience', fade: 2, volume: 0.6 })
.then((h) => {
// Сцена уже вышла, пока луп заводился, — гасим без хвостов.
if (this.exited) {
h?.stop(1);
return;
}
this.roomtoneHandle = h;
});
}
/** Погасить свои лупы (exit сцены) — без хвостов поверх следующей сцены. */
exit(): void {
this.exited = true;
this.roomtoneHandle?.stop(1);
this.roomtoneHandle = null;
this.stemHandle?.stop(1);
this.stemHandle = null;
this.stemBuf = null;
this.stemPending = false;
this.stemLevel = 0;
}
/**
* Тик амбиент-слоёв: громкость каждого — от дистанции героя до ближайшего
* источника (тайлы или точка); глобальный слой (без источников) — константа.
* Пустой набор — setLayers сам снимет лишние.
*/
updateLayers(): void {
const p = this.deps.heroPos();
const defs: AudioLayerDef[] = [];
for (const layer of this.deps.area.ambienceLayers ?? []) {
const d = this.layerDistance(layer, p);
const k = layer.radius === undefined ? 1 : Math.max(0, 1 - d / layer.radius);
const volume = (layer.volume ?? 0.5) * k;
if (volume > 0.01) defs.push({ key: layer.key, volume, fade: 0.5 });
}
this.deps.worldAudio.setLayers(defs);
}
/**
* Дистанция до ближайшего источника слоя (юниты; тайл — центр ромба).
* Слой без at и tiles — глобальный (ветер): источников нет, дистанция 0.
*/
private layerDistance(layer: AmbienceLayerDef, p: Vec2): number {
if (!layer.at && !(layer.tiles && layer.tiles.length > 0)) return 0;
if (layer.at) return Math.hypot(p.x - (layer.at.x + 0.5), p.y - (layer.at.y + 0.5));
let best = Infinity;
for (const id of layer.tiles ?? []) {
for (const tile of this.deps.layerTilePos.get(id) ?? []) {
const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5));
if (d < best) best = d;
}
}
return best;
}
/** Редкие дальние шорохи пепла: панорама и высота — случайные (rng сцены). */
updateAmbient(dt: number): void {
this.rustleIn -= dt;
if (this.rustleIn > 0) return;
this.rustleIn = 10 + this.ambientRng.next() * 20;
playSfx(this.deps.audio, 'sfx/rustle', {
volume: 0.25 + this.ambientRng.next() * 0.15,
pan: (this.ambientRng.next() * 2 - 1) * 0.7,
rate: 0.85 + this.ambientRng.next() * 0.4
});
}
/**
* Динамическая музыка по уровню угрозы: погоня — боевая тема вместо темы
* области; настороженность — тихий tension-стем под темой (громкость
* по угрозе, сглаженная); спокойствие — тема области. Сцена зовёт после
* combat.update — угроза свежая.
*/
updateMusic(dt: number): void {
const threat = this.deps.threat();
// Боевая тема вытесняет тему области; обратно — как только погоня кончилась.
this.deps.playTheme(threat >= 1 ? 'combat' : (this.deps.area.theme ?? ''));
// Стем: тихо в бою (тема боя сама громкая), громче при wary, ноль в тишине.
const target = threat >= 1 ? 0.15 : threat > 0 ? 0.6 * threat : 0;
if (!this.stemHandle && !this.stemPending && target > 0) {
this.stemBuf ??= this.deps.audio.createBuffer(renderMusicStereo(TENSION_STEM), RATE, 2);
if (this.stemBuf) {
this.stemPending = true;
void this.deps.audio
.playLoopBuffer('music/tension', this.stemBuf, { fade: 1, volume: 0 })
.then((h) => {
this.stemPending = false;
if (!h) return;
// Сцена сменилась, пока луп заводился, — гасим без хвостов.
if (!this.stemBuf) h.stop(1);
else this.stemHandle = h;
});
}
}
// Громкость ведём плавно (dt-подход), stop в exit.
this.stemLevel += (target - this.stemLevel) * Math.min(1, dt * 2);
this.stemHandle?.setVolume(this.stemLevel * 0.8);
}
}