/**
* Аудио демки (перенос обвязки v1): sfx на спек-синтезе движка, позиционный
* звук мира (затухание по дистанции + панорама по изо-экрану) и шаги с
* вариациями. Спеки — формулы v1 (apps/game/src/data/sfxSpecs.ts), только
* нужные демке ключи; WAV-пайплайна в v2 нет — всё синтезируется в рантайме.
*/
import { renderSpec, type AudioManager, type PlayOptions, type SoundSpec } from '@rpg/engine';
import type { Hor } from '@rpg/engine';
/** Спек-звуки демки: бой, урон героя и шаги (пепел/трава). */
export const SPEC_SFX: Record<string, SoundSpec> = {
// Звон удара конусом: 1 партиал + клик, normalize 0.7 (v1 bell_hit)
'sfx/bell_hit': { kind: 'chime', dur: 0.35, freq: 520, power: 5, seed: 1, peak: 0.7 },
// Низкий звон резонанса: три тона с тремоло 6 Гц (v1 bell_low)
'sfx/bell_low': {
kind: 'tone', dur: 0.9, freq: 110, w: 0.5, power: 2, peak: 0.6,
wobbleHz: 6, wobbleDepth: 0.3,
layers: [{ freq: 165, w: 0.25 }, { freq: 220.5, w: 0.12 }],
},
// Плевок плевуна: тон со свипом 700→350 Гц + шипящий хвост (v1 spit)
'sfx/spit': {
kind: 'tone', dur: 0.2, freq: 700, freqTo: 350, w: 0.6, power: 3, peak: 0.5,
layers: [{ low: 2000, high: 6000, seed: 37, w: 0.4 }],
},
// Пробуждение сгустка: шипение с нарастанием 0.08 с (v1 ash_hiss)
'sfx/ash_hiss': { kind: 'scrape', dur: 0.4, low: 900, high: 4200, seed: 11, power: 2, attack: 0.08, peak: 0.55 },
// Развеивание сгустка: шум, громкость спадает вдвое к концу (v1 ash_die)
'sfx/ash_die': { kind: 'scrape', dur: 0.6, low: 300, high: 2400, seed: 23, power: 3, fadeTo: 0.5, peak: 0.6 },
// Урон герою: глухой удар 90 Гц + низкий шум (v1 hurt)
'sfx/hurt': {
kind: 'tone', dur: 0.25, freq: 90, w: 0.8, power: 3, peak: 0.65,
layers: [{ low: 80, high: 400, seed: 41, w: 0.3, dur: 0.125, power: 2 }],
},
// Шаги: до unlock играет базовый спек, варианты — сид + i·10 (StepVariants)
'sfx/step_ash': { kind: 'hit', dur: 0.14, low: 200, high: 1100, seed: 254, power: 2.5, peak: 0.3 },
'sfx/step_grass': { kind: 'hit', dur: 0.12, low: 400, high: 2000, seed: 253, power: 2, peak: 0.3 },
};
/** Единая точка sfx по ключу (спек-звук компилирует движок, буфер кэшируется). */
export function playSfx(audio: AudioManager, key: string, opts?: number | PlayOptions): void {
const spec = SPEC_SFX[key];
if (spec) void audio.playSpec(key, spec, opts);
else void audio.play(key, opts);
}
/** Поверхности шагов демки. */
export type StepSurface = 'grass' | 'ash';
/**
* Вариативные шаги (перенос v1): 3 варианта на поверхность, сид варианта =
* базовый + i·10 — звучат похоже, но не идентично. Буферы синтезируются при
* первом успешном шаге (до unlock контекста нет — играет базовый спек).
*/
const STEP_VARIANTS = 3;
export class StepVariants {
private cache = new Map<StepSurface, AudioBuffer[]>();
private seq = 0;
constructor(private audio: AudioManager) {}
/** Играть очередной вариант шага (до готовности буферов — базовый спек). */
play(surface: StepSurface, volume: number): void {
const key = `sfx/step_${surface}`;
const list = this.ensure(surface);
if (list.length > 0) {
const i = this.seq++ % list.length;
void this.audio.playBuffer(`${key}#${i}`, list[i]!, { volume });
} else {
playSfx(this.audio, key, { volume });
}
}
/** Синтезировать варианты поверхности (нет контекста — пустой список). */
private ensure(surface: StepSurface): AudioBuffer[] {
const existing = this.cache.get(surface);
if (existing) return existing;
const base = SPEC_SFX[`sfx/step_${surface}`];
if (!base) return [];
const list: AudioBuffer[] = [];
for (let i = 0; i < STEP_VARIANTS; i++) {
const buf = this.audio.createBuffer(renderSpec({ ...base, seed: (base.seed ?? 1) + i * 10 }));
if (!buf) return []; // контекста нет — попробуем на следующем шаге
list.push(buf);
}
this.cache.set(surface, list);
return list;
}
}
/** Минимальный интервал между запусками одного ключа (полифония, v1). */
const MIN_INTERVAL = 0.08;
/** Панорама источника: изо-экранный сдвиг ∝ (dx − dz), половина кадра 240 px. */
function panOf(source: Hor, listener: Hor): number {
const dx = (source.x - listener.x) * 16; // 16 px/юнит (тайл v1 32/2)
return Math.max(-0.8, Math.min(0.8, dx / 240));
}
/**
* Позиционный звук мира демки: затухание (1 − d/радиус)^1.5, за радиусом —
* тишина; панорама по разности изо-экранных X источника и слушателя. Шаги —
* дедуп по поверхности. Слушатель — герой.
*/
export class DemoAudio {
private clock = 0;
private lastAt = new Map<string, number>();
private steps: StepVariants;
constructor(
private audio: AudioManager,
/** Позиция слушателя (герой) в боевых юнитах. */
private getListener: () => Hor
) {
this.steps = new StepVariants(audio);
}
/** Часы полифонии (тик демки). */
update(dt: number): void {
this.clock += dt;
}
/** Звук в точке мира (юниты): затухание по дистанции + панорама. */
playAt(key: string, pos: Hor, base = 1, radius = 12): void {
const l = this.getListener();
const d = Math.hypot(pos.x - l.x, pos.z - l.z);
const falloff = Math.max(0, 1 - d / radius);
const volume = base * Math.pow(falloff, 1.5);
if (volume < 0.02) return;
this.play(key, { volume, pan: panOf(pos, l) });
}
/** Шаг героя по поверхности: слышен только вблизи слушателя. */
playStep(surface: StepSurface, pos: Hor, base = 1): void {
const l = this.getListener();
const d = Math.hypot(pos.x - l.x, pos.z - l.z);
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);
}
/** Разовый sfx с дедупом по ключу (боевые подписки демки). */
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.audio, key, opts);
}
}