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)
192 lines
7.4 KiB
JavaScript
192 lines
7.4 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
}
|