player/src/engine/BlockManager.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
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)
2026-05-27 23:04:04 +03:00

859 lines
40 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.

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