player/src/engine/BlockManager.js
МИН d5968f7cb8 feat(09): сочные круглые studs (v4) — паритет со студией
- Текстура studs v4 (круглые, объём+тени, сочный цвет), URL studs_v4_*.
- PrimitiveManager: emissive 45% цвета + новые константы (GRID 4, UNIT 1).
- BlockManager/BlockTypes: studs-block на v4-текстуре, specular убран.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:22:03 +03:00

947 lines
45 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;
// Окрашиваемые блоки (studs-block, задача 09): per-instance color через
// ThinInstance color buffer. blockTypeId → Float32Array(maxBlocks*4 RGBA).
this._colorsByProto = new Map();
this._STUDS_MAX = 20000; // макс блоков одного окрашиваемого типа
}
/** Вызывать каждый кадр для анимации воды/лавы. */
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);
// Окрашиваемый блок (studs-block): цвет берётся per-instance из vertex
// color буфера ThinInstance и умножается на серую текстуру. Включаем
// useVertexColors, normal map (выпуклость кружков), мягкий спекуляр.
if (blockType.colorable) {
const tex = new Texture(texturePath, this.scene);
mat.diffuseTexture = tex;
mat.diffuseColor = new Color3(1, 1, 1); // нейтраль — цвет идёт из vertex color
if (blockType.normal) {
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
}
// Сочность (Roblox-look): почти-белая текстура × яркий vertex color,
// specular убран (он белит/тускнит цвет).
mat.specularColor = new Color3(0, 0, 0);
mat.useVertexColors = true;
return mat;
}
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, color) {
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;
// Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
const colorable = !!typeDef?.colorable;
const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
// Для жидкостей оставляем старую логику: невидимый куб + единый 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 });
// Окрашиваемый блок — пишем цвет инстанса в color buffer.
if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
// Логический «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,
color: instColor, // per-instance цвет окрашиваемого блока
},
// Минимальные методы, которые ожидает остальной код
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);
// Окрашиваемый блок — включаем per-instance color buffer (vertex colors).
const _bt = getBlockType(blockTypeId);
if (_bt && _bt.colorable) {
proto.useVertexColors = true;
proto.hasVertexAlpha = false;
const buf = new Float32Array(this._STUDS_MAX * 4);
// Дефолт-цвет (белый) для всех слотов — иначе невыставленные = чёрные.
buf.fill(1);
proto.thinInstanceSetBuffer('color', buf, 4, false);
this._colorsByProto.set(blockTypeId, buf);
}
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; }
/**
* Записать цвет инстанса окрашиваемого блока в color buffer (RGBA float).
* idx — индекс thin-instance. hex — '#rrggbb'. В batch-режиме обновление
* GPU откладывается (флаг dirty), иначе сразу thinInstanceBufferUpdated.
*/
_setBlockColorAt(blockTypeId, idx, hex) {
const buf = this._colorsByProto.get(blockTypeId);
if (!buf) return;
const c = Color3.FromHexString(hex || '#cccccc');
const o = idx * 4;
buf[o] = c.r; buf[o + 1] = c.g; buf[o + 2] = c.b; buf[o + 3] = 1;
const proto = this._protoMeshes.get(blockTypeId);
if (!proto) return;
if (this._batchMode) {
if (!this._colorDirtyProtos) this._colorDirtyProtos = new Set();
this._colorDirtyProtos.add(blockTypeId);
} else {
try { proto.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
}
}
/**
* Сменить цвет окрашиваемого блока в (x,y,z) на лету (для scene.setColor /
* color-пикера). Возвращает true если блок окрашиваемый и цвет применён.
*/
setBlockColor(x, y, z, hex) {
const key = this._key(Math.round(x), Math.round(y), Math.round(z));
const inst = this._cellToInst.get(key);
if (!inst) return false;
const bt = getBlockType(inst.typeId);
if (!bt || !bt.colorable) return false;
this._setBlockColorAt(inst.typeId, inst.idx, hex);
const mp = this.blocks.get(key);
if (mp && mp.metadata) mp.metadata.color = hex;
this._notifyChange();
return true;
}
/** Установить флаг 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,
// per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
...(m.color ? { color: m.color } : {}),
});
}
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, b.color);
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 */ }
}
// Финальный refresh color-буферов окрашиваемых блоков (batch).
if (this._colorDirtyProtos) {
for (const typeId of this._colorDirtyProtos) {
const proto = this._protoMeshes.get(typeId);
try { proto?.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
}
this._colorDirtyProtos.clear();
}
}
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();
}
}