/** * ModelManager — управление 3D-моделями (GLB) в сцене Babylon.js. * * Концепция: * - GLB-файл загружается ОДИН РАЗ через SceneLoader.LoadAssetContainerAsync * и кешируется как "prototype" (AssetContainer). * - При размещении модели на сцене делаем clone() из prototype — это создаёт * обычный mesh, который можно двигать/поворачивать/удалять независимо. * * Хранилище инстансов: * instances: Map * * 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:' (Фаза 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(); } }