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

209 lines
8.5 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.

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