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