Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
859 lines
40 KiB
JavaScript
859 lines
40 KiB
JavaScript
/**
|
||
* BlockManager — управление voxel-блоками в сцене Babylon.js.
|
||
*
|
||
* Поддерживает:
|
||
* 1. Простые кубы — одна текстура на все 6 граней (поле `texture`).
|
||
* 2. Мульти-материал кубы — разные текстуры на верх/стороны/низ
|
||
* (поля `top`, `side`, `bottom`). Используется для травы, бревна,
|
||
* печки, тыквы.
|
||
*
|
||
* Все блоки 1×1×1 на целочисленных координатах: блок (gridX, gridY, gridZ)
|
||
* имеет центр в (gridX, gridY + 0.5, gridZ) — низ на y=gridY.
|
||
*
|
||
* Public API:
|
||
* addBlock(x, y, z, blockTypeId) — поставить
|
||
* removeBlock(x, y, z) — удалить
|
||
* removeBlockByMesh(mesh)
|
||
* hasBlock(x, y, z)
|
||
* count() / clear()
|
||
* serialize() / loadFromArray()
|
||
*
|
||
* Материалы и текстуры кешируются по типу блока.
|
||
*/
|
||
import {
|
||
MeshBuilder,
|
||
StandardMaterial,
|
||
MultiMaterial,
|
||
SubMesh,
|
||
Texture,
|
||
DynamicTexture,
|
||
Color3,
|
||
Color4,
|
||
Vector3,
|
||
Matrix,
|
||
Mesh,
|
||
VertexData,
|
||
VertexBuffer,
|
||
} from '@babylonjs/core';
|
||
import { getBlockType } from './BlockTypes';
|
||
|
||
/**
|
||
* Какой материал применить к какой грани куба Babylon-MeshBuilder.CreateBox.
|
||
*
|
||
* Babylon BoxBuilder делит куб на 12 треугольников = 6 граней по 2 треугольника.
|
||
* Порядок граней в индексах:
|
||
* 0: +Z (front)
|
||
* 1: -Z (back)
|
||
* 2: +X (right)
|
||
* 3: -X (left)
|
||
* 4: +Y (top)
|
||
* 5: -Y (bottom)
|
||
*
|
||
* Каждая грань = 2 треугольника = 6 индексов. На каждую — отдельный submesh.
|
||
*/
|
||
const FACE_INDEX_MAP = {
|
||
front: 0,
|
||
back: 1,
|
||
right: 2,
|
||
left: 3,
|
||
top: 4,
|
||
bottom: 5,
|
||
};
|
||
|
||
export class BlockManager {
|
||
constructor(scene) {
|
||
this.scene = scene;
|
||
// Логическая запись блоков: "x,y,z" → metadata (без отдельного меша!)
|
||
// Это позволяет не создавать тысячи individual mesh'ей и использовать
|
||
// thinInstances. Для совместимости со старым кодом ниже мы создаём
|
||
// legacy-обёртку (Map "x,y,z" → mesh-proxy) — proxy это переиспользуемый
|
||
// невидимый pick-mesh, который заполняется при pick/select.
|
||
this.blocks = new Map();
|
||
// Кеш материалов по типу блока — { material, isMulti }
|
||
this._materials = new Map();
|
||
// Prototype-меши для thinInstances: blockTypeId → Mesh (с thinInstances).
|
||
this._protoMeshes = new Map();
|
||
// Для каждого blockTypeId — массив логических ключей по индексу инстанса:
|
||
// _instanceKeys.get(typeId)[idx] === "x,y,z" (или null для удалённых, см. _freeSlots)
|
||
this._instanceKeys = new Map();
|
||
// Свободные слоты в массиве thin-instances (после удаления) — переиспользуем.
|
||
this._freeSlots = new Map(); // blockTypeId → [idx, idx, ...]
|
||
// Обратный индекс: "x,y,z" → { typeId, idx } для быстрого pick/remove
|
||
this._cellToInst = new Map();
|
||
this._onChange = null;
|
||
// Callback вызывается когда создаётся новый proto-меш — BabylonScene
|
||
// подписывается чтобы зарегистрировать его как shadow caster.
|
||
this._onProtoCreated = null;
|
||
// Жидкости — отдельный single-mesh водной поверхности с волнами
|
||
this._waterMeshes = new Set(); // меши-«невидимки» блоков воды (для логики)
|
||
this._lavaMeshes = new Set();
|
||
this._waterSurface = null; // единый mesh всей воды
|
||
this._waterSurfaceBaseY = null; // массив исходных Y верхних вершин (для волн)
|
||
this._waterDirty = false; // нужна ли пересборка surface-меша
|
||
this._lavaSurface = null;
|
||
this._lavaSurfaceBaseY = null;
|
||
this._lavaDirty = false;
|
||
this._animTime = 0;
|
||
}
|
||
|
||
/** Вызывать каждый кадр для анимации воды/лавы. */
|
||
tick(dt) {
|
||
this._animTime += dt;
|
||
// Пересборка single-mesh при изменениях
|
||
if (this._waterDirty) { this._rebuildLiquidSurface('water'); this._waterDirty = false; }
|
||
if (this._lavaDirty) { this._rebuildLiquidSurface('lava'); this._lavaDirty = false; }
|
||
|
||
// Per-block bobbing — одна формула sin на блок, считается каждый кадр.
|
||
// Это дёшево (≤ 5000 операций для крупного моря) и обеспечивает плавность.
|
||
// Skip только для гигантских поверхностей > 8000 блоков.
|
||
this._waterAnimFrame = ((this._waterAnimFrame || 0) + 1) | 0;
|
||
const waterCount = this._waterSurfaceBaseY?.length || 0;
|
||
if (waterCount > 0) {
|
||
const skip = waterCount > 8000 ? 2 : 1;
|
||
if (this._waterAnimFrame % skip === 0) {
|
||
this._animateLiquidSurface(this._waterSurface, this._waterSurfaceBaseY, 2.0, 0.10);
|
||
}
|
||
}
|
||
const lavaCount = this._lavaSurfaceBaseY?.length || 0;
|
||
if (lavaCount > 0) {
|
||
const skip = lavaCount > 8000 ? 2 : 1;
|
||
if (this._waterAnimFrame % skip === 0) {
|
||
// Лава — медленная (freq=0.7) и амплитуда чуть меньше воды (0.07).
|
||
// Та же per-block bobbing-механика что и у воды.
|
||
this._animateLiquidSurface(this._lavaSurface, this._lavaSurfaceBaseY, 0.7, 0.07);
|
||
}
|
||
}
|
||
}
|
||
|
||
_animateLiquidSurface(mesh, baseY, freq, amp) {
|
||
if (!mesh || mesh.isDisposed?.()) return;
|
||
const positions = mesh.getVerticesData(VertexBuffer.PositionKind);
|
||
if (!positions || !baseY) return;
|
||
const t = this._animTime;
|
||
// Per-block bobbing: одна высота волны на блок (4 вершины двигаются синхронно).
|
||
// Фаза = пространственная (x*0.6+z*0.4) → волна плавно бежит через поверхность.
|
||
for (let k = 0; k < baseY.length; k++) {
|
||
const grp = baseY[k];
|
||
const wave = Math.sin(t * freq + grp.phase) * amp;
|
||
const newY = grp.y + wave;
|
||
if (Array.isArray(grp.i)) {
|
||
positions[grp.i[0] * 3 + 1] = newY;
|
||
positions[grp.i[1] * 3 + 1] = newY;
|
||
positions[grp.i[2] * 3 + 1] = newY;
|
||
positions[grp.i[3] * 3 + 1] = newY;
|
||
} else {
|
||
positions[grp.i * 3 + 1] = newY;
|
||
}
|
||
}
|
||
mesh.updateVerticesData(VertexBuffer.PositionKind, positions);
|
||
}
|
||
|
||
/** Пересоздать single-mesh для воды или лавы. */
|
||
_rebuildLiquidSurface(kind) {
|
||
const isWater = kind === 'water';
|
||
const oldMesh = isWater ? this._waterSurface : this._lavaSurface;
|
||
if (oldMesh) {
|
||
try { oldMesh.dispose(); } catch (e) {}
|
||
}
|
||
const blocks = isWater ? this._waterMeshes : this._lavaMeshes;
|
||
if (blocks.size === 0) {
|
||
if (isWater) { this._waterSurface = null; this._waterSurfaceBaseY = null; }
|
||
else { this._lavaSurface = null; this._lavaSurfaceBaseY = null; }
|
||
return;
|
||
}
|
||
|
||
// Собираем set координат блоков для проверки соседей
|
||
const set = new Set();
|
||
for (const m of blocks) {
|
||
const md = m.metadata;
|
||
set.add(`${md.gridX},${md.gridY},${md.gridZ}`);
|
||
}
|
||
const has = (x, y, z) => set.has(`${x},${y},${z}`);
|
||
|
||
const positions = [];
|
||
const indices = [];
|
||
const normals = [];
|
||
const uvs = [];
|
||
// baseY теперь — массив групп { i: [v0..v3], y: baseY, phase, freq }
|
||
// Каждая группа = один блок воды с верхней гранью; все 4 вершины
|
||
// двигаются вместе → блок остаётся плоским квадратом, без сглаживания.
|
||
const baseY = [];
|
||
|
||
let vIdx = 0;
|
||
// Для каждого блока — добавляем грани, у которых нет соседа той же жидкости.
|
||
for (const m of blocks) {
|
||
const md = m.metadata;
|
||
const x = md.gridX, y = md.gridY, z = md.gridZ;
|
||
// ВЕРХ — рисуем если выше нет блока этой же жидкости
|
||
if (!has(x, y + 1, z)) {
|
||
this._addQuad(positions, indices, normals, uvs,
|
||
x - 0.5, y + 1, z - 0.5,
|
||
x + 0.5, y + 1, z - 0.5,
|
||
x + 0.5, y + 1, z + 0.5,
|
||
x - 0.5, y + 1, z + 0.5,
|
||
0, 1, 0,
|
||
vIdx);
|
||
// Записываем группу из 4 вершин с фазой, плавно зависящей от координат
|
||
// блока. Главная компонента — `x*0.6 + z*0.4` — даёт волну, которая
|
||
// непрерывно «бежит» через поверхность (соседи отличаются мало).
|
||
// Поверх — крошечный рандом, чтобы блоки не были полностью синхронны.
|
||
const seed = (x * 73856093) ^ (z * 83492791);
|
||
const r1 = ((seed * 1103515245 + 12345) >>> 16) & 0x7FFF;
|
||
const jitter = (r1 / 0x7FFF - 0.5) * 0.5; // ±0.25 рад
|
||
const phase = x * 0.6 + z * 0.4 + jitter;
|
||
baseY.push({
|
||
i: [vIdx, vIdx + 1, vIdx + 2, vIdx + 3],
|
||
y: y + 1,
|
||
phase,
|
||
});
|
||
vIdx += 4;
|
||
}
|
||
// НИЗ — рисуем если ниже нет блока этой же жидкости (часто пол)
|
||
if (!has(x, y - 1, z)) {
|
||
this._addQuad(positions, indices, normals, uvs,
|
||
x - 0.5, y, z + 0.5,
|
||
x + 0.5, y, z + 0.5,
|
||
x + 0.5, y, z - 0.5,
|
||
x - 0.5, y, z - 0.5,
|
||
0, -1, 0,
|
||
vIdx);
|
||
vIdx += 4;
|
||
}
|
||
// СТОРОНЫ
|
||
if (!has(x + 1, y, z)) {
|
||
this._addQuad(positions, indices, normals, uvs,
|
||
x + 0.5, y, z - 0.5,
|
||
x + 0.5, y, z + 0.5,
|
||
x + 0.5, y + 1, z + 0.5,
|
||
x + 0.5, y + 1, z - 0.5,
|
||
1, 0, 0,
|
||
vIdx);
|
||
vIdx += 4;
|
||
}
|
||
if (!has(x - 1, y, z)) {
|
||
this._addQuad(positions, indices, normals, uvs,
|
||
x - 0.5, y, z + 0.5,
|
||
x - 0.5, y, z - 0.5,
|
||
x - 0.5, y + 1, z - 0.5,
|
||
x - 0.5, y + 1, z + 0.5,
|
||
-1, 0, 0,
|
||
vIdx);
|
||
vIdx += 4;
|
||
}
|
||
if (!has(x, y, z + 1)) {
|
||
this._addQuad(positions, indices, normals, uvs,
|
||
x + 0.5, y, z + 0.5,
|
||
x - 0.5, y, z + 0.5,
|
||
x - 0.5, y + 1, z + 0.5,
|
||
x + 0.5, y + 1, z + 0.5,
|
||
0, 0, 1,
|
||
vIdx);
|
||
vIdx += 4;
|
||
}
|
||
if (!has(x, y, z - 1)) {
|
||
this._addQuad(positions, indices, normals, uvs,
|
||
x - 0.5, y, z - 0.5,
|
||
x + 0.5, y, z - 0.5,
|
||
x + 0.5, y + 1, z - 0.5,
|
||
x - 0.5, y + 1, z - 0.5,
|
||
0, 0, -1,
|
||
vIdx);
|
||
vIdx += 4;
|
||
}
|
||
}
|
||
|
||
const mesh = new Mesh(`liquidSurface_${kind}`, this.scene);
|
||
const vd = new VertexData();
|
||
vd.positions = positions;
|
||
vd.indices = indices;
|
||
vd.normals = normals;
|
||
vd.uvs = uvs;
|
||
vd.applyToMesh(mesh, true); // updatable=true — будем менять positions
|
||
mesh.isPickable = false;
|
||
mesh.alphaIndex = 100; // рендерить после непрозрачных
|
||
mesh.material = isWater ? this._buildWaterMaterial() : this._buildLavaMaterial();
|
||
// Метим что это жидкость — игрок не должен по нему «коллидить»
|
||
mesh.metadata = { isLiquidSurface: true, kind };
|
||
if (isWater) {
|
||
this._waterSurface = mesh;
|
||
this._waterSurfaceBaseY = baseY;
|
||
} else {
|
||
this._lavaSurface = mesh;
|
||
this._lavaSurfaceBaseY = baseY;
|
||
}
|
||
}
|
||
|
||
_addQuad(positions, indices, normals, uvs, ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz, nx, ny, nz, vIdx) {
|
||
positions.push(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz);
|
||
normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz, nx, ny, nz);
|
||
// UV — мировые координаты делёные на 1 (плитка совпадает с блоком)
|
||
// Для верха/низа берём X/Z, для боков — соответствующие.
|
||
if (Math.abs(ny) > 0.5) {
|
||
uvs.push(ax, az, bx, bz, cx, cz, dx, dz);
|
||
} else if (Math.abs(nx) > 0.5) {
|
||
uvs.push(az, ay, bz, by, cz, cy, dz, dy);
|
||
} else {
|
||
uvs.push(ax, ay, bx, by, cx, cy, dx, dy);
|
||
}
|
||
indices.push(vIdx, vIdx + 1, vIdx + 2, vIdx, vIdx + 2, vIdx + 3);
|
||
}
|
||
|
||
_buildWaterMaterial() {
|
||
// Кешируем — один материал на всю сцену
|
||
if (this._waterMatCache) return this._waterMatCache;
|
||
const mat = new StandardMaterial('waterSurfaceMat', this.scene);
|
||
// Простой материал без текстуры — анимация даётся per-block bobbing.
|
||
// Чистый цвет без тайлинга = никаких швов и решёток.
|
||
mat.diffuseColor = new Color3(0.30, 0.62, 0.92);
|
||
// disableLighting=true — без освещения нет «диагональных» полос от
|
||
// треугольных триангуляций quad'ов (когда нормали вершин слегка разъехались
|
||
// после анимации). Вода становится плоского цвета — это и есть Roblox-look.
|
||
mat.disableLighting = true;
|
||
mat.emissiveColor = new Color3(0.30, 0.62, 0.92); // дублируем как «светящийся» цвет
|
||
mat.alpha = 0.78;
|
||
mat.backFaceCulling = false;
|
||
// disableDepthWrite=true — устраняет alpha-blend «полоски»
|
||
mat.disableDepthWrite = true;
|
||
this._waterMatCache = mat;
|
||
return mat;
|
||
}
|
||
|
||
_buildLavaMaterial() {
|
||
// Кешируем — один материал на сцену
|
||
if (this._lavaMatCache) return this._lavaMatCache;
|
||
const mat = new StandardMaterial('lavaSurfaceMat', this.scene);
|
||
// По аналогии с водой: чистый цвет + disableLighting → ровный flat shading,
|
||
// никаких швов/диагоналей. Per-block bobbing анимирует поверхность.
|
||
mat.diffuseColor = new Color3(1.0, 0.45, 0.08);
|
||
mat.disableLighting = true;
|
||
mat.emissiveColor = new Color3(1.0, 0.45, 0.08); // лава светится
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
mat.alpha = 0.95;
|
||
mat.backFaceCulling = false;
|
||
mat.disableDepthWrite = true;
|
||
this._lavaMatCache = mat;
|
||
return mat;
|
||
}
|
||
|
||
/** Установить колбэк изменения (BabylonScene → KubikonEditor.markDirty). */
|
||
setOnChange(cb) {
|
||
this._onChange = cb;
|
||
}
|
||
|
||
_notifyChange() {
|
||
// Инвалидируем кэш высоты поверхности (используется ZombieManager).
|
||
// Просто инкрементим версию — старые записи в _surfaceCache при
|
||
// следующем чтении не пройдут проверку cached.v === version.
|
||
this._surfaceCacheVersion = (this._surfaceCacheVersion || 0) + 1;
|
||
if (this._onChange) this._onChange();
|
||
}
|
||
|
||
_key(x, y, z) {
|
||
return `${Math.round(x)},${Math.round(y)},${Math.round(z)}`;
|
||
}
|
||
|
||
/**
|
||
* Создать StandardMaterial с одной текстурой и Minecraft-параметрами.
|
||
*/
|
||
_createSingleMat(blockType, texturePath, name) {
|
||
const mat = new StandardMaterial(name, this.scene);
|
||
mat.specularColor = new Color3(0, 0, 0);
|
||
|
||
if (texturePath) {
|
||
const tex = new Texture(texturePath, this.scene);
|
||
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
|
||
mat.diffuseTexture = tex;
|
||
if (blockType.alpha != null && blockType.alpha < 1) {
|
||
mat.diffuseTexture.hasAlpha = true;
|
||
mat.useAlphaFromDiffuseTexture = true;
|
||
mat.alpha = blockType.alpha;
|
||
}
|
||
if (Array.isArray(blockType.emissive)) {
|
||
mat.emissiveColor = new Color3(
|
||
blockType.emissive[0], blockType.emissive[1], blockType.emissive[2]
|
||
);
|
||
}
|
||
// Запоминаем материалы жидкостей для анимации (uOffset/vOffset)
|
||
if (blockType.isWater && !this._waterMat) {
|
||
this._waterMat = mat;
|
||
tex.wrapU = Texture.WRAP_ADDRESSMODE;
|
||
tex.wrapV = Texture.WRAP_ADDRESSMODE;
|
||
}
|
||
if (blockType.isLava && !this._lavaMat) {
|
||
this._lavaMat = mat;
|
||
tex.wrapU = Texture.WRAP_ADDRESSMODE;
|
||
tex.wrapV = Texture.WRAP_ADDRESSMODE;
|
||
}
|
||
} else {
|
||
mat.diffuseColor = Color3.FromHexString(blockType.color || '#888');
|
||
}
|
||
return mat;
|
||
}
|
||
|
||
/**
|
||
* Получить (создать если нет) материал для типа блока.
|
||
* Возвращает: { material, isMulti } — где material это StandardMaterial
|
||
* (для simple куба) или MultiMaterial (для куба с разными гранями).
|
||
*/
|
||
_getMaterial(blockTypeId) {
|
||
if (this._materials.has(blockTypeId)) {
|
||
return this._materials.get(blockTypeId);
|
||
}
|
||
const blockType = getBlockType(blockTypeId);
|
||
|
||
let entry;
|
||
if (blockType.top || blockType.side || blockType.bottom) {
|
||
// Мульти-материал куб
|
||
const matTop = this._createSingleMat(blockType, blockType.top || blockType.side, `mat_${blockTypeId}_top`);
|
||
const matSide = this._createSingleMat(blockType, blockType.side || blockType.top, `mat_${blockTypeId}_side`);
|
||
const matBottom = this._createSingleMat(blockType, blockType.bottom || blockType.side, `mat_${blockTypeId}_bot`);
|
||
|
||
const multi = new MultiMaterial(`multi_${blockTypeId}`, this.scene);
|
||
multi.subMaterials[FACE_INDEX_MAP.front] = matSide;
|
||
multi.subMaterials[FACE_INDEX_MAP.back] = matSide;
|
||
multi.subMaterials[FACE_INDEX_MAP.right] = matSide;
|
||
multi.subMaterials[FACE_INDEX_MAP.left] = matSide;
|
||
multi.subMaterials[FACE_INDEX_MAP.top] = matTop;
|
||
multi.subMaterials[FACE_INDEX_MAP.bottom] = matBottom;
|
||
|
||
// Замораживаем sub-материалы — Babylon перестанет проверять
|
||
// их dirty-флаги каждый кадр (мы их уже не меняем).
|
||
try { matSide.freeze?.(); matTop.freeze?.(); matBottom.freeze?.(); } catch (e) {}
|
||
entry = { material: multi, isMulti: true };
|
||
} else {
|
||
// Простой куб с одной текстурой
|
||
const mat = this._createSingleMat(blockType, blockType.texture, `mat_${blockTypeId}`);
|
||
try { mat.freeze?.(); } catch (e) {}
|
||
entry = { material: mat, isMulti: false };
|
||
}
|
||
|
||
this._materials.set(blockTypeId, entry);
|
||
return entry;
|
||
}
|
||
|
||
/**
|
||
* Поставить блок в (x, y, z).
|
||
* Возвращает meshProxy (для совместимости со старым API) или null если занято.
|
||
*
|
||
* Производительность: для обычных блоков используем thinInstances —
|
||
* один meshes-prototype на тип блока, тысячи позиций в одном draw call.
|
||
* Жидкости (water/lava) идут по старому пути — у них свой single-surface.
|
||
*/
|
||
addBlock(x, y, z, blockTypeId) {
|
||
const ix = Math.round(x);
|
||
const iy = Math.round(y);
|
||
const iz = Math.round(z);
|
||
const key = this._key(ix, iy, iz);
|
||
if (this.blocks.has(key)) return null;
|
||
|
||
const typeDef = getBlockType(blockTypeId);
|
||
const isWater = !!typeDef?.isWater;
|
||
const isLava = !!typeDef?.isLava;
|
||
|
||
// Для жидкостей оставляем старую логику: невидимый куб + единый surface
|
||
if (isWater || isLava) {
|
||
const mesh = MeshBuilder.CreateBox(`block_${key}`, { size: 1 }, this.scene);
|
||
mesh.position = new Vector3(ix, iy + 0.5, iz);
|
||
mesh.isPickable = false; // surface ловит pick
|
||
mesh.metadata = {
|
||
isBlock: true, blockTypeId,
|
||
gridX: ix, gridY: iy, gridZ: iz,
|
||
anchored: true,
|
||
canCollide: false,
|
||
visible: true,
|
||
isWater, isLava,
|
||
mass: 1,
|
||
folderId: null,
|
||
_liquidProxy: true, // признак proxy-mesh (не настоящий блок)
|
||
};
|
||
mesh.setEnabled(false);
|
||
if (isWater) { this._waterMeshes.add(mesh); this._waterDirty = true; }
|
||
else { this._lavaMeshes.add(mesh); this._lavaDirty = true; }
|
||
this.blocks.set(key, mesh);
|
||
this._notifyChange();
|
||
return mesh;
|
||
}
|
||
|
||
// === ОБЫЧНЫЕ БЛОКИ — через thinInstances ===
|
||
const proto = this._getOrCreateProto(blockTypeId);
|
||
if (!proto) return null;
|
||
|
||
// Берём свободный слот или append
|
||
let idx;
|
||
const freeList = this._freeSlots.get(blockTypeId);
|
||
const matrix = Matrix.Translation(ix, iy + 0.5, iz);
|
||
// refresh=false и для set, и для add — финальный refresh делаем один раз в loadFromArray
|
||
// или при первом render-кадре через thinInstanceBufferUpdated.
|
||
if (freeList && freeList.length > 0) {
|
||
idx = freeList.pop();
|
||
proto.thinInstanceSetMatrixAt(idx, matrix, !this._batchMode);
|
||
} else {
|
||
idx = proto.thinInstanceAdd(matrix, !this._batchMode);
|
||
}
|
||
|
||
// Сохраняем обратный индекс
|
||
const keysArr = this._instanceKeys.get(blockTypeId);
|
||
keysArr[idx] = key;
|
||
this._cellToInst.set(key, { typeId: blockTypeId, idx });
|
||
|
||
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
|
||
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
|
||
const meshProxy = {
|
||
_isBlockProxy: true,
|
||
metadata: {
|
||
isBlock: true, blockTypeId,
|
||
gridX: ix, gridY: iy, gridZ: iz,
|
||
anchored: true,
|
||
canCollide: true,
|
||
visible: true,
|
||
isWater: false,
|
||
isLava: false,
|
||
mass: 1,
|
||
folderId: null,
|
||
_thinIdx: idx,
|
||
},
|
||
// Минимальные методы, которые ожидает остальной код
|
||
position: new Vector3(ix, iy + 0.5, iz),
|
||
isDisposed: () => !this.blocks.has(key),
|
||
dispose: () => this.removeBlock(ix, iy, iz),
|
||
setEnabled: () => { /* видимость через thinInstance scaling, упрощённо */ },
|
||
getTotalVertices: () => 36,
|
||
// Pick-helper: возвращает proto-меш и индекс инстанса (для подсветки)
|
||
_proto: proto,
|
||
_thinIdx: idx,
|
||
};
|
||
|
||
this.blocks.set(key, meshProxy);
|
||
this._notifyChange();
|
||
return meshProxy;
|
||
}
|
||
|
||
/** Получить или создать prototype-меш для типа блока. */
|
||
_getOrCreateProto(blockTypeId) {
|
||
let proto = this._protoMeshes.get(blockTypeId);
|
||
if (proto) return proto;
|
||
|
||
proto = MeshBuilder.CreateBox(`proto_${blockTypeId}`, { size: 1 }, this.scene);
|
||
const { material, isMulti } = this._getMaterial(blockTypeId);
|
||
proto.material = material;
|
||
if (isMulti) this._setupSubmeshes(proto);
|
||
|
||
proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
|
||
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
|
||
proto.isPickable = true;
|
||
proto.thinInstanceEnablePicking = true;
|
||
// alwaysSelectAsActiveMesh = true: на сцене ~500 блоков, прото — это
|
||
// mesh с гигантским bounding box покрывающим всю карту. Frustum-test
|
||
// на нём всё равно почти всегда true → пропускаем дорогие проверки.
|
||
proto.alwaysSelectAsActiveMesh = true;
|
||
// doNotSyncBoundingInfo = true — не пересчитывать bbox при изменениях
|
||
// thin-instances (мы не используем bbox для frustum, см. выше).
|
||
proto.doNotSyncBoundingInfo = true;
|
||
proto.metadata = {
|
||
_isBlockProto: true,
|
||
blockTypeId,
|
||
};
|
||
// Тени: блоки принимают тени от других объектов (персонажа,
|
||
// деревьев, моделей). Сами блоки автоматически становятся
|
||
// shadow casters через addShadowCaster в refreshAllShadows.
|
||
proto.receiveShadows = true;
|
||
|
||
this._protoMeshes.set(blockTypeId, proto);
|
||
this._instanceKeys.set(blockTypeId, []);
|
||
this._freeSlots.set(blockTypeId, []);
|
||
|
||
if (typeof this._onProtoCreated === 'function') {
|
||
try { this._onProtoCreated(proto); } catch (e) { /* ignore */ }
|
||
}
|
||
return proto;
|
||
}
|
||
|
||
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
|
||
setOnProtoCreated(cb) { this._onProtoCreated = cb; }
|
||
|
||
/** Установить флаг anchored у блока. */
|
||
setBlockAnchored(x, y, z, anchored) {
|
||
const mesh = this.blocks.get(this._key(x, y, z));
|
||
if (!mesh) return;
|
||
mesh.metadata.anchored = !!anchored;
|
||
// Поддерживаем Set _unanchoredBlocks — для O(1)-доступа в PhysicsAABB.
|
||
if (!this._unanchoredBlocks) this._unanchoredBlocks = new Set();
|
||
if (anchored) this._unanchoredBlocks.delete(mesh);
|
||
else this._unanchoredBlocks.add(mesh);
|
||
this._notifyChange();
|
||
}
|
||
|
||
/** Установить свойства блока (canCollide / visible / mass). */
|
||
setBlockProps(x, y, z, patch) {
|
||
const key = this._key(x, y, z);
|
||
const mesh = this.blocks.get(key);
|
||
if (!mesh) return;
|
||
if (patch.canCollide !== undefined) mesh.metadata.canCollide = !!patch.canCollide;
|
||
if (patch.visible !== undefined) {
|
||
mesh.metadata.visible = !!patch.visible;
|
||
// Для thin-instance proxy: переключаем матрицу (нормальная ↔ scale=0)
|
||
if (mesh._isBlockProxy) {
|
||
const inst = this._cellToInst.get(key);
|
||
if (inst) {
|
||
const proto = this._protoMeshes.get(inst.typeId);
|
||
if (proto) {
|
||
const m = mesh.metadata.visible
|
||
? Matrix.Translation(mesh.metadata.gridX, mesh.metadata.gridY + 0.5, mesh.metadata.gridZ)
|
||
: Matrix.Scaling(0, 0, 0);
|
||
proto.thinInstanceSetMatrixAt(inst.idx, m, true);
|
||
}
|
||
}
|
||
} else if (typeof mesh.setEnabled === 'function') {
|
||
mesh.setEnabled(mesh.metadata.visible);
|
||
}
|
||
}
|
||
if (patch.mass !== undefined) {
|
||
const m = Number(patch.mass);
|
||
if (Number.isFinite(m) && m > 0) mesh.metadata.mass = m;
|
||
}
|
||
this._notifyChange();
|
||
}
|
||
|
||
/**
|
||
* Разбить cube-mesh на 6 SubMesh (по одной на грань).
|
||
* Babylon CreateBox использует 36 индексов (12 треугольников = 36 вершин-индексов),
|
||
* по 6 индексов на грань. Грани идут в порядке +Z, -Z, +X, -X, +Y, -Y.
|
||
*/
|
||
_setupSubmeshes(mesh) {
|
||
const verticesCount = mesh.getTotalVertices();
|
||
mesh.subMeshes = [];
|
||
const indicesPerFace = 6;
|
||
for (let face = 0; face < 6; face++) {
|
||
new SubMesh(
|
||
face, // material index
|
||
0, // verticesStart
|
||
verticesCount, // verticesCount
|
||
face * indicesPerFace, // indexStart
|
||
indicesPerFace, // indexCount
|
||
mesh
|
||
);
|
||
}
|
||
}
|
||
|
||
removeBlock(x, y, z) {
|
||
const key = this._key(x, y, z);
|
||
const mesh = this.blocks.get(key);
|
||
if (!mesh) return false;
|
||
// Удаляем из set unanchored если он там был — иначе утечка.
|
||
if (this._unanchoredBlocks) this._unanchoredBlocks.delete(mesh);
|
||
// Жидкости — старый путь: dispose + dirty
|
||
if (mesh.metadata?._liquidProxy) {
|
||
if (mesh.metadata.isWater) this._waterDirty = true;
|
||
if (mesh.metadata.isLava) this._lavaDirty = true;
|
||
this._waterMeshes.delete(mesh);
|
||
this._lavaMeshes.delete(mesh);
|
||
try { mesh.dispose(); } catch (e) { /* ignore */ }
|
||
this.blocks.delete(key);
|
||
this._notifyChange();
|
||
return true;
|
||
}
|
||
// Обычный блок — thin instance: «погасить» матрицу (схлопнуть в 0) и пометить слот свободным
|
||
const inst = this._cellToInst.get(key);
|
||
if (inst) {
|
||
const proto = this._protoMeshes.get(inst.typeId);
|
||
if (proto) {
|
||
// Прячем экземпляр через scale=0 — его не видно, но остаётся в буфере
|
||
const zeroMat = Matrix.Scaling(0, 0, 0);
|
||
proto.thinInstanceSetMatrixAt(inst.idx, zeroMat, true);
|
||
}
|
||
const keysArr = this._instanceKeys.get(inst.typeId);
|
||
if (keysArr) keysArr[inst.idx] = null;
|
||
const free = this._freeSlots.get(inst.typeId);
|
||
if (free) free.push(inst.idx);
|
||
this._cellToInst.delete(key);
|
||
}
|
||
this.blocks.delete(key);
|
||
this._notifyChange();
|
||
return true;
|
||
}
|
||
|
||
hasBlock(x, y, z) {
|
||
return this.blocks.has(this._key(x, y, z));
|
||
}
|
||
|
||
removeBlockByMesh(mesh) {
|
||
if (!mesh) return false;
|
||
// Прокси-mesh (water/lava или старый legacy)
|
||
if (mesh.metadata?.isBlock) {
|
||
const { gridX, gridY, gridZ } = mesh.metadata;
|
||
return this.removeBlock(gridX, gridY, gridZ);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Найти proxy-блок по результату raycast'а.
|
||
* Если попало в proto-меш с thinInstanceIndex — возвращает соответствующий proxy.
|
||
*/
|
||
findProxyByPickInfo(pickInfo) {
|
||
if (!pickInfo || !pickInfo.pickedMesh) return null;
|
||
const m = pickInfo.pickedMesh;
|
||
if (m.metadata?._isBlockProto) {
|
||
// В разных версиях Babylon индекс инстанса называется по-разному.
|
||
// Пробуем все известные варианты.
|
||
const idx = (typeof pickInfo.thinInstanceIndex === 'number' && pickInfo.thinInstanceIndex >= 0)
|
||
? pickInfo.thinInstanceIndex
|
||
: (typeof pickInfo.instanceIndex === 'number' && pickInfo.instanceIndex >= 0)
|
||
? pickInfo.instanceIndex
|
||
: -1;
|
||
const typeId = m.metadata.blockTypeId;
|
||
if (idx >= 0) {
|
||
const keysArr = this._instanceKeys.get(typeId);
|
||
const key = keysArr ? keysArr[idx] : null;
|
||
if (key) {
|
||
const proxy = this.blocks.get(key);
|
||
if (proxy) return proxy;
|
||
}
|
||
}
|
||
// Fallback: ищем по точке контакта. Учитываем нормаль грани —
|
||
// точка лежит на поверхности блока, нужно сместиться "внутрь"
|
||
// на 0.01 по противоположному направлению нормали и взять клетку.
|
||
const p = pickInfo.pickedPoint;
|
||
if (!p) return null;
|
||
// Нормаль грани (если доступна)
|
||
let nx = 0, ny = 0, nz = 0;
|
||
try {
|
||
const n = pickInfo.getNormal && pickInfo.getNormal(true);
|
||
if (n) { nx = n.x; ny = n.y; nz = n.z; }
|
||
} catch (e) { /* ignore */ }
|
||
// Точка чуть «внутрь» блока против направления нормали
|
||
const innerX = p.x - nx * 0.05;
|
||
const innerY = p.y - ny * 0.05;
|
||
const innerZ = p.z - nz * 0.05;
|
||
// Координаты клетки: блок (gx, gy, gz) занимает Y от gy до gy+1,
|
||
// X/Z центр на gx (от gx-0.5 до gx+0.5).
|
||
const gx = Math.round(innerX);
|
||
const gy = Math.floor(innerY);
|
||
const gz = Math.round(innerZ);
|
||
const key = this._key(gx, gy, gz);
|
||
if (this.blocks.has(key)) return this.blocks.get(key);
|
||
// Расширенный поиск в окрестности (на случай погрешностей)
|
||
for (let ddy = -1; ddy <= 1; ddy++) {
|
||
for (let ddx = -1; ddx <= 1; ddx++) {
|
||
for (let ddz = -1; ddz <= 1; ddz++) {
|
||
if (ddx === 0 && ddy === 0 && ddz === 0) continue;
|
||
const k2 = this._key(gx + ddx, gy + ddy, gz + ddz);
|
||
if (this.blocks.has(k2)) {
|
||
const proxy = this.blocks.get(k2);
|
||
// Только если это блок ИМЕННО того же типа что прото (точно тот меш)
|
||
if (proxy?.metadata?.blockTypeId === typeId) return proxy;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
if (m.metadata?.isBlock) return m; // legacy/liquid proxy
|
||
return null;
|
||
}
|
||
|
||
count() {
|
||
return this.blocks.size;
|
||
}
|
||
|
||
serialize() {
|
||
const out = [];
|
||
for (const mesh of this.blocks.values()) {
|
||
const m = mesh.metadata;
|
||
out.push({
|
||
x: m.gridX, y: m.gridY, z: m.gridZ,
|
||
type: m.blockTypeId,
|
||
anchored: m.anchored !== false,
|
||
canCollide: m.canCollide !== false,
|
||
visible: m.visible !== false,
|
||
mass: m.mass ?? 1,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
loadFromArray(arr) {
|
||
this.clear();
|
||
// Массовый режим — буферы thinInstances обновляются один раз в конце
|
||
this._batchMode = true;
|
||
try {
|
||
for (const b of arr) {
|
||
const mesh = this.addBlock(b.x, b.y, b.z, b.type);
|
||
if (!mesh) continue;
|
||
if (b.anchored === false) {
|
||
mesh.metadata.anchored = false;
|
||
if (!this._unanchoredBlocks) this._unanchoredBlocks = new Set();
|
||
this._unanchoredBlocks.add(mesh);
|
||
}
|
||
if (b.canCollide === false) mesh.metadata.canCollide = false;
|
||
if (b.visible === false) {
|
||
mesh.metadata.visible = false;
|
||
// setBlockProps вызовет setMatrixAt — но мы в batchMode, так что без refresh
|
||
}
|
||
if (b.mass != null) mesh.metadata.mass = b.mass;
|
||
}
|
||
} finally {
|
||
this._batchMode = false;
|
||
}
|
||
// Финальный refresh всех буферов thinInstances + bounding info
|
||
for (const proto of this._protoMeshes.values()) {
|
||
try {
|
||
if (proto.thinInstanceBufferUpdated) proto.thinInstanceBufferUpdated('matrix');
|
||
proto.thinInstanceRefreshBoundingInfo(true);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
clear() {
|
||
const had = this.blocks.size > 0;
|
||
// Жидкости-проксы — dispose
|
||
for (const mesh of this.blocks.values()) {
|
||
if (mesh && mesh.metadata?._liquidProxy && typeof mesh.dispose === 'function') {
|
||
try { mesh.dispose(); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
// Прототипы thin-instances — сбрасываем все instances но сами proto оставляем
|
||
// (можно переиспользовать; материалы тоже остаются)
|
||
for (const proto of this._protoMeshes.values()) {
|
||
try { proto.thinInstanceCount = 0; } catch (e) { /* ignore */ }
|
||
}
|
||
for (const arr of this._instanceKeys.values()) arr.length = 0;
|
||
for (const arr of this._freeSlots.values()) arr.length = 0;
|
||
this._cellToInst.clear();
|
||
|
||
this.blocks.clear();
|
||
this._waterMeshes.clear();
|
||
this._lavaMeshes.clear();
|
||
this._waterDirty = true;
|
||
this._lavaDirty = true;
|
||
if (had) this._notifyChange();
|
||
}
|
||
|
||
dispose() {
|
||
this.clear();
|
||
// Дисозим proto-меши блоков
|
||
for (const proto of this._protoMeshes.values()) {
|
||
try { proto.dispose(); } catch (e) { /* ignore */ }
|
||
}
|
||
this._protoMeshes.clear();
|
||
this._instanceKeys.clear();
|
||
this._freeSlots.clear();
|
||
for (const entry of this._materials.values()) {
|
||
const mat = entry.material;
|
||
if (entry.isMulti) {
|
||
// MultiMaterial — диспозим под-материалы и их текстуры
|
||
for (const sub of mat.subMaterials) {
|
||
if (sub) {
|
||
if (sub.diffuseTexture) sub.diffuseTexture.dispose();
|
||
sub.dispose();
|
||
}
|
||
}
|
||
} else {
|
||
if (mat.diffuseTexture) mat.diffuseTexture.dispose();
|
||
}
|
||
mat.dispose();
|
||
}
|
||
this._materials.clear();
|
||
}
|
||
}
|