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

192 lines
7.4 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.

/**
* ChunkSerializer — компактная сериализация чанков через RLE + base64.
*
* Чанк хранится как 32768 байт (по 1 байту на ячейку, индекс материала
* в палитре слоя). Большинство ячеек обычно пустые → высокая степень
* сжатия через Run-Length Encoding.
*
* Binary формат одного чанка:
* uint16 numRuns
* foreach run:
* uint16 startOffset — где в data[] начинается run
* uint16 length — сколько подряд ячеек
* uint8 matIdx — материал run'а
*
* Сохраняем ТОЛЬКО non-empty runs. Пустые промежутки между ними
* подразумеваются (расшифровщик заполняет нулями всё что не покрыто).
*
* Пример (типовая земля с холмами): 32 768 байт → ~200-1000 байт →
* base64 → ~300-1400 символов. На карте 80×80м (126 чанков) суммарно
* ~50-200 КБ vs 5.6 МБ JSON. Уменьшение в 30-100 раз.
*
* Не используем zlib/gzip — браузер всё равно gzip'ит HTTP-ответы,
* и второй слой compression не даст много (RLE уже устраняет повторения).
*/
import { VoxelChunk, CHUNK_VOLUME } from './VoxelChunk';
// ============================================================================
// Encode: VoxelChunk → base64 RLE
// ============================================================================
/**
* Закодировать data[] чанка в RLE байты. Возвращает Uint8Array.
* @param {Uint8Array} data — длина CHUNK_VOLUME (32768)
* @returns {Uint8Array}
*/
export function encodeChunkRLE(data) {
if (data.length !== CHUNK_VOLUME) {
throw new Error(`encodeChunkRLE: expected ${CHUNK_VOLUME} bytes, got ${data.length}`);
}
// Сначала собираем runs в массив
const runs = []; // [{start, length, matIdx}, ...]
let i = 0;
while (i < CHUNK_VOLUME) {
const mat = data[i];
if (mat === 0) { i++; continue; } // пустую ячейку не записываем
// Сколько подряд ячеек того же материала?
let j = i + 1;
// RLE длина run'а — max uint16 = 65535, но 32768 < 65535, поэтому
// одно ограничение — длина CHUNK_VOLUME.
while (j < CHUNK_VOLUME && data[j] === mat) j++;
const length = j - i;
runs.push({ start: i, length, matIdx: mat });
i = j;
}
// Аллоцируем буфер: 2 байта header + 5 байт на каждый run
const bufSize = 2 + runs.length * 5;
const buf = new Uint8Array(bufSize);
const view = new DataView(buf.buffer);
view.setUint16(0, runs.length, true); // little-endian
let offset = 2;
for (const r of runs) {
view.setUint16(offset, r.start, true);
view.setUint16(offset + 2, r.length, true);
view.setUint8(offset + 4, r.matIdx);
offset += 5;
}
return buf;
}
/**
* Декодировать RLE байты обратно в data[] чанка.
* @param {Uint8Array} bytes
* @returns {Uint8Array} — длина CHUNK_VOLUME (32768)
*/
export function decodeChunkRLE(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const numRuns = view.getUint16(0, true);
const data = new Uint8Array(CHUNK_VOLUME);
let offset = 2;
for (let i = 0; i < numRuns; i++) {
const start = view.getUint16(offset, true);
const length = view.getUint16(offset + 2, true);
const matIdx = view.getUint8(offset + 4);
for (let j = 0; j < length; j++) {
data[start + j] = matIdx;
}
offset += 5;
}
return data;
}
// ============================================================================
// Base64 helpers — встроенный browser btoa/atob с binary-safe wrapper.
// ============================================================================
/** Uint8Array → base64 строка. */
export function bytesToBase64(bytes) {
// Chunked encoding чтобы не упереться в stack-limit при больших массивах
let binary = '';
const len = bytes.length;
const chunkSize = 8192;
for (let i = 0; i < len; i += chunkSize) {
const chunk = bytes.subarray(i, Math.min(i + chunkSize, len));
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
/** base64 строка → Uint8Array. */
export function base64ToBytes(b64) {
const binary = atob(b64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// ============================================================================
// High-level: VoxelChunk ↔ base64
// ============================================================================
/** VoxelChunk → base64 строка. */
export function chunkToBase64(chunk) {
if (!(chunk instanceof VoxelChunk)) {
throw new Error('chunkToBase64: expected VoxelChunk');
}
const rle = encodeChunkRLE(chunk.data);
return bytesToBase64(rle);
}
/**
* base64 строка → новый VoxelChunk с указанными (cx,cy,cz).
* data[] заполняется десериализованным RLE.
*/
export function chunkFromBase64(b64, cx, cy, cz) {
const rle = base64ToBytes(b64);
const data = decodeChunkRLE(rle);
const chunk = new VoxelChunk(cx, cy, cz);
// Прямая подмена data + пересчёт nonEmptyCount
chunk.data = data;
let count = 0;
for (let i = 0; i < CHUNK_VOLUME; i++) {
if (data[i] !== 0) count++;
}
chunk.nonEmptyCount = count;
chunk.dirty = true; // нужен ремеш после загрузки
return chunk;
}
// ============================================================================
// Layer-level: VoxelLayer ↔ JSON serializable
// ============================================================================
/**
* Сериализовать VoxelLayer в plain-object для JSON.
* Не сохраняем пустые чанки (там 0 voxel'ов).
*/
export function serializeLayer(layer) {
const chunks = {};
for (const [key, chunk] of layer.chunks) {
if (chunk.isEmpty()) continue;
chunks[key] = chunkToBase64(chunk);
}
return {
voxelSize: layer.voxelSize,
palette: layer.palette.slice(), // copy
chunks,
};
}
/**
* Десериализовать слой из plain-object. Возвращает массив { key, chunk,
* palette } — caller должен загрузить в VoxelLayer (через прямую запись
* .chunks и .palette).
*/
export function deserializeLayerData(layerData) {
const palette = layerData.palette ?? [null];
const chunks = new Map();
for (const [key, b64] of Object.entries(layerData.chunks ?? {})) {
const [cx, cy, cz] = key.split(',').map(n => parseInt(n, 10));
const ch = chunkFromBase64(b64, cx, cy, cz);
chunks.set(key, ch);
}
return {
voxelSize: layerData.voxelSize ?? 0.25,
palette,
chunks,
};
}