/** * PrimitiveManager — параметрические 3D-примитивы (как Part в Roblox). * * Каждый инстанс — отдельный mesh с настраиваемыми: * - тип (cube/sphere/cylinder/cone/plane/torus/trigger/checkpoint) * - позиция (x, y, z) * - размер (sx, sy, sz) * - цвет (#hex) * - материал ('matte' | 'metal' | 'glass' | 'neon') * - canCollide (bool) — участвует ли в физике коллизий * - visible (bool) — рисуется ли (anchored — пока заготовка) * * Хранится в `instances: Map`. * * Triggers — отдельный slice через kind='trigger': mesh полупрозрачный * жёлтый куб (виден в редакторе, скрыт в Play), не участвует в коллизиях. * * Checkpoints — kind='checkpoint': зелёный полупрозрачный цилиндр. * При касании игроком обновляет spawnPoint сцены. */ import { MeshBuilder, StandardMaterial, Color3, Vector3, PointLight, Mesh, VertexData, Texture, DynamicTexture, } from '@babylonjs/core'; import { getPrimitiveType } from './PrimitiveTypes'; export class PrimitiveManager { constructor(scene) { this.scene = scene; this.instances = new Map(); // id → { mesh, type, x, y, z, sx, sy, sz, color, material, canCollide, visible } this._nextId = 1; this._onChange = null; } setOnChange(cb) { this._onChange = cb; } _notifyChange() { if (this._onChange) this._onChange(); } /** * Создать примитив. Все поля кроме type — опциональны. * Возвращает id или null. */ addInstance(type, opts = {}) { const typeDef = getPrimitiveType(type); if (!typeDef) return null; // Если в opts передан id — используем его (нужно для loadFromArray // и шаблонов со скриптами, где target.id ссылается на конкретный id). // Иначе берём следующий из счётчика. let id; if (Number.isFinite(opts.id) && opts.id > 0 && !this.instances.has(opts.id)) { id = opts.id; if (id >= this._nextId) this._nextId = id + 1; } else { id = this._nextId++; } const sx = opts.sx ?? typeDef.defaultScale.x; const sy = opts.sy ?? typeDef.defaultScale.y; const sz = opts.sz ?? typeDef.defaultScale.z; const color = opts.color ?? typeDef.defaultColor; // GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики. // Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции. const isGdKind = typeDef.kind === 'gd_portal' || typeDef.kind === 'gd_finish' || typeDef.kind === 'gd_coin'; const isGlowingGd = isGdKind; const isGdSpike = typeDef.kind === 'gd_spike'; const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte'); // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции) const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike; const visible = opts.visible !== false; const anchored = opts.anchored !== false; // по умолчанию заякорен // Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков. const rawMass = (opts.sx ?? typeDef.defaultScale.x) * (opts.sy ?? typeDef.defaultScale.y) * (opts.sz ?? typeDef.defaultScale.z); const defaultMass = Math.max(0.1, Math.round(rawMass * 100) / 100); const mass = opts.mass != null ? Number(opts.mass) : defaultMass; const x = opts.x ?? 0; const y = opts.y ?? 0.5; const z = opts.z ?? 0; const rotationX = opts.rotationX ?? 0; const rotationY = opts.rotationY ?? 0; const rotationZ = opts.rotationZ ?? 0; const mesh = this._createMeshForType(typeDef, id, sx, sy, sz); mesh.position = new Vector3(x, y, z); mesh.rotation = new Vector3(rotationX, rotationY, rotationZ); mesh.isPickable = true; // Тени: примитивы принимают тени от других объектов. mesh.receiveShadows = true; mesh.metadata = { isPrimitive: true, primitiveId: id, primitiveType: type, primitiveKind: typeDef.kind, }; // textureAsset — id картинки из AssetManager (пользовательская // текстура на гранях). Хранится в data, сериализуется, применяется // на материал поверх цвета. const textureAsset = typeof opts.textureAsset === 'string' ? opts.textureAsset : null; const data = { id, mesh, type, x, y, z, sx, sy, sz, rotationX, rotationY, rotationZ, color, material, canCollide, visible, anchored, mass, textureAsset, // locked — объект защищён от выделения/перемещения в редакторе // (Фаза 5.11). На геймплей не влияет. locked: opts.locked === true, name: opts.name || null, folderId: opts.folderId ?? null, }; this._applyMaterial(mesh, typeDef, color, material); this._applyVisible(mesh, visible, typeDef); // Пользовательская текстура — поверх базового материала. if (textureAsset) this._applyAssetTexture(data); // === Лампа: создаём привязанный PointLight === if (typeDef.kind === 'light') { // brightness — яркость свечения, range — радиус действия (метры). data.brightness = Number.isFinite(opts.brightness) ? opts.brightness : 1.5; data.range = Number.isFinite(opts.range) ? opts.range : 12; const light = new PointLight(`primLight_${id}`, new Vector3(x, y, z), this.scene); light.diffuse = Color3.FromHexString(color || '#ffe9a0'); light.intensity = data.brightness; light.range = data.range; data.light = light; // Маркер-сфера светится (neon-вид) — чтобы лампу было видно. if (mesh.material) { mesh.material.emissiveColor = Color3.FromHexString(color || '#ffe9a0'); } } // === Эмиттер: создаём постоянную систему частиц === if (typeDef.kind === 'emitter') { // effect — тип эффекта: 'fire' | 'smoke' | 'sparks' | 'magic'. data.effect = typeof opts.effect === 'string' ? opts.effect : 'fire'; if (this.scene3d && this.scene3d.createEmitterParticles) { data.particles = this.scene3d.createEmitterParticles( data.effect, { x, y, z }, color); } if (mesh.material) { mesh.material.emissiveColor = Color3.FromHexString(color || '#ff8833'); } } // === 3D-табличка (billboard): натягиваем DynamicTexture с GUI === if (typeDef.kind === 'billboard' && this.billboardUiManager) { // Сохраняем настройки билборда в data.billboardOpts чтобы // serialize мог записать их обратно в JSON проекта. const billboardOpts = { template: opts.template || 'shop-item', face: opts.face || 'fixed', content: opts.content || null, elements: opts.elements || null, rotationY: opts.rotationY, }; this.billboardUiManager.applyToMesh(data, billboardOpts); // billboardOpts хранится в data.billboard после applyToMesh. } this.instances.set(id, data); // Авто-регистрация в shadow casters (Этап 4 теней). try { if (this.scene3d && typeof this.scene3d.addShadowCaster === 'function') { this.scene3d.addShadowCaster(mesh); } } catch (e) { /* ignore */ } this._notifyChange(); return id; } /** Создать базовый mesh нужной формы (без материала). */ _createMeshForType(typeDef, id, sx, sy, sz) { const name = `prim_${typeDef.id}_${id}`; switch (typeDef.id) { case 'cube': case 'trigger': return MeshBuilder.CreateBox(name, { width: sx, height: sy, depth: sz }, this.scene); case 'sphere': return MeshBuilder.CreateSphere(name, { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene); case 'cylinder': case 'checkpoint': // GD-порталы: высокий тонкий цилиндр-«столб» (как в Geometry Dash) case 'gd_cube': case 'gd_ship': case 'gd_ball': case 'gd_ufo': case 'gd_wave': case 'gd_robot': return MeshBuilder.CreateCylinder(name, { diameter: sx, height: sy, tessellation: 24 }, this.scene); case 'cone': case 'gd_spike': // GD-шип = тот же cone (треугольный остриём вверх) return MeshBuilder.CreateCylinder(name, { diameterTop: 0, diameterBottom: sx, height: sy, tessellation: 24 }, this.scene); case 'gd_finish': // GD-финиш = неоновый цилиндр-столб return MeshBuilder.CreateCylinder(name, { diameter: sx, height: sy, tessellation: 24 }, this.scene); case 'gd_coin': // GD-монета = сфера return MeshBuilder.CreateSphere(name, { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene); case 'light': case 'emitter': // Лампа / эмиттер = маленькая сфера-маркер. Свет/частицы // создаются отдельно в addInstance. return MeshBuilder.CreateSphere(name, { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene); case 'billboard': { // 3D-табличка — плоскость с пропорциями таблички (sx × sy), // sz — толщина рамки (визуально-незаметная). // ВАЖНО: FRONTSIDE, не DOUBLESIDE — иначе сквозь back-side // видно зеркальную сторону UV (текст справа-налево). // BillboardMode разворачивает FRONT к камере. const m = MeshBuilder.CreatePlane(name, { width: sx, height: sy, sideOrientation: Mesh.FRONTSIDE }, this.scene); return m; } case 'plane': return MeshBuilder.CreateBox(name, { width: sx, height: sy, depth: sz }, this.scene); case 'torus': return MeshBuilder.CreateTorus(name, { diameter: sx, thickness: sy * 0.5, tessellation: 24 }, this.scene); case 'wedge': return this._buildWedgeMesh(name, sx, sy, sz); case 'cornerwedge': return this._buildCornerWedgeMesh(name, sx, sy, sz); default: return MeshBuilder.CreateBox(name, { width: sx, height: sy, depth: sz }, this.scene); } } /** * Клин — куб со срезанной верхней гранью (наклонная рампа). * Высокий край у +Z, остриё-ребро у -Z. Центрирован в (0,0,0), * как CreateBox. У каждой грани свои вершины (плоское затенение). */ _buildWedgeMesh(name, sx, sy, sz) { const hx = sx / 2, hy = sy / 2, hz = sz / 2; // 6 уникальных точек клина: // низ: A(-hx,-hy,-hz) B(hx,-hy,-hz) C(hx,-hy,hz) D(-hx,-hy,hz) // верх: E(-hx,hy,hz) F(hx,hy,hz) — только у задней (+Z) грани const positions = []; const indices = []; const normals = []; const uvs = []; let vi = 0; // Добавить грань (массив точек [x,y,z], нормаль, uv-набор) триангуляцией веером. const face = (pts, nrm, uv) => { const start = vi; for (let i = 0; i < pts.length; i++) { positions.push(pts[i][0], pts[i][1], pts[i][2]); normals.push(nrm[0], nrm[1], nrm[2]); uvs.push(uv[i][0], uv[i][1]); vi++; } for (let i = 1; i < pts.length - 1; i++) { indices.push(start, start + i, start + i + 1); } }; // Низ (y=-hy), нормаль вниз — порядок по часовой при взгляде снизу face( [[-hx,-hy,-hz],[-hx,-hy,hz],[hx,-hy,hz],[hx,-hy,-hz]], [0,-1,0], [[0,0],[0,1],[1,1],[1,0]] ); // Задняя вертикальная грань (z=+hz), нормаль +Z face( [[-hx,-hy,hz],[-hx,hy,hz],[hx,hy,hz],[hx,-hy,hz]], [0,0,1], [[0,0],[0,1],[1,1],[1,0]] ); // Наклонная грань: от нижнего ребра (-Z) к верхнему (+Z). // Нормаль перпендикулярна склону в плоскости YZ: (0, sz, sy) норм. const slopeLen = Math.hypot(sy, sz) || 1; face( [[-hx,-hy,-hz],[hx,-hy,-hz],[hx,hy,hz],[-hx,hy,hz]], [0, sz / slopeLen, sy / slopeLen], [[0,0],[1,0],[1,1],[0,1]] ); // Левый бок (x=-hx) — треугольник, нормаль -X face( [[-hx,-hy,-hz],[-hx,hy,hz],[-hx,-hy,hz]], [-1,0,0], [[0,0],[1,1],[1,0]] ); // Правый бок (x=+hx) — треугольник, нормаль +X face( [[hx,-hy,-hz],[hx,-hy,hz],[hx,hy,hz]], [1,0,0], [[0,0],[1,0],[1,1]] ); const mesh = new Mesh(name, this.scene); const vd = new VertexData(); vd.positions = positions; vd.indices = indices; vd.normals = normals; vd.uvs = uvs; vd.applyToMesh(mesh); return mesh; } /** * Угловой клин — скос по двум осям, остриё в углу (-X,-Z). * Высокая точка у (+X,+Z). Для внутренних углов крыш. */ _buildCornerWedgeMesh(name, sx, sy, sz) { const hx = sx / 2, hy = sy / 2, hz = sz / 2; const positions = []; const indices = []; const normals = []; const uvs = []; let vi = 0; const face = (pts, nrm, uv) => { const start = vi; for (let i = 0; i < pts.length; i++) { positions.push(pts[i][0], pts[i][1], pts[i][2]); normals.push(nrm[0], nrm[1], nrm[2]); uvs.push(uv[i][0], uv[i][1]); vi++; } for (let i = 1; i < pts.length - 1; i++) { indices.push(start, start + i, start + i + 1); } }; // Низ — квадрат основания face( [[-hx,-hy,-hz],[-hx,-hy,hz],[hx,-hy,hz],[hx,-hy,-hz]], [0,-1,0], [[0,0],[0,1],[1,1],[1,0]] ); // Верх — единственная высокая точка T(hx,hy,hz). Скос идёт к ней // из трёх нижних точек. Грани: const T = [hx, hy, hz]; // Задняя грань (z=+hz): нижнее ребро [-hx..hx] поднимается к T справа face( [[-hx,-hy,hz],[hx,-hy,hz],T], [0,0,1], [[0,0],[1,0],[1,1]] ); // Правая грань (x=+hx): нижнее ребро [-hz..hz] поднимается к T сзади face( [[hx,-hy,-hz],[hx,-hy,hz],T], [1,0,0], [[0,0],[1,0],[1,1]] ); // Наклонная треугольная грань — от низкого угла (-X,-Z) к T. // Нормаль = нормаль плоскости через 3 точки. const p1 = [-hx,-hy,-hz], p2 = [hx,-hy,-hz], p3 = T; const u = [p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2]]; const v = [p3[0]-p1[0], p3[1]-p1[1], p3[2]-p1[2]]; let n1 = [ u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0], ]; let nl = Math.hypot(n1[0],n1[1],n1[2]) || 1; n1 = [n1[0]/nl, n1[1]/nl, n1[2]/nl]; face([p1,p2,p3], n1, [[0,0],[1,0],[0.5,1]]); // Вторая наклонная грань — от (-X,-Z) к T со стороны -X. const q1 = [-hx,-hy,-hz], q2 = T, q3 = [-hx,-hy,hz]; const u2 = [q2[0]-q1[0], q2[1]-q1[1], q2[2]-q1[2]]; const v2 = [q3[0]-q1[0], q3[1]-q1[1], q3[2]-q1[2]]; let n2 = [ u2[1]*v2[2]-u2[2]*v2[1], u2[2]*v2[0]-u2[0]*v2[2], u2[0]*v2[1]-u2[1]*v2[0], ]; let nl2 = Math.hypot(n2[0],n2[1],n2[2]) || 1; n2 = [n2[0]/nl2, n2[1]/nl2, n2[2]/nl2]; face([q1,q2,q3], n2, [[0,0],[0.5,1],[1,0]]); const mesh = new Mesh(name, this.scene); const vd = new VertexData(); vd.positions = positions; vd.indices = indices; vd.normals = normals; vd.uvs = uvs; vd.applyToMesh(mesh); return mesh; } /** Применить цвет и материал. */ _applyMaterial(mesh, typeDef, color, material, textureUrl) { const matName = `${mesh.name}_mat`; const mat = new StandardMaterial(matName, this.scene); mat.diffuseColor = Color3.FromHexString(color || '#888888'); // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // используется для GD-скинов куба (например /gd/skins/cube_smile.png). // Цвет работает как multiplier для текстуры — для нейтрального эффекта // ставим белый (или сам color, чтобы можно было тонировать). if (textureUrl) { try { // Lazy-import: класс Texture в babylon достаём через require. // Babylon уже импортирован выше — используем глобал. const tex = new Texture(textureUrl, this.scene, true, false); tex.hasAlpha = true; mat.diffuseTexture = tex; // Для текстурных скинов цвет берём белый, чтобы не тонировать mat.diffuseColor = new Color3(1, 1, 1); } catch (e) { console.warn('[PrimitiveManager] не удалось загрузить текстуру', textureUrl, e); } } switch (material) { case 'metal': mat.specularColor = new Color3(0.7, 0.7, 0.7); mat.specularPower = 32; break; case 'glass': mat.alpha = 0.4; mat.specularColor = new Color3(0.5, 0.5, 0.5); break; case 'neon': mat.emissiveColor = Color3.FromHexString(color || '#888888'); mat.specularColor = new Color3(0, 0, 0); break; case 'matte': default: mat.specularColor = new Color3(0, 0, 0); break; } // Триггеры — всегда полупрозрачные жёлтые в редакторе if (typeDef.kind === 'trigger') { mat.diffuseColor = new Color3(1, 0.92, 0.2); mat.alpha = 0.35; mat.specularColor = new Color3(0, 0, 0); mat.emissiveColor = new Color3(0.3, 0.27, 0); } // Чекпоинты — зелёные полупрозрачные с эмиссией if (typeDef.kind === 'checkpoint') { mat.diffuseColor = new Color3(0.3, 0.95, 0.4); mat.alpha = 0.55; mat.emissiveColor = new Color3(0.1, 0.5, 0.15); mat.specularColor = new Color3(0, 0, 0); } mesh.material = mat; } /** Видимость. Триггеры в Play-режиме скрываются (флаг проставляет BabylonScene). */ _applyVisible(mesh, visible, typeDef) { mesh.setEnabled(visible); // Доп. логика для триггеров — может ставиться в _updatePlayModeVisibility } /** * Применить пользовательскую текстуру (картинку из AssetManager) на * грани примитива. data.textureAsset — id ассета. dataURL берём из * assetManager. Цвет материала становится белым, чтобы не тонировать. */ _applyAssetTexture(data) { if (!data || !data.mesh || !data.mesh.material) return; const assetId = data.textureAsset; if (!assetId || !this.assetManager) return; const dataUrl = this.assetManager.getDataUrl(assetId); if (!dataUrl) return; try { const mat = data.mesh.material; // Снимаем прежнюю diffuseTexture, чтобы не текла память. if (mat.diffuseTexture && mat.diffuseTexture.dispose) { try { mat.diffuseTexture.dispose(); } catch (e) { /* ignore */ } } const tex = new Texture(dataUrl, this.scene, true, false); tex.hasAlpha = true; mat.diffuseTexture = tex; mat.diffuseColor = new Color3(1, 1, 1); // neon-материал светится текстурой — emissive тоже из неё. if (data.material === 'neon') { mat.emissiveTexture = tex; } } catch (e) { console.warn('[PrimitiveManager] _applyAssetTexture failed', assetId, e); } } /** * Установить динамическую текстуру примитива из dataURL (base64-PNG). * Используется GD-скинами куба: canvas-фабрика в скрипте рисует лицо, * посылает dataURL сюда — мы создаём DynamicTexture и подменяем * diffuseTexture на материале примитива. */ setTexture(id, dataUrl) { const data = this.instances.get(id); if (!data || !data.mesh || !data.mesh.material) return false; try { // Lazy-import DynamicTexture const mat = data.mesh.material; // Очистка предыдущей текстуры если была if (mat.diffuseTexture && mat.diffuseTexture.dispose) { try { mat.diffuseTexture.dispose(); } catch (e) { /* ignore */ } } // Создаём 256×256 DynamicTexture, рисуем в неё через временное Image const dt = new DynamicTexture(`dt_${id}_${Date.now()}`, { width: 256, height: 256 }, this.scene, false); const img = new Image(); img.onload = () => { if (dt.isDisposed && dt.isDisposed()) return; const ctx = dt.getContext(); if (!ctx) return; ctx.clearRect(0, 0, 256, 256); ctx.drawImage(img, 0, 0, 256, 256); dt.update(false); }; img.onerror = () => { try { dt.dispose(); } catch (e) {} }; img.src = dataUrl; // Babylon CreateBox UV: V-ось перевёрнута относительно canvas → флипаем dt.vScale = -1; dt.vOffset = 1; mat.diffuseTexture = dt; mat.diffuseColor = new Color3(1, 1, 1); // Для neon — emissive из текстуры тоже if (data.material === 'neon') { mat.emissiveTexture = dt; } return true; } catch (e) { console.warn('[PrimitiveManager] setTexture failed', id, e); return false; } } /** Скрыть/показать триггеры (вызывается при enterPlayMode). */ setTriggersVisible(visible) { for (const data of this.instances.values()) { if (data.type === 'trigger' && data.mesh) { if (data.visible !== false) { data.mesh.setEnabled(visible); } } } } /** Обновить параметры примитива (позиция / размер / цвет / материал / флаги). */ updateInstance(id, patch) { const data = this.instances.get(id); if (!data) return; // Позиция if (patch.x !== undefined) data.x = patch.x; if (patch.y !== undefined) data.y = patch.y; if (patch.z !== undefined) data.z = patch.z; data.mesh.position.set(data.x, data.y, data.z); // Поворот let rotChanged = false; if (patch.rotationX !== undefined) { data.rotationX = patch.rotationX; rotChanged = true; } if (patch.rotationY !== undefined) { data.rotationY = patch.rotationY; rotChanged = true; } if (patch.rotationZ !== undefined) { data.rotationZ = patch.rotationZ; rotChanged = true; } if (rotChanged) { data.mesh.rotation.set(data.rotationX || 0, data.rotationY || 0, data.rotationZ || 0); } // Размер — только через scaling, чтобы не пересоздавать mesh let scaleChanged = false; if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; } if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; } if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; } if (scaleChanged) { // Поскольку MeshBuilder уже создал mesh с базовыми размерами, // изменения через scaling кажутся правильными. Простой способ — // пересоздать mesh: this._recreateMesh(data); } // Пользовательская текстура: id картинки из AssetManager или null/'' // (снять текстуру). Меняем data.textureAsset — переприменение ниже. let textureChanged = false; if (patch.textureAsset !== undefined) { data.textureAsset = patch.textureAsset || null; textureChanged = true; } // Цвет / материал let matChanged = false; if (patch.color !== undefined) { data.color = patch.color; matChanged = true; } if (patch.material !== undefined) { data.material = patch.material; matChanged = true; } if (matChanged) { const typeDef = getPrimitiveType(data.type); // Удаляем старый material if (data.mesh.material) { try { data.mesh.material.dispose(); } catch (e) { /* ignore */ } } this._applyMaterial(data.mesh, typeDef, data.color, data.material); } // Текстуру переприменяем если: сменили саму текстуру, или // пересоздали материал (matChanged) / mesh (scaleChanged) — // в этих случаях прежняя текстура потеряна. if (data.textureAsset && (textureChanged || matChanged || scaleChanged)) { this._applyAssetTexture(data); } else if (textureChanged && !data.textureAsset && matChanged === false) { // Текстуру сняли — пересоздаём чистый материал из цвета. const typeDef = getPrimitiveType(data.type); if (data.mesh.material) { try { data.mesh.material.dispose(); } catch (e) { /* ignore */ } } this._applyMaterial(data.mesh, typeDef, data.color, data.material); } if (patch.canCollide !== undefined) data.canCollide = patch.canCollide; if (patch.locked !== undefined) data.locked = !!patch.locked; if (patch.visible !== undefined) { data.visible = patch.visible; data.mesh.setEnabled(data.visible); } // Прозрачность: 1 = непрозрачно, 0 = невидимо. Меняет material.alpha. if (patch.opacity !== undefined) { const op = Number(patch.opacity); if (Number.isFinite(op)) { data.opacity = Math.max(0, Math.min(1, op)); if (data.mesh.material) data.mesh.material.alpha = data.opacity; } } // Billboard: пересоздать GUI-текстуру при изменении template/content/face/elements if (patch.billboardOpts && this.billboardUiManager && data.type === 'billboard') { this.billboardUiManager.applyToMesh(data, patch.billboardOpts); } // === Лампа: синхронизируем привязанный PointLight === if (data.light) { // позиция света — за маркером data.light.position.set(data.x, data.y, data.z); if (patch.color !== undefined) { data.light.diffuse = Color3.FromHexString(data.color || '#ffe9a0'); if (data.mesh.material) { data.mesh.material.emissiveColor = Color3.FromHexString(data.color || '#ffe9a0'); } } if (patch.brightness !== undefined) { const b = Number(patch.brightness); if (Number.isFinite(b) && b >= 0) { data.brightness = b; data.light.intensity = b; } } if (patch.range !== undefined) { const r = Number(patch.range); if (Number.isFinite(r) && r > 0) { data.range = r; data.light.range = r; } } } // === Эмиттер: синхронизируем систему частиц === if (data.effect !== undefined && this.scene3d) { // позиция частиц — за маркером if (data.particles && data.particles.emitter) { data.particles.emitter.set(data.x, data.y, data.z); } // смена типа эффекта или цвета — пересоздаём систему if (patch.effect !== undefined || patch.color !== undefined) { if (patch.effect !== undefined && typeof patch.effect === 'string') { data.effect = patch.effect; } // dispose(false) — НЕ удалять particleTexture: она расшарена // между всеми эмиттерами (_particleTex). dispose(true) убил бы // текстуру для всех остальных систем частиц. try { if (data.particles) data.particles.dispose(false); } catch (e) {} if (this.scene3d.createEmitterParticles) { data.particles = this.scene3d.createEmitterParticles( data.effect, { x: data.x, y: data.y, z: data.z }, data.color); } if (patch.color !== undefined && data.mesh.material) { data.mesh.material.emissiveColor = Color3.FromHexString(data.color || '#ff8833'); } } } if (patch.anchored !== undefined) data.anchored = patch.anchored; if (patch.mass !== undefined) { const m = Number(patch.mass); if (Number.isFinite(m) && m > 0) data.mass = m; } this._notifyChange(); } /** Пересоздать mesh при смене размера (т.к. Box-builder работает в base-size). */ _recreateMesh(data) { const oldMesh = data.mesh; const oldPos = oldMesh.position.clone(); const oldRot = oldMesh.rotation?.clone(); const oldMat = oldMesh.material; const typeDef = getPrimitiveType(data.type); const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz); newMesh.position = oldPos; if (oldRot) newMesh.rotation = oldRot; newMesh.material = oldMat; newMesh.isPickable = true; newMesh.metadata = { ...oldMesh.metadata }; newMesh.setEnabled(data.visible); // Удаляем старый try { oldMesh.dispose(/*doNotRecurse*/ true, /*disposeMaterial*/ false); } catch (e) { /* ignore */ } data.mesh = newMesh; } /** Удалить инстанс. */ removeInstance(id) { const data = this.instances.get(id); if (!data) return false; try { // У лампы есть привязанный PointLight — освобождаем его. if (data.light) data.light.dispose(); // У эмиттера — система частиц (dispose(false) — текстура расшарена). if (data.particles) data.particles.dispose(false); if (data.mesh.material) data.mesh.material.dispose(); data.mesh.dispose(); } catch (e) { /* ignore */ } this.instances.delete(id); this._notifyChange(); return true; } /** Удалить инстанс по mesh (после raycast). */ removeInstanceByMesh(mesh) { const id = mesh?.metadata?.primitiveId; if (id != null) return this.removeInstance(id); return false; } getInstanceCount() { return this.instances.size; } /** Все инстансы как массив (для Hierarchy). */ getAll() { return Array.from(this.instances.values()) // Исключаем скриптовые спавны — они эфемерные и не должны // попадать в project_data (иначе при каждом Play копятся дубли). .filter(d => !d._scriptSpawned) .map(d => ({ id: d.id, type: d.type, x: d.x, y: d.y, z: d.z, sx: d.sx, sy: d.sy, sz: d.sz, rotationX: d.rotationX || 0, rotationY: d.rotationY || 0, rotationZ: d.rotationZ || 0, color: d.color, material: d.material, canCollide: d.canCollide, visible: d.visible, anchored: d.anchored, mass: d.mass, name: d.name || null, // locked — защита от выделения/перемещения (Фаза 5.11). ...(d.locked ? { locked: true } : {}), // id пользовательской текстуры (картинка из AssetManager). ...(d.textureAsset ? { textureAsset: d.textureAsset } : {}), // Параметры лампы (только для type='light', иначе undefined) ...(d.light ? { brightness: d.brightness, range: d.range } : {}), // Параметр эмиттера (только для type='emitter') ...(d.effect !== undefined ? { effect: d.effect } : {}), // Параметры билборда (только для type='billboard') ...(d.billboard ? { template: d.billboard.template, face: d.billboard.face, content: d.billboard.content, ...(d.billboard.elements ? { elements: d.billboard.elements } : {}), } : {}), })); } /** * Заморозить worldMatrix у статичных примитивов на старте Play — * Babylon перестанет пересчитывать матрицы каждый кадр, frustum-test * по ним идёт без world-matrix recompute. Огромный буст FPS на сценах * с сотнями декоративных примитивов (Only Up клон, паркур-башни). * * Не замораживаем: примитивы с anchored=false (физика), и помеченные * скриптом как «движущиеся» (через мета-флаг или имя в moving-списке). * Скрипт всё равно зовёт scene.move через self.move/scene.move которые * в GameRuntime НЕ требуют world-matrix unfreezed (сам ставит position * без trigger reset). Но на всякий случай — пропускаем те у кого * имя начинается с MX_/MZ_/PEND_/PIST_ (наши соглашения для движущихся). */ freezeStaticPrimitives() { for (const data of this.instances.values()) { if (data._worldMatrixFrozen) continue; if (data.anchored === false) continue; if (!data.mesh) continue; const name = data.name || ''; // Пропускаем известные типы движущихся платформ if (name.startsWith('MX_') || name.startsWith('MZ_') || name.startsWith('PEND_') || name.startsWith('PIST_')) continue; // Пропускаем примитивы внутри папок — их может вращать setFolderYaw // (например, голова куклы Squid Game в папке DollHead). if (data.folderId != null) continue; try { data.mesh.computeWorldMatrix(true); data.mesh.freezeWorldMatrix(); // НЕ ставим doNotSyncBoundingInfo=true — на больших удалённых // примитивах (кукла Squid Game на z=90) это ломало frustum-cull // и mesh пропадал из видимости. data._worldMatrixFrozen = true; } catch (e) { /* ignore */ } } } unfreezeStaticPrimitives() { for (const data of this.instances.values()) { if (!data._worldMatrixFrozen) continue; try { data.mesh?.unfreezeWorldMatrix?.(); data._worldMatrixFrozen = false; } catch (e) { /* ignore */ } } } /** Найти все mesh-чекпоинты (для проверки игроком). */ getCheckpoints() { const out = []; for (const data of this.instances.values()) { if (data.type === 'checkpoint') out.push(data); } return out; } serialize() { return this.getAll(); } loadFromArray(arr) { this.clear(); for (const item of arr) { this.addInstance(item.type, item); } } clear() { 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(); } dispose() { this.clear(); } }