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)
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();
|
||
}
|
||
}
|