Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
136 lines
4.7 KiB
JavaScript
136 lines
4.7 KiB
JavaScript
/**
|
||
* SoundLibrary — библиотека пользовательских звуков проекта (Фаза 5.5).
|
||
*
|
||
* Зачем: даёт автору загрузить свои короткие звуки (mp3/wav/ogg) и
|
||
* проигрывать их в игре через game.sound.* — в т.ч. как 3D-позиционный
|
||
* звук от точки в мире.
|
||
*
|
||
* Как хранится: каждый звук — { id, name, dataUrl }, где dataUrl —
|
||
* base64 аудио. Сериализуется в scene.sounds, едет в БД с проектом
|
||
* (своего файлового хранилища у Рублокс-веба нет — как картинки в
|
||
* AssetManager).
|
||
*
|
||
* Ограничения (звук в base64 раздувает JSON проекта):
|
||
* MAX_SOUNDS — не больше 8 звуков на проект;
|
||
* MAX_BYTES — не больше 2 МБ на звук (mp3 ~30-60 сек; для коротких
|
||
* фоновых треков и эффектов хватает).
|
||
*/
|
||
|
||
export const MAX_SOUNDS = 8;
|
||
export const MAX_SOUND_BYTES = 2 * 1024 * 1024;
|
||
|
||
let _soundIdSeq = 1;
|
||
|
||
export class SoundLibrary {
|
||
constructor() {
|
||
// Map id → { id, name, dataUrl }
|
||
this.sounds = new Map();
|
||
}
|
||
|
||
/** Все звуки массивом — для UI-панели. */
|
||
list() {
|
||
return [...this.sounds.values()];
|
||
}
|
||
|
||
get(id) {
|
||
return this.sounds.get(id) || null;
|
||
}
|
||
|
||
/** dataURL по id — то, что отдаём в Audio / decodeAudioData. */
|
||
getDataUrl(id) {
|
||
const s = this.sounds.get(id);
|
||
return s ? s.dataUrl : null;
|
||
}
|
||
|
||
count() {
|
||
return this.sounds.size;
|
||
}
|
||
|
||
/**
|
||
* Загрузить звук из File (input type=file).
|
||
* Возвращает Promise<{ ok, id?, error? }>.
|
||
*/
|
||
addFromFile(file) {
|
||
return new Promise((resolve) => {
|
||
if (this.sounds.size >= MAX_SOUNDS) {
|
||
resolve({ ok: false, error: `Лимит ${MAX_SOUNDS} звуков на проект` });
|
||
return;
|
||
}
|
||
if (!file || !/^audio\//.test(file.type)) {
|
||
resolve({ ok: false, error: 'Это не звуковой файл' });
|
||
return;
|
||
}
|
||
// Грубая проверка размера ДО чтения (base64 ~+33% сверху).
|
||
if (file.size > MAX_SOUND_BYTES) {
|
||
resolve({
|
||
ok: false,
|
||
error: `Звук слишком большой (макс ${Math.round(MAX_SOUND_BYTES / 1024)} КБ)`,
|
||
});
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onerror = () => resolve({ ok: false, error: 'Не удалось прочитать файл' });
|
||
reader.onload = () => {
|
||
const dataUrl = reader.result;
|
||
if (typeof dataUrl !== 'string') {
|
||
resolve({ ok: false, error: 'Битый файл' });
|
||
return;
|
||
}
|
||
if (dataUrl.length > MAX_SOUND_BYTES * 1.4) {
|
||
resolve({ ok: false, error: 'Звук слишком большой' });
|
||
return;
|
||
}
|
||
const name = (file.name || 'звук').replace(/\.[^.]+$/, '');
|
||
const id = this.add(name, dataUrl);
|
||
resolve({ ok: true, id });
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
/** Добавить звук с готовым dataUrl. Возвращает id. */
|
||
add(name, dataUrl) {
|
||
const id = `sound_${_soundIdSeq++}`;
|
||
this.sounds.set(id, { id, name: name || 'звук', dataUrl });
|
||
return id;
|
||
}
|
||
|
||
rename(id, name) {
|
||
const s = this.sounds.get(id);
|
||
if (s) s.name = name || s.name;
|
||
}
|
||
|
||
remove(id) {
|
||
this.sounds.delete(id);
|
||
}
|
||
|
||
// ============ STATE ============
|
||
|
||
serialize() {
|
||
return this.list().map(s => ({
|
||
id: s.id, name: s.name, dataUrl: s.dataUrl,
|
||
}));
|
||
}
|
||
|
||
load(data) {
|
||
this.sounds.clear();
|
||
if (!Array.isArray(data)) return;
|
||
let maxNum = 0;
|
||
for (const s of data) {
|
||
if (!s || typeof s.id !== 'string' || typeof s.dataUrl !== 'string') continue;
|
||
this.sounds.set(s.id, {
|
||
id: s.id,
|
||
name: typeof s.name === 'string' ? s.name : 'звук',
|
||
dataUrl: s.dataUrl,
|
||
});
|
||
const m = /^sound_(\d+)$/.exec(s.id);
|
||
if (m) maxNum = Math.max(maxNum, Number(m[1]));
|
||
}
|
||
if (maxNum >= _soundIdSeq) _soundIdSeq = maxNum + 1;
|
||
}
|
||
|
||
dispose() {
|
||
this.sounds.clear();
|
||
}
|
||
}
|