studio/src/editor/engine/ModelManager.js
min 018fce474b fix(studio): объекты больше не вываливаются из папки после Play/Stop (folderId в serialize)
Корень: serialize примитивов/моделей/userModel НЕ сохранял folderId. При
Play→Stop сцена восстанавливалась из снапшота без группировки → все части
кита (светофор/шипы/дверь) вываливались из папки в общие «Примитивы».
Добавлен folderId в serialize всех 3 менеджеров + восстановление в loadFromArray
(model/userModel явно, primitive через opts.folderId).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:00:36 +03:00

806 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) {
this.scene = scene;
this.instances = new Map();
this._prototypes = new Map();
this._nextInstanceId = 1;
this._onChange = null; // dirty-tracking
this._scene3d = null; // BabylonScene — для доступа к folderManager/primitiveManager
}
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 };
}
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) {
// Импортированная .glb — file это base64 data-URL.
// SceneLoader: rootUrl пустой, filename = data-URL, расширение явно.
rootUrl = '';
filename = modelType.file;
pluginExt = '.glb';
} else {
// Парсим путь для SceneLoader: ему нужны (rootUrl, sceneFilename).
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-модель принимает тени от мира. На InstancedMesh
// receiveShadows не действует (Babylon-warning + лишняя работа) —
// ставим только на обычных мешах.
if (m.getClassName && m.getClassName() !== 'InstancedMesh') {
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 теней).
try {
const bs = this.scene3d || this._scene3d;
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({
// СОХРАНЯЕМ instanceId — иначе при загрузке id у моделей
// могут сместиться (если до сейва были удаления), и
// привязки скриптов через target={kind:'model', id:N} порвутся.
instanceId: data.instanceId,
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,
// folderId — принадлежность к папке (иначе модели вываливаются
// из папки после Play/Stop). Баг 2026-06-05.
...(data.folderId != null ? { folderId: data.folderId } : {}),
// Параметры геймплея (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) {
// Если в сохранёнке есть instanceId — резервируем его ПЕРЕД addInstance,
// чтобы restored id точно совпали с теми, на которые ссылаются скрипты
// (target.id у model/primitive). Иначе при удалении/добавлении моделей
// id моделей в новой загрузке съезжают и привязка скрипт→модель рвётся.
const savedId = (typeof m.instanceId === 'number' && m.instanceId > 0)
? m.instanceId : null;
if (savedId != null) {
this._nextInstanceId = savedId;
}
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 (m.folderId != null) data.folderId = m.folderId; // восстановить папку
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
}
// Гарантируем что _nextInstanceId стоит ПОСЛЕ максимального восстановленного id —
// иначе следующий addInstance может выдать id, который уже занят и конфликтует
// со скрипт-target от другой модели.
let maxId = 0;
for (const id of this.instances.keys()) if (id > maxId) maxId = id;
this._nextInstanceId = maxId + 1;
}
/** Полная очистка при 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();
}
}