Newer
Older
rpg / apps / game / src / systems / LocationAudio.ts
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);
    }
}