import { RATE, renderMusicStereo, renderSpec, type AudioManager, type MusicHandle, type Vec2 } from '@rpg/engine';
import { AudioSystem, type AudioLayerDef } from './AudioSystem';
import { 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;
/** Амбиент области (Game.playAmbience: кроссфейд 1.5 с, тот же ключ не перезапускает). */
playAmbience: (key: string) => void;
/** Фактор ночи 0..1 (кривая суток, как у света). */
nightFactor: () => number;
/** Уровень угрозы боевого мира 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;
/** exit() вызван: гасим хвосты асинхронных заводок (roomtone/стем). */
private exited = false;
/** Текущий выбор амбиента день/ночь (для гистерезиса порога). */
private nightAmbient = 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, positions и tiles — глобальный (ветер): источников нет.
*/
private layerDistance(layer: AmbienceLayerDef, p: Vec2): number {
if (!layer.at && !(layer.positions && layer.positions.length > 0) && !(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 tile of layer.positions ?? []) {
const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5));
if (d < best) best = d;
}
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;
}
/**
* Амбиент дня/ночи: за порогом кривой суток (с гистерезисом ±0.05, чтобы
* не трещало на закате) играем ночной луп области — кроссфейд даёт сам
* Game.playAmbience (1.5 с). Ночной ключ — суффикс `_night` у дневного.
*/
updateDayNightAmbient(): void {
const base = this.deps.area.ambience;
if (!base) return; // интерьеры без амбиента — нечего переключать
const night = this.deps.nightFactor() >= (this.nightAmbient ? 0.45 : 0.55);
if (night === this.nightAmbient) return;
this.nightAmbient = night;
this.deps.playAmbience(night ? `${base}_night` : base);
}
/** Начальный амбиент (enter сцены): день или ночь по текущим часам. */
startAmbient(): void {
this.nightAmbient = this.deps.nightFactor() >= 0.5;
const base = this.deps.area.ambience;
if (base) this.deps.playAmbience(this.nightAmbient ? `${base}_night` : base);
}
/**
* Динамическая музыка по уровню угрозы: погоня — боевая тема вместо темы
* области; настороженность — тихий 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);
}
}