/** * SoundManager — воспроизведение пользовательских звуков (Фаза 5.5). * * Берёт dataUrl из SoundLibrary, декодирует через Web Audio и проигрывает. * Поддерживает 3D-позиционный звук: громкость падает с расстоянием от * игрока до точки звука. Звук может быть: * - 2D (обычный, фикс. громкость) — UI-клики, музыка; * - 3D в точке мира — взрыв, выстрел в точке; * - 3D, привязанный к объекту — звук следует за объектом, громкость * обновляется каждый кадр. * * Живёт только в Play-режиме. */ // Дистанция, дальше которой 3D-звук не слышно (м). const MAX_HEAR_DIST = 40; // Дистанция, ближе которой громкость максимальная (м). const FULL_VOLUME_DIST = 4; let _soundInstSeq = 1; export class SoundManager { constructor(scene3d) { this.scene3d = scene3d; this.scene = scene3d.scene; this._ctx = null; // Декодированные AudioBuffer по soundId (кэш — декодируем один раз). this._buffers = new Map(); // ВСЕ играющие звуки (2D и 3D) по instId: {instId,gain,src,getPos,baseVol}. this._byInstId = new Map(); // Активные 3D-звуки — подмножество _byInstId, для обновления // громкости в _tick каждый кадр. this._active = new Map(); // instId звуков, остановленных через stopSound ДО конца декодирования // — play().then() их не стартует. this._stoppedIds = new Set(); this._renderHook = null; // Идёт ли Play-сессия. play() стартует звук в async-then — // если за это время был Stop, _running=false и звук НЕ стартует // (иначе звук «переживает» Stop из-за гонки с декодированием). this._running = false; } start() { this._running = true; if (this._renderHook) return; this._ensureCtx(); this._renderHook = () => this._tick(); this.scene.registerBeforeRender(this._renderHook); } stop() { this._running = false; if (this._renderHook) { try { this.scene.unregisterBeforeRender(this._renderHook); } catch (e) {} this._renderHook = null; } // Останавливаем ВСЕ играющие звуки (2D и 3D). for (const a of this._byInstId.values()) { try { a.src.stop(); } catch (e) { /* ignore */ } } this._byInstId.clear(); this._active.clear(); this._stoppedIds.clear(); } _ensureCtx() { if (this._ctx) return; try { const Ctx = window.AudioContext || window.webkitAudioContext; if (Ctx) this._ctx = new Ctx(); } catch (e) { /* ignore */ } } /** * Получить AudioBuffer звука (декодирует и кэширует). * Возвращает Promise. */ async _getBuffer(soundId) { if (this._buffers.has(soundId)) return this._buffers.get(soundId); const lib = this.scene3d.soundLibrary; const dataUrl = lib && lib.getDataUrl(soundId); if (!dataUrl || !this._ctx) return null; try { // dataUrl → ArrayBuffer → decodeAudioData. const resp = await fetch(dataUrl); const arr = await resp.arrayBuffer(); const buf = await this._ctx.decodeAudioData(arr); this._buffers.set(soundId, buf); return buf; } catch (e) { return null; } } /** * Проиграть звук. opts: * { volume: 0..1 (базовая громкость), * loop: bool, * at: {x,y,z} — 3D-точка (громкость по дистанции), * attachRef: ref-строка — 3D-звук следует за объектом }. * Возвращает instId (для stopSound) или null. */ play(soundId, opts = {}) { this._ensureCtx(); if (!this._ctx) return null; // Web Audio мог быть suspended (autoplay policy) — будим. if (this._ctx.state === 'suspended') { this._ctx.resume().catch(() => {}); } const instId = _soundInstSeq++; const baseVol = Number.isFinite(opts.volume) ? opts.volume : 1; const loop = !!opts.loop; // getPos — функция текущей позиции источника, или null для 2D-звука. let getPos = null; if (opts.attachRef) { getPos = () => this._resolvePos(opts.attachRef); } else if (opts.at && Number.isFinite(opts.at.x)) { const fixed = { x: opts.at.x, y: opts.at.y, z: opts.at.z }; getPos = () => fixed; } // Декодируем (async) и запускаем. this._getBuffer(soundId).then((buf) => { if (!buf) return; // За время декодирования мог быть Stop — не стартуем «осиротевший» // звук (иначе он играет уже после выхода из Play). if (!this._running) return; // Звук мог быть остановлен через stop() ещё ДО декодирования — // тогда он помечен в _stoppedIds, не стартуем. if (this._stoppedIds.has(instId)) { this._stoppedIds.delete(instId); return; } const src = this._ctx.createBufferSource(); src.buffer = buf; src.loop = loop; const gain = this._ctx.createGain(); gain.gain.value = getPos ? this._volumeAt(getPos(), baseVol) // 3D — по дистанции : baseVol; // 2D — фикс. src.connect(gain); gain.connect(this._ctx.destination); src.start(); // Единый реестр ВСЕХ звуков (2D и 3D) по instId — для stopSound. this._byInstId.set(instId, { instId, gain, src, getPos, baseVol }); if (getPos) { // 3D-звук — обновляем громкость в _tick (подмножество _byInstId). this._active.set(instId, this._byInstId.get(instId)); } // Звук завершился (или остановлен) — снимаем из реестров. src.onended = () => { this._active.delete(instId); this._byInstId.delete(instId); }; }); return instId; } /** Остановить звук по instId — работает для 2D и 3D. */ stopSound(instId) { const id = Number(instId); const a = this._byInstId.get(id); if (a) { try { a.src.stop(); } catch (e) { /* ignore */ } this._byInstId.delete(id); this._active.delete(id); } else { // Звук ещё декодируется (src не создан) — помечаем, чтобы // play().then() не стартовал его. this._stoppedIds.add(id); } } // ===== внутреннее ===== _tick() { if (this._active.size === 0) return; for (const a of this._active.values()) { const pos = a.getPos(); if (!pos) continue; const v = this._volumeAt(pos, a.baseVol); try { a.gain.gain.value = v; } catch (e) { /* ignore */ } } } /** Громкость 3D-звука в точке pos с учётом дистанции до игрока. */ _volumeAt(pos, baseVol) { if (!pos) return 0; const p = this.scene3d.player && this.scene3d.player._pos; if (!p) return baseVol; const d = Math.hypot(pos.x - p.x, (pos.y || 0) - p.y, pos.z - p.z); if (d <= FULL_VOLUME_DIST) return baseVol; if (d >= MAX_HEAR_DIST) return 0; // Линейное затухание между FULL_VOLUME_DIST и MAX_HEAR_DIST. const k = 1 - (d - FULL_VOLUME_DIST) / (MAX_HEAR_DIST - FULL_VOLUME_DIST); return baseVol * Math.max(0, k); } /** Позиция объекта по ref ('player' | 'primitive:N' | 'model:N'). */ _resolvePos(ref) { if (ref === 'player') { const p = this.scene3d.player && this.scene3d.player._pos; return p ? { x: p.x, y: p.y, z: p.z } : null; } const rt = this.scene3d.gameRuntime; let r = ref; if (rt && rt._localToReal && rt._localToReal.has(r)) { r = rt._localToReal.get(r); } const colon = r.indexOf(':'); if (colon < 0) return null; const kind = r.slice(0, colon); const rest = r.slice(colon + 1); if (kind === 'block') { const [bx, by, bz] = rest.split(',').map(Number); if ([bx, by, bz].every(Number.isFinite)) return { x: bx, y: by, z: bz }; return null; } const mgr = kind === 'primitive' ? this.scene3d.primitiveManager : (kind === 'model' ? this.scene3d.modelManager : null); if (!mgr || !mgr.instances) return null; let d = mgr.instances.get(rest); if (!d) { const n = Number(rest); if (Number.isFinite(n)) d = mgr.instances.get(n); } return d ? { x: d.x, y: d.y, z: d.z } : null; } }