/** * BeamManager — лучи (Beam), следы (Trail) и стрелки-указатели (Pointer) * как объекты сцены. * * Beam — светящаяся линия/лента между двумя точками. Точки могут быть * фиксированными координатами, ref объектов ИЛИ 'player' — тогда * конец следует за объектом/игроком каждый кадр. * Trail — шлейф за движущимся объектом (Babylon TrailMesh). * Pointer — высокоуровневая «стрелка иди сюда»: текстурированная лента * (бегущие шевроны/стрелки), с пресетами, curved-дугой, градиентом. * * Задача 08: расширены опции addBeam (texture/textureSpeed/curved/colorSequence/ * faceMode/strokeColor/...) + game.fx.pointer. Живут только в Play-режиме * (и в превью редактора со скоростью анимации 0). */ import { MeshBuilder, StandardMaterial, Color3, Color4, Vector3, DynamicTexture, Texture, VertexBuffer, TransformNode, } from '@babylonjs/core'; import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh'; let _fxIdSeq = 1; // Кэш сгенерированных текстур по ключу (форма+обводка) — одна на сцену. // Текстуры белые (рисуются белым), реальный цвет даёт mat.emissiveColor/diffuse. const _texCache = new Map(); // Встроенные пресеты для game.fx.pointer — разворачиваются в опции beam. const POINTER_PRESETS = { guide: { texture: 'chevron', color: '#ff3a3a', strokeColor: '#000000', strokeWidth: 4, textureSpeed: 3, width: 0.9, textureScale: 1 }, quest: { texture: 'chevron', color: '#ffd23a', strokeColor: '#5a3a00', strokeWidth: 3, textureSpeed: 3.5, width: 0.9, textureScale: 1 }, danger: { texture: 'lightning', color: '#ff2a2a', strokeColor: '#3a0000', strokeWidth: 2, textureSpeed: 5, width: 1.0, textureScale: 1.2 }, gift: { texture: 'sparkle', color: '#ffffff', strokeColor: null, strokeWidth: 0, textureSpeed: 2, width: 1.0, textureScale: 1.4, colorSequence: [{ p: 0, c: '#ff5a5a' }, { p: 0.25, c: '#ffd23a' }, { p: 0.5, c: '#5aff7a' }, { p: 0.75, c: '#3a9aff' }, { p: 1, c: '#c45aff' }] }, }; export class BeamManager { constructor(scene3d) { this.scene3d = scene3d; this.scene = scene3d.scene; /** @type {Map} id → fx state (beam | trail | pointer) */ this.items = new Map(); this._renderHook = null; this._lastTime = 0; // В превью редактора (не Play) анимацию текстур замораживаем. this.animationEnabled = true; } 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(); // Парящий quest-marker: root (TransformNode) с дочерним конусом — // dispose с потомками убирает и конус, и его outline. if (it._markerMesh) it._markerMesh.dispose(); if (it._marker) it._marker.dispose(); if (it._markerMat) it._markerMat.dispose(); } catch (e) { /* ignore */ } } // =================================================================== // ТЕКСТУРЫ ЛУЧА — генерируются на лету через Canvas2D (без PNG-файлов). // Все белые с альфа-каналом; цвет даёт материал. strokeColor рисуется // под основной формой. // =================================================================== /** Получить (с кэшем) DynamicTexture для формы. */ _getBeamTexture(shape, strokeColor, strokeWidth) { const key = shape + '|' + (strokeColor || '') + '|' + (strokeWidth || 0); if (_texCache.has(key)) return _texCache.get(key); const S = 128; const dyn = new DynamicTexture('beamTex_' + key, { width: S, height: S }, this.scene, true); dyn.hasAlpha = true; dyn.wrapU = Texture.WRAP_ADDRESSMODE; dyn.wrapV = Texture.CLAMP_ADDRESSMODE; const ctx = dyn.getContext(); ctx.clearRect(0, 0, S, S); this._drawShape(ctx, shape, S, strokeColor, strokeWidth); dyn.update(); _texCache.set(key, dyn); return dyn; } /** * Рисует форму на canvas. Ориентация: текстура натягивается вдоль ленты * так, что U идёт ПО длине (from→to). Шеврон рисуем «>» указывающим в * сторону +U (к цели). */ _drawShape(ctx, shape, S, strokeColor, strokeWidth) { const sw = Number(strokeWidth) || 0; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; const drawPath = (pathFn, fill) => { // Обводка (под формой) — рисуем толще тем же путём. if (strokeColor && sw > 0) { ctx.beginPath(); pathFn(); ctx.strokeStyle = strokeColor; ctx.lineWidth = sw * 2 + 14; ctx.stroke(); } ctx.beginPath(); pathFn(); if (fill) { ctx.fillStyle = '#ffffff'; ctx.fill(); } else { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 16; ctx.stroke(); } }; const m = S * 0.18; // отступ const cx = S / 2, cy = S / 2; switch (shape) { case 'chevron': { // «>» указывающий вправо (к цели вдоль +U) drawPath(() => { ctx.moveTo(m, m); ctx.lineTo(S - m, cy); ctx.lineTo(m, S - m); }, false); break; } case 'arrow': { // наконечник-треугольник вправо drawPath(() => { ctx.moveTo(m, m + 6); ctx.lineTo(S - m, cy); ctx.lineTo(m, S - m - 6); ctx.closePath(); }, true); break; } case 'dot': { drawPath(() => { ctx.arc(cx, cy, S * 0.22, 0, Math.PI * 2); }, true); break; } case 'line': { ctx.fillStyle = '#ffffff'; if (strokeColor && sw > 0) { ctx.fillStyle = strokeColor; ctx.fillRect(0, cy - (S * 0.28) / 2 - sw, S, S * 0.28 + sw * 2); ctx.fillStyle = '#ffffff'; } ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28); break; } case 'dash': { ctx.fillStyle = '#ffffff'; ctx.fillRect(S * 0.12, cy - (S * 0.22) / 2, S * 0.76, S * 0.22); break; } case 'wave': { drawPath(() => { ctx.moveTo(0, cy); for (let x = 0; x <= S; x += 4) { ctx.lineTo(x, cy + Math.sin((x / S) * Math.PI * 2) * (S * 0.28)); } }, false); break; } case 'lightning': { drawPath(() => { ctx.moveTo(m, m); ctx.lineTo(cx + 6, cy - 6); ctx.lineTo(cx - 6, cy + 6); ctx.lineTo(S - m, S - m); }, false); break; } case 'sparkle': { // 4-лучевая звёздочка drawPath(() => { const r = S * 0.30, ri = S * 0.10; for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2 - Math.PI / 2; const rad = (i % 2 === 0) ? r : ri; const x = cx + Math.cos(a) * rad, y = cy + Math.sin(a) * rad; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); }, true); break; } default: { // fallback — сплошная линия ctx.fillStyle = '#ffffff'; ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28); } } } // =================================================================== // BEAM (расширенный — задача 08) // =================================================================== /** * Создать луч/ленту между двумя точками. * opts: { * from, to — {x,y,z} | ref-строка | 'player'; * color, width; * texture: 'chevron'|'arrow'|'dot'|'line'|'wave'|'lightning'|'dash'|'sparkle'|'custom'|null; * customTextureUrl, textureMode, textureSpeed, textureScale, * strokeColor, strokeWidth, * colorSequence:[{p,c}], transparencySequence:[{p,t}], widthSequence:[{p,w}], * faceMode: 'billboard'|'flat-x'|'flat-y'|'flat-z', * segments, curved, curveHeight, attachOffset:{fromY,toY}, * ignoreDepth, * } * Возвращает id. */ addBeam(opts = {}) { const id = _fxIdSeq++; const hasTexture = !!opts.texture && opts.texture !== 'none'; const it = { id, type: 'beam', from: opts.from, to: opts.to, width: Number.isFinite(opts.width) ? opts.width : (hasTexture ? 0.8 : 0.15), color: opts.color || '#66ccff', texture: hasTexture ? opts.texture : null, customTextureUrl: opts.customTextureUrl || null, textureMode: opts.textureMode || 'wrap', textureSpeed: Number.isFinite(opts.textureSpeed) ? opts.textureSpeed : 0, textureScale: Number.isFinite(opts.textureScale) ? opts.textureScale : 1, strokeColor: opts.strokeColor || null, strokeWidth: Number.isFinite(opts.strokeWidth) ? opts.strokeWidth : 3, colorSequence: Array.isArray(opts.colorSequence) ? opts.colorSequence : null, transparencySequence: Array.isArray(opts.transparencySequence) ? opts.transparencySequence : null, widthSequence: Array.isArray(opts.widthSequence) ? opts.widthSequence : null, faceMode: opts.faceMode || 'billboard', segments: Math.max(2, Number.isFinite(opts.segments) ? opts.segments : 24), curved: !!opts.curved, curveHeight: Number.isFinite(opts.curveHeight) ? opts.curveHeight : 2, attachOffset: opts.attachOffset || { fromY: 0, toY: 0 }, ignoreDepth: opts.ignoreDepth !== false, // по умолчанию рисуем поверх uOffset: 0, mesh: null, mat: null, }; this._buildBeamMaterial(it); this.items.set(id, it); this._updateBeam(it); return id; } _buildBeamMaterial(it) { if (it.mat) { try { it.mat.dispose(); } catch (e) {} } const mat = new StandardMaterial('beamMat_' + it.id, this.scene); const col = Color3.FromHexString(it.color); mat.diffuseColor = col; mat.emissiveColor = col; mat.disableLighting = true; mat.backFaceCulling = false; if (it.texture) { let tex; if (it.texture === 'custom' && it.customTextureUrl) { tex = new Texture(it.customTextureUrl, this.scene); tex.hasAlpha = true; tex.wrapU = Texture.WRAP_ADDRESSMODE; tex.wrapV = Texture.CLAMP_ADDRESSMODE; } else { tex = this._getBeamTexture(it.texture, it.strokeColor, it.strokeWidth); } mat.diffuseTexture = tex; mat.emissiveTexture = tex; mat.opacityTexture = tex; // альфа формы mat.useAlphaFromDiffuseTexture = true; } mat.alpha = it.texture ? 1 : 0.9; if (it.ignoreDepth) { mat.disableDepthWrite = true; // рисуем поверх геометрии } it.mat = mat; } /** Сменить цвет луча. */ setBeamColor(id, color) { const it = this.items.get(Number(id)); if (!it || !it.mat || !color) return; it.color = color; const col = Color3.FromHexString(color); it.mat.diffuseColor = col; it.mat.emissiveColor = col; this._recolorMarker(it, col); // если есть градиент — он перекрывает; иначе vertexColors обновим в _updateBeam it.colorSequence = null; } /** Перекрасить quest-marker в цвет луча (при смене пресета). */ _recolorMarker(it, col) { if (it && it._markerMat && col) { it._markerMat.diffuseColor = col; it._markerMat.emissiveColor = col; } } /** Сменить концы луча (координаты | ref | 'player'). */ setBeamEndpoints(id, from, to) { const it = this.items.get(Number(id)); if (!it) return; if (from !== undefined) it.from = from; if (to !== undefined) it.to = to; } /** Обновить произвольные опции луча на лету (для pointer.update). */ updateBeam(id, opts = {}) { const it = this.items.get(Number(id)); if (!it) return; let rebuild = false; for (const k of ['texture', 'strokeColor', 'strokeWidth', 'customTextureUrl', 'ignoreDepth']) { if (opts[k] !== undefined && opts[k] !== it[k]) { it[k] = opts[k]; rebuild = true; } } for (const k of ['color', 'width', 'textureMode', 'textureSpeed', 'textureScale', 'faceMode', 'segments', 'curved', 'curveHeight', 'attachOffset', 'colorSequence', 'transparencySequence', 'widthSequence']) { if (opts[k] !== undefined) it[k] = opts[k]; } if (opts.from !== undefined) it.from = opts.from; if (opts.to !== undefined) it.to = opts.to; if (rebuild) { this._buildBeamMaterial(it); } else if (opts.color !== undefined && !it.colorSequence) { const col = Color3.FromHexString(it.color); it.mat.diffuseColor = col; it.mat.emissiveColor = col; } // Перекрасить quest-marker под новый пресет. Для градиента (gift) — // средний цвет последовательности. if (it._markerMat) { let mc = it.color; if (it.colorSequence && it.colorSequence.length) { mc = this._sampleSeqColor(it.colorSequence, 0.5) || it.color; } if (mc) this._recolorMarker(it, Color3.FromHexString(mc)); } // геометрия (curved/segments/width) пересоберётся в _updateBeam — сбросим mesh if (opts.curved !== undefined || opts.segments !== undefined || opts.width !== undefined || opts.widthSequence !== undefined || opts.colorSequence !== undefined) { if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} it.mesh = null; } } this._updateBeam(it); } setVisible(id, vis) { const it = this.items.get(Number(id)); if (it && it.mesh) it.mesh.setEnabled(!!vis); if (it && it._marker) it._marker.setEnabled(!!vis); // и маркер if (it) it._hidden = !vis; } /** * Создать шлейф за объектом. (без изменений — Фаза 5.2) */ 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; 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; const now = performance.now() / 1000; let dt = this._lastTime ? (now - this._lastTime) : 0.016; this._lastTime = now; if (dt > 0.1) dt = 0.016; // защита от больших скачков (вкладка спала) for (const it of this.items.values()) { if (it.type === 'trail') continue; // Babylon сам if (it._hidden) continue; this._updateBeam(it); // Анимация бегущей текстуры. if (this.animationEnabled && it.texture && it.textureSpeed && it.mat && it.mat.diffuseTexture && it.mat.diffuseTexture.uOffset !== undefined) { it.uOffset -= it.textureSpeed * dt * 0.4; it.mat.diffuseTexture.uOffset = it.uOffset; } // Парящий 3D-маркер над целью: позиция + подпрыгивание + вращение. if (it._marker) this._tickMarker(it, now); } } /** Анимировать quest-marker над целью (bob + spin), держать над to. */ _tickMarker(it, now) { const root = it._marker; if (!root) return; // Цель: точка `to` (без attachOffset — нам нужна верхушка объекта). const tgt = this._point(it.to, 0); if (!tgt) { root.setEnabled(false); return; } root.setEnabled(true); const ph = it._markerPhase || 0; // bob: плавный синус ±0.22м; высота над целью ~2.2м. const bob = Math.sin(now * 3 + ph) * 0.22; root.position.set(tgt.x, tgt.y + 2.2 + bob, tgt.z); // Вращение всего маркера вокруг Y (дочерний конус остаётся остриём вниз). root.rotation.y = now * 1.6 + ph; } /** * Построить/обновить геометрию луча. Для текстурированных/curved — * ribbon из сегментов с UV вдоль длины. Для простого — цилиндр (легаси). */ _updateBeam(it) { const a = this._point(it.from, it.attachOffset && it.attachOffset.fromY); const b = this._point(it.to, it.attachOffset && it.attachOffset.toY); if (!a || !b) { if (it.mesh) it.mesh.setEnabled(false); 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.01) { if (it.mesh) it.mesh.setEnabled(false); return; } // Текстурированный/curved/градиент — лента (ribbon). if (it.texture || it.curved || it.colorSequence || it.widthSequence) { this._updateRibbon(it, a, b, len); return; } // Простой луч — легаси цилиндр. if (!it.mesh || it._meshKind !== 'cyl') { if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} } it.mesh = MeshBuilder.CreateCylinder('beam_' + it.id, { height: 1, diameter: it.width, tessellation: 8 }, this.scene); it.mesh.material = it.mat; it.mesh.isPickable = false; it.mesh.renderingGroupId = 1; it._meshKind = 'cyl'; } it.mesh.setEnabled(true); it.mesh.scaling.y = len; it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2); const dir = new Vector3(dx, dy, dz).normalize(); 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); } /** * Построить ленту (ribbon): две линии вершин вдоль направления from→to, * смещённые перпендикулярно (billboard — к камере). UV: u вдоль длины * (количество тайлов = длина/ширина × textureScale), v поперёк. */ _updateRibbon(it, a, b, len) { const seg = it.curved ? it.segments : 1; const A = new Vector3(a.x, a.y, a.z); const B = new Vector3(b.x, b.y, b.z); // центральная линия (curved = квадратичная Безье через приподнятый midpoint) const center = []; if (it.curved) { const mid = A.add(B).scale(0.5); mid.y += it.curveHeight; for (let i = 0; i <= seg; i++) { const t = i / seg; const omt = 1 - t; // B(t) = (1-t)^2 A + 2(1-t)t M + t^2 B const x = omt * omt * A.x + 2 * omt * t * mid.x + t * t * B.x; const y = omt * omt * A.y + 2 * omt * t * mid.y + t * t * B.y; const z = omt * omt * A.z + 2 * omt * t * mid.z + t * t * B.z; center.push(new Vector3(x, y, z)); } } else { center.push(A, B); } // направление «вбок» для ширины: billboard → перпендикуляр к лучу и к камере. const cam = this.scene.activeCamera; const camPos = cam ? cam.position : new Vector3(0, 50, 0); const widthAt = (t) => { if (it.widthSequence && it.widthSequence.length) { return this._sampleSeq(it.widthSequence, t, 'w', it.width); } return it.width; }; const left = [], right = []; for (let i = 0; i < center.length; i++) { const p = center[i]; const t = center.length > 1 ? i / (center.length - 1) : 0; // касательная к линии const prev = center[Math.max(0, i - 1)]; const next = center[Math.min(center.length - 1, i + 1)]; const tangent = next.subtract(prev); if (tangent.lengthSquared() < 1e-6) tangent.copyFrom(B.subtract(A)); tangent.normalize(); let side; if (it.faceMode === 'flat-y') { side = new Vector3(0, 1, 0).cross(tangent); // лежит горизонтально } else if (it.faceMode === 'flat-x') { side = new Vector3(1, 0, 0); } else if (it.faceMode === 'flat-z') { side = new Vector3(0, 0, 1); } else { // billboard — перпендикуляр к лучу, в плоскости к камере const toCam = camPos.subtract(p); side = tangent.cross(toCam); } if (side.lengthSquared() < 1e-6) side = new Vector3(1, 0, 0); side.normalize().scaleInPlace(widthAt(t) / 2); left.push(p.add(side)); right.push(p.subtract(side)); } const pathArray = [left, right]; // UV вдоль длины: тайлов = длина / ширина × scale const tiles = Math.max(1, Math.round((len / Math.max(0.1, it.width)) * 0.6 * it.textureScale)); // (пере)создание ribbon. updatable, чтобы менять каждый кадр дёшево. const needNew = !it.mesh || it._meshKind !== 'ribbon' || it._segCount !== center.length; if (needNew) { if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} } // sideOrientation НЕ DOUBLESIDE: при DOUBLESIDE Babylon удваивает // вершины (фронт+бэк), а наши UV/vertexColors считаются на 2n вершин // → setVerticesData выходит за vertex buffer → GL_INVALID_OPERATION // «Vertex buffer is not big enough» (стрелка не рисовалась, WebGL // отрубал контекст). Двусторонность даёт материал (backFaceCulling // = false), DOUBLESIDE-геометрия не нужна. it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, { pathArray, updatable: true, }, this.scene); it.mesh.material = it.mat; it.mesh.isPickable = false; it.mesh.renderingGroupId = it.ignoreDepth ? 3 : 1; it._meshKind = 'ribbon'; it._segCount = center.length; this._applyRibbonUV(it, center.length, tiles); this._applyRibbonColors(it, center.length); } else { it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, { pathArray, instance: it.mesh, }); // UV/colors при изменении длины тайлов if (it._tiles !== tiles) { this._applyRibbonUV(it, center.length, tiles); } } it._tiles = tiles; it.mesh.setEnabled(true); } /** UV: u = тайлы вдоль длины (для wrap-повтора), v = 0..1 поперёк. */ _applyRibbonUV(it, n, tiles) { if (!it.mesh) return; const uv = []; // pathArray=[left,right] → вершины идут: left[0..n-1], right[0..n-1] for (let i = 0; i < n; i++) { const u = (i / (n - 1 || 1)) * tiles; uv.push(u, 0); } for (let i = 0; i < n; i++) { const u = (i / (n - 1 || 1)) * tiles; uv.push(u, 1); } try { it.mesh.setVerticesData(VertexBuffer.UVKind, uv, true); } catch (e) {} } /** Градиент цвета/прозрачности вдоль длины через vertexColors. */ _applyRibbonColors(it, n) { if (!it.mesh) return; if (!it.colorSequence && !it.transparencySequence) { // равномерный цвет — vertexColors не нужны (материал даёт цвет) return; } const colors = []; const base = Color3.FromHexString(it.color); const sample = (t) => { let c = base; if (it.colorSequence) { const hex = this._sampleSeqColor(it.colorSequence, t); if (hex) c = Color3.FromHexString(hex); } const alpha = it.transparencySequence ? 1 - this._sampleSeq(it.transparencySequence, t, 't', 0) : 1; return new Color4(c.r, c.g, c.b, alpha); }; for (let i = 0; i < n; i++) { const t = i / (n - 1 || 1); const c = sample(t); colors.push(c.r, c.g, c.b, c.a); } for (let i = 0; i < n; i++) { const t = i / (n - 1 || 1); const c = sample(t); colors.push(c.r, c.g, c.b, c.a); } try { it.mesh.setVerticesData(VertexBuffer.ColorKind, colors, true); it.mat.useVertexColor = true; if (it.transparencySequence) it.mat.alpha = 1; // альфа из vertexColor } catch (e) {} } _sampleSeq(seq, t, key, def) { if (!seq || !seq.length) return def; let prev = seq[0], next = seq[seq.length - 1]; for (let i = 0; i < seq.length; i++) { if (seq[i].p <= t) prev = seq[i]; if (seq[i].p >= t) { next = seq[i]; break; } } if (prev === next || next.p === prev.p) return prev[key]; const f = (t - prev.p) / (next.p - prev.p); return prev[key] + (next[key] - prev[key]) * f; } _sampleSeqColor(seq, t) { if (!seq || !seq.length) return null; let prev = seq[0], next = seq[seq.length - 1]; for (let i = 0; i < seq.length; i++) { if (seq[i].p <= t) prev = seq[i]; if (seq[i].p >= t) { next = seq[i]; break; } } const ca = Color3.FromHexString(prev.c), cb = Color3.FromHexString(next.c); if (prev === next || next.p === prev.p) return prev.c; const f = (t - prev.p) / (next.p - prev.p); const r = Math.round((ca.r + (cb.r - ca.r) * f) * 255); const g = Math.round((ca.g + (cb.g - ca.g) * f) * 255); const b = Math.round((ca.b + (cb.b - ca.b) * f) * 255); return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join(''); } // =================================================================== // POINTER (game.fx.pointer) — высокоуровневая стрелка-указатель // =================================================================== /** * Создать стрелку-указатель. opts: { from, to, preset, ...override }. * from/to: {x,y,z} | ref | 'player'. preset: guide|quest|danger|gift|custom. * Возвращает id (это beam с pointer-флагом). */ addPointer(opts = {}) { const preset = POINTER_PRESETS[opts.preset] || (opts.preset === 'custom' ? {} : POINTER_PRESETS.guide); const merged = { from: opts.from, to: opts.to, ...preset, // явные override из opts перебивают пресет: ...(opts.color !== undefined ? { color: opts.color } : {}), ...(opts.texture !== undefined ? { texture: opts.texture } : {}), ...(opts.textureSpeed !== undefined ? { textureSpeed: opts.textureSpeed } : {}), ...(opts.strokeColor !== undefined ? { strokeColor: opts.strokeColor } : {}), ...(opts.colorSequence !== undefined ? { colorSequence: opts.colorSequence } : {}), ...(opts.curved !== undefined ? { curved: opts.curved } : {}), ...(opts.curveHeight !== undefined ? { curveHeight: opts.curveHeight } : {}), ...(opts.faceMode !== undefined ? { faceMode: opts.faceMode } : {}), ...(opts.customTextureUrl !== undefined ? { texture: 'custom', customTextureUrl: opts.customTextureUrl } : {}), // Указатель — горизонтальная лента (flat-y), лежит над землёй и // всегда видна сверху. billboard на curved-ленте вырождался (side- // вектор tangent×toCam схлопывался на сегментах смотрящих на камеру) // → стрелка пропадала. flat-y держит ширину стабильно. faceMode: opts.faceMode || 'flat-y', curved: opts.curved !== undefined ? opts.curved : true, curveHeight: opts.curveHeight !== undefined ? opts.curveHeight : 0.8, // Лента над землёй, цель — у её центра. Хорошо видно, не в земле. width: opts.width !== undefined ? opts.width : 1.4, attachOffset: opts.attachOffset || { fromY: 1.6, toY: 1.2 }, }; const id = this.addBeam(merged); const it = this.items.get(id); if (it) { it._isPointer = true; // Парящая 3D-стрелка над целью (как quest-marker в Roblox): // объёмный конус вершиной ВНИЗ, подпрыгивает + вращается + светится. this._makePointerMarker(it); } return id; } /** * 3D-маркер над целью (Roblox quest-pin): гладкий конус остриём ВНИЗ, * светится, с чёрной обводкой. Анимация (bob + spin) — на РОДИТЕЛЕ * (TransformNode), сам конус ориентирован вниз статично, поэтому spin * не ломает «вниз» направление и обводка не съезжает. */ _makePointerMarker(it) { try { const col = Color3.FromHexString(it.color || '#ff3a3a'); // Родитель: только позиция/bob/spin. Дочерний конус — геометрия. const root = new TransformNode('ptrMarkerRoot_' + it.id, this.scene); // Гладкий конус (tessellation 18 — не пирамида). Остриё вниз: // CreateCylinder остриём ВВЕРХ (diameterTop=0), поворот X=PI → вниз. const m = MeshBuilder.CreateCylinder('ptrMarker_' + it.id, { height: 1.0, diameterTop: 0, diameterBottom: 0.55, tessellation: 18, }, this.scene); m.parent = root; m.rotation.x = Math.PI; // остриём вниз (на цель) m.isPickable = false; m.renderingGroupId = 3; // поверх геометрии (как beam) const mat = new StandardMaterial('ptrMarkerMat_' + it.id, this.scene); mat.diffuseColor = col; mat.emissiveColor = col; // светится mat.disableLighting = true; mat.disableDepthWrite = true; // рисуем поверх m.material = mat; // Мультяшная обводка — встроенный Babylon outline (не второй меш, // потому не съезжает). Толщину даём заметную. m.renderOutline = true; m.outlineColor = new Color3(0, 0, 0); m.outlineWidth = 0.06; it._marker = root; it._markerMesh = m; it._markerMat = mat; it._markerPhase = (it.id % 7) * 0.5; // разный фазовый сдвиг bob } catch (e) { console.warn('[BeamManager] marker create failed:', e); } } /** Сменить цель указателя. */ setPointerTarget(id, to) { const it = this.items.get(Number(id)); if (it) it.to = to; } /** Применить пресет к существующему указателю (для pointer.update({preset})). */ applyPointerPreset(id, preset) { const p = POINTER_PRESETS[preset]; if (!p) return; this.updateBeam(id, p); } // =================================================================== /** Точка из {x,y,z} | ref | 'player'. yOff — доп. смещение по Y. */ _point(p, yOff) { const off = Number.isFinite(yOff) ? yOff : 0; if (!p) return null; if (p === 'player') { const pl = this.scene3d && this.scene3d.player; if (pl && pl._pos) return { x: pl._pos.x, y: pl._pos.y + off, z: pl._pos.z }; return null; } if (typeof p === 'object' && Number.isFinite(p.x)) { return { x: p.x, y: p.y + off, z: p.z }; } if (typeof p === 'string') { const d = this._resolve(p); if (d) return { x: d.x, y: d.y + off, 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; } }