/**
 * NpcManager — управляемые скриптом персонажи (NPC) в Play-режиме.
 *
 * Отличие от ZombieManager: зомби — враги с фиксированным AI (wander/
 * chase/attack), а NPC полностью под управлением скрипта:
 *   moveTo / follow / stop / say / setLabel / damage / onDeath.
 *
 * NPC = модель из ModelManager + state. AI простой (без worker'а — NPC
 * на сцене десятки, не сотни): в main-thread tick двигаем к цели в
 * плоскости XZ. Высоту Y держим на стартовом уровне (NPC ставятся на
 * ровную площадку); если есть гладкий ландшафт — подгоняем к поверхности.
 *
 * Реплика над головой (say) — через billboard DynamicTexture-плашку,
 * исчезает через несколько секунд. Имя — постоянная метка.
 */

import {
    Vector3, MeshBuilder, StandardMaterial, Color3, TransformNode,
    DynamicTexture, SceneLoader,
} from '@babylonjs/core';
import '@babylonjs/loaders/glTF';
import { R15Skeleton } from './R15Skeleton';
import { R15Animator } from './R15Animator';

const NPC_DEFAULTS = {
    hp: 100,
    speed: 2.6,         // м/с при moveTo / follow
    arriveDist: 0.6,    // на каком расстоянии считаем «дошёл»
    followGap: 2.5,     // на каком расстоянии останавливаться при follow
};

let _npcIdSeq = 1;

export class NpcManager {
    constructor(scene3d) {
        this.scene3d = scene3d;
        this.scene = scene3d.scene;
        /** @type {Map<number, object>} npcId → state */
        this.npcs = new Map();
        this._renderHook = null;
        this._lastTick = performance.now() / 1000;
        // Колбэк смерти NPC — GameRuntime подписывается, шлёт в скрипты.
        this._onNpcDeath = null;
    }

    setOnDeath(cb) { this._onNpcDeath = cb; }

    start() {
        if (this._renderHook) return;
        this._renderHook = () => this._tick();
        this.scene.registerBeforeRender(this._renderHook);
        this._lastTick = performance.now() / 1000;
    }

    stop() {
        if (this._renderHook) {
            try { this.scene.unregisterBeforeRender(this._renderHook); } catch (e) {}
            this._renderHook = null;
        }
        // Удаляем модели NPC и их UI.
        for (const npc of this.npcs.values()) {
            this._disposeNpcVisuals(npc);
            this._disposeNpcModel(npc);
        }
        this.npcs.clear();
    }

    _disposeNpcVisuals(npc) {
        const hb = npc.healthBar;
        if (hb) {
            try {
                hb.anchor?.dispose();
                hb.bg?.dispose();
                hb.fill?.dispose();
                hb.bgMat?.dispose();
                hb.fillMat?.dispose();
            } catch (e) { /* ignore */ }
        }
        const sb = npc.speechBubble;
        if (sb) {
            try {
                sb.plane?.dispose();
                sb.mat?.dispose();
                sb.tex?.dispose();
            } catch (e) { /* ignore */ }
        }
    }

    /** Удалить 3D-модель NPC: и Kenney-инстанс, и R15-скин. */
    _disposeNpcModel(npc) {
        try {
            if (npc.instanceId != null) {
                // обычный NPC — инстанс ModelManager
                this.scene3d.modelManager?.removeInstance(npc.instanceId);
            } else if (npc.data && npc.data._isR15Npc) {
                // R15-NPC — диспозим root-меш и AssetContainer
                npc.data.rootMesh?.dispose(false, true);
                try { npc.data._r15Container?.dispose(); } catch (e) {}
            }
        } catch (e) { /* ignore */ }
    }

    /**
     * Создать NPC: спавнит модель и регистрирует state.
     * Возвращает Promise<npcId> (async — модель грузится через GLB).
     * opts: { x, y, z, rotationY, hp, name, speed }
     */
    async spawnNpc(modelType, opts = {}) {
        const mm = this.scene3d.modelManager;
        if (!mm) return null;
        const x = Number(opts.x) || 0;
        const y = Number(opts.y) || 0;
        const z = Number(opts.z) || 0;
        const rotationY = Number(opts.rotationY) || 0;

        // R15-скин ('skin_*') — отдельная ветка: грузим body.glb,
        // строим R15Skeleton + R15Animator (процедурные анимации
        // run/idle). Так NPC может быть полноценным R15-персонажем
        // (полицейский в раннере и т.п.), а не статичной Kenney-моделью.
        let instId, data, r15Animator = null;
        if (typeof modelType === 'string' && modelType.startsWith('skin_')) {
            const r15 = await this._spawnR15Skin(modelType, x, y, z, rotationY);
            if (!r15) return null;
            data = r15.data;
            instId = null;            // не из ModelManager
            r15Animator = r15.animator;
        } else {
            try {
                instId = await mm.addInstance(modelType, x, y, z, rotationY);
            } catch (e) {
                return null;
            }
            if (instId == null) return null;
            data = mm.instances.get(instId);
        }
        if (data && opts.name) data.name = opts.name;

        const id = _npcIdSeq++;
        const hp = Number.isFinite(opts.hp) ? opts.hp : NPC_DEFAULTS.hp;
        const npc = {
            id,
            instanceId: instId,
            data,
            hp,
            maxHp: hp,
            name: opts.name || ('NPC ' + id),
            speed: Number.isFinite(opts.speed) ? opts.speed : NPC_DEFAULTS.speed,
            // Режим: 'idle' | 'move' | 'follow'
            mode: 'idle',
            targetX: x, targetZ: z,    // куда идём (для move)
            followRef: null,           // за кем следуем (для follow)
            x, y, z, yaw: rotationY,
            originY: y,
            walkPhase: Math.random() * Math.PI * 2,
            isMoving: false,
            healthBar: this._createHealthBar(),
            speechBubble: null,
            speechUntil: 0,
            dead: false,
            // R15-аниматор (только для skin_*-NPC) — иначе null.
            r15Animator,
        };
        this.npcs.set(id, npc);
        // Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
        // в metadata. Без pickable raycast оружия проходит сквозь NPC (задача 40).
        try {
            const root = npc.data && npc.data.rootMesh;
            if (root) {
                root.isPickable = true;
                root.metadata = Object.assign({}, root.metadata, { npcId: id });
                for (const m of root.getChildMeshes(false)) {
                    m.isPickable = true;
                    m.metadata = Object.assign({}, m.metadata, { npcId: id });
                }
            }
        } catch (e) { /* ignore */ }
        return id;
    }

    /**
     * Загрузить R15-скин как NPC-модель. Возвращает { data, animator }
     * или null. data — объект-обёртка с rootMesh (как у ModelManager),
     * чтобы остальной код NpcManager работал единообразно.
     */
    async _spawnR15Skin(skinId, x, y, z, rotationY) {
        // путь к body.glb скина
        const file = `/kubikon-assets/characters/${skinId}/body.glb`;
        const lastSlash = file.lastIndexOf('/');
        const rootUrl = file.substring(0, lastSlash + 1);
        const filename = file.substring(lastSlash + 1);
        let container;
        try {
            container = await SceneLoader.LoadAssetContainerAsync(
                rootUrl, filename, this.scene);
        } catch (e) {
            // eslint-disable-next-line no-console
            console.error('[NpcManager] R15-скин не загружен:', skinId, e);
            return null;
        }
        const root = new TransformNode('npcR15_' + skinId, this.scene);
        // тот же масштаб что у игрока-R15 (модели нормализованы к 5.98)
        const SC = 0.301;
        root.scaling = new Vector3(SC, SC, SC);
        root.position.set(x, y, z);
        root.rotation.y = rotationY;
        // ВАЖНО: R15-скин ('body.glb') ориентирован лицом в -Z. NpcManager
        // двигает NPC в +Z с npc.yaw=atan2(dx,dz)=0 — для Kenney-моделей
        // (лицом в +Z) это «вперёд». Чтобы R15-NPC тоже бежал лицом
        // вперёд, ставим промежуточный узел, развёрнутый на π: тогда
        // root.rotation.y работает в той же системе, что у Kenney.
        const faceNode = new TransformNode('npcR15face_' + skinId, this.scene);
        faceNode.parent = root;
        faceNode.rotation.y = Math.PI;
        const inst = container.instantiateModelsToScene(
            (n) => `npc_${skinId}_${n}`, true, { doNotInstantiate: false });
        for (const r of inst.rootNodes) r.parent = faceNode;
        // глушим авто-играющие animationGroups (анимация — процедурная)
        if (inst.animationGroups) {
            for (const g of inst.animationGroups) { try { g.stop(); } catch (e) {} }
        }
        // строим R15-скелет/аниматор
        let animator = null;
        let sk = (inst.skeletons && inst.skeletons[0])
            || (container.skeletons && container.skeletons[0]) || null;
        if (!sk) {
            const m = root.getChildMeshes(false).find((mm2) => mm2.skeleton);
            if (m) sk = m.skeleton;
        }
        if (sk) {
            const r15 = new R15Skeleton(sk);
            if (r15.isValidR15()) animator = new R15Animator(r15, {});
        }
        // меши NPC не должны ловить raycast игрока
        const meshes = root.getChildMeshes(false);
        for (const m of meshes) {
            m.isPickable = false;
            // Тени: NPC принимает тени от окружения и отбрасывает свою
            m.receiveShadows = true;
        }
        // Регистрируем NPC как shadow caster (тень от него ложится на мир)
        try {
            if (this.scene3d && typeof this.scene3d.addShadowCaster === 'function') {
                for (const m of meshes) this.scene3d.addShadowCaster(m);
            }
        } catch (e) { /* ignore */ }
        // data-обёртка — совместима с тем, что ждёт NpcManager
        return {
            data: {
                rootMesh: root, clonedMeshes: meshes,
                x, y, z, rotationY,
                _isR15Npc: true, _r15Container: container,
            },
            animator,
        };
    }

    /** Изменить скорость NPC (м/с) на лету. */
    setSpeed(id, speed) {
        const npc = this.npcs.get(Number(id));
        if (!npc || npc.dead) return;
        const s = Number(speed);
        if (Number.isFinite(s) && s > 0) npc.speed = s;
    }

    /** Приказать NPC идти в точку (XZ). */
    moveTo(id, x, z) {
        const npc = this.npcs.get(Number(id));
        if (!npc || npc.dead) return;
        npc.mode = 'move';
        npc.targetX = Number(x) || 0;
        npc.targetZ = Number(z) || 0;
        npc.followRef = null;
    }

    /** Приказать NPC следовать за объектом/игроком (ref-строка или 'player'). */
    follow(id, ref) {
        const npc = this.npcs.get(Number(id));
        if (!npc || npc.dead) return;
        npc.mode = 'follow';
        npc.followRef = typeof ref === 'string' ? ref : null;
    }

    /** Остановить NPC (перейти в idle). */
    stopNpc(id) {
        const npc = this.npcs.get(Number(id));
        if (!npc) return;
        npc.mode = 'idle';
        npc.isMoving = false;
    }

    /** Включить/выключить анимацию атаки. */
    setAttacking(id, on) {
        const npc = this.npcs.get(Number(id));
        if (npc) npc.attacking = !!on;
    }

    /** Реплика над головой NPC на duration секунд. */
    say(id, text, duration = 3) {
        const npc = this.npcs.get(Number(id));
        if (!npc || npc.dead) return;
        this._setSpeech(npc, String(text == null ? '' : text));
        npc.speechUntil = performance.now() / 1000 + (Number(duration) || 3);
    }

    /** Нанести урон NPC. При hp<=0 — смерть. */
    damage(id, amount) {
        const npc = this.npcs.get(Number(id));
        if (!npc || npc.dead) return;
        const amt = Number(amount) || 0;
        npc.hp = Math.max(0, npc.hp - amt);
        // Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
        if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
            try {
                this.scene3d.floaters.spawn(
                    { x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
            } catch (e) { /* ignore */ }
        }
        if (npc.hp <= 0) this._killNpc(npc);
    }

    /** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
     *  содержат hit-меш (или предка). Вызывает damage() → авто-floater. */
    damageByMesh(mesh, amount) {
        if (!mesh) return false;
        let m = mesh;
        for (let i = 0; i < 8 && m; i++) {
            const nid = m.metadata && m.metadata.npcId;
            if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
            m = m.parent;
        }
        for (const npc of this.npcs.values()) {
            if (npc.dead) continue;
            const root = npc.data && npc.data.rootMesh;
            if (!root) continue;
            let mm = mesh;
            for (let i = 0; i < 8 && mm; i++) {
                if (mm === root) { this.damage(npc.id, amount); return true; }
                mm = mm.parent;
            }
        }
        return false;
    }

    /** Удалить NPC по id (без эффекта смерти — просто убрать). */
    removeNpc(id) {
        const npc = this.npcs.get(Number(id));
        if (!npc) return;
        this._disposeNpcVisuals(npc);
        this._disposeNpcModel(npc);
        this.npcs.delete(npc.id);
    }

    _killNpc(npc) {
        if (npc.dead) return;
        npc.dead = true;
        const pos = { x: npc.x, y: npc.y, z: npc.z };
        // Уведомляем GameRuntime → скрипты (npc.onDeath).
        if (this._onNpcDeath) {
            try { this._onNpcDeath(npc.id, pos); } catch (e) { /* ignore */ }
        }
        // Прячем модель и UI, потом удаляем.
        this._disposeNpcVisuals(npc);
        this._disposeNpcModel(npc);
        this.npcs.delete(npc.id);
    }

    /** Снимок всех NPC — для скрипт-воркеров (game.scene.npcs). */
    getSnapshot() {
        const out = [];
        for (const npc of this.npcs.values()) {
            out.push({
                id: npc.id,
                name: npc.name,
                x: npc.x, y: npc.y, z: npc.z,
                hp: npc.hp, maxHp: npc.maxHp,
                mode: npc.mode,
            });
        }
        return out;
    }

    // ===== внутреннее =====

    _tick() {
        const now = performance.now() / 1000;
        const dt = Math.min(0.05, now - this._lastTick);
        this._lastTick = now;
        if (this.npcs.size === 0) return;

        for (const npc of this.npcs.values()) {
            if (npc.dead) continue;
            this._tickNpc(npc, dt, now);
        }
    }

    _tickNpc(npc, dt, now) {
        const data = npc.data;
        const root = data?.rootMesh;
        if (!root) return;

        // Определяем целевую точку движения.
        let tx = null, tz = null;
        if (npc.mode === 'move') {
            tx = npc.targetX; tz = npc.targetZ;
        } else if (npc.mode === 'follow' && npc.followRef) {
            const fp = this._resolveRefPos(npc.followRef);
            if (fp) { tx = fp.x; tz = fp.z; }
        }

        let moving = false;
        if (tx != null) {
            const dx = tx - npc.x;
            const dz = tz - npc.z;
            const dist = Math.hypot(dx, dz);
            // Порог остановки: для follow — followGap, для move — arriveDist.
            const stopDist = npc.mode === 'follow'
                ? NPC_DEFAULTS.followGap : NPC_DEFAULTS.arriveDist;
            if (dist > stopDist) {
                const step = Math.min(dist - stopDist, npc.speed * dt);
                npc.x += (dx / dist) * step;
                npc.z += (dz / dist) * step;
                moving = true;
                // Поворот лицом по направлению движения. Kenney-модели
                // персонажей смотрят в +Z, поэтому без +π (иначе NPC
                // идёт спиной вперёд).
                const targetYaw = Math.atan2(dx, dz);
                npc.yaw = this._lerpAngle(npc.yaw, targetYaw, dt * 8);
            } else if (npc.mode === 'move') {
                // Дошёл до точки move — переходим в idle.
                npc.mode = 'idle';
            }
        }
        npc.isMoving = moving;

        // Высота: если есть гладкий ландшафт — подгоняем Y к поверхности,
        // иначе держим стартовый уровень.
        const surfY = this._sampleSurface(npc.x, npc.z);
        npc.y = surfY != null ? surfY : npc.originY;

        // Применяем к мешу.
        if (root._isWorldMatrixFrozen) {
            try { root.unfreezeWorldMatrix(); } catch (e) {}
        }
        // Процедурная анимация ходьбы (у Kenney-моделей нет скелета).
        if (moving) npc.walkPhase += dt * 10;
        let bobY = 0, lean = 0;
        if (moving && !npc.r15Animator) {
            bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12;
            lean = Math.sin(npc.walkPhase) * 0.08;
        }
        root.position.set(npc.x, npc.y + bobY, npc.z);
        root.rotation.y = npc.yaw;
        root.rotation.z = lean;
        // data.x/y/z — чтобы scene.find/getPosition видели NPC.
        data.x = npc.x; data.y = npc.y; data.z = npc.z;
        // R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
        if (npc.r15Animator) {
            try {
                npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle'));
                npc.r15Animator.update(dt);
            } catch (e) { /* ignore */ }
        }

        // Health-bar над головой при уроне.
        const hb = npc.healthBar;
        if (hb) {
            const show = npc.hp < npc.maxHp;
            hb.anchor.setEnabled(show);
            if (show) {
                hb.anchor.position.set(npc.x, npc.y + 2.4, npc.z);
                const pct = Math.max(0, Math.min(1, npc.hp / npc.maxHp));
                hb.fill.scaling.x = pct;
                hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;
                hb.fillMat.emissiveColor.set(1 - pct * 0.6, 0.2 + pct * 0.7, 0.1);
            }
        }

        // Реплика над головой — только авто-скрытие. Позиция держится
        // через parent (plane закреплён на rootMesh при создании).
        const sb = npc.speechBubble;
        if (sb) {
            sb.plane.setEnabled(now <= npc.speechUntil);
        }
    }

    /** Позиция объекта по ref ('player' | 'model:N' | 'primitive:N' | 'block:x,y,z'). */
    _resolveRefPos(ref) {
        if (ref === 'player') {
            const p = this.scene3d.player?._pos;
            return p ? { x: p.x, y: p.y, z: p.z } : null;
        }
        const colon = ref.indexOf(':');
        if (colon < 0) return null;
        const kind = ref.slice(0, colon);
        const rest = ref.slice(colon + 1);
        if (kind === 'block') {
            const [bx, by, bz] = rest.split(',').map(Number);
            if ([bx, by, bz].every(Number.isFinite)) return { x: bx, y: by, z: bz };
            return null;
        }
        const mgr = kind === 'primitive'
            ? this.scene3d.primitiveManager
            : (kind === 'model' ? this.scene3d.modelManager : null);
        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 ? { x: d.x, y: d.y, z: d.z } : null;
    }

    /** Высота поверхности гладкого ландшафта в точке или null. */
    _sampleSurface(x, z) {
        const phys = this.scene3d.physics;
        if (phys && typeof phys._sampleRobloxSurface === 'function') {
            try {
                const y = phys._sampleRobloxSurface(x, z);
                if (y != null && Number.isFinite(y)) return y;
            } catch (e) { /* ignore */ }
        }
        return null;
    }

    _lerpAngle(from, to, t) {
        let diff = to - from;
        while (diff > Math.PI) diff -= Math.PI * 2;
        while (diff < -Math.PI) diff += Math.PI * 2;
        return from + diff * Math.min(1, t);
    }

    _createHealthBar() {
        const BAR_W = 1.4, BAR_H = 0.16;
        const anchor = new TransformNode('npcHpAnchor', this.scene);
        anchor.billboardMode = 7;
        anchor.setEnabled(false);

        const bg = MeshBuilder.CreatePlane('npcHpBg', { width: BAR_W, height: BAR_H }, this.scene);
        const bgMat = new StandardMaterial('npcHpBgMat', this.scene);
        bgMat.emissiveColor = new Color3(0.05, 0.05, 0.05);
        bgMat.disableLighting = true;
        bgMat.backFaceCulling = false;
        bg.material = bgMat;
        bg.isPickable = false;
        bg.renderingGroupId = 1;
        bg.parent = anchor;
        bg.position.z = 0.001;

        const fill = MeshBuilder.CreatePlane('npcHpFill', { width: BAR_W * 0.92, height: BAR_H * 0.7 }, this.scene);
        const fillMat = new StandardMaterial('npcHpFillMat', this.scene);
        fillMat.emissiveColor = new Color3(0.3, 0.85, 0.3);
        fillMat.disableLighting = true;
        fillMat.backFaceCulling = false;
        fill.material = fillMat;
        fill.isPickable = false;
        fill.renderingGroupId = 1;
        fill.parent = anchor;

        return { anchor, bg, fill, bgMat, fillMat, barWidth: BAR_W * 0.92 };
    }

    /** Создать/обновить плашку-реплику над головой NPC. */
    _setSpeech(npc, text) {
        // Пересоздаём текстуру под новый текст (текст меняется редко).
        if (npc.speechBubble) {
            try {
                npc.speechBubble.plane.dispose();
                npc.speechBubble.mat.dispose();
                npc.speechBubble.tex.dispose();
            } catch (e) { /* ignore */ }
            npc.speechBubble = null;
        }
        if (!text) return;
        // Текст переносим по словам на несколько строк, плашка
        // растягивается по высоте под количество строк.
        const W = 1024;
        const FONT_PX = 96;
        const LINE_H = 130;          // высота строки в пикселях canvas
        const PAD_Y = 40;            // вертикальные поля внутри пузыря
        const MARGIN = 16;           // отступ пузыря от края текстуры
        // Шрифт нужен ДО разбивки — measureText зависит от font.
        // Считаем на временном canvas.
        const measureCanvas = document.createElement('canvas');
        const mctx = measureCanvas.getContext('2d');
        mctx.font = 'bold ' + FONT_PX + 'px sans-serif';
        const maxTextW = W - 2 * MARGIN - 2 * PAD_Y;
        const lines = this._wrapText(mctx, String(text), maxTextW);
        // Высота canvas = поля + строки. Кратно 4 для текстуры.
        let H = MARGIN * 2 + PAD_Y * 2 + lines.length * LINE_H;
        H = Math.ceil(H / 4) * 4;

        // 4-й аргумент true — как у LabelManager (там текст не перевёрнут).
        const tex = new DynamicTexture('npcSpeechTex', { width: W, height: H }, this.scene, true);
        const ctx = tex.getContext();
        ctx.clearRect(0, 0, W, H);
        // Пузырь — скруглённый прямоугольник на всю текстуру минус поля.
        ctx.fillStyle = 'rgba(255,255,255,0.95)';
        this._roundRect(ctx, MARGIN, MARGIN, W - 2 * MARGIN, H - 2 * MARGIN, 36);
        ctx.fill();
        // Строки текста по центру.
        ctx.fillStyle = '#1a1a1a';
        ctx.font = 'bold ' + FONT_PX + 'px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        const firstY = MARGIN + PAD_Y + LINE_H / 2;
        for (let i = 0; i < lines.length; i++) {
            ctx.fillText(lines[i], W / 2, firstY + i * LINE_H);
        }
        tex.update(true);   // invertY=true — как у LabelManager

        // Размер плашки в мире — ширина фикс, высота по соотношению сторон.
        const planeW = 4.4;
        const planeH = planeW * (H / W);
        const plane = MeshBuilder.CreatePlane('npcSpeech',
            { width: planeW, height: planeH }, this.scene);
        plane.billboardMode = 7;
        plane.isPickable = false;
        plane.renderingGroupId = 1;
        const mat = new StandardMaterial('npcSpeechMat', this.scene);
        mat.diffuseTexture = tex;
        mat.diffuseTexture.hasAlpha = true;
        mat.emissiveColor = new Color3(1, 1, 1);
        mat.disableLighting = true;
        mat.backFaceCulling = false;
        mat.useAlphaFromDiffuseTexture = true;
        mat.disableDepthWrite = true;
        plane.material = mat;
        // Точно как LabelManager (там текст не перевёрнут): плоскость
        // крепится parent'ом к мешу NPC и висит над ним. Билборд
        // разворачивает её к камере без зеркальности — потому что
        // плоскость наследует worldMatrix меша, а не висит сама по себе.
        const root = npc.data && npc.data.rootMesh;
        if (root) {
            plane.parent = root;
            plane.position.set(0, 3.0, 0);
        }
        plane.setEnabled(false);

        npc.speechBubble = { plane, mat, tex };
    }

    /**
     * Разбить текст на строки по словам так, чтобы каждая влезала в maxW.
     * Слишком длинное одиночное слово режется посимвольно.
     * Возвращает массив строк (минимум одна).
     */
    _wrapText(ctx, text, maxW) {
        const words = text.split(/\s+/).filter(Boolean);
        const lines = [];
        let cur = '';
        const pushChunked = (word) => {
            // Слово длиннее строки — режем по символам.
            let part = '';
            for (const ch of word) {
                if (ctx.measureText(part + ch).width > maxW && part) {
                    lines.push(part);
                    part = ch;
                } else {
                    part += ch;
                }
            }
            return part;
        };
        for (const w of words) {
            const test = cur ? cur + ' ' + w : w;
            if (ctx.measureText(test).width <= maxW) {
                cur = test;
            } else {
                if (cur) lines.push(cur);
                if (ctx.measureText(w).width > maxW) {
                    cur = pushChunked(w);
                } else {
                    cur = w;
                }
            }
        }
        if (cur) lines.push(cur);
        return lines.length > 0 ? lines : [''];
    }

    _roundRect(ctx, x, y, w, h, r) {
        ctx.beginPath();
        ctx.moveTo(x + r, y);
        ctx.arcTo(x + w, y, x + w, y + h, r);
        ctx.arcTo(x + w, y + h, x, y + h, r);
        ctx.arcTo(x, y + h, x, y, r);
        ctx.arcTo(x, y, x + w, y, r);
        ctx.closePath();
    }
}
