Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
798 lines
38 KiB
JavaScript
798 lines
38 KiB
JavaScript
/**
|
||
* 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-модель принимает тени от мира.
|
||
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,
|
||
// Параметры геймплея (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 (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();
|
||
}
|
||
}
|