studio/src/editor/engine/voxel/VoxelWorld.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

229 lines
9.0 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.

/**
* 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 };
}
}