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.8 KiB
JavaScript
136 lines
4.8 KiB
JavaScript
/**
|
||
* GlbLibrary — библиотека импортированных пользователем 3D-моделей .glb
|
||
* (Фаза 5.8).
|
||
*
|
||
* Зачем: автор может загрузить свою glTF/GLB-модель и ставить её в сцену
|
||
* как обычную модель — через палитру или scene.spawn('glb:<id>').
|
||
*
|
||
* Как хранится: каждая модель — { id, name, dataUrl }, dataUrl — base64
|
||
* GLB. Сериализуется в scene.glbModels, едет в БД с проектом (своего
|
||
* файлового хранилища у Рублокс-веба нет — как звуки/картинки).
|
||
*
|
||
* Ограничения (GLB в base64 сильно раздувает JSON проекта):
|
||
* MAX_GLB — не больше 6 моделей на проект;
|
||
* MAX_BYTES — не больше 4 МБ на модель.
|
||
*/
|
||
|
||
// Phase 6.7: лимиты увеличены для эталонных игр (Tower of Hell, Магазин, Tycoon).
|
||
// 20 моделей × 4 МБ = до 80 МБ на проект -- крупные игры с GLB-ассетами влезают.
|
||
export const MAX_GLB = 20;
|
||
export const MAX_GLB_BYTES = 4 * 1024 * 1024;
|
||
|
||
let _glbIdSeq = 1;
|
||
|
||
export class GlbLibrary {
|
||
constructor() {
|
||
// Map id → { id, name, dataUrl }
|
||
this.models = new Map();
|
||
}
|
||
|
||
/** Все модели массивом — для UI-панели и палитры. */
|
||
list() {
|
||
return [...this.models.values()];
|
||
}
|
||
|
||
get(id) {
|
||
return this.models.get(id) || null;
|
||
}
|
||
|
||
/** dataURL по id — base64 GLB для SceneLoader. */
|
||
getDataUrl(id) {
|
||
const m = this.models.get(id);
|
||
return m ? m.dataUrl : null;
|
||
}
|
||
|
||
count() {
|
||
return this.models.size;
|
||
}
|
||
|
||
/**
|
||
* Загрузить .glb из File (input type=file).
|
||
* Возвращает Promise<{ ok, id?, error? }>.
|
||
*/
|
||
addFromFile(file) {
|
||
return new Promise((resolve) => {
|
||
if (this.models.size >= MAX_GLB) {
|
||
resolve({ ok: false, error: `Лимит ${MAX_GLB} моделей на проект` });
|
||
return;
|
||
}
|
||
const name = (file && file.name) || '';
|
||
if (!/\.(glb|gltf)$/i.test(name)) {
|
||
resolve({ ok: false, error: 'Нужен файл .glb или .gltf' });
|
||
return;
|
||
}
|
||
if (file.size > MAX_GLB_BYTES) {
|
||
resolve({
|
||
ok: false,
|
||
error: `Модель слишком большая (макс ${Math.round(MAX_GLB_BYTES / 1024 / 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_GLB_BYTES * 1.4) {
|
||
resolve({ ok: false, error: 'Модель слишком большая' });
|
||
return;
|
||
}
|
||
const cleanName = name.replace(/\.[^.]+$/, '');
|
||
const id = this.add(cleanName, dataUrl);
|
||
resolve({ ok: true, id });
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
/** Добавить модель с готовым dataUrl. Возвращает id. */
|
||
add(name, dataUrl) {
|
||
const id = `glb_${_glbIdSeq++}`;
|
||
this.models.set(id, { id, name: name || 'модель', dataUrl });
|
||
return id;
|
||
}
|
||
|
||
rename(id, name) {
|
||
const m = this.models.get(id);
|
||
if (m) m.name = name || m.name;
|
||
}
|
||
|
||
remove(id) {
|
||
this.models.delete(id);
|
||
}
|
||
|
||
// ============ STATE ============
|
||
|
||
serialize() {
|
||
return this.list().map(m => ({
|
||
id: m.id, name: m.name, dataUrl: m.dataUrl,
|
||
}));
|
||
}
|
||
|
||
load(data) {
|
||
this.models.clear();
|
||
if (!Array.isArray(data)) return;
|
||
let maxNum = 0;
|
||
for (const m of data) {
|
||
if (!m || typeof m.id !== 'string' || typeof m.dataUrl !== 'string') continue;
|
||
this.models.set(m.id, {
|
||
id: m.id,
|
||
name: typeof m.name === 'string' ? m.name : 'модель',
|
||
dataUrl: m.dataUrl,
|
||
});
|
||
const mt = /^glb_(\d+)$/.exec(m.id);
|
||
if (mt) maxNum = Math.max(maxNum, Number(mt[1]));
|
||
}
|
||
if (maxNum >= _glbIdSeq) _glbIdSeq = maxNum + 1;
|
||
}
|
||
|
||
dispose() {
|
||
this.models.clear();
|
||
}
|
||
}
|