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)
134 lines
4.5 KiB
JavaScript
134 lines
4.5 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 МБ на модель.
|
||
*/
|
||
|
||
export const MAX_GLB = 6;
|
||
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();
|
||
}
|
||
}
|