studio/src/editor/engine/SoundManager.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

231 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<AudioBuffer|null>.
*/
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;
}
}