/** * BeamManager — лучи (Beam) и следы (Trail) как объекты сцены (Фаза 5.2). * * Beam — светящаяся линия между двумя точками. Точки могут быть * фиксированными координатами или ref объектов — тогда луч * следует за объектами каждый кадр (лазеры, мосты света, * соединения, цепи). * Trail — шлейф, тянущийся за движущимся объектом (Babylon TrailMesh). * * Живут только в Play-режиме. Управляются скриптом через game.fx.* — * каждый вызов возвращает прокси-объект. */ import { MeshBuilder, StandardMaterial, Color3, Vector3, } from '@babylonjs/core'; import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh'; let _fxIdSeq = 1; export class BeamManager { constructor(scene3d) { this.scene3d = scene3d; this.scene = scene3d.scene; /** @type {Map} id → fx state (beam | trail) */ this.items = new Map(); this._renderHook = null; } start() { if (this._renderHook) return; this._renderHook = () => this._tick(); this.scene.registerBeforeRender(this._renderHook); } stop() { if (this._renderHook) { try { this.scene.unregisterBeforeRender(this._renderHook); } catch (e) {} this._renderHook = null; } for (const it of this.items.values()) this._disposeItem(it); this.items.clear(); } _disposeItem(it) { try { if (it.mesh) it.mesh.dispose(); if (it.mat) it.mat.dispose(); } catch (e) { /* ignore */ } } /** * Создать луч между двумя точками. * opts: { from, to — {x,y,z} или ref-строка объекта; * color: '#hex', width: толщина (м) }. * Возвращает id. */ addBeam(opts = {}) { const id = _fxIdSeq++; const width = Number.isFinite(opts.width) ? opts.width : 0.15; const mat = new StandardMaterial('beamMat_' + id, this.scene); const col = Color3.FromHexString(opts.color || '#66ccff'); mat.diffuseColor = col; mat.emissiveColor = col; mat.disableLighting = true; // Цилиндр-заготовка единичной высоты — масштабируем под длину луча. const mesh = MeshBuilder.CreateCylinder('beam_' + id, { height: 1, diameter: width, tessellation: 8 }, this.scene); mesh.material = mat; mesh.isPickable = false; mesh.renderingGroupId = 1; const it = { id, type: 'beam', mesh, mat, from: opts.from, to: opts.to, }; this.items.set(id, it); this._updateBeam(it); // сразу позиционируем return id; } /** Сменить цвет луча. */ setBeamColor(id, color) { const it = this.items.get(Number(id)); if (!it || it.type !== 'beam' || !it.mat) return; const col = Color3.FromHexString(color || '#66ccff'); it.mat.diffuseColor = col; it.mat.emissiveColor = col; } /** Сменить концы луча (координаты или ref). */ setBeamEndpoints(id, from, to) { const it = this.items.get(Number(id)); if (!it || it.type !== 'beam') return; if (from !== undefined) it.from = from; if (to !== undefined) it.to = to; } /** * Создать шлейф за объектом. * ref — ref-строка объекта. opts: { color, width, lifetime (сек) }. * Возвращает id. */ addTrail(ref, opts = {}) { const data = this._resolve(ref); const mesh = data && (data.mesh || data.rootMesh || data.rootNode); if (!mesh) return null; const id = _fxIdSeq++; const width = Number.isFinite(opts.width) ? opts.width : 0.4; const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5; // TrailMesh(name, generator, scene, diameter, length, autoStart). // length — сколько сегментов хранить; считаем из lifetime (≈60 fps). const segments = Math.max(10, Math.round(lifetime * 60)); const trail = new TrailMesh('trail_' + id, mesh, this.scene, width, segments, true); const mat = new StandardMaterial('trailMat_' + id, this.scene); const col = Color3.FromHexString(opts.color || '#ffcc44'); mat.diffuseColor = col; mat.emissiveColor = col; mat.disableLighting = true; mat.alpha = 0.7; trail.material = mat; trail.isPickable = false; trail.renderingGroupId = 1; const it = { id, type: 'trail', mesh: trail, mat }; this.items.set(id, it); return id; } /** Убрать луч/след по id. */ remove(id) { const it = this.items.get(Number(id)); if (!it) return; this._disposeItem(it); this.items.delete(it.id); } // ===== внутреннее ===== _tick() { if (this.items.size === 0) return; for (const it of this.items.values()) { // Trail обновляется самим Babylon (autoStart). Beam — мы. if (it.type === 'beam') this._updateBeam(it); } } _updateBeam(it) { const a = this._point(it.from); const b = this._point(it.to); if (!a || !b || !it.mesh) return; const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z; const len = Math.hypot(dx, dy, dz); if (len < 0.001) { it.mesh.setEnabled(false); return; } it.mesh.setEnabled(true); // Цилиндр единичной высоты вдоль локальной оси Y. Растягиваем по длине. it.mesh.scaling.y = len; // Центр луча. it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2); // Ориентируем ось Y цилиндра вдоль вектора a→b. const dir = new Vector3(dx, dy, dz).normalize(); // yaw + pitch так, чтобы +Y смотрел вдоль dir. const yaw = Math.atan2(dir.x, dir.z); const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y))); it.mesh.rotation.set(pitch, yaw, 0); } /** Точка из {x,y,z} или ref-строки объекта. */ _point(p) { if (!p) return null; if (typeof p === 'object' && Number.isFinite(p.x)) { return { x: p.x, y: p.y, z: p.z }; } if (typeof p === 'string') { const d = this._resolve(p); if (d) return { x: d.x, y: d.y, z: d.z }; } return null; } /** Резолв ref в data-объект менеджера. */ _resolve(ref) { if (typeof ref !== 'string') return null; const rt = this.scene3d.gameRuntime; let r = ref; if (rt && rt._localToReal && rt._localToReal.has(r)) { r = rt._localToReal.get(r); } const colon = r.indexOf(':'); if (colon < 0) return null; const kind = r.slice(0, colon); const rest = r.slice(colon + 1); const getFrom = (mgr) => { if (!mgr || !mgr.instances) return null; let d = mgr.instances.get(rest); if (!d) { const n = Number(rest); if (Number.isFinite(n)) d = mgr.instances.get(n); } return d || null; }; if (kind === 'primitive') return getFrom(this.scene3d.primitiveManager); if (kind === 'model') return getFrom(this.scene3d.modelManager); return null; } }