Newer
Older
rpg / apps / game / src / systems / AudioSystem.ts
import {
    worldToScreen,
    EventBus,
    type AudioManager,
    type MusicHandle,
    type Vec2
} from '@rpg/engine';
import { Game } from '../Game';
import { playSfx } from '../data/sfxSpecs';
import { StepVariants, type StepSurface } from './StepVariants';

/**
 * Позиционный звук мира: громкость и панорама от расстояния до героя,
 * звуки боя — подпиской на события шины (симуляция чиста), локальные
 * амбиент-слои — лупами в шине 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 layers = new Map<string, { def: AudioLayerDef; handle: MusicHandle | null; volume: number }>();
    /** Ключи запрошенных слоёв (и до асинхронного старта лупов). */
    private wanted = new Set<string>();
    /** Вариативные шаги (процедурные буферы; до unlock — файловый шаг). */
    private steps: StepVariants;

    constructor(private deps: AudioSystemDeps) {
        this.steps = new StepVariants(deps.audio);
    }

    /** Тик: часы полифонии + доводка громкости слоёв к целям (fade сек до target). */
    update(dt: number): void {
        this.clock += dt;
        for (const layer of this.layers.values()) {
            if (!layer.handle) continue; // луп ещё грузится
            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 });
    }

    /**
     * Шаг по поверхности ('grass'|'ash'|'water'|'floor'): процедурный вариант
     * из StepVariants (до unlock — файловый шаг), дедуп по поверхности.
     */
    playStep(surface: StepSurface, 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;
        if ((this.lastAt.get(`step:${surface}`) ?? -1) + MIN_INTERVAL > this.clock) return;
        this.lastAt.set(`step:${surface}`, this.clock);
        this.steps.play(surface, volume);
    }

    /** Подписки на события боя и фауны; возвращает 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 })),
            on.on<{ pos: Vec2 }>('fauna:startle', ({ pos }) => this.playAt('sfx/deer_startle', pos, 0.7))
        ];
        return () => this.off();
    }

    /** Отписаться от событий (сцена сменяется). */
    off(): void {
        for (const off of this.offs) off();
        this.offs = [];
    }

    /**
     * Локальные амбиент-слои (вода, гул, дома) — лупы в шине ambience поверх
     * областного. Вызывать каждый тик с текущим набором: лишние стопятся,
     * громкости доводятся в update.
     */
    setLayers(defs: AudioLayerDef[]): void {
        this.wanted = new Set(defs.map((d) => d.key));
        for (const [key, layer] of this.layers) {
            if (!this.wanted.has(key)) {
                layer.handle?.stop(1); // pending-луп остановит .then (слой уже не нужен)
                this.layers.delete(key);
            }
        }
        for (const def of defs) {
            const existing = this.layers.get(def.key);
            if (existing) {
                existing.def = def;
                continue;
            }
            // Синхронная пометка «слой занят»: повторный вызов до резолва лупа
            // не перезапускает его (playLoop асинхронен).
            const pending: { def: AudioLayerDef; handle: MusicHandle | null; volume: number } = {
                def,
                handle: null,
                volume: 0.001
            };
            this.layers.set(def.key, pending);
            void this.deps.audio
                .playLoop(def.key, { bus: 'ambience', fade: def.fade ?? 1, volume: 0.001 })
                .then((handle) => {
                    // Слой уже не нужен или луп не декодировался — без хвостов.
                    if (!handle || !this.wanted.has(def.key)) {
                        if (this.layers.get(def.key) === pending) this.layers.delete(def.key);
                        handle?.stop(0.5);
                        return;
                    }
                    pending.handle = handle;
                });
        }
    }

    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);
        playSfx(this.deps.audio, key, opts);
    }
}