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)
215 lines
7.9 KiB
JavaScript
215 lines
7.9 KiB
JavaScript
/**
|
||
* DensityGrid — хранилище для Roblox-style smooth terrain.
|
||
*
|
||
* Содержит два параллельных массива одного размера:
|
||
* - matData[idx]: Uint8 — id материала (0=пусто, 1+=palette[i])
|
||
* - densityData[idx]: Uint8 — плотность 0..255 (0=пусто, 255=solid)
|
||
* Промежуточные значения позволяют **плавные** поверхности
|
||
* (Surface Nets интерполирует позицию вершины).
|
||
*
|
||
* Размер ячейки — 4м (как Roblox). Карта 500×128×500 м = 125×32×125 = 500K
|
||
* ячеек = 500 КБ matData + 500 КБ density = 1 МБ. Это в 200 раз компактнее
|
||
* чем voxel-terrain старой системы на той же площади.
|
||
*
|
||
* Палитра — общая с TERRAIN_MATERIALS (`grass`, `rock`, `sand`, ...).
|
||
*
|
||
* Сериализация — компактный RLE (matData отдельно от density).
|
||
*/
|
||
|
||
import { TERRAIN_MATERIALS } from '../TerrainManager';
|
||
|
||
/** Размер одной ячейки в метрах. Roblox использует 4м. */
|
||
export const CELL_SIZE = 4;
|
||
|
||
/** Threshold: ячейка считается solid если density >= 128 (0..255). */
|
||
export const DENSITY_THRESHOLD = 128;
|
||
|
||
export class DensityGrid {
|
||
/**
|
||
* @param {Object} opts
|
||
* @param {{x,y,z}} opts.origin — мировые координаты ячейки (0,0,0). Размер целочисленный (в cell-units, потом × CELL_SIZE = м).
|
||
* @param {{x,y,z}} opts.size — размер в ячейках
|
||
* @param {string[]} [opts.palette]
|
||
*/
|
||
constructor(opts) {
|
||
const { origin, size, palette } = opts;
|
||
if (!origin || !size) throw new Error('DensityGrid: origin and size required');
|
||
this.origin = { x: origin.x|0, y: origin.y|0, z: origin.z|0 };
|
||
this.size = { x: size.x|0, y: size.y|0, z: size.z|0 };
|
||
// Палитра по умолчанию: [пусто, и всё что есть в TERRAIN_MATERIALS]
|
||
this.palette = palette ? palette.slice() : ['', ...Object.keys(TERRAIN_MATERIALS)];
|
||
this._matIdByKey = new Map();
|
||
for (let i = 0; i < this.palette.length; i++) {
|
||
if (this.palette[i]) this._matIdByKey.set(this.palette[i], i);
|
||
}
|
||
// Strides для линейной индексации: data[z*sxy + y*sx + x]
|
||
this._sx = this.size.x;
|
||
this._sxy = this.size.x * this.size.y;
|
||
const total = this.size.x * this.size.y * this.size.z;
|
||
this.matData = new Uint8Array(total);
|
||
this.densityData = new Uint8Array(total);
|
||
}
|
||
|
||
/** Линейный индекс. */
|
||
_idx(ix, iy, iz) {
|
||
return iz * this._sxy + iy * this._sx + ix;
|
||
}
|
||
|
||
/** В пределах ли grid. */
|
||
inBounds(ix, iy, iz) {
|
||
return ix >= 0 && ix < this.size.x
|
||
&& iy >= 0 && iy < this.size.y
|
||
&& iz >= 0 && iz < this.size.z;
|
||
}
|
||
|
||
/** Получить density (0..255). Вне bounds → 0. */
|
||
getDensity(ix, iy, iz) {
|
||
if (!this.inBounds(ix, iy, iz)) return 0;
|
||
return this.densityData[this._idx(ix, iy, iz)];
|
||
}
|
||
|
||
/** Получить material-id (0=пусто). Вне bounds → 0. */
|
||
getMatId(ix, iy, iz) {
|
||
if (!this.inBounds(ix, iy, iz)) return 0;
|
||
return this.matData[this._idx(ix, iy, iz)];
|
||
}
|
||
|
||
/** Получить material-key ('grass' и т.п.) или ''. */
|
||
getMatKey(ix, iy, iz) {
|
||
const m = this.getMatId(ix, iy, iz);
|
||
return this.palette[m] || '';
|
||
}
|
||
|
||
/** Решение: считается ли ячейка "solid". */
|
||
isSolid(ix, iy, iz) {
|
||
return this.getDensity(ix, iy, iz) >= DENSITY_THRESHOLD;
|
||
}
|
||
|
||
/**
|
||
* Установить density + material для ячейки.
|
||
* @param {number} density 0..255
|
||
* @param {string|null} matKey
|
||
*/
|
||
set(ix, iy, iz, density, matKey) {
|
||
if (!this.inBounds(ix, iy, iz)) return;
|
||
const idx = this._idx(ix, iy, iz);
|
||
this.densityData[idx] = Math.max(0, Math.min(255, density|0));
|
||
if (matKey != null) {
|
||
let m = this._matIdByKey.get(matKey);
|
||
if (m === undefined) {
|
||
if (this.palette.length >= 255) return;
|
||
m = this.palette.length;
|
||
this.palette.push(matKey);
|
||
this._matIdByKey.set(matKey, m);
|
||
}
|
||
this.matData[idx] = m;
|
||
}
|
||
// Если density упал до 0 — обнуляем mat (логически empty).
|
||
if (this.densityData[idx] === 0) this.matData[idx] = 0;
|
||
}
|
||
|
||
/** Очистить полностью. */
|
||
clear() {
|
||
this.matData.fill(0);
|
||
this.densityData.fill(0);
|
||
}
|
||
|
||
/** Подсчёт solid-ячеек. */
|
||
countSolid() {
|
||
let n = 0;
|
||
for (let i = 0; i < this.densityData.length; i++) {
|
||
if (this.densityData[i] >= DENSITY_THRESHOLD) n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** Преобразование cell-coord → world position center (метры). */
|
||
cellToWorld(ix, iy, iz) {
|
||
return {
|
||
x: (this.origin.x + ix + 0.5) * CELL_SIZE,
|
||
y: (this.origin.y + iy + 0.5) * CELL_SIZE,
|
||
z: (this.origin.z + iz + 0.5) * CELL_SIZE,
|
||
};
|
||
}
|
||
|
||
// =========================================================================
|
||
// СЕРИАЛИЗАЦИЯ — RLE
|
||
// =========================================================================
|
||
|
||
serialize() {
|
||
const matRLE = this._encodeRLE(this.matData);
|
||
const densityRLE = this._encodeRLE(this.densityData);
|
||
return {
|
||
format: 'robloxterrain-v1',
|
||
origin: this.origin,
|
||
size: this.size,
|
||
palette: this.palette,
|
||
mat: this._uint8ToBase64(matRLE),
|
||
density: this._uint8ToBase64(densityRLE),
|
||
};
|
||
}
|
||
|
||
_encodeRLE(arr) {
|
||
const buf = [];
|
||
const N = arr.length;
|
||
let i = 0;
|
||
while (i < N) {
|
||
const v = arr[i];
|
||
let run = 1;
|
||
while (i + run < N && arr[i + run] === v && run < 65535) run++;
|
||
buf.push(run & 0xff, (run >> 8) & 0xff, v);
|
||
i += run;
|
||
}
|
||
return new Uint8Array(buf);
|
||
}
|
||
|
||
static _decodeRLE(bytes, totalLength) {
|
||
const out = new Uint8Array(totalLength);
|
||
let outIdx = 0;
|
||
let i = 0;
|
||
while (i < bytes.length && outIdx < totalLength) {
|
||
const run = bytes[i] | (bytes[i + 1] << 8);
|
||
const v = bytes[i + 2];
|
||
i += 3;
|
||
for (let k = 0; k < run && outIdx < totalLength; k++) {
|
||
out[outIdx++] = v;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
static deserialize(obj) {
|
||
if (!obj || obj.format !== 'robloxterrain-v1') {
|
||
throw new Error('DensityGrid: bad format ' + (obj?.format ?? 'undefined'));
|
||
}
|
||
const grid = new DensityGrid({
|
||
origin: obj.origin,
|
||
size: obj.size,
|
||
palette: obj.palette,
|
||
});
|
||
const matRLE = DensityGrid._base64ToUint8(obj.mat);
|
||
const densityRLE = DensityGrid._base64ToUint8(obj.density);
|
||
const total = grid.matData.length;
|
||
grid.matData = DensityGrid._decodeRLE(matRLE, total);
|
||
grid.densityData = DensityGrid._decodeRLE(densityRLE, total);
|
||
return grid;
|
||
}
|
||
|
||
_uint8ToBase64(arr) {
|
||
let binary = '';
|
||
const chunkSize = 8192;
|
||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||
const chunk = arr.subarray(i, Math.min(i + chunkSize, arr.length));
|
||
binary += String.fromCharCode.apply(null, chunk);
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
|
||
static _base64ToUint8(b64) {
|
||
const binary = atob(b64);
|
||
const out = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||
return out;
|
||
}
|
||
}
|