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)
229 lines
9.0 KiB
JavaScript
229 lines
9.0 KiB
JavaScript
/**
|
||
* VoxelWorld — корневой объект воксельного мира.
|
||
*
|
||
* Содержит несколько слоёв (VoxelLayer) с разными voxel-size:
|
||
* terrain — 0.25м (земля, скалы)
|
||
* deco — 0.05м (трава, цветы, грибы; не коллидит)
|
||
* build — 0.5м (пользовательские блоки; не первая версия)
|
||
*
|
||
* Также хранит seed для процедурной генерации (Этап 7) и параметры
|
||
* генератора (после Этапа 7b).
|
||
*
|
||
* Сериализация (Этап 3 — RLE):
|
||
* project_data.scene.voxelWorld = {
|
||
* seed: number,
|
||
* version: 1,
|
||
* layers: {
|
||
* terrain: { voxelSize: 0.25, palette: [...], chunks: { "0,0,0": "<base64>", ... } },
|
||
* deco: { voxelSize: 0.05, palette: [...], chunks: { ... } },
|
||
* },
|
||
* generator: { ... GeneratorParams ... } // только после Этапа 7b
|
||
* }
|
||
*
|
||
* Legacy миграция:
|
||
* Если есть project_data.scene.terrain (массив {x,y,z,m}) — конвертируем
|
||
* в voxelWorld.layers.terrain через loadFromArray.
|
||
*/
|
||
|
||
import { VoxelLayer } from './VoxelLayer';
|
||
import { serializeLayer, deserializeLayerData } from './ChunkSerializer';
|
||
|
||
/** Стандартные размеры ячеек слоёв. */
|
||
export const LAYER_VOXEL_SIZES = {
|
||
terrain: 0.25,
|
||
deco: 0.05,
|
||
build: 0.5,
|
||
};
|
||
|
||
export class VoxelWorld {
|
||
constructor() {
|
||
/** Map<name, VoxelLayer>. */
|
||
this.layers = new Map();
|
||
/** Seed для процедурной генерации (заполняется при загрузке/создании). */
|
||
this.seed = 0;
|
||
/** Версия формата данных (для миграций в будущем). */
|
||
this.version = 1;
|
||
/** Колбэк изменений (для авто-сохранения и оповещения рендера). */
|
||
this._onChange = null;
|
||
/** Параметры процедурной генерации (заполняются на Этапе 7b). */
|
||
this.generatorParams = null;
|
||
}
|
||
|
||
setOnChange(cb) { this._onChange = cb; }
|
||
_emit() { try { this._onChange?.(); } catch (e) {} }
|
||
|
||
// ========================================================================
|
||
// Слои
|
||
// ========================================================================
|
||
|
||
/** Получить (или создать) слой. */
|
||
getOrCreateLayer(name, voxelSize = null) {
|
||
let layer = this.layers.get(name);
|
||
if (!layer) {
|
||
const vs = voxelSize ?? LAYER_VOXEL_SIZES[name] ?? 0.25;
|
||
layer = new VoxelLayer(name, vs);
|
||
this.layers.set(name, layer);
|
||
}
|
||
return layer;
|
||
}
|
||
|
||
getLayer(name) {
|
||
return this.layers.get(name) ?? null;
|
||
}
|
||
|
||
hasLayer(name) {
|
||
return this.layers.has(name);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Удобные обёртки на слое terrain (90% операций идут с ним)
|
||
// ========================================================================
|
||
|
||
/** Кратко: VoxelWorld.setVoxel = setVoxelOnLayer('terrain', ...). */
|
||
setVoxel(gx, gy, gz, matId, layerName = 'terrain') {
|
||
const layer = this.getOrCreateLayer(layerName);
|
||
const changed = layer.setVoxel(gx, gy, gz, matId);
|
||
if (changed) this._emit();
|
||
return changed;
|
||
}
|
||
|
||
getVoxel(gx, gy, gz, layerName = 'terrain') {
|
||
const layer = this.getLayer(layerName);
|
||
return layer ? layer.getVoxel(gx, gy, gz) : null;
|
||
}
|
||
|
||
removeVoxel(gx, gy, gz, layerName = 'terrain') {
|
||
return this.setVoxel(gx, gy, gz, null, layerName);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Legacy миграция: terrain[] → voxelWorld.layers.terrain
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Конвертирует legacy-формат [{x,y,z,m}, ...] в новый формат с чанками.
|
||
* Не очищает существующий слой — добавляет поверх.
|
||
*/
|
||
loadLegacyTerrain(voxels) {
|
||
if (!Array.isArray(voxels) || voxels.length === 0) return;
|
||
const layer = this.getOrCreateLayer('terrain');
|
||
layer.loadFromArray(voxels);
|
||
this._emit();
|
||
}
|
||
|
||
/**
|
||
* Конвертирует legacy-формат decorations[] в слой deco (если когда-нибудь
|
||
* будет такой формат до миграции). На текущий момент — заглушка.
|
||
*/
|
||
loadLegacyDecorations(decos) {
|
||
if (!Array.isArray(decos) || decos.length === 0) return;
|
||
const layer = this.getOrCreateLayer('deco');
|
||
layer.loadFromArray(decos);
|
||
this._emit();
|
||
}
|
||
|
||
// ========================================================================
|
||
// Сериализация (Этап 3 — RLE + base64)
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Компактная сериализация в plain object для JSON. Каждый чанк —
|
||
* одна base64-строка с RLE-байтами. Палитра общая для слоя.
|
||
*
|
||
* Размер на проекте 1 (110К voxel'ов): ~150-300 КБ vs 5.6 МБ legacy.
|
||
* Уменьшение в ~30 раз.
|
||
*/
|
||
serialize() {
|
||
const layersOut = {};
|
||
for (const [name, layer] of this.layers) {
|
||
layersOut[name] = serializeLayer(layer);
|
||
}
|
||
return {
|
||
version: this.version,
|
||
seed: this.seed,
|
||
format: 'rle-v1',
|
||
layers: layersOut,
|
||
generator: this.generatorParams,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Десериализация. Поддерживает оба формата:
|
||
* - format='rle-v1' — новый RLE+base64 (Этап 3+)
|
||
* - legacy без format — массив {voxels:[{x,y,z,m},...]} per layer
|
||
*/
|
||
static deserialize(data) {
|
||
const w = new VoxelWorld();
|
||
if (!data) return w;
|
||
w.version = data.version ?? 1;
|
||
w.seed = data.seed ?? 0;
|
||
w.generatorParams = data.generator ?? null;
|
||
if (!data.layers) return w;
|
||
|
||
const isRLE = data.format === 'rle-v1';
|
||
for (const [name, layerData] of Object.entries(data.layers)) {
|
||
const layer = w.getOrCreateLayer(name, layerData.voxelSize);
|
||
if (isRLE) {
|
||
// RLE формат: { voxelSize, palette: [...], chunks: { "cx,cy,cz": "base64" } }
|
||
const decoded = deserializeLayerData(layerData);
|
||
// Прямая запись данных слоя: палитра + чанки.
|
||
layer.palette = decoded.palette;
|
||
layer.idToIdx = new Map();
|
||
for (let i = 1; i < decoded.palette.length; i++) {
|
||
if (decoded.palette[i]) layer.idToIdx.set(decoded.palette[i], i);
|
||
}
|
||
for (const [k, ch] of decoded.chunks) {
|
||
layer.chunks.set(k, ch);
|
||
}
|
||
} else if (Array.isArray(layerData.voxels)) {
|
||
// Legacy plain JSON
|
||
layer.loadFromArray(layerData.voxels);
|
||
}
|
||
}
|
||
return w;
|
||
}
|
||
|
||
/**
|
||
* Подробная статистика размера сериализованных данных. Использовать
|
||
* для debug-команды __voxelWorldBenchmarkRLE.
|
||
*/
|
||
measureSize() {
|
||
const out = { layers: {}, totalBytes: 0 };
|
||
for (const [name, layer] of this.layers) {
|
||
const layerData = serializeLayer(layer);
|
||
const json = JSON.stringify(layerData);
|
||
const bytes = new Blob([json]).size;
|
||
const chunkCount = Object.keys(layerData.chunks).length;
|
||
// Средний размер одного chunk base64
|
||
let chunkBytes = 0;
|
||
for (const v of Object.values(layerData.chunks)) chunkBytes += v.length;
|
||
out.layers[name] = {
|
||
voxels: layer.voxelCount(),
|
||
chunks: chunkCount,
|
||
jsonBytes: bytes,
|
||
chunkBase64Bytes: chunkBytes,
|
||
avgChunkBytes: chunkCount > 0 ? Math.round(chunkBytes / chunkCount) : 0,
|
||
};
|
||
out.totalBytes += bytes;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ========================================================================
|
||
// Stats и отладка
|
||
// ========================================================================
|
||
|
||
stats() {
|
||
const layers = {};
|
||
let totalVoxels = 0;
|
||
let totalChunks = 0;
|
||
for (const [name, layer] of this.layers) {
|
||
const s = layer.stats();
|
||
layers[name] = s;
|
||
totalVoxels += s.voxels;
|
||
totalChunks += s.chunks;
|
||
}
|
||
return { seed: this.seed, totalVoxels, totalChunks, layers };
|
||
}
|
||
}
|