All checks were successful
- 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>
807 lines
37 KiB
JavaScript
807 lines
37 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|