studio/src/editor/engine/BeamManager.js
МИН e3e5960241
All checks were successful
CI / Lint (pull_request) Successful in 2m10s
CI / Build (pull_request) Successful in 1m58s
CI / Secret scan (pull_request) Successful in 2m33s
CI / PR size check (pull_request) Successful in 10s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat: 3D-стрелка-указатель (game.fx.pointer) + is_test + Ctrl+V в редакторе + вики-карточка
- game.fx.pointer: лента бегущих шевронов + парящий quest-marker над целью
  (конус остриём вниз, свечение, bob+вращение, обводка); presets guide/quest/
  danger/gift/custom; расширенный game.fx.beam (8 текстур, curved, градиент).
- Примитив pointer в палитре (Геймплей) + инспектор + _activatePointers при Play.
- is_test: переключатель «Тестовая игра» в GameSettingsModal (не течёт в каталог).
- Ctrl+V в Monaco-редакторе скриптов (guard на .monaco-editor + activeElement).
- Вики: карточка g5 «Туториал — собери монетки» (Разбор готовых игр).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:45:57 +03:00

807 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<number, object>} 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;
}
}