/** * GlbLibrary — библиотека импортированных пользователем 3D-моделей .glb * (Фаза 5.8). * * Зачем: автор может загрузить свою glTF/GLB-модель и ставить её в сцену * как обычную модель — через палитру или scene.spawn('glb:'). * * Как хранится: каждая модель — { 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(); } }