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)
231 lines
9.9 KiB
JavaScript
231 lines
9.9 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|