player/src/engine/ModelManager.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

802 lines
38 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.

/**
* ModelManager — управление 3D-моделями (GLB) в сцене Babylon.js.
*
* Концепция:
* - GLB-файл загружается ОДИН РАЗ через SceneLoader.LoadAssetContainerAsync
* и кешируется как "prototype" (AssetContainer).
* - При размещении модели на сцене делаем clone() из prototype — это создаёт
* обычный mesh, который можно двигать/поворачивать/удалять независимо.
*
* Хранилище инстансов:
* instances: Map<instanceId, { rootMesh, modelTypeId, gridX, gridY, gridZ, rotation }>
*
* Public API:
* addInstance(modelTypeId, x, y, z, rotationY) → instanceId | null
* removeInstance(instanceId) → bool
* removeInstanceByMesh(mesh) → bool
* getInstanceCount()
* serialize() / loadFromArray()
* preloadAll(progressCb) → promise (опционально)
*
* Инстансы НЕ привязаны к 1×1×1 сетке — модели могут стоять в произвольных
* координатах (некоторые модели Kenney крупнее единичного куба, например стены).
*/
import {
SceneLoader,
Vector3,
Color3,
TransformNode,
VertexBuffer,
MeshBuilder,
StandardMaterial,
} from '@babylonjs/core';
import '@babylonjs/loaders/glTF'; // регистрирует GLB/GLTF loader
import { getModelType } from './ModelTypes';
export class ModelManager {
constructor(scene, scene3d = null) {
this.scene = scene;
this.instances = new Map();
this._prototypes = new Map();
this._nextInstanceId = 1;
this._onChange = null; // dirty-tracking
// BabylonScene-обёртка — для доступа к folderManager/primitiveManager
// и addShadowCaster (авто-регистрация моделей при спавне).
this._scene3d = scene3d;
this.scene3d = scene3d; // алиас (NpcManager-style)
}
setScene3D(scene3d) {
this._scene3d = scene3d;
}
setOnChange(cb) {
this._onChange = cb;
}
/**
* Поставить «сложную» модель — она имеет parts:[] и кладётся в папку.
* Каждая part может быть:
* { kind: 'mesh', model: 'id-другой-простой-модели', dx, dy, dz, ry }
* { kind: 'primitive', type: 'cube', sx, sy, sz, color, dx, dy, dz, ry }
*
* Возвращает instanceId первой part (главной модели), чтобы выделение работало.
*/
/**
* Synthetic-модели не из GLB. Сейчас поддерживается только 'spawner-disk' —
* фиолетовый диск + кольцо для спавнера зомби.
*/
_addSyntheticInstance(modelType, x, y, z, rotationY = 0) {
const instanceId = this._nextInstanceId++;
const root = new TransformNode(`syn_${modelType.id}_${instanceId}`, this.scene);
root.position = new Vector3(x, y, z);
root.rotation = new Vector3(0, rotationY, 0);
const clonedMeshes = [];
if (modelType.synthetic === 'spawner-disk') {
// Радиус 1 блок (диаметр 2)
const disk = MeshBuilder.CreateCylinder(`spawnerDisk_${instanceId}`, {
diameter: 2.0, height: 0.18, tessellation: 24,
}, this.scene);
disk.parent = root;
disk.position.set(0, 0.09, 0);
const diskMat = new StandardMaterial(`spawnerDiskMat_${instanceId}`, this.scene);
diskMat.emissiveColor = new Color3(0.55, 0.1, 0.55);
diskMat.diffuseColor = new Color3(0.3, 0.05, 0.3);
diskMat.specularColor = new Color3(0.1, 0.1, 0.1);
disk.material = diskMat;
disk.isPickable = true;
disk.metadata = { isModel: true, instanceId };
clonedMeshes.push(disk);
const ring = MeshBuilder.CreateTorus(`spawnerRing_${instanceId}`, {
diameter: 1.7, thickness: 0.14, tessellation: 24,
}, this.scene);
ring.parent = root;
ring.position.set(0, 0.22, 0);
const ringMat = new StandardMaterial(`spawnerRingMat_${instanceId}`, this.scene);
ringMat.emissiveColor = new Color3(1, 0.35, 1);
ringMat.diffuseColor = new Color3(0.5, 0.2, 0.5);
ring.material = ringMat;
ring.isPickable = true;
ring.metadata = { isModel: true, instanceId };
clonedMeshes.push(ring);
}
const data = {
instanceId,
rootMesh: root,
modelTypeId: modelType.id,
x, y, z, rotationY,
clonedMeshes,
anchored: true, canCollide: false, visible: true, mass: 1,
folderId: null,
localAABB: { minX: -1, maxX: 1, minY: 0, maxY: 0.4, minZ: -1, maxZ: 1 },
gameplay: modelType.gameplay || null,
// Тег чтобы знать что это synthetic — для пульсации в render-loop
_synthetic: modelType.synthetic,
};
this.instances.set(instanceId, data);
// Регистрируем тик пульсации для синтетики
this._ensureSyntheticTick();
this._notifyChange();
return instanceId;
}
/** Регистрация общего тика для пульсации synthetic-объектов. */
_ensureSyntheticTick() {
if (this._synTickHook) return;
this._synTickHook = () => {
const t = performance.now() * 0.001;
for (const data of this.instances.values()) {
if (data._synthetic !== 'spawner-disk') continue;
const ring = data.clonedMeshes?.[1];
if (!ring) continue;
const k = 0.85 + 0.15 * Math.sin(t * 3);
ring.scaling.x = k;
ring.scaling.z = k;
ring.rotation.y = t * 0.7;
}
};
this.scene.registerBeforeRender(this._synTickHook);
}
async _addCompoundInstance(modelType, x, y, z, rotationY = 0) {
const folderId = this._scene3d.folderManager.createFolder(modelType.name, null);
const cosY = Math.cos(rotationY), sinY = Math.sin(rotationY);
const place = (dx, dz) => ({ wx: x + dx * cosY + dz * sinY, wz: z - dx * sinY + dz * cosY });
let firstId = null;
for (const part of modelType.parts) {
const dx = part.dx || 0, dy = part.dy || 0, dz = part.dz || 0;
const ry = (part.ry || 0) + rotationY;
const w = place(dx, dz);
if (part.kind === 'mesh' && part.model) {
// Рекурсивно ставим простую модель — она попадает в this.instances
const id = await this.addInstance(part.model, w.wx, y + dy, w.wz, ry);
if (id != null) {
this._scene3d.folderManager.assignToFolder('model', id, folderId);
if (firstId == null) firstId = id;
}
} else if (part.kind === 'primitive' && this._scene3d.primitiveManager) {
const pid = this._scene3d.primitiveManager.addInstance(part.type || 'cube', {
x: w.wx, y: y + dy, z: w.wz,
sx: part.sx, sy: part.sy, sz: part.sz,
color: part.color, material: part.material,
rotationX: part.rx || 0, rotationY: ry, rotationZ: part.rz || 0,
});
if (pid != null) {
this._scene3d.folderManager.assignToFolder('primitive', pid, folderId);
}
}
}
return firstId;
}
_notifyChange() {
if (this._onChange) this._onChange();
}
/**
* Резолв modelTypeId в запись модели.
* Для 'glb:<id>' (Фаза 5.8 — импортированная пользователем .glb)
* строит inline-запись с data-URL из GlbLibrary. Иначе — getModelType.
*/
_resolveModelType(modelTypeId) {
if (typeof modelTypeId === 'string' && modelTypeId.indexOf('glb:') === 0) {
const lib = this._scene3d && this._scene3d.glbLibrary;
const dataUrl = lib && lib.getDataUrl(modelTypeId.slice(4));
if (!dataUrl) return null;
return { id: modelTypeId, name: 'GLB-модель', file: dataUrl, _isGlb: true };
}
// Фаза 5.6 RUBLOX_DESIGNER_PLAN.md: прямой URL — для preview моделей
// от дизайнеров (rublox_designer_models.file_path).
// Формат: 'url:<полный URL к .glb>' — например
// 'url:/api-storys/assets/rublox-designer/models/1/oak_chair.glb'.
if (typeof modelTypeId === 'string' && modelTypeId.indexOf('url:') === 0) {
let url = modelTypeId.slice(4);
// Если URL относительный /api-storys/... — превращаем в абсолютный.
// Используем VITE_API_BASE если задан, иначе текущий origin.
if (url && url.startsWith('/api-storys/')) {
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
const base = env.VITE_API_BASE
|| (typeof window !== 'undefined' ? window.location.origin : '');
if (base) url = base + url;
}
return { id: modelTypeId, name: 'Designer-модель', file: url, _isGlb: true };
}
return getModelType(modelTypeId);
}
/**
* Загрузить (или взять из кеша) GLB-модель.
* Возвращает AssetContainer.
*/
async _loadPrototype(modelTypeId) {
if (this._prototypes.has(modelTypeId)) {
const cached = this._prototypes.get(modelTypeId);
return cached instanceof Promise ? await cached : cached;
}
const modelType = this._resolveModelType(modelTypeId);
if (!modelType) {
// eslint-disable-next-line no-console
console.error('[ModelManager] Unknown model type:', modelTypeId);
return null;
}
let rootUrl, filename, pluginExt;
if (modelType._isGlb && modelType.file.startsWith('data:')) {
// Импортированная .glb — file это base64 data-URL.
// SceneLoader: rootUrl пустой, filename = data-URL, расширение явно.
rootUrl = '';
filename = modelType.file;
pluginExt = '.glb';
} else {
// Парсим путь для SceneLoader: ему нужны (rootUrl, sceneFilename).
// Работает для абсолютных https://... и относительных /path/file.glb.
const lastSlash = modelType.file.lastIndexOf('/');
rootUrl = modelType.file.substring(0, lastSlash + 1);
filename = modelType.file.substring(lastSlash + 1);
pluginExt = undefined;
}
const promise = SceneLoader.LoadAssetContainerAsync(rootUrl, filename, this.scene, null, pluginExt)
.then(container => {
this._prototypes.set(modelTypeId, container);
return container;
})
.catch(err => {
// eslint-disable-next-line no-console
console.error('[ModelManager] failed to load:', modelTypeId,
'from', rootUrl + filename, err);
this._prototypes.delete(modelTypeId);
return null;
});
this._prototypes.set(modelTypeId, promise);
return await promise;
}
/**
* Поставить инстанс модели в (x, y, z).
* x/y/z — мировые координаты центра модели на полу (низ модели = y).
* rotationY — поворот вокруг вертикальной оси в радианах.
* Возвращает instanceId.
*/
async addInstance(modelTypeId, x, y, z, rotationY = 0) {
const modelType = this._resolveModelType(modelTypeId);
if (!modelType) return null;
// Synthetic-модели: GLB не загружается, меш рисуется примитивом.
if (modelType.synthetic) {
return this._addSyntheticInstance(modelType, x, y, z, rotationY);
}
// Если модель «сложная» (parts) — создаём папку через folderManager
// и добавляем каждую часть в неё. Возвращаем instanceId главной части.
if (Array.isArray(modelType.parts) && modelType.parts.length > 0
&& this._scene3d?.folderManager) {
return await this._addCompoundInstance(modelType, x, y, z, rotationY);
}
const proto = await this._loadPrototype(modelTypeId);
if (!proto) return null;
// Клонируем все меши в новый корневой узел
const root = new TransformNode(`model_${this._nextInstanceId}`, this.scene);
const clonedMeshes = [];
// proto.meshes содержит __root__ и все дочерние. Используем
// instantiateModelsToScene — Babylon-овский способ инстанцировать GLB.
const inst = proto.instantiateModelsToScene(
(name) => `${modelTypeId}_${this._nextInstanceId}_${name}`,
true,
{ doNotInstantiate: false }
);
// КРИТИЧНО ДЛЯ FPS: Babylon при cloneAnimations=true КЛОНИРУЕТ animationGroups
// и они автоматически могут включаться (некоторые GLB имеют autoplay).
// На зомби-острове это означает что 6-10 моделей анимируют ВСЕ свои
// animationGroups (idle/walk/death/attack — десятки) каждый кадр, даже
// если рендер использует только idle. Останавливаем все клонированные
// группы — ZombieManager / PlayerController сами стартуют нужную через
// animations.idle.start() когда пора.
if (inst.animationGroups) {
for (const g of inst.animationGroups) {
try { g.stop(); } catch (e) {}
}
}
// Все root-узлы инстанса парентим к нашему TransformNode
for (const r of inst.rootNodes) {
r.parent = root;
// Все mesh-чилдрены — pickable, помечаем metadata, принимают тени
r.getChildMeshes(false).forEach(m => {
m.isPickable = true;
m.metadata = { isModel: true, instanceId: this._nextInstanceId };
// Тени: GLB-модель и принимает тени, и отбрасывает их
// (через addShadowCaster в refreshAllShadows).
m.receiveShadows = true;
clonedMeshes.push(m);
});
// И сам root тоже на всякий
if (r.getTotalVertices && r.getTotalVertices() > 0) {
r.isPickable = true;
r.metadata = { isModel: true, instanceId: this._nextInstanceId };
r.receiveShadows = true;
clonedMeshes.push(r);
}
}
// ОПТИМИЗАЦИЯ убрана: попытка делить материалы между инстансами
// ломала AssetContainer прото для других пользователей (PlayerController,
// MultiplayerSync), которые тоже делают instantiateModelsToScene.
// dispose() клонированного материала иногда уничтожал прото-материал
// → последующие инстансы получали material=null → невидимы.
// Babylon самостоятельно батчит draw calls по uniqueId материала.
// Делаем все клонированные меши «всегда активными» — они НЕ должны
// фильтроваться через scene.freezeActiveMeshes() / frustum culling
// если их добавили после заморозки активных мешей.
for (const m of clonedMeshes) {
if (m && m.alwaysSelectAsActiveMesh !== undefined) {
m.alwaysSelectAsActiveMesh = true;
}
}
root.position = new Vector3(x, y, z);
root.rotation = new Vector3(0, rotationY, 0);
// Вычисляем итоговый scale.
// Если у модели задан targetHeight — нормализуем размер так,
// чтобы реальная высота AABB равнялась targetHeight (в блоках мира).
// Это спасает от того что Kenney-модели имеют разные единицы (дом ~10, еда ~0.1).
let finalScale = modelType.scale ?? 1;
if (modelType.targetHeight && modelType.targetHeight > 0) {
try {
root.scaling = new Vector3(1, 1, 1);
root.computeWorldMatrix(true);
for (const m of clonedMeshes) {
if (m.computeWorldMatrix) m.computeWorldMatrix(true);
}
// Реальная высота по позициям вершин (без skin-аксессоров)
let minY = Infinity, maxY = -Infinity;
const tmp = new Vector3();
for (const m of clonedMeshes) {
if (!m || typeof m.getTotalVertices !== 'function') continue;
if (m.getTotalVertices() <= 0) continue;
let positions;
try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
catch (e) { continue; }
if (!positions) continue;
const wm = m.getWorldMatrix();
for (let i = 0; i < positions.length; i += 3) {
tmp.set(positions[i], positions[i + 1], positions[i + 2]);
const w = Vector3.TransformCoordinates(tmp, wm);
if (w.y < minY) minY = w.y;
if (w.y > maxY) maxY = w.y;
}
}
const realHeight = maxY - minY;
if (realHeight > 0.001 && isFinite(realHeight)) {
finalScale = modelType.targetHeight / realHeight;
}
} catch (e) { /* fallback на 1 */ }
}
root.scaling = new Vector3(finalScale, finalScale, finalScale);
const instanceId = this._nextInstanceId++;
const data = {
instanceId,
rootMesh: root,
modelTypeId,
x, y, z, rotationY,
clonedMeshes,
anchored: true,
canCollide: true,
visible: true,
mass: 1,
folderId: null,
localAABB: null,
// Gameplay-метаданные из ModelTypes (для зомби, спавнеров и т.п.)
gameplay: modelType.gameplay || null,
};
this.instances.set(instanceId, data);
// Авто-регистрация в shadow casters (Этап 4 теней). Скрипты
// спавнящие модели через scene.spawn должны видеть тень сразу,
// а не после ручного вызова refreshAllShadows.
try {
const bs = this.scene3d || this.babylonScene;
if (bs && typeof bs.addShadowCaster === 'function' && clonedMeshes) {
for (const m of clonedMeshes) bs.addShadowCaster(m);
}
} catch (e) { /* ignore */ }
// Считаем bounding box после небольшой задержки — Babylon мог ещё не
// обновить world-матрицы дочерних мешей сразу после instantiate.
// Берём локальный AABB (без учёта позиции rootMesh).
try {
this.scene.executeWhenReady(() => {
const inst = this.instances.get(instanceId);
if (!inst || !inst.rootMesh) return;
this._computeLocalAABB(inst);
// ОПТИМИЗАЦИЯ: для статичных моделей (деревья, дома, камни)
// — те что не зомби и не спавнеры — замораживаем world-matrix.
// Babylon перестанет пересчитывать матрицу каждого child-меша
// при render. Это даёт заметный выигрыш на сценах с десятками
// GLB-объектов.
// freeze world matrix — выполняется централизованно через
// ModelManager.freezeStaticModels() при enterPlayMode.
// Здесь не делаем — у редактора с гизмо это сломало бы
// перетаскивание моделей.
});
} catch (e) { /* ignore */ }
this._notifyChange();
return instanceId;
}
/** Удалить инстанс по id. */
removeInstance(instanceId) {
const data = this.instances.get(instanceId);
if (!data) return false;
for (const m of data.clonedMeshes) {
try { m.dispose(); } catch (e) { /* ignore */ }
}
try { data.rootMesh.dispose(); } catch (e) { /* ignore */ }
this.instances.delete(instanceId);
this._notifyChange();
return true;
}
/** Удалить инстанс по mesh (после raycast). */
removeInstanceByMesh(mesh) {
if (!mesh || !mesh.metadata?.isModel) return false;
return this.removeInstance(mesh.metadata.instanceId);
}
/** Сколько инстансов на сцене. */
getInstanceCount() {
return this.instances.size;
}
/**
* Заморозить world-matrix всех статичных GLB-моделей (не зомби и не
* спавнеров). Babylon перестаёт пересчитывать матрицы каждого
* child-mesh каждый кадр — заметный буст на сценах с GLB-проп'ами
* (деревья, дома, камни).
*
* Вызывается из BabylonScene.enterPlayMode после регистрации зомби.
*/
freezeStaticModels() {
for (const inst of this.instances.values()) {
if (inst._worldMatrixFrozen) continue;
const gp = inst.gameplay;
if (gp?.isZombie || gp?.isZombieSpawner) continue;
// Зомби, спавнутые во время Play (через ZombieSpawnerManager),
// НЕ имеют gameplay.isZombie (они character-c), но точно не должны
// замораживаться — за ними двигает зомби-менеджер.
if (inst._spawnedAtRuntime) continue;
if (inst.anchored === false) continue; // unanchored = физическое тело
if (!inst.rootMesh) continue;
try {
inst.rootMesh.computeWorldMatrix(true);
inst.rootMesh.freezeWorldMatrix();
for (const m of inst.clonedMeshes) {
if (m && m.computeWorldMatrix) {
m.computeWorldMatrix(true);
if (m.freezeWorldMatrix) m.freezeWorldMatrix();
m.doNotSyncBoundingInfo = true;
}
}
inst._worldMatrixFrozen = true;
} catch (e) { /* ignore */ }
}
}
/** Разморозить — вызывается при exitPlayMode чтобы редактор мог двигать. */
unfreezeStaticModels() {
for (const inst of this.instances.values()) {
if (!inst._worldMatrixFrozen) continue;
try {
inst.rootMesh?.unfreezeWorldMatrix?.();
for (const m of inst.clonedMeshes) {
if (m && m.unfreezeWorldMatrix) m.unfreezeWorldMatrix();
if (m) m.doNotSyncBoundingInfo = false;
}
inst._worldMatrixFrozen = false;
} catch (e) { /* ignore */ }
}
}
/** Очистить всё. */
clear() {
// removeInstance внутри уже вызывает _notifyChange — отключаем на время
// массовой очистки и шлём один уведомление в конце.
const cb = this._onChange;
this._onChange = null;
const had = this.instances.size > 0;
for (const id of Array.from(this.instances.keys())) {
this.removeInstance(id);
}
this._onChange = cb;
if (had) this._notifyChange();
}
/** Сериализация для сохранения проекта. */
serialize() {
const out = [];
for (const data of this.instances.values()) {
// Спавнерные зомби (созданные в Play) не сохраняем
if (data._spawnedAtRuntime) continue;
out.push({
type: data.modelTypeId,
x: data.x, y: data.y, z: data.z,
rotationY: data.rotationY,
anchored: data.anchored !== false,
canCollide: data.canCollide !== false,
visible: data.visible !== false,
mass: data.mass ?? 1,
opacity: typeof data.opacity === 'number' ? data.opacity : 1,
tint: data.tint || null,
name: data.name || null,
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
gameplayParams: data.gameplayParams || null,
});
}
return out;
}
/**
* Посчитать локальный AABB модели (относительно её rootMesh.position).
* Делаем один раз — модели в этом редакторе не деформируются.
* scale rootMesh учитывается автоматически (мы вычисляем уже после
* того как scaling применён).
*/
_computeLocalAABB(data) {
if (!data.rootMesh) return;
try {
data.rootMesh.computeWorldMatrix(true);
for (const m of data.clonedMeshes || []) {
if (m.computeWorldMatrix) m.computeWorldMatrix(true);
}
// Считаем bbox по РЕАЛЬНЫМ позициям вершин (а не через boundingInfo,
// которое у Kenney character-моделей ломается из-за skin/joint-аксессоров).
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
let foundAny = false;
const tmp = new Vector3();
for (const m of data.clonedMeshes || []) {
if (!m || typeof m.getTotalVertices !== 'function') continue;
if (m.getTotalVertices() <= 0) continue;
if (m.isEnabled && !m.isEnabled()) continue;
if (m.isVisible === false) continue;
let positions;
try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
catch (e) { continue; }
if (!positions || positions.length === 0) continue;
let wm;
try { wm = m.getWorldMatrix(); } catch (e) { continue; }
if (!wm) continue;
for (let i = 0; i < positions.length; i += 3) {
tmp.set(positions[i], positions[i + 1], positions[i + 2]);
const w = Vector3.TransformCoordinates(tmp, wm);
if (w.x < minX) minX = w.x;
if (w.y < minY) minY = w.y;
if (w.z < minZ) minZ = w.z;
if (w.x > maxX) maxX = w.x;
if (w.y > maxY) maxY = w.y;
if (w.z > maxZ) maxZ = w.z;
foundAny = true;
}
}
if (!foundAny) {
// Fallback на старую логику
const { min, max } = data.rootMesh.getHierarchyBoundingVectors(true);
minX = min.x; minY = min.y; minZ = min.z;
maxX = max.x; maxY = max.y; maxZ = max.z;
}
const rp = data.rootMesh.position;
// Локальные координаты относительно rootMesh.position
minX -= rp.x; maxX -= rp.x;
minY -= rp.y; maxY -= rp.y;
minZ -= rp.z; maxZ -= rp.z;
// Сжимаем AABB по горизонтали на 30% — у GLB часто широкие коллайдеры
// (листва дерева, лапы животных), и игрок упирается в пустое место.
const SHRINK_HORZ = 0.7;
const cx = (minX + maxX) / 2;
const cz = (minZ + maxZ) / 2;
minX = cx + (minX - cx) * SHRINK_HORZ;
maxX = cx + (maxX - cx) * SHRINK_HORZ;
minZ = cz + (minZ - cz) * SHRINK_HORZ;
maxZ = cz + (maxZ - cz) * SHRINK_HORZ;
data.localAABB = { minX, maxX, minY, maxY, minZ, maxZ };
} catch (e) {
// Fallback: куб 1×1×1 от позиции вверх
data.localAABB = {
minX: -0.5, maxX: 0.5,
minY: 0, maxY: 1,
minZ: -0.5, maxZ: 0.5,
};
}
}
/**
* Получить мировой AABB модели (для физики).
* Возвращает null если модель ещё не готова.
*/
getInstanceAABB(instanceId) {
const data = this.instances.get(instanceId);
if (!data || !data.rootMesh) return null;
if (!data.localAABB) {
this._computeLocalAABB(data);
if (!data.localAABB) return null;
}
const a = data.localAABB;
let lminX = a.minX, lmaxX = a.maxX, lminZ = a.minZ, lmaxZ = a.maxZ;
const rotY = data.rotationY || 0;
if (rotY !== 0) {
const c = Math.cos(rotY), s = Math.sin(rotY);
const corners = [
{ x: a.minX, z: a.minZ }, { x: a.maxX, z: a.minZ },
{ x: a.maxX, z: a.maxZ }, { x: a.minX, z: a.maxZ },
];
lminX = Infinity; lmaxX = -Infinity; lminZ = Infinity; lmaxZ = -Infinity;
for (const cr of corners) {
const rx = cr.x * c - cr.z * s;
const rz = cr.x * s + cr.z * c;
if (rx < lminX) lminX = rx;
if (rx > lmaxX) lmaxX = rx;
if (rz < lminZ) lminZ = rz;
if (rz > lmaxZ) lmaxZ = rz;
}
}
return {
minX: data.x + lminX, maxX: data.x + lmaxX,
minY: data.y + a.minY, maxY: data.y + a.maxY,
minZ: data.z + lminZ, maxZ: data.z + lmaxZ,
};
}
/** Установить свойство модели и применить эффект к мешам. */
setInstanceProps(instanceId, patch) {
const data = this.instances.get(instanceId);
if (!data) return;
if (patch.anchored !== undefined) data.anchored = !!patch.anchored;
if (patch.canCollide !== undefined) data.canCollide = !!patch.canCollide;
if (patch.mass !== undefined) {
const m = Number(patch.mass);
if (Number.isFinite(m) && m > 0) data.mass = m;
}
if (patch.visible !== undefined) {
data.visible = !!patch.visible;
if (data.rootMesh) {
// setEnabled(false) скрывает всю иерархию (TransformNode + дети)
data.rootMesh.setEnabled(data.visible);
}
}
if (patch.opacity !== undefined) {
const a = Math.max(0, Math.min(1, Number(patch.opacity)));
if (Number.isFinite(a)) {
data.opacity = a;
this._applyMaterialOverrides(data);
}
}
if (patch.tint !== undefined) {
// tint = '#RRGGBB' или null/'' для сброса
data.tint = patch.tint || null;
this._applyMaterialOverrides(data);
}
if (patch.gameplayParams !== undefined) {
data.gameplayParams = patch.gameplayParams || null;
}
this._notifyChange();
}
/**
* Применить tint (умножение albedo) и opacity (alpha) ко всем материалам
* меша модели. Сохраняем оригинальные значения в _origMat для возможности
* сброса (когда tint=null и opacity=1).
*/
_applyMaterialOverrides(data) {
if (!data?.rootMesh) return;
const meshes = data.rootMesh.getChildMeshes ? data.rootMesh.getChildMeshes() : [];
const tintHex = data.tint || null;
const alpha = (typeof data.opacity === 'number') ? data.opacity : 1;
// Парсим hex в Color3
let tintColor = null;
if (tintHex && /^#[0-9a-fA-F]{6}$/.test(tintHex)) {
const r = parseInt(tintHex.substr(1, 2), 16) / 255;
const g = parseInt(tintHex.substr(3, 2), 16) / 255;
const b = parseInt(tintHex.substr(5, 2), 16) / 255;
tintColor = new Color3(r, g, b);
}
for (const m of meshes) {
const mat = m.material;
if (!mat) continue;
// Сохраняем оригиналы первый раз
if (!mat._kubikonOriginalAlpha) {
mat._kubikonOriginalAlpha = (typeof mat.alpha === 'number') ? mat.alpha : 1;
}
// Альфа
mat.alpha = mat._kubikonOriginalAlpha * alpha;
mat.transparencyMode = (mat.alpha < 1) ? 2 : 0; // ALPHABLEND : OPAQUE
// Tint (умножение базового цвета)
// Pbr-материал: albedoColor; Standard: diffuseColor
if (tintColor) {
if ('albedoColor' in mat) {
if (!mat._kubikonOrigAlbedo) mat._kubikonOrigAlbedo = mat.albedoColor.clone();
mat.albedoColor = mat._kubikonOrigAlbedo.multiplyByFloats(tintColor.r, tintColor.g, tintColor.b);
} else if ('diffuseColor' in mat) {
if (!mat._kubikonOrigDiffuse) mat._kubikonOrigDiffuse = mat.diffuseColor.clone();
mat.diffuseColor = mat._kubikonOrigDiffuse.multiplyByFloats(tintColor.r, tintColor.g, tintColor.b);
}
} else {
// сброс tint
if (mat._kubikonOrigAlbedo && 'albedoColor' in mat) mat.albedoColor = mat._kubikonOrigAlbedo.clone();
if (mat._kubikonOrigDiffuse && 'diffuseColor' in mat) mat.diffuseColor = mat._kubikonOrigDiffuse.clone();
}
}
}
/** Восстановление из массива. */
async loadFromArray(arr) {
this.clear();
// ОПТИМИЗАЦИЯ: предзагружаем ВСЕ уникальные прототипы параллельно.
const uniqueTypes = new Set();
for (const m of arr) {
if (m.type && !m.synthetic) uniqueTypes.add(m.type);
}
// Параллельная предзагрузка — общий запрос для всех типов сразу.
await Promise.all(
Array.from(uniqueTypes).map(t =>
this._loadPrototype(t).catch(() => null)
)
);
// Теперь addInstance для каждого инстанса — мгновенный (proto в кэше).
for (const m of arr) {
const id = await this.addInstance(m.type, m.x, m.y, m.z, m.rotationY || 0);
if (id == null) continue;
const data = this.instances.get(id);
if (!data) continue;
if (m.anchored === false) data.anchored = false;
if (m.canCollide === false) data.canCollide = false;
if (m.visible === false) {
data.visible = false;
data.rootMesh?.setEnabled(false);
}
if (m.mass != null) data.mass = m.mass;
if (typeof m.opacity === 'number' && m.opacity !== 1) data.opacity = m.opacity;
if (m.tint) data.tint = m.tint;
if (m.name) data.name = m.name;
if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
}
}
/** Полная очистка при unmount. */
/** Снять тик пульсации спавнеров. */
_disposeSyntheticTick() {
if (this._synTickHook) {
try { this.scene.unregisterBeforeRender(this._synTickHook); } catch (e) {}
this._synTickHook = null;
}
}
dispose() {
this._disposeSyntheticTick();
this.clear();
// Диспозим все загруженные prototypes-контейнеры
for (const proto of this._prototypes.values()) {
if (proto && typeof proto.dispose === 'function') {
try { proto.dispose(); } catch (e) { /* ignore */ }
}
}
this._prototypes.clear();
}
}