/**
 * MultiplayerSync — мост между Colyseus.js room и Babylon-сценой плеера.
 *
 * Что делает:
 *   1. Принимает room (colyseus.js Room) и scene (BabylonScene).
 *   2. На каждый remote-игрока создаёт Babylon-меш (капсула + ник-плашка).
 *   3. На state-snapshot обновляет ТАРГЕТНЫЕ позиции, рендер интерполирует
 *      между ними (lerp 60 FPS) — движение плавное, без рывков на 50 мс между snapshot'ами.
 *   4. Раз в 50 мс шлёт серверу свою позицию из PlayerController._pos.
 *   5. Слушает 'shot' для трейсеров, 'hit' для красной вспышки, 'kill' для лога,
 *      'respawn' для возврата в spawn-point.
 *
 * Использование:
 *   const sync = new MultiplayerSync(scene, room, getMyPlayerCtl);
 *   sync.start();
 *   ...
 *   sync.dispose();   // отключает все колбэки и удаляет меши
 *
 * Координаты Colyseus state: x/y/z в мире (метры). y игнорируем — у нас
 * 2D-плоскость в этом этапе; реальная высота берётся с сервера в подэтапе
 * физики (4.x). Сейчас all players y=0.
 */

import {
    MeshBuilder,
    StandardMaterial,
    Color3,
    Vector3,
    DynamicTexture,
    TransformNode,
    SceneLoader,
} from '@babylonjs/core';
import { getStateCallbacks } from 'colyseus.js';
import { getModelType } from './ModelTypes';
import { MIXAMO_SKINS } from './PlayerController';
import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
// Подфаза 3.10 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md
import { AccessoryManager } from './AccessoryManager';

/**
 * Определить источник модели для modelType удалённого игрока.
 * Точная копия логики PlayerController._resolveModelSource:
 *   - 'skin_*' → Mixamo-скин из /character-assets/skins/<id>.glb
 *   - иначе    → старая Kenney-модель через getModelType()
 * @returns {Promise<{file:string, isMixamo?:boolean, kind?:string}|null>}
 */
async function resolveRemoteModelSource(modelType) {
    const typeId = modelType || 'skin_y-bot';
    if (typeId.startsWith('skin_')) {
        // 2026-06: скины Рублокса — Mixamo (single GLB на rublox-site).
        // R15-формат (characters/<id>/body.glb) больше НЕ используется.
        // Грузим как локальный игрок (PlayerController._resolveModelSource):
        // /character-assets/skins/<id>.glb. Логика 1:1 с PlayerController,
        // чтобы remote-игроки выглядели так же, как сам игрок.
        const base = (typeof window !== 'undefined'
            && window.location.hostname === 'localhost')
            ? 'http://localhost:3000'
            : 'https://rublox.pro';
        return {
            file: `${base}/character-assets/skins/${typeId}.glb?v=20260614`,
            isR15: false,
            kind: 'non-humanoid-rigged',   // Mixamo-rig
            isMixamo: true,
            overrides: {},
        };
    }
    const mt = getModelType(typeId);
    if (!mt || !mt.file) return null;
    return { file: mt.file, isMixamo: false, overrides: {} };
}

/** Как часто шлём свою позицию серверу (ms).
 *  Было 50 (20Hz) — приводило к backpressure WS-буфера при 4+ игроках,
 *  сервер закрывал сокет → бесконечный цикл reconnect.
 *  100мс = 10Hz — стандарт для multiplayer FPS (Counter-Strike, Valorant).
 *  Сервер интерполирует, на клиенте лагов не видно. */
const INPUT_INTERVAL_MS = 100;
/** Максимальная скорость интерполяции — 1 / lerpFactor.
 *  0.18 ≈ комфортно: за ~7 кадров (60fps = 110 мс) почти догоняет таргет. */
const LERP_FACTOR = 0.18;

export class MultiplayerSync {
    /**
     * @param {object} scene Babylon scene
     * @param {object} room  Colyseus.js Room
     * @param {() => {x:number,y:number,z:number,yaw:number}} getMyPos
     *        Колбэк, возвращающий текущую позицию ЛОКАЛЬНОГО игрока.
     *        Обычно: () => {
     *          const p = playerController._pos;
     *          return { x: p.x, y: 0, z: p.z, yaw: playerController._yaw };
     *        }
     * @param {object} [callbacks] Опциональные колбэки для UI
     *        {
     *          onChat: (msg) => void,
     *          onLocalHit: (damage, hp, maxHp) => void,
     *          onKilled: (killerName) => void,
     *          onRespawn: () => void,
     *          onLog: (level, ...parts) => void,
     *        }
     */
    constructor(scene, room, getMyPos, callbacks = {}) {
        this.scene = scene;
        this.room = room;
        this.getMyPos = getMyPos;
        this.cb = callbacks;

        /** sessionId → { mesh, label, target, current, hp, maxHp, isDead } */
        this.remotePlayers = new Map();

        /** Активные трейсеры выстрелов. */
        this.shots = [];

        this._inputTimer = null;
        this._renderObserver = null;
        this._cleanupFns = [];
    }

    start() {
        // 1. Подписки на state
        const $ = getStateCallbacks(this.room);

        // Защита от повторного срабатывания onAdd (Colyseus 0.16 + immediate:true
        // может триггерить .onAdd на каждый schema patch). Локальный set хранит
        // sessionId которые уже обработаны в ТЕКУЩЕМ sync объекте.
        const _addedSessionIds = new Set();
        const handleAdd = (player, sessionId) => {
            // Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
            if (sessionId === this.room.sessionId) return;
            // Защита от дублирующих onAdd событий для уже добавленного игрока
            if (_addedSessionIds.has(sessionId)) return;
            _addedSessionIds.add(sessionId);
            this._addRemotePlayer(sessionId, player);
            // Подписываемся на изменения этого Player'а
            $(player).onChange(() => this._updateRemoteTarget(sessionId, player));
            // Точечная подписка на смену оружия — чтобы перецеплять GLB
            // только когда поле реально поменялось (а не каждый input).
            $(player).listen('weaponModelId', (val) => {
                this._attachRemoteWeapon(sessionId, val || '');
            });
        };
        // Используем тот же set в handleRemove чтобы при настоящем уходе игрока
        // потом можно было его снова добавить.
        this._addedSessionIds = _addedSessionIds;
        const handleRemove = (player, sessionId) => {
            if (this._addedSessionIds) this._addedSessionIds.delete(sessionId);
            this._removeRemotePlayer(sessionId);
        };

        // immediate=true — критично! Без этого onAdd НЕ сработает для тех
        // игроков, которые уже были в комнате до нашего подключения.
        // Мы видим только тех, кто зашёл ПОСЛЕ нас, что для мультиплеера = баг.
        $(this.room.state).players.onAdd(handleAdd, true);
        $(this.room.state).players.onRemove(handleRemove);

        // 2. Сообщения боя
        const onShot = (m) => {
            // Не рисуем трейсер от своего же выстрела — клиент уже его «выстрелил»
            // (хотя для отладки можно показать; пока скрываем своих)
            if (m.shooterSessionId === this.room.sessionId) return;
            // Origin от сервера = центр игрока (XZ его позиции).
            // Дуло пушки находится впереди и выше центра — смещаем:
            //   - по горизонтали на ~0.9м вперёд (рука + ствол)
            //   - по вертикали на ~1.4м (уровень руки) от позиции стрелка
            // Без этого смещения трейсер начинается из живота персонажа.
            const shooter = this.remotePlayers.get(m.shooterSessionId);
            const baseY = shooter ? shooter.current.y : 0;
            const MUZZLE_FWD = 0.9;
            const MUZZLE_UP = 1.4;
            const muzzleX = m.originX + m.dirX * MUZZLE_FWD;
            const muzzleZ = m.originZ + m.dirZ * MUZZLE_FWD;
            this.shots.push({
                originX: muzzleX,
                originZ: muzzleZ,
                dirX: m.dirX,
                dirZ: m.dirZ,
                distance: Math.max(0.5, (m.hitDistance || 30) - MUZZLE_FWD),
                hit: !!m.hit,
                expiresAt: Date.now() + 200,
                mesh: this._createTracerMesh(
                    muzzleX, baseY + MUZZLE_UP, muzzleZ,
                    m.dirX, m.dirZ,
                    Math.max(0.5, (m.hitDistance || 30) - MUZZLE_FWD),
                    !!m.hit,
                ),
            });
        };
        this.room.onMessage('shot', onShot);

        const onHit = (m) => {
            if (m.victimSessionId === this.room.sessionId) {
                // Сами получили урон — даём UI показать flash и уменьшить HP
                this.cb.onLocalHit?.(m.damage, m.victimHp, m.victimMaxHp);
            } else {
                // Кто-то получил урон — мигаем меш красным
                const rp = this.remotePlayers.get(m.victimSessionId);
                if (rp) this._flashRemote(rp);
            }
        };
        this.room.onMessage('hit', onHit);

        const onKill = (m) => {
            if (m.victimSessionId === this.room.sessionId) {
                this.cb.onKilled?.(m.killerName);
            }
            this.cb.onLog?.('info', `${m.killerName} убил ${m.victimName}`);
        };
        this.room.onMessage('kill', onKill);

        const onRespawn = (m) => {
            if (m.sessionId === this.room.sessionId) {
                this.cb.onRespawn?.();
            }
        };
        this.room.onMessage('respawn', onRespawn);

        // === Мультиплеер-API скриптов (Фаза 4.3) ===
        // roomData — общее состояние комнаты (game.room.set/get/onChange).
        // Сервер кладёт значения как JSON-строки; парсим и шлём в GameRuntime
        // событие roomChange — у скриптов сработает room.onChange.
        const rt = () => this.scene?.gameRuntime || null;
        const emitRoomChange = (key, jsonValue) => {
            let value;
            try { value = JSON.parse(jsonValue); } catch (e) { value = jsonValue; }
            const r = rt();
            if (r) {
                if (!r._roomState) r._roomState = {};
                r._roomState[key] = value;
                r.routeGlobalEvent('roomChange', { key, value });
            }
        };
        $(this.room.state).roomData.onAdd((value, key) => emitRoomChange(key, value), true);
        $(this.room.state).roomData.onChange((value, key) => emitRoomChange(key, value));

        // scriptMessage — адресное сообщение от game.sendTo. Сервер релеит
        // только тому клиенту, кому адресовано → шлём всем скриптам как mpMessage.
        this.room.onMessage('scriptMessage', (m) => {
            const r = rt();
            if (r && m) {
                r.routeGlobalEvent('mpMessage', {
                    from: m.from, name: m.name, data: m.data,
                });
            }
        });

        // 3. Тик отправки позиции серверу.
        //    ВАЖНО: шлём ВСЕГДА, даже если позиция ещё не готова — иначе сервер
        //    выкинет нас по timeout (Colyseus default pingTimeout ~30c).
        //    Если позиции нет, используем последнюю известную или нули.
        this._lastSentPos = null;
        this._inputTimer = setInterval(() => {
            // Защита от спама консоли: если WebSocket уже закрыт/закрывается,
            // НЕ зовём send (Colyseus сам кричит "WebSocket is already in
            // CLOSING or CLOSED state" в setInterval каждые 50мс).
            // Также самостоятельно останавливаем таймер — нечего пинать.
            const ws = this.room?.connection?.transport?.ws;
            if (ws && ws.readyState !== 1 /* OPEN */) {
                clearInterval(this._inputTimer);
                this._inputTimer = null;
                return;
            }
            const p = this.getMyPos();
            // Если позиции нет — повторяем последнюю известную (keepalive).
            // Если её тоже нет — шлём нули, чтобы сервер видел что мы живы.
            const send = p || this._lastSentPos || { x: 0, y: 0, z: 0, yaw: 0 };
            if (p) this._lastSentPos = p;
            try {
                this.room.send('input', {
                    x: send.x, y: send.y || 0, z: send.z, yaw: send.yaw || 0,
                });
            } catch (e) { /* room closed */ }
        }, INPUT_INTERVAL_MS);

        // 4. Каждый кадр Babylon — интерполируем remote-меши к target-позициям
        //    и чистим устаревшие трейсеры.
        this._lastTickAt = performance.now();
        this._debris = [];
        this._renderObserver = this.scene.onBeforeRenderObservable.add(() => {
            const nowMs = Date.now();
            const nowPerf = performance.now();
            const dt = Math.min(0.05, (nowPerf - this._lastTickAt) / 1000);
            this._lastTickAt = nowPerf;

            // Интерполяция remote-игроков (позиция + yaw ставится на root,
            // модель — child root'а — следует за ним).
            // 2026-06-05: читаем target напрямую из room.state.players —
            // в Colyseus 0.16 onChange может не срабатывать для всех полей
            // (особенно yaw/animState), а target.x/y/z/yaw обновляется
            // через _updateRemoteTarget только из onChange. Подстраховка.
            for (const rp of this.remotePlayers.values()) {
                if (!rp.root || !rp.target) continue;
                const livePlayer = this.room?.state?.players?.get?.(rp.sessionId);
                if (livePlayer) {
                    rp.target.x = livePlayer.x;
                    rp.target.y = livePlayer.y;
                    rp.target.z = livePlayer.z;
                    rp.target.yaw = livePlayer.yaw || 0;
                    if (livePlayer.animState) rp.animState = livePlayer.animState;
                }
                const cur = rp.current;
                cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
                cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
                cur.z += (rp.target.z - cur.z) * LERP_FACTOR;
                cur.yaw += this._lerpAngle(cur.yaw, rp.target.yaw, LERP_FACTOR);
                // Серверный y — это ЦЕНТР игрока (HALF_H выше пола), а origin
                // GLB-модели находится у НОГ. Сдвигаем root вниз на HALF_H=0.9,
                // чтобы ноги попали на пол. Без этого вся модель парит на полблока.
                rp.root.position.x = cur.x;
                rp.root.position.y = cur.y - 0.9;
                rp.root.position.z = cur.z;
                rp.root.rotation.y = cur.yaw;

                // Ник-плашка следует за игроком. Высота: серверный y — это
                // центр игрока (HALF_H=0.9 над полом), макушка модели на
                // ~+0.9 от центра, ник ставим ещё на 0.6м выше неё → +1.5.
                // НО мы выше сдвинули root на -0.9, а label НЕ привязан к
                // root — его координаты в мире. Значит относительно мира
                // ник = пол + (cur.y + offset). Чтобы ник был на ~0.6м над
                // макушкой 1.7м-модели, стоящей на полу: y = 1.7 + 0.6 = 2.3.
                // cur.y=0.9 (центр) → offset = 2.3 - 0.9 = 1.4.
                // Но если игрок в воздухе/прыжке, cur.y растёт → ник тоже.
                // На скрине ник почти впритык к голове — поднимаю до +2.5.
                if (rp.label) {
                    rp.label.position.x = cur.x;
                    rp.label.position.y = cur.y + 2.5;
                    rp.label.position.z = cur.z;
                }

                // === Анимация удара ===
                // Сервер выставляет animState='attack' на 300мс при выстреле.
                // Ловим фронт переключения в attack — запускаем swing.
                if (rp.animState === 'attack' && rp.lastAnimState !== 'attack') {
                    rp.attackAnimStart = nowPerf;
                }
                rp.lastAnimState = rp.animState;

                // === Анимация ===
                // Развилка: Mixamo-скины анимируются через MixamoAnimator
                // (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
                if (rp.isMixamo && rp.mixamoAnimator && rp.modelLoaded) {
                    // Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'.
                    // MixamoAnimator понимает idle/walk/run/jump/fall.
                    // attack показывается отдельным swing руки (ниже).
                    let mixState;
                    if (rp.isDead) {
                        mixState = 'idle';
                    } else if (rp.animState === 'jump') {
                        mixState = 'jump';
                    } else if (rp.animState === 'fall') {
                        mixState = 'fall';
                    } else if (rp.animState === 'run') {
                        mixState = 'run';
                    } else {
                        // 'attack' / 'idle' / неизвестное — стоим
                        mixState = 'idle';
                    }
                    rp.mixamoAnimator.setState(mixState);
                    // update(dt) не нужен — Babylon сам тикает AnimationGroup'ы.
                } else if (!rp.isMixamo) {
                    // === Kenney: поза руки с оружием ===
                    // Форсируем меш правой руки в «вытянутую вперёд» позу
                    // (rotation.x=-π/2). glTF-анимация постоянно возвращает руку
                    // в idle через quaternion — каждый кадр обнуляем и пишем Эйлер.
                    if (rp.rightArmMesh && rp.weaponModelId) {
                        if (rp.rightArmMesh.rotationQuaternion) {
                            rp.rightArmMesh.rotationQuaternion = null;
                        }
                        rp.rightArmMesh.rotation.x = -Math.PI / 2;
                        rp.rightArmMesh.rotation.y = 0;
                        rp.rightArmMesh.rotation.z = 0;
                    }
                    // Анимация ходьбы/idle через AnimationGroups.
                    const wantAnim = rp.isDead
                        ? 'idle'
                        : (rp.animState === 'attack'
                            ? (rp.lastNonAttackAnim || 'idle')
                            : (rp.animState || 'idle'));
                    if (rp.animState !== 'attack') {
                        rp.lastNonAttackAnim = rp.animState || 'idle';
                    }
                    if (rp.currentAnim !== wantAnim) {
                        this._playRemoteAnim(rp, wantAnim);
                    }
                }

                // === Анимация удара рукой (swing) ===
                // Работает и для Kenney, и для Mixamo — короткий замах правой
                // руки при animState='attack'. _tickAttackSwing сам проверяет
                // наличие rightArmMesh.
                this._tickAttackSwing(rp, nowPerf);

                // Видимость модели/оружия/ника при смерти.
                // Покойник полностью невидим — на сцене остаются только debris-кубики,
                // которые мы спавним в _spawnDeathDebris. После респавна сервер
                // выставит isDead=false и модель снова покажется.
                if (rp._lastDead !== rp.isDead) {
                    const visible = !rp.isDead;
                    if (rp.modelRoot) rp.modelRoot.setEnabled(visible);
                    if (rp.weaponRoot) rp.weaponRoot.setEnabled(visible);
                    if (rp.weaponAnchor) rp.weaponAnchor.setEnabled(visible);
                    if (rp.label) rp.label.setEnabled(visible);
                    if (rp.fallbackMesh) rp.fallbackMesh.setEnabled(visible);
                    rp._lastDead = rp.isDead;
                }
            }

            // Тикаем debris (оживший visual после смерти соперника)
            this._tickDebris(dt);

            // Удаляем просроченные трейсеры
            for (let i = this.shots.length - 1; i >= 0; i--) {
                if (this.shots[i].expiresAt <= nowMs) {
                    try { this.shots[i].mesh?.dispose(); } catch (e) {}
                    this.shots.splice(i, 1);
                }
            }
        });
    }

    /**
     * Анимация удара рукой у remote-игрока. Делает короткий swing
     * правой руки (~300мс): замах вверх → удар вниз → возврат.
     * Если активная анимация GLB крутит ту же руку — наш override
     * перетирается в следующем кадре, поэтому пишем каждый кадр.
     */
    _tickAttackSwing(rp, nowPerf) {
        if (!rp.rightArmMesh) return;
        if (!rp.attackAnimStart) return;
        const dur = 300;
        const elapsed = nowPerf - rp.attackAnimStart;
        // База: если оружие есть — рука зафиксирована в -π/2 (предыдущий блок
        // в render-loop). Если нет — берём исходную ротацию.
        const hasWeapon = !!rp.weaponModelId;
        const baseX = hasWeapon ? -Math.PI / 2 : (rp.rightArmBaseRotation?.x ?? 0);
        const baseZ = hasWeapon ? 0 : (rp.rightArmBaseRotation?.z ?? 0);

        if (elapsed >= dur) {
            // Возвращаем базовую позу. Если оружия нет — пишем явно;
            // если есть — render-loop в следующем кадре поставит -π/2.
            if (!hasWeapon && rp.rightArmBaseRotation) {
                rp.rightArmMesh.rotation.x = rp.rightArmBaseRotation.x;
                rp.rightArmMesh.rotation.y = rp.rightArmBaseRotation.y;
                rp.rightArmMesh.rotation.z = rp.rightArmBaseRotation.z;
            }
            rp.attackAnimStart = 0;
            return;
        }
        const k = elapsed / dur;
        // k=0..0.3 — замах назад/вверх
        // k=0.3..0.7 — резкий удар вперёд
        // k=0.7..1.0 — возврат к 0
        let off;
        if (k < 0.3) {
            off = -0.6 * (k / 0.3);
        } else if (k < 0.7) {
            off = -0.6 + 1.8 * ((k - 0.3) / 0.4);
        } else {
            off = 1.2 * (1 - (k - 0.7) / 0.3);
        }
        if (rp.rightArmMesh.rotationQuaternion) {
            rp.rightArmMesh.rotationQuaternion = null;
        }
        rp.rightArmMesh.rotation.x = baseX + off;
        rp.rightArmMesh.rotation.z = baseZ + Math.sin(k * Math.PI) * 0.25;
    }

    /**
     * Спавнит ~12 кубов в позиции игрока: разлетаются по гравитации,
     * затухают за 2 секунды. Вызывается при isDead false→true.
     */
    _spawnDeathDebris(rp) {
        const cx = rp.current.x;
        const cy = rp.current.y;
        const cz = rp.current.z;
        // eslint-disable-next-line no-console
        console.log('[debris] spawn for', rp.sessionId, 'at',
            cx.toFixed(2), cy.toFixed(2), cz.toFixed(2));
        const colors = [
            Color3.FromHexString('#f4c79a'),  // кожа
            Color3.FromHexString('#b2826a'),
            Color3.FromHexString('#6680c4'),  // одежда
            Color3.FromHexString('#4a3f33'),
        ];
        for (let i = 0; i < 12; i++) {
            const size = 0.18 + Math.random() * 0.14;
            const cube = MeshBuilder.CreateBox(
                `mpDebris_${rp.sessionId}_${i}_${Date.now()}`,
                { size }, this.scene,
            );
            const mat = new StandardMaterial(`mpDebrisMat_${i}_${Date.now()}`, this.scene);
            mat.diffuseColor = colors[i % colors.length];
            mat.specularColor = new Color3(0, 0, 0);
            cube.material = mat;
            cube.position.set(
                cx + (Math.random() - 0.5) * 0.5,
                cy + 0.8 + Math.random() * 0.6,
                cz + (Math.random() - 0.5) * 0.5,
            );
            cube.rotation.set(
                Math.random() * Math.PI,
                Math.random() * Math.PI,
                Math.random() * Math.PI,
            );
            cube.isPickable = false;
            cube.alwaysSelectAsActiveMesh = true;
            cube.renderingGroupId = 1;  // поверх всего — точно видно
            this._debris.push({
                mesh: cube, mat,
                vx: (Math.random() - 0.5) * 5,
                vy: 4 + Math.random() * 3,
                vz: (Math.random() - 0.5) * 5,
                rx: (Math.random() - 0.5) * 10,
                ry: (Math.random() - 0.5) * 10,
                rz: (Math.random() - 0.5) * 10,
                age: 0,
                life: 2.0,
            });
        }
    }

    _tickDebris(dt) {
        if (!this._debris || this._debris.length === 0) return;
        const G = -10;
        const next = [];
        for (const d of this._debris) {
            d.age += dt;
            if (d.age >= d.life) {
                try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
                continue;
            }
            d.vy += G * dt;
            d.mesh.position.x += d.vx * dt;
            d.mesh.position.y += d.vy * dt;
            d.mesh.position.z += d.vz * dt;
            if (d.mesh.position.y < 0.1) {
                d.mesh.position.y = 0.1;
                d.vy *= -0.4;
                d.vx *= 0.6;
                d.vz *= 0.6;
            }
            d.mesh.rotation.x += d.rx * dt;
            d.mesh.rotation.y += d.ry * dt;
            d.mesh.rotation.z += d.rz * dt;
            const fadeStart = d.life - 0.5;
            if (d.age > fadeStart) {
                const k = 1 - (d.age - fadeStart) / 0.5;
                d.mesh.visibility = Math.max(0, k);
            }
            next.push(d);
        }
        this._debris = next;
    }

    /** Сообщить серверу что мы экипировали оружие с modelId.
     *  Передаётся в state.weaponModelId, чужие клиенты подгрузят GLB
     *  и прикрепят к руке нашей модели. */
    sendWeapon(modelId) {
        try {
            this.room.send('weapon', { modelId: modelId || '' });
        } catch (e) { /* ignore */ }
    }

    /** Подфаза 3.10 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md.
     *  Сообщить серверу список accessory item-ов которые мы носим. Сервер
     *  передаст соседям → они вызовут applyRemoteAccessories у своей сцены.
     *
     *  Формат: { hat: 42, tool: 17, hair: 5, face: null, shirt: null, pants: null }
     *  (item-id для каждого слота или null).
     *
     *  ПРИМЕЧАНИЕ: серверная сторона (kubikon-realtime/GameRoom.ts) пока
     *  не поддерживает это сообщение — она его получит и просто проигнорирует.
     *  Будет работать после деплоя серверной части.
     */
    sendAccessories(slotsToItemIds) {
        try {
            this.room.send('accessories', slotsToItemIds || {});
        } catch (e) { /* ignore */ }
    }

    /** Применить полный список аксессуаров на удалённого игрока.
     *  Если модель ещё не загружена — складываем в pendingAccessories,
     *  применим когда AccessoryManager будет создан в _loadRemoteModel.
     *
     *  items: массив RubloxItem.serialize (с path и attachment).
     */
    applyRemoteAccessories(sessionId, items) {
        const rp = this.remotePlayers.get(sessionId);
        if (!rp) return;
        const list = Array.isArray(items) ? items.filter((i) => i && !i.is_body_skin) : [];
        if (!rp.accessoryManager) {
            rp.pendingAccessories = list;
            return;
        }
        // Снимаем старые и наносим новые
        rp.accessoryManager.detachAll();
        for (const it of list) {
            rp.accessoryManager.attach(it).catch((e) => {
                console.warn('[MultiplayerSync] remote accessory failed', e);
            });
        }
    }

    /** Отправить выстрел в сторону точки worldX/worldZ. */
    sendShoot(originX, originZ, dirX, dirZ) {
        const len = Math.hypot(dirX, dirZ);
        if (len < 0.001) return;
        try {
            this.room.send('shoot', {
                originX, originZ,
                dirX: dirX / len, dirZ: dirZ / len,
            });
        } catch (e) { /* ignore */ }
    }

    dispose() {
        if (this._inputTimer) {
            clearInterval(this._inputTimer);
            this._inputTimer = null;
        }
        if (this._renderObserver) {
            this.scene.onBeforeRenderObservable.remove(this._renderObserver);
            this._renderObserver = null;
        }
        for (const rp of this.remotePlayers.values()) {
            // снимаем аниматор до dispose скелета
            try { rp.mixamoAnimator?.detach(); } catch (e) {}
            rp.mixamoAnimator = null;
            try { rp.fallbackMesh?.dispose(); } catch (e) {}
            try { rp.label?.dispose(); } catch (e) {}
            try { rp.weaponRoot?.dispose(false, true); } catch (e) {}
            try { rp.weaponAnchor?.dispose(false, true); } catch (e) {}
            try { rp.modelRoot?.dispose(false, true); } catch (e) {}
            try { rp.root?.dispose(false, true); } catch (e) {}
        }
        this.remotePlayers.clear();
        for (const s of this.shots) {
            try { s.mesh?.dispose(); } catch (e) {}
        }
        this.shots = [];
        if (this._debris) {
            for (const d of this._debris) {
                try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
            }
            this._debris = [];
        }
    }

    // =================================================================
    // === Внутреннее: меши remote-игроков ===
    // =================================================================
    _addRemotePlayer(sessionId, player) {
        // Защита от дублей при Colyseus reconnect: state получается заново
        // и onAdd срабатывает для тех же sessionId. Без этой проверки в
        // сцене появляются клоны игроков (см. issue после 2026-06-05).
        if (this.remotePlayers && this.remotePlayers.has(sessionId)) {
            const existing = this.remotePlayers.get(sessionId);
            // Обновим target позицию и пометим что игрок жив
            const sx2 = player.x || 0, sy2 = player.y || 0, sz2 = player.z || 0, yaw2 = player.yaw || 0;
            existing.target = { x: sx2, y: sy2, z: sz2, yaw: yaw2 };
            existing.username = player.username || sessionId;
            existing.modelType = player.modelType || existing.modelType;
            existing.hp = player.hp ?? existing.hp;
            existing.maxHp = player.maxHp ?? existing.maxHp;
            existing.isDead = !!player.isDead;
            existing.animState = player.animState || existing.animState;
            console.log(`[MultiplayerSync] re-add (reconnect): ${sessionId} (${player.username}) — обновили без пересоздания меша`);
            return;
        }
        const sx = player.x || 0;
        const sy = player.y || 0;
        const sz = player.z || 0;
        const yaw = player.yaw || 0;

        // === Корневой transform-node ===
        // Сюда грузится модель и крепится ник-плашка.
        // На него же ставим интерполированную позицию.
        const root = new TransformNode(`remoteRoot_${sessionId}`, this.scene);
        root.position.x = sx;
        root.position.y = sy;
        root.position.z = sz;

        // Запись в реестр СРАЗУ (до загрузки модели) — чтобы _updateRemoteTarget
        // и onChange находили её даже пока модель ещё не загружена.
        const rp = {
            sessionId,
            root,
            modelRoot: null,    // сюда придёт TransformNode загруженной модели
            label: null,
            animations: {},     // { idle, run/walk/sprint, attack/jump }
            currentAnim: null,
            material: null,     // основной mat первого меша (для смены цвета на смерть)
            target: { x: sx, y: sy, z: sz, yaw },
            current: { x: sx, y: sy, z: sz, yaw },
            hp: player.hp ?? 100,
            maxHp: player.maxHp ?? 100,
            isDead: !!player.isDead,
            username: player.username || sessionId,
            modelType: player.modelType || 'skin_y-bot',
            animState: player.animState || 'idle',
            // Если модель не успеет загрузиться, висит fallback-капсула.
            fallbackMesh: null,
            // === Mixamo-скин (skin_*) ===
            // Mixamo-скины анимируются через MixamoAnimator (как у локального
            // игрока в PlayerController). mixamoAnimator появляется асинхронно,
            // после загрузки анимаций.
            isMixamo: false,         // true → анимируем через mixamoAnimator
            mixamoAnimator: null,    // MixamoAnimator или null для Kenney-моделей
            modelLoaded: false,      // флаг: модель уже на сцене (для тика анимаций)
            // === Оружие ===
            weaponModelId: player.weaponModelId || '',
            weaponRoot: null,           // TransformNode загруженного оружия
            weaponMeshes: [],           // меши, для cleanup
            weaponLoadingId: null,      // защита от race при быстрых сменах
            // === Анимация удара ===
            rightArmMesh: null,         // меш правой руки (для swing)
            rightArmBaseRotation: null, // исходная ротация руки (Vector3)
            weaponAnchor: null,         // TransformNode на плече для крепления оружия
            attackAnimStart: 0,         // performance.now() начала, 0 = не активна
            lastAnimState: 'idle',      // для отслеживания фронта attack
        };
        this.remotePlayers.set(sessionId, rp);

        // === Fallback-капсула (видна сразу, пока модель грузится) ===
        const cap = MeshBuilder.CreateCapsule(`remoteCap_${sessionId}`, {
            height: 1.8, radius: 0.3, tessellation: 8,
        }, this.scene);
        cap.parent = root;
        cap.position.y = 0;
        const capMat = new StandardMaterial(`remoteCapMat_${sessionId}`, this.scene);
        capMat.diffuseColor = Color3.FromHexString('#3357ff');
        capMat.alpha = 0.5;
        cap.material = capMat;
        cap.isPickable = false;
        rp.fallbackMesh = cap;

        // === Ник-плашка над головой ===
        // НЕ парентим к root — иначе billboard может срабатывать криво
        // из-за вращения parent'а. Позицию обновляем каждый кадр в
        // render-loop: root.position + (0, 2.0, 0).
        const label = this._createNameLabel(player.username || sessionId);
        rp.label = label;

        // === Грузим GLB-модель асинхронно ===
        this._loadRemoteModel(rp).catch(err => {
            console.warn(`[MultiplayerSync] failed to load model for ${sessionId}:`, err);
        });

        console.log(`[MultiplayerSync] +remote ${sessionId} (${player.username}) at (${sx.toFixed(2)}, ${sy.toFixed(2)}, ${sz.toFixed(2)}) modelType=${rp.modelType}`);
        this.cb.onLog?.('info', `+ player ${player.username || sessionId}`);
    }

    /**
     * Загрузить GLB-модель удалённого игрока. По образцу PlayerController._loadPlayerModel.
     * Модель цепляется как child корневого transform-node.
     */
    async _loadRemoteModel(rp) {
        // Резолвим источник: Mixamo-скин ('skin_*') или старая Kenney-модель.
        const source = await resolveRemoteModelSource(rp.modelType);
        if (!source || !source.file) {
            console.warn(`[MultiplayerSync] unknown modelType=${rp.modelType}`);
            return;
        }

        // ВАЖНО: грузим напрямую через SceneLoader, НЕ через ModelManager-кэш.
        // Если использовать shared AssetContainer (который ModelManager уже
        // мог инстанцировать ранее с freeze materials), повторный
        // instantiateModelsToScene даёт меши с битыми ссылками на материалы.
        // Babylon HTTP-кэш всё равно убирает сетевые запросы.
        const lastSlash = source.file.lastIndexOf('/');
        const rootUrl = source.file.substring(0, lastSlash + 1);
        const filename = source.file.substring(lastSlash + 1);
        let container;
        try {
            container = await SceneLoader.LoadAssetContainerAsync(
                rootUrl, filename, this.scene
            );
        } catch (e) {
            console.warn(`[MultiplayerSync] failed to load model `
                + `${source.file} for ${rp.sessionId}:`, e);
            return;
        }

        // Если игрок успел уйти, пока модель грузилась — выбрасываем
        if (!this.remotePlayers.has(rp.sessionId)) {
            try { container.dispose(); } catch (e) {}
            return;
        }

        // Создаём корневой узел для модели — applies scale, parent = root
        const modelRoot = new TransformNode(`remoteModel_${rp.sessionId}`, this.scene);
        modelRoot.parent = rp.root;
        // Масштаб — точно как у локального игрока (PlayerController):
        //   - Mixamo-скины: 1.0 (GLB авторингованы в мировом масштабе ~1.8м).
        //   - Kenney-модели: 0.72.
        const modelScale = source.isMixamo ? 1.0 : 0.72;
        modelRoot.scaling = new Vector3(modelScale, modelScale, modelScale);

        const inst = container.instantiateModelsToScene(
            (name) => `remote_${rp.sessionId}_${name}`,
            /*cloneAnimations*/ true,
            { doNotInstantiate: false },
        );
        for (const r of inst.rootNodes) {
            r.parent = modelRoot;
            // 2026-05-27: тот же fix face_dir_invert что в PlayerController
            // (см. PlayerController.js:786). Без этого remote-игроки с
            // half аватаров бегают задом наперёд.
            if (source.overrides && source.overrides.face_dir_invert) {
                r.rotation.y = (r.rotation.y || 0) + Math.PI;
            }
        }

        // Запоминаем меши (для смены alpha при смерти)
        const meshes = modelRoot.getChildMeshes(false);
        for (const m of meshes) {
            m.isPickable = false;
            // alwaysSelectAsActiveMesh: даже если в сцене активен freeze
            // или octree не знает о новых мешах — этот меш всегда виден.
            if (m.alwaysSelectAsActiveMesh !== undefined) {
                m.alwaysSelectAsActiveMesh = true;
            }
        }
        rp.modelMeshes = meshes;
        rp.modelRoot = modelRoot;

        // === Mixamo-скин: детекция скелета и создание аниматора ===
        // Скины Рублокса = Mixamo-rig (single GLB). Анимации (idle/walk/run/
        // jump/fall) грузятся отдельными GLB через loadMixamoAnimations
        // (singleton-кэш — повторно по сети не качаются). Логика 1:1 с
        // PlayerController._loadPlayerModel (Mixamo-ветка).
        rp.isMixamo = false;
        rp.mixamoAnimator = null;
        if (source.isMixamo || source.kind === 'non-humanoid-rigged') {
            let sk = (inst.skeletons && inst.skeletons[0]) || null;
            if (!sk && container.skeletons && container.skeletons.length > 0) {
                sk = container.skeletons[0];
            }
            if (!sk) {
                const meshWithSkel = meshes.find((m) => m.skeleton);
                if (meshWithSkel) sk = meshWithSkel.skeleton;
            }
            if (sk) {
                const animator = new MixamoAnimator();
                // Анимации грузятся асинхронно; mixamoAnimator появится в rp
                // только после загрузки (render-loop проверяет его наличие).
                loadMixamoAnimations(this.scene).then(() => {
                    // игрок мог уйти, пока грузились анимации
                    if (!this.remotePlayers.has(rp.sessionId)) {
                        try { animator.detach(); } catch (e) {}
                        return;
                    }
                    animator.attach(this.scene, sk, modelRoot);
                    animator.setState('idle');
                    rp.mixamoAnimator = animator;
                    rp.isMixamo = true;
                    console.log(`[MultiplayerSync] ${rp.sessionId} Mixamo-скин `
                        + `'${rp.modelType}' готов — костей ${sk.bones.length}`);
                }).catch((e) => {
                    console.warn(`[MultiplayerSync] ${rp.sessionId} Mixamo-анимации `
                        + `не загрузились:`, e);
                });
            } else {
                console.warn(`[MultiplayerSync] ${rp.sessionId} Mixamo-скин `
                    + `'${rp.modelType}' — нет скелета в glb`);
            }
        }

        // Ищем правую руку — точно по тем же правилам что в PlayerController.
        // На этой меше будет крепиться оружие и крутиться swing-анимация атаки.
        let rightArm = null;
        for (const m of meshes) {
            const n = (m.name || '').toLowerCase();
            if (n.endsWith('arm-right')
                || n.includes('right-arm') || n.includes('rightarm')
                || n.includes('right-hand') || n.includes('hand-right')) {
                rightArm = m;
                break;
            }
        }
        // Fallback по позиции: ищем меш с x<0 на уровне рук
        if (!rightArm) {
            let bestX = Infinity;
            for (const m of meshes) {
                if (!m.position) continue;
                const n = (m.name || '').toLowerCase();
                if (n.includes('leg') || n.includes('foot') || n.includes('head')
                    || n.includes('hat') || n.includes('torso') || n.includes('root')) continue;
                const px = m.position.x;
                const py = m.position.y;
                if (py < 0.8 || py > 2.2) continue;
                if (px >= -0.05) continue;
                if (px < bestX) { bestX = px; rightArm = m; }
            }
        }
        if (rightArm && rightArm.rotation) {
            rp.rightArmMesh = rightArm;
            rp.rightArmBaseRotation = rightArm.rotation.clone();

            // Чистый якорь оружия — TransformNode без вращения, координаты
            // в _modelRoot отзеркалены по X относительно меша руки.
            // Формула 1:1 как в PlayerController._updateExtendedArm.
            const weaponAnchor = new TransformNode(
                `remoteWpnAnchor_${rp.sessionId}`, this.scene,
            );
            weaponAnchor.parent = modelRoot;
            const ax = -(rightArm.position?.x ?? -0.4) + 0.15;
            const ay = (rightArm.position?.y ?? 1.1) + 0.7;
            const az = (rightArm.position?.z ?? 0) + 0.95;
            weaponAnchor.position.set(ax, ay, az);
            rp.weaponAnchor = weaponAnchor;
        }

        // Анимации glTF — ТОЛЬКО для Kenney-моделей. Mixamo-скины анимируются
        // через mixamoAnimator (см. render-loop в start()). Mixamo загружается
        // асинхронно, поэтому проверяем по source.isMixamo, а не по rp.isMixamo
        // (тот выставится позже, после loadMixamoAnimations).
        if (!source.isMixamo && source.kind !== 'non-humanoid-rigged') {
            const allGroups = inst.animationGroups || [];
            for (const g of allGroups) {
                const n = (g.name || '').toLowerCase();
                if (n.includes('idle')) rp.animations.idle = g;
                else if (n.includes('sprint') || n.includes('run')) rp.animations.run = g;
                else if (n.includes('walk') && !rp.animations.run) rp.animations.run = g;
                else if (n.includes('attack') || n.includes('punch') || n.includes('kick')) {
                    rp.animations.attack = g;
                }
                else if (n.includes('jump')) rp.animations.jump = g;
                g.stop();
            }
            console.log(`[MultiplayerSync] ${rp.sessionId} animations:`,
                allGroups.map(g => g.name),
                '→ mapped:', Object.keys(rp.animations));
            // Стартовая анимация
            this._playRemoteAnim(rp, rp.animState || 'idle');
        }

        // Модель полностью на сцене — render-loop теперь может тикать анимации.
        rp.modelLoaded = true;

        // Удаляем fallback-капсулу
        if (rp.fallbackMesh) {
            try { rp.fallbackMesh.dispose(); } catch (e) {}
            rp.fallbackMesh = null;
        }

        // Если за время загрузки модели игрок уже успел экипировать оружие
        // (или зашёл в комнату с оружием) — цепляем его сейчас.
        if (rp.weaponModelId) {
            this._attachRemoteWeapon(rp.sessionId, rp.weaponModelId);
        }
    }

    /**
     * Прицепить (или сменить) GLB-оружие к правой руке remote-игрока.
     * modelId='' → снять оружие.
     */
    async _attachRemoteWeapon(sessionId, modelId) {
        const rp = this.remotePlayers.get(sessionId);
        if (!rp) return;
        rp.weaponModelId = modelId;

        // Снимаем старое оружие (всегда — и при смене, и при пустом modelId)
        if (rp.weaponMeshes && rp.weaponMeshes.length) {
            for (const m of rp.weaponMeshes) {
                try { m.dispose(); } catch (e) {}
            }
            rp.weaponMeshes = [];
        }
        rp.weaponRoot = null;

        if (!modelId) return;
        // Если якорь ещё не создан (модель грузится) — пропустим;
        // когда модель доедет, _loadRemoteModel сам вызовет _attachRemoteWeapon.
        if (!rp.weaponAnchor) return;

        const proto = getModelType(modelId);
        if (!proto || !proto.file) return;

        // Token для защиты от гонок: пока грузим — пользователь мог сменить
        // оружие 3 раза. Применяем только результат последнего запроса.
        const token = (rp.weaponLoadingId = `${modelId}_${Date.now()}_${Math.random()}`);

        const lastSlash = proto.file.lastIndexOf('/');
        const rootUrl = proto.file.substring(0, lastSlash + 1);
        const filename = proto.file.substring(lastSlash + 1);

        let container;
        try {
            container = await SceneLoader.LoadAssetContainerAsync(
                rootUrl, filename, this.scene
            );
        } catch (e) {
            console.warn(`[MultiplayerSync] failed to load weapon ${modelId}:`, e);
            return;
        }

        // Стейл-проверки: игрок ушёл / оружие сменилось пока грузили.
        if (!this.remotePlayers.has(sessionId)
            || rp.weaponLoadingId !== token
            || !rp.weaponAnchor) {
            container.dispose();
            return;
        }

        const inst = container.instantiateModelsToScene(
            (name) => `remoteWpn_${sessionId}_${name}`,
            true,
            { doNotInstantiate: false },
        );

        // Корневой узел оружия парентится к weaponAnchor — TransformNode
        // на плече без вращений. Меш руки крутится анимацией, но оружие
        // через якорь смотрит ровно вперёд персонажа.
        // ВАЖНО: добавляем +π к Y-rotation, потому что у локального игрока
        // viewModel рисуется с учётом ориентации меша руки (которая повёрнута
        // на ~180° относительно weaponAnchor), а у нас родитель — anchor без
        // вращения. Дефолт rotation3rd:{0,0,0} в ModelTypes подкручен ровно
        // под локальный сценарий, поэтому здесь компенсируем разворотом.
        const weaponRoot = new TransformNode(`remoteWpnRoot_${sessionId}`, this.scene);
        weaponRoot.parent = rp.weaponAnchor;
        const vm = proto.gameplay?.viewModel;
        const scale = vm?.scale3rd ?? 1.2;
        const pos = vm?.position3rd ?? { x: 0, y: 0, z: 0 };
        const rot = vm?.rotation3rd ?? { x: 0, y: 0, z: 0 };
        weaponRoot.position.set(pos.x, pos.y, pos.z);
        weaponRoot.rotation.set(rot.x, rot.y + Math.PI, rot.z);
        weaponRoot.scaling.set(scale, scale, scale);

        const weaponMeshes = [];
        for (const r of inst.rootNodes) {
            r.parent = weaponRoot;
            weaponMeshes.push(r);
            if (r.getChildMeshes) {
                for (const cm of r.getChildMeshes(false)) {
                    cm.isPickable = false;
                    weaponMeshes.push(cm);
                }
            }
        }
        rp.weaponRoot = weaponRoot;
        rp.weaponMeshes = weaponMeshes;
    }

    /** Запустить указанную анимацию у remote-игрока (с fallback на idle). */
    _playRemoteAnim(rp, name) {
        if (!rp.animations) return;
        if (rp.currentAnim === name) return;
        let target = rp.animations[name];
        if (!target && name !== 'idle') target = rp.animations.idle;
        if (!target) return;

        // Стопим предыдущую
        if (rp.currentAnim && rp.animations[rp.currentAnim]) {
            try { rp.animations[rp.currentAnim].stop(); } catch (e) {}
        }
        try { target.start(/*loop*/ true, /*speed*/ 1); } catch (e) {}
        rp.currentAnim = name;
    }

    _updateRemoteTarget(sessionId, player) {
        const rp = this.remotePlayers.get(sessionId);
        if (!rp) return;
        rp.target.x = player.x;
        rp.target.y = player.y;
        rp.target.z = player.z;
        rp.target.yaw = player.yaw || 0;
        rp.hp = player.hp;
        rp.maxHp = player.maxHp;
        const wasDead = rp.isDead;
        rp.isDead = !!player.isDead;
        // Фронт false→true — игрок только что умер, спавним debris.
        if (!wasDead && rp.isDead) {
            this._spawnDeathDebris(rp);
        }
        if (player.animState) rp.animState = player.animState;
    }

    _removeRemotePlayer(sessionId) {
        const rp = this.remotePlayers.get(sessionId);
        if (!rp) return;
        // Стопим анимации, удаляем меши
        if (rp.animations) {
            for (const a of Object.values(rp.animations)) {
                try { a.stop(); a.dispose?.(); } catch (e) {}
            }
        }
        // Mixamo-аниматор: dispose модели снесёт скелет; detach + обнуляем
        // ссылку, чтобы render-loop в следующем кадре не дёргал невалидный.
        try { rp.mixamoAnimator?.detach(); } catch (e) {}
        rp.mixamoAnimator = null;
        rp.isMixamo = false;
        rp.modelLoaded = false;
        // Подфаза 3.10: чистим AccessoryManager если был
        try { rp.accessoryManager?.detachAll(); } catch (e) {}
        rp.accessoryManager = null;
        try { rp.fallbackMesh?.dispose(); } catch (e) {}
        try { rp.label?.dispose(); } catch (e) {}
        try { rp.weaponRoot?.dispose(false, true); } catch (e) {}
        try { rp.weaponAnchor?.dispose(false, true); } catch (e) {}
        try { rp.modelRoot?.dispose(false, true); } catch (e) {}
        try { rp.root?.dispose(false, true); } catch (e) {}
        this.remotePlayers.delete(sessionId);
        this.cb.onLog?.('info', `- player ${rp.username}`);
    }

    /**
     * Создать плоскость с ником в стиле Roblox: прозрачный фон, белый
     * текст с чёрной обводкой, без плашки. Всегда поверх всего, всегда
     * лицом к камере.
     */
    _createNameLabel(text) {
        // Высокое разрешение текстуры — ник остаётся чётким даже на
        // близком расстоянии и при низком DPR рендер-буфера. 1024×256 ок
        // по памяти (~1 МБ на текстуру).
        const W = 1024, H = 256;
        // 4-й аргумент DynamicTexture — generateMipMaps. Включаем (true),
        // плюс trilinear sampling даёт чёткий текст на любых расстояниях
        // без алиасинга (мерцания).
        const tex = new DynamicTexture(`nameTex_${text}_${Date.now()}`,
            { width: W, height: H }, this.scene, true);
        tex.updateSamplingMode?.(3);  // 3 = TRILINEAR_SAMPLINGMODE
        tex.anisotropicFilteringLevel = 8;
        const ctx = tex.getContext();
        ctx.clearRect(0, 0, W, H);
        // Чёрная обводка + белый текст. Размер шрифта подобран под H=256.
        ctx.font = 'bold 128px "Roboto Condensed", "Segoe UI", sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.lineWidth = 16;
        ctx.lineJoin = 'round';
        ctx.miterLimit = 2;
        ctx.strokeStyle = '#000';
        ctx.strokeText(text, W / 2, H / 2);
        ctx.fillStyle = '#fff';
        ctx.fillText(text, W / 2, H / 2);
        // invertY=true (дефолт). Без этого текст оказывается вверх ногами,
        // потому что UV.v у CreatePlane по Babylon-конвенции инвертирован.
        tex.update(true);
        tex.hasAlpha = true;

        // Размеры plane подбираем под пропорции текстуры (4:1).
        // Делаем побольше — чтобы ник было видно с дистанции.
        const plane = MeshBuilder.CreatePlane(`nameLabel_${text}`,
            { width: 2.2, height: 0.55 },
            this.scene);
        const mat = new StandardMaterial(`nameLabelMat_${text}`, this.scene);
        mat.diffuseTexture = tex;
        mat.diffuseTexture.hasAlpha = true;
        mat.useAlphaFromDiffuseTexture = false;
        mat.emissiveColor = new Color3(1, 1, 1);
        mat.disableLighting = true;
        mat.backFaceCulling = false;
        // Без depth-теста — ник всегда видно, даже сквозь стены/тело
        // (как в Roblox). disableDepthWrite + alphaIndex для корректной
        // прозрачности.
        mat.disableDepthWrite = true;
        plane.material = mat;
        // BILLBOARDMODE_ALL = 7 (X|Y|Z). Каждый кадр Babylon разворачивает
        // плоскость лицом к камере независимо от parent-вращения.
        plane.billboardMode = 7;
        // renderingGroupId=1 — рисуется ПОСЛЕ всей геометрии (group=0),
        // поверх торса/стен/деревьев.
        plane.renderingGroupId = 1;
        plane.isPickable = false;
        return plane;
    }

    /** Создать яркий трейсер выстрела (статичный цилиндр от origin до hit).
     *  Y передаётся явно — обычно `shooter.y + 1.4` (уровень руки в мире).
     *  renderingGroupId=1 + alwaysSelectAsActiveMesh — виден поверх всего,
     *  не отбрасывается octree/freeze. */
    _createTracerMesh(originX, originY, originZ, dirX, dirZ, distance, hit) {
        const start = new Vector3(originX, originY, originZ);
        const end = new Vector3(
            originX + dirX * distance, originY, originZ + dirZ * distance,
        );

        const len = Vector3.Distance(start, end);
        const tracer = MeshBuilder.CreateCylinder(
            `tracer_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
            { height: len, diameter: 0.12, tessellation: 6 },
            this.scene,
        );
        const mat = new StandardMaterial(`tracerMat_${Date.now()}`, this.scene);
        const colorHit = new Color3(1.0, 0.45, 0.30);   // оранжево-красный для попадания
        const colorMiss = new Color3(1.0, 0.95, 0.50);  // ярко-жёлтый для промаха
        mat.diffuseColor = hit ? colorHit : colorMiss;
        mat.emissiveColor = hit ? colorHit : colorMiss;
        mat.disableLighting = true;
        tracer.material = mat;
        tracer.isPickable = false;
        tracer.alwaysSelectAsActiveMesh = true;
        tracer.renderingGroupId = 1;

        // Позиционируем посередине отрезка и поворачиваем в направлении конца.
        const mid = start.add(end).scale(0.5);
        tracer.position = mid;
        const forward = end.subtract(start).normalize();
        const yawAngle = Math.atan2(forward.x, forward.z);
        tracer.rotation.y = yawAngle;
        tracer.rotation.x = Math.PI / 2;

        return tracer;
    }

    _flashRemote(rp) {
        if (!rp.material) return;
        const orig = rp.material.diffuseColor.clone();
        rp.material.diffuseColor = Color3.FromHexString('#ef4444');
        setTimeout(() => {
            try {
                if (!rp._appliedDead) {
                    rp.material.diffuseColor = orig;
                }
            } catch (e) { /* mesh disposed */ }
        }, 200);
    }

    /** Lerp угла с учётом цикличности [-π, π]. */
    _lerpAngle(a, b, t) {
        let diff = b - a;
        while (diff > Math.PI) diff -= Math.PI * 2;
        while (diff < -Math.PI) diff += Math.PI * 2;
        return diff * t;
    }
}
