/** * AssetManager — библиотека пользовательских картинок проекта. * * Зачем: даёт автору игры загрузить свои PNG/JPG и использовать их * - как текстуру на гранях примитива (поле `textureAsset` в данных примитива); * - как картинку в image-GUI (поле `imageAsset` у GUI-элемента). * * Как хранится: каждый ассет — { id, name, dataUrl }, где dataUrl — * base64-PNG. Библиотека сериализуется в scene.assets и едет в БД вместе * с проектом (своего файлового хранилища у Рублокс-веба пока нет, поэтому * картинки встроены в JSON — так же как аватары пользователей в нативе). * * Ограничения (чтобы JSON проекта не раздувался и не ломал автосейв): * MAX_ASSETS — не больше 24 картинок на проект; * MAX_SIDE — картинка ужимается до 512px по большей стороне; * приводится к PNG через canvas — единый формат, отсекает EXIF и т.п. */ export const MAX_ASSETS = 24; export const MAX_SIDE = 512; let _idSeq = 1; export class AssetManager { constructor() { // Map id → { id, name, dataUrl } this.assets = new Map(); } /** Все ассеты массивом — для UI-панели. */ list() { return [...this.assets.values()]; } get(id) { return this.assets.get(id) || null; } /** dataURL по id — то, что отдаём в Babylon Texture / . */ getDataUrl(id) { const a = this.assets.get(id); return a ? a.dataUrl : null; } count() { return this.assets.size; } /** * Загрузить картинку из File (input type=file). * Возвращает Promise<{ ok, id?, error? }>. * Картинка нормализуется: ужимается до MAX_SIDE, конвертится в PNG. */ addFromFile(file) { return new Promise((resolve) => { if (this.assets.size >= MAX_ASSETS) { resolve({ ok: false, error: `Лимит ${MAX_ASSETS} картинок на проект` }); return; } if (!file || !/^image\//.test(file.type)) { resolve({ ok: false, error: 'Это не картинка' }); return; } const reader = new FileReader(); reader.onerror = () => resolve({ ok: false, error: 'Не удалось прочитать файл' }); reader.onload = () => { const img = new Image(); img.onerror = () => resolve({ ok: false, error: 'Битая картинка' }); img.onload = () => { try { const dataUrl = AssetManager._normalize(img); const name = (file.name || 'картинка').replace(/\.[^.]+$/, ''); const id = this.add(name, dataUrl); resolve({ ok: true, id }); } catch (e) { resolve({ ok: false, error: 'Ошибка обработки картинки' }); } }; img.src = reader.result; }; reader.readAsDataURL(file); }); } /** Добавить ассет с готовым dataUrl. Возвращает id. */ add(name, dataUrl) { const id = `asset_${_idSeq++}`; this.assets.set(id, { id, name: name || 'картинка', dataUrl }); return id; } rename(id, name) { const a = this.assets.get(id); if (a) a.name = name || a.name; } remove(id) { this.assets.delete(id); } /** Нормализация: ужать до MAX_SIDE, перерисовать в canvas → PNG dataURL. */ static _normalize(img) { let w = img.naturalWidth || img.width; let h = img.naturalHeight || img.height; if (w <= 0 || h <= 0) throw new Error('zero size'); if (w > MAX_SIDE || h > MAX_SIDE) { const k = MAX_SIDE / Math.max(w, h); w = Math.round(w * k); h = Math.round(h * k); } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); return canvas.toDataURL('image/png'); } // ============ STATE ============ serialize() { return this.list().map(a => ({ id: a.id, name: a.name, dataUrl: a.dataUrl, })); } load(data) { this.assets.clear(); if (!Array.isArray(data)) return; let maxNum = 0; for (const a of data) { if (!a || typeof a.id !== 'string' || typeof a.dataUrl !== 'string') continue; this.assets.set(a.id, { id: a.id, name: typeof a.name === 'string' ? a.name : 'картинка', dataUrl: a.dataUrl, }); const m = /^asset_(\d+)$/.exec(a.id); if (m) maxNum = Math.max(maxNum, Number(m[1])); } // Чтобы новые id не конфликтовали с загруженными. if (maxNum >= _idSeq) _idSeq = maxNum + 1; } dispose() { this.assets.clear(); } }