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)
209 lines
8.5 KiB
JavaScript
209 lines
8.5 KiB
JavaScript
/**
|
||
* VoxelLayer — слой воксельного мира с фиксированным размером ячейки.
|
||
*
|
||
* Один слой = одна сетка чанков с одним voxelSize. Между слоями нет связи —
|
||
* это позволяет иметь несколько разрешений в одном мире:
|
||
* terrain — 0.25м, твёрдый, рендерится с MultiMaterial
|
||
* deco — 0.05м (мини-voxel'ы для травы/цветов), не коллидит, без текстур
|
||
* build — 0.5м (пользовательские блоки) — опционально, не первая версия
|
||
*
|
||
* Палитра — общий список строковых id материалов слоя. Внутри чанков
|
||
* хранится индекс в этой палитре (1 байт), что экономит 99% размера vs
|
||
* хранение строки на каждый voxel.
|
||
*
|
||
* Public API:
|
||
* getVoxel(gx, gy, gz) — id материала или null
|
||
* setVoxel(gx, gy, gz, matId) — устанавливает voxel
|
||
* removeVoxel(gx, gy, gz) — то же что setVoxel(..., null)
|
||
* getMatIdx(gx, gy, gz) — числовой индекс (для меширования)
|
||
* matIdxToId(idx) / matIdToIdx(id) — конвертация
|
||
* forEachChunk(fn) — обход всех чанков
|
||
* stats() — статистика для отладки
|
||
*
|
||
* Координаты voxel'ов (gx, gy, gz) — целочисленные grid-индексы.
|
||
* Перевод в мировые метры: worldX = gx * voxelSize.
|
||
*/
|
||
|
||
import { VoxelChunk, voxelToChunk, chunkKey, CHUNK_SIZE } from './VoxelChunk';
|
||
|
||
export class VoxelLayer {
|
||
/**
|
||
* @param {string} name - 'terrain' | 'deco' | 'build' | ...
|
||
* @param {number} voxelSize - метры на одну ячейку
|
||
*/
|
||
constructor(name, voxelSize) {
|
||
this.name = name;
|
||
this.voxelSize = voxelSize;
|
||
/** Map<"cx,cy,cz", VoxelChunk>. Пустые чанки удаляются. */
|
||
this.chunks = new Map();
|
||
/**
|
||
* Палитра материалов. Индекс 0 зарезервирован под "пусто" (нельзя
|
||
* использовать). 1..255 — реальные материалы.
|
||
* Например: [null, 'grass', 'rock', 'sand', ...]
|
||
*/
|
||
this.palette = [null];
|
||
/** Обратный индекс: matId → matIdx. */
|
||
this.idToIdx = new Map();
|
||
}
|
||
|
||
// ========================================================================
|
||
// Палитра
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Получить или зарезервировать индекс материала. Палитра растёт по мере
|
||
* появления новых материалов. Возвращает 0 если попытка зарегать null.
|
||
*/
|
||
matIdToIdx(matId) {
|
||
if (matId == null) return 0;
|
||
let idx = this.idToIdx.get(matId);
|
||
if (idx !== undefined) return idx;
|
||
if (this.palette.length >= 256) {
|
||
console.warn(`[VoxelLayer ${this.name}] palette overflow (>255 materials), cannot register '${matId}'`);
|
||
return 0;
|
||
}
|
||
idx = this.palette.length;
|
||
this.palette.push(matId);
|
||
this.idToIdx.set(matId, idx);
|
||
return idx;
|
||
}
|
||
|
||
/** Получить matId по индексу. null если пусто или неизвестен. */
|
||
matIdxToId(idx) {
|
||
if (idx <= 0 || idx >= this.palette.length) return null;
|
||
return this.palette[idx];
|
||
}
|
||
|
||
// ========================================================================
|
||
// CRUD voxel'ов
|
||
// ========================================================================
|
||
|
||
/** Получить чанк по chunk-координатам. Возвращает null если не существует. */
|
||
getChunk(cx, cy, cz) {
|
||
return this.chunks.get(chunkKey(cx, cy, cz)) ?? null;
|
||
}
|
||
|
||
/** Получить или создать чанк. */
|
||
getOrCreateChunk(cx, cy, cz) {
|
||
const k = chunkKey(cx, cy, cz);
|
||
let ch = this.chunks.get(k);
|
||
if (!ch) {
|
||
ch = new VoxelChunk(cx, cy, cz);
|
||
this.chunks.set(k, ch);
|
||
}
|
||
return ch;
|
||
}
|
||
|
||
/** material id ('grass'/'rock'/...) или null если пусто. */
|
||
getVoxel(gx, gy, gz) {
|
||
const { cx, cy, cz, lx, ly, lz } = voxelToChunk(gx, gy, gz);
|
||
const ch = this.getChunk(cx, cy, cz);
|
||
if (!ch) return null;
|
||
return this.matIdxToId(ch.getLocal(lx, ly, lz));
|
||
}
|
||
|
||
/** Числовой индекс материала (для меширования — быстрее чем строки). */
|
||
getMatIdx(gx, gy, gz) {
|
||
const { cx, cy, cz, lx, ly, lz } = voxelToChunk(gx, gy, gz);
|
||
const ch = this.getChunk(cx, cy, cz);
|
||
if (!ch) return 0;
|
||
return ch.getLocal(lx, ly, lz);
|
||
}
|
||
|
||
/**
|
||
* Установить voxel. matId=null или undefined → удаление.
|
||
* Возвращает true если значение изменилось.
|
||
*/
|
||
setVoxel(gx, gy, gz, matId) {
|
||
const { cx, cy, cz, lx, ly, lz } = voxelToChunk(gx, gy, gz);
|
||
const matIdx = this.matIdToIdx(matId);
|
||
// Если ячейка очищается и чанка нет — нечего делать.
|
||
if (matIdx === 0) {
|
||
const ch = this.getChunk(cx, cy, cz);
|
||
if (!ch) return false;
|
||
const changed = ch.setLocal(lx, ly, lz, 0);
|
||
// Если чанк опустел — удалим его (но не сейчас, чтобы не
|
||
// дёрнуть renderer; пометим dirty и пусть mesher решит)
|
||
return changed;
|
||
}
|
||
const ch = this.getOrCreateChunk(cx, cy, cz);
|
||
return ch.setLocal(lx, ly, lz, matIdx);
|
||
}
|
||
|
||
/** Удалить voxel. */
|
||
removeVoxel(gx, gy, gz) {
|
||
return this.setVoxel(gx, gy, gz, null);
|
||
}
|
||
|
||
/** Существует ли voxel. */
|
||
hasVoxel(gx, gy, gz) {
|
||
return this.getMatIdx(gx, gy, gz) !== 0;
|
||
}
|
||
|
||
// ========================================================================
|
||
// Bulk операции
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Bulk-загрузка из плоского массива {x, y, z, m} — используется при
|
||
* миграции legacy формата terrain[] в новый формат.
|
||
*/
|
||
loadFromArray(voxels) {
|
||
const t0 = performance.now();
|
||
let added = 0;
|
||
for (const v of voxels) {
|
||
if (this.setVoxel(v.x, v.y, v.z, v.m)) added++;
|
||
}
|
||
const dt = performance.now() - t0;
|
||
console.log(`[VoxelLayer ${this.name}] loadFromArray: ${added}/${voxels.length} voxels, ${this.chunks.size} chunks, ${dt.toFixed(0)}ms`);
|
||
}
|
||
|
||
/** Полная очистка слоя. */
|
||
clear() {
|
||
for (const ch of this.chunks.values()) {
|
||
ch.clear();
|
||
}
|
||
this.chunks.clear();
|
||
}
|
||
|
||
/** Сколько занятых ячеек суммарно. */
|
||
voxelCount() {
|
||
let n = 0;
|
||
for (const ch of this.chunks.values()) n += ch.nonEmptyCount;
|
||
return n;
|
||
}
|
||
|
||
/** Обход всех чанков (включая пустые с dirty=true — для очистки рендера). */
|
||
forEachChunk(fn) {
|
||
for (const ch of this.chunks.values()) fn(ch);
|
||
}
|
||
|
||
/** Удалить пустые чанки. Безопасно вызывать после batch-операций. */
|
||
purgeEmptyChunks() {
|
||
const toDelete = [];
|
||
for (const [k, ch] of this.chunks) {
|
||
if (ch.isEmpty()) toDelete.push(k);
|
||
}
|
||
for (const k of toDelete) this.chunks.delete(k);
|
||
return toDelete.length;
|
||
}
|
||
|
||
/** Отладочная статистика. */
|
||
stats() {
|
||
let totalVoxels = 0;
|
||
let dirtyChunks = 0;
|
||
for (const ch of this.chunks.values()) {
|
||
totalVoxels += ch.nonEmptyCount;
|
||
if (ch.dirty) dirtyChunks++;
|
||
}
|
||
return {
|
||
name: this.name,
|
||
voxelSize: this.voxelSize,
|
||
chunks: this.chunks.size,
|
||
voxels: totalVoxels,
|
||
dirtyChunks,
|
||
paletteSize: this.palette.length - 1,
|
||
};
|
||
}
|
||
}
|