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