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)
149 lines
5.6 KiB
JavaScript
149 lines
5.6 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
}
|