/** * 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": "", ... } }, * 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. */ 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 }; } }