studio/src/editor/engine/AssetManager.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
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)
2026-05-27 23:41:10 +03:00

149 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 / <img src>. */
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();
}
}