/**
 * PlayerController — игрок в режиме Play (FPS-камера, гравитация, столкновения).
 *
 * Камера:
 *   1st person — камера в позиции глаз игрока, модель невидима.
 *   3rd person — камера сзади-сверху, модель видна.
 *   Переключение: клавиша C циклит first ↔ third.
 *   Колесо мыши в third-person — меняет дистанцию (zoom).
 *
 * Модель игрока:
 *   Грузим GLB через ModelManager (тот же что для editor-моделей).
 *   Корневой TransformNode хранит position/rotation модели.
 *   В 3rd person модель видна и крутится в направлении движения.
 *   Анимации (idle/walk/sprint) переключаются по скорости/спринту.
 *
 * Управление:
 *   W/A/S/D / стрелки    — движение в горизонтальной плоскости
 *   Space                 — прыжок
 *   Shift                 — спринт (×1.7)
 *   C                     — переключить камеру 1st ↔ 3rd
 *   Esc                   — выйти из игры (через pointer-lock release)
 */
import {
    Vector3, UniversalCamera, SceneLoader, TransformNode,
    MeshBuilder, StandardMaterial, Color3,
    Quaternion, Space, Ray,
} from '@babylonjs/core';
import { getModelType } from './ModelTypes';
import { R15Skeleton } from './R15Skeleton';
import { R15Animator } from './R15Animator';
import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
// Подфаза 3.2/3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md
import { AccessoryManager } from './AccessoryManager';

// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
/* Mixamo-скины (новые персонажи rublox-site /character-assets/skins/).
 * 2026-06-11: эти 80 ID перенесены сюда из data/skinsCatalog.js фронта
 * чтобы плеер их распознавал и грузил по правильному пути.
 * Дефолтные: skin_x-bot (male), skin_y-bot (female/null). */
export const MIXAMO_SKINS = new Set([
    'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
    'skin_castle-guard-1', 'skin_castle-guard-2',
    'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
    'skin_ch09', 'skin_ch10', 'skin_ch11', 'skin_ch13', 'skin_ch14', 'skin_ch15',
    'skin_ch16', 'skin_ch17', 'skin_ch18', 'skin_ch19', 'skin_ch20', 'skin_ch21',
    'skin_ch22', 'skin_ch23', 'skin_ch24', 'skin_ch29', 'skin_ch31', 'skin_ch32',
    'skin_ch33', 'skin_ch34', 'skin_ch35', 'skin_ch39', 'skin_ch40', 'skin_ch42',
    'skin_ch43', 'skin_ch44', 'skin_ch45', 'skin_ch46', 'skin_ch47', 'skin_ch48',
    'skin_claire', 'skin_demon', 'skin_ely', 'skin_erika-archer', 'skin_erika-archer-bow',
    'skin_eve', 'skin_exo-gray', 'skin_exo-red', 'skin_ganfaul', 'skin_heraklios',
    'skin_kachujin', 'skin_kaya', 'skin_knight', 'skin_lola', 'skin_maria',
    'skin_maria-wprop', 'skin_maw', 'skin_medea', 'skin_mutant', 'skin_nightshade',
    'skin_paladin', 'skin_passive-marker-man', 'skin_peasant-girl', 'skin_peasant-man',
    'skin_prisoner', 'skin_pumpkinhulk', 'skin_skeleton-zombie', 'skin_sporty-granny',
    'skin_survivor', 'skin_swat', 'skin_ty', 'skin_uriel', 'skin_vampire',
    'skin_war-zombie', 'skin_warrok', 'skin_white-clown', 'skin_x-bot', 'skin_y-bot',
]);

const CAMERA_MODES = ['third', 'first', 'front'];
// Для режима 'sideview' (Кубикон Dash):
//   - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z)
//   - дистанция SIDEVIEW_DIST и высота SIDEVIEW_HEIGHT подобраны чтобы куб
//     и ~12м препятствий впереди влезали в кадр на 16:9.
const SIDEVIEW_DIST = 14;
const SIDEVIEW_HEIGHT = 2.5;

// Строит абсолютный URL для /api-storys.
// Использует VITE_API_BASE если задан (предпочтительно когда плеер и API
// на разных доменах), иначе fallback:
//   - пустой base в dev (vite-proxy роутит сам)
//   - текущий origin в prod (предполагаем что API на том же домене)
function _storysApiUrl(path) {
    const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
    if (env.VITE_API_BASE) {
        return env.VITE_API_BASE + '/api-storys' + path;
    }
    const isDev = (typeof window !== 'undefined'
        && (window.location.hostname === 'localhost'
            || window.location.hostname === '127.0.0.1'));
    const base = isDev ? '' : (typeof window !== 'undefined' ? window.location.origin : '');
    return base + '/api-storys' + path;
}

export class PlayerController {
    constructor(scene, canvas, physics, scene3d = null) {
        this.scene = scene;
        this.canvas = canvas;
        this.physics = physics;
        this._scene3d = scene3d;       // BabylonScene-обёртка (для checkpoint → setSpawnPoint)
        this._activatedCheckpoints = new Set(); // id чекпоинтов которые уже активировали

        // AABB
        this.HALF_W = 0.3;
        this.HALF_H = 0.9;
        this.HALF_D = 0.3;
        this.EYE_HEIGHT = 0.7;     // глаза от центра AABB

        this.WALK_SPEED   = 4.5;
        this.SPRINT_MULT  = 1.7;
        this.JUMP_VELOCITY = 8;
        this._jumpPowerMul = 1;        // множитель силы прыжка (настраивается извне)
        this._speedMul = 1;            // множитель скорости передвижения
        this._gravityMul = 1;          // множитель гравитации (для GD-стиля нужна повышенная)
        this._shipMode = false;        // GD-гейммод Ship: тап-удержание = подъём (вертолёт)
        this._ufoMode = false;         // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе
        this._waveMode = false;        // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
        this._robotMode = false;       // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
        this._robotBoostLeft = 0;      // оставшееся время boost-фазы (с)
        // Лестница (ladder_vertical): касание + W/S → ladder-mode (гравитация
        // отключена, W/S = вверх/вниз, Space = отпрыг).
        this._ladderMode = false;
        this._ladderData = null;
        this.CLIMB_SPEED = 2.5;        // скорость лазания вверх/вниз (м/с)
        // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
        // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
        this._autoRunSpeed = 0;
        // Кубикон Dash: накопленный угол вращения куба вокруг Z (в воздухе).
        // В sideview-камере при прыжке куб эффектно крутится — визитка GD.
        this._dashSpinAngle = 0;
        // Camera shake: amplitude + remaining time. Применяется в _tick после
        // _computeCameraPos. Используется через game.camera.shake(amp, dur).
        this._cameraShakeAmp = 0;
        this._cameraShakeLeft = 0;
        // Управление камерой из скрипта (Фаза 5.7). null = обычная камера
        // игрока. Иначе объект режима:
        //   { mode:'focus', getTarget } — следить за объектом;
        //   { mode:'cutscene', points, durations, ... } — пролёт по точкам.
        this._cameraOverride = null;
        // Coyote-time: окно после схода с платформы когда ещё можно прыгнуть.
        // Сглаживает жёсткие GD-таймиги. Сбрасывается на 0.12 при onGround.
        this._coyoteLeft = 0;
        this._doubleJumpEnabled = false;
        this._doubleJumpUsed = false;  // использован ли второй прыжок в текущем «полёте»
        // Кубикон Dash: направление гравитации. +1 = нормально (вниз),
        // -1 = инвертировано (вверх, как после blue orb / gravity portal в GD).
        // Применяется только в sideview-режиме. Влияет на:
        //   - vy += GRAVITY * gravityDir * dt
        //   - jump: vy = JUMP_VELOCITY * gravityDir (вверх или вниз)
        //   - "onGround" определение: hitY + vy*gravityDir < 0
        // Также куб-визуал переворачивается через _gravityDirVisual (см. moveCube в скрипте).
        this._gravityDir = 1;
        // Скользкость (лёд): 0 = нормальное мгновенное движение/остановка,
        // 1 = полностью скользко (инерция держится бесконечно). Реалистичный
        // лёд = ~0.85. Настраивается через game.player.setIceFriction(value).
        this._iceFriction = 0;
        this._iceVelX = 0;
        this._iceVelZ = 0;
        // Присед — уменьшает высоту AABB. Включается через
        // game.player.setCrouch(true). HALF_H_NORMAL = 0.9, HALF_H_CROUCH = 0.45.
        this._crouching = false;
        this.HALF_H_NORMAL = 0.9;
        this.HALF_H_CROUCH = 0.45;
        this.GRAVITY      = -22;
        this.MOUSE_SENSITIVITY = 0.0025;

        // 3rd person camera
        this.THIRD_DISTANCE_MIN = 2.5;
        this.THIRD_DISTANCE_MAX = 12;
        this.THIRD_DISTANCE_DEFAULT = 5;
        this.THIRD_HEIGHT_OFFSET = 1.0;   // на сколько выше плеча игрока

        this.camera = null;
        this._active = false;
        this._onExitRequest = null;

        // Состояние игрока
        this._pos = new Vector3(0, 5, 0);
        this._vy = 0;
        this._yaw = 0;
        this._pitch = 0;

        // Камера. Дефолт — первое лицо (как в большинстве игр).
        this._cameraMode = 'third';
        this._thirdDistance = this.THIRD_DISTANCE_DEFAULT;
        // Порог авто-перехода third→first при зуме колесом (Roblox-style).
        this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7;
        // Если true — нельзя выйти из first-person зумом (lockfirst-режим).
        this._lockFirstPerson = false;
        // Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере.
        this._shiftLock = false;
        // Задача 02: ПКМ зажата сейчас (orbit в third) + видимость курсора.
        this._rmbHeld = false;
        this._mouseIconVisible = true;

        // Ввод
        this._codes = new Set();
        this._shift = false;

        // Auto-step visual smoothing. Когда PhysicsAABB телепортирует
        // игрока вверх на уступ (steppedUpBy), физически он уже наверху,
        // но мы интерполируем рендер: показываем модель и камеру со сдвигом
        // ВНИЗ на эту величину и за ~120мс плавно уменьшаем оффсет до 0.
        // Получается визуально как плавный «полупрыжок» без рывка.
        this._stepUpVisualOffset = 0;
        // Скорость спадания оффсета (м/с). 4.5 м/с → 0.55м (макс. step) спадёт за ~120мс.
        this._stepUpDecay = 4.5;

        // Модель игрока (грузится в start)
        // 2026-06-13: дефолт сменён на skin_y-bot (Mixamo Y-Bot).
        this._modelTypeId = 'skin_y-bot';
        this._modelRoot = null;
        this._modelMeshes = [];
        // Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет
        // _skinVisibleScripted = false. Это значение применяется КАЖДЫЙ КАДР
        // в _tick (после анимаций), чтобы скрин оставался скрытым даже после
        // асинхронной загрузки модели или после _applyCameraMode.
        this._skinVisibleScripted = true;
        this._animations = {};
        this._currentAnim = null;
        // Масштаб модели чтобы её рост соответствовал AABB (~1.8 = 2 блока).
        // Kenney character GLB примерно 2.5 ед высотой → 1.8 / 2.5 ≈ 0.72.
        this._modelScale = 0.72;

        // === R15-скин (bacon-hair и др.) ===
        // R15-скины — это glTF с встроенным скелетом Mixamo (без анимаций).
        // Если _modelTypeId начинается с 'skin_' — грузим R15-скин из
        // characters/<id>/body.glb, детектируем скелет, анимируем
        // процедурно через R15Animator (см. _loadPlayerModel / _tick).
        this._isR15 = false;          // флаг: загружен валидный R15-скелет
        this._r15Skeleton = null;     // R15Skeleton — резолвер костей
        this._r15Animator = null;     // R15Animator — процедурные анимации
        this._mixamoAnimator = null;  // MixamoAnimator — Mixamo-скины
        this._skinManifest = null;    // кеш skins_manifest.json
        this._skinOverrides = {};     // overrides текущего скина

        // === non-humanoid скины (задача 07) ===
        // Скин без R15-скелета (животное, машина, абстрактная модель).
        // Для них центрируем pivot, считаем собственный AABB и анимируем
        // процедурно через _animateNonHumanoidMesh (см. _loadPlayerModel/_tick).
        this._modelKind = 'r15';      // 'r15' | 'non-humanoid-mesh'
        this._modelHipHeight = null;  // локальная база модели (опущена на ноги)
        this._nonHumanoidBox = null;  // {hw,hh,hd} собственный AABB модели
        this._lastFrameSpeed = 0;     // горизонтальная скорость кадра (для анимаций)
        this._isGrounded = true;      // флаг «на земле» (для анимаций)

        // === Блокировка ввода/камеры для модалов (задача 04) ===
        this._inputBlocked = false;   // глотает игровой ввод (кроме Esc/Tab/Enter)
        this._cameraFrozen = false;   // замораживает вращение/зум камеры
        this._savedCameraState = null;// сохранённое состояние камеры (focusOnTarget)

        // === Жизни игрока ===
        this.maxHp = 100;
        this.hp = 100;
        this._lastDamageTime = 0;
        this._invulnerabilityTime = 0.5; // 500мс i-frames после удара
        this._onHpChange = null;
        this._onDeath = null;

        // Звук шагов — простой генератор через Web Audio API.
        // Шаг проигрывается когда игрок прошёл по горизонтали STEP_DISTANCE.
        this._audioCtx = null;
        this._distanceSinceLastStep = 0;
        this.STEP_DISTANCE_WALK = 1.6;
        this.STEP_DISTANCE_SPRINT = 1.1;

        // Угол поворота модели — следует за направлением движения, а не yaw камеры.
        // Когда игрок стоит — сохраняем последний угол.
        this._modelYaw = 0;
        this.MODEL_TURN_SPEED = 12;   // скорость доворота к нужному углу (рад/с)

        this._listeners = [];
        this._beforeRender = null;

        // === Stick к движущейся платформе ===
        // Если игрок стоит на примитиве/модели — следующий кадр сдвигает его
        // на дельту движения этого объекта (его позиция могла измениться).
        this._lastGroundData = null;
        this._lastGroundPos = null;

        // === Тач-режим (мобилки/планшеты) ===
        // Если true — pointer-lock не запрашивается, mouse-listener не активен,
        // ввод управляется снаружи через setVirtualKey/addCameraDelta.
        this._touchMode = false;
        // Фактическая скорость поворота камеры от тача (рад/пиксель).
        this.TOUCH_SENSITIVITY = 0.005;
    }

    /**
     * Включить тач-режим. Вызывать ДО start(), на тач-устройствах.
     * В этом режиме:
     *   - pointer-lock НЕ запрашивается
     *   - mousemove игнорируется
     *   - keyboard всё ещё слушается (на случай Bluetooth-клавиатуры),
     *     но дополнительно работают setVirtualKey() / addCameraDelta().
     */
    setTouchMode(enabled) {
        this._touchMode = !!enabled;
    }

    /**
     * Установить «виртуально нажатую» клавишу. code как у KeyboardEvent.code:
     *   'KeyW' | 'KeyA' | 'KeyS' | 'KeyD' | 'Space'
     * Для шифта — отдельный параметр.
     */
    setVirtualKey(code, pressed) {
        if (pressed) this._codes.add(code);
        else this._codes.delete(code);
    }

    /** Программное нажатие/отпускание Shift (бег). */
    setVirtualShift(pressed) {
        this._shift = !!pressed;
    }

    /**
     * Добавить дельту к yaw/pitch камеры (для тач-свайпа поверх 3D-сцены).
     * dx, dy — пиксели свайпа.
     */
    addCameraDelta(dx, dy) {
        this._yaw   += dx * this.TOUCH_SENSITIVITY;
        this._pitch += dy * this.TOUCH_SENSITIVITY;
        const lim = Math.PI / 2 - 0.05;
        if (this._pitch > lim) this._pitch = lim;
        if (this._pitch < -lim) this._pitch = -lim;
    }

    /** Прыжок (один кадр) — пушим Space, в следующем кадре уберём. */
    triggerJump() {
        this._codes.add('Space');
        // Через 100мс отпускаем — этого хватает контроллеру чтобы заметить
        // нажатие и инициировать прыжок (он проверяет onGround + Space).
        setTimeout(() => this._codes.delete('Space'), 100);
    }

    /**
     * Аналоговый ввод движения для тач-джойстика.
     * x, y ∈ [-1, 1] в локальной системе игрока: y=1 — вперёд (от камеры),
     * x=1 — вправо. Магнитуда vector'а определяет скорость (0..walk..sprint).
     *
     * Если задано — используется ВМЕСТО KeyW/A/S/D в _tick. Чтобы вернуться
     * к дискретным клавишам, передай null.
     */
    setAnalogMove(x, y) {
        if (x === null || y === null) {
            this._analogMove = null;
            return;
        }
        if (!this._analogMove) this._analogMove = { x: 0, y: 0 };
        this._analogMove.x = x;
        this._analogMove.y = y;
    }

    setOnExitRequest(cb) {
        this._onExitRequest = cb;
    }

    /** Установить тип модели персонажа — должен быть вызван ДО start(). */
    setModelType(typeId) {
        this._modelTypeId = typeId || 'character-a';
    }

    /**
     * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07).
     * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту,
     * грузит новую модель (R15 или non-humanoid). Возвращает Promise.
     *
     * Используется из game.player.setSkin(slug).
     */
    async reloadSkin(typeId) {
        if (!this._active) return false;
        const newType = typeId || 'character-a';
        if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин
        // 1) Выгрузить текущую модель и связанные аниматоры.
        try {
            if (this._modelRoot) { this._modelRoot.dispose(false, true); }
        } catch (e) { /* ignore */ }
        this._modelRoot = null;
        this._modelMeshes = [];
        this._rightArmMeshes = [];
        this._r15Skeleton = null;
        this._r15Animator = null;
        this._isR15 = false;
        try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {}
        this._mixamoAnimator = null;
        this._modelKind = 'r15';
        this._modelHipHeight = null;
        this._nonHumanoidBox = null;
        // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит).
        this.HALF_W = 0.3;
        this.HALF_H = 0.9;
        this.HALF_D = 0.3;
        this.HALF_H_NORMAL = 0.9;
        this.EYE_HEIGHT = 0.7;
        // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу.
        this._pos.y += 0.5;
        // 4) Загрузить новую модель.
        this._modelTypeId = newType;
        await this._loadPlayerModel();
        return !!this._modelRoot;
    }

    /**
     * Запустить режим игры.
     * spawnPos — точка спавна. Если не указано — (0, 5, 0).
     */
    async start(spawnPos = null) {
        if (this._active) return;
        this._active = true;

        if (spawnPos) {
            this._pos = new Vector3(spawnPos.x, spawnPos.y + this.HALF_H, spawnPos.z);
        } else {
            this._pos = new Vector3(0, 5 + this.HALF_H, 0);
        }
        this._vy = 0;
        this._yaw = 0;
        this._pitch = 0;
        this._modelYaw = 0;
        this._codes.clear();
        this._shift = false;

        // FPS-камера
        const cam = new UniversalCamera('playerCamera', new Vector3(0, 0, 0), this.scene);
        cam.minZ = 0.1;
        cam.maxZ = 1000;
        cam.fov = 1.05;
        cam.inputs.clear();
        this.scene.activeCamera = cam;
        this.camera = cam;

        this._setupInput();

        // Грузим модель персонажа. Ждём — иначе игрок секунду-две стоит
        // без меша (или появляется частично), а движение/колайдер уже
        // активны. start() теперь async-функция — все её вызовы (`await`).
        await this._loadPlayerModel();

        // Render-loop hook
        this._beforeRender = () => this._tick();
        this.scene.registerBeforeRender(this._beforeRender);

        // === Задача 02: pointer-lock берём ТОЛЬКО в режимах с постоянным lock
        // (first/lockfirst/sideview/shift-lock). В third курсор виден свободно —
        // кликает GUI и 3D-таблички, камера крутится только при зажатой ПКМ.
        if (this._isPermaLockMode()) {
            this._requestPointerLockSafe();
        }
        this._applyCursorVisibility();
    }

    /** Задача 02: режим с ПОСТОЯННЫМ pointer-lock (мышь всегда крутит камеру). */
    _isPermaLockMode() {
        return this._cameraMode === 'first' || this._cameraMode === 'lockfirst'
            || this._cameraMode === 'sideview' || this._shiftLock;
    }

    /** Задача 02: показать/скрыть курсор. В third виден (если нет lock), в
     *  first/lock — скрыт. Учитывает game.input.setMouseIconVisible. */
    _applyCursorVisibility() {
        if (!this.canvas) return;
        const locked = (document.pointerLockElement === this.canvas);
        const show = (this._mouseIconVisible !== false) && !locked;
        try { this.canvas.style.cursor = show ? '' : 'none'; } catch (e) { /* ignore */ }
    }

    /**
     * Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых
     * Play→Stop→Play подряд. Если предыдущий lock не отпущен — ждём
     * pointerlockchange и пробуем снова один раз.
     */
    /**
     * Включить/выключить «UI-режим курсора».
     * В этом режиме мышь свободна (можно кликать по GUI), камера не вращается.
     * Чтобы вернуться к управлению камерой — снова setUiCursorMode(false).
     */
    /** Колбэк изменения HP — ({hp, maxHp}). */
    setOnHpChange(cb) { this._onHpChange = cb; }
    setOnDeath(cb) { this._onDeath = cb; }

    /** Нанести урон игроку (с учётом i-frames). */
    takeDamage(amount, source) {
        if (this.hp <= 0) return;
        const now = performance.now() / 1000;
        if (now - this._lastDamageTime < this._invulnerabilityTime) return;
        this._lastDamageTime = now;
        this.hp = Math.max(0, this.hp - Math.max(0, amount));
        // Flash-эффект для UI (через onHpChange флаг damaged=true)
        if (this._onHpChange) {
            try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp, source, damaged: true }); } catch (e) {}
        }
        // Звук «ой»
        this._playHurtSound();
        if (this.hp === 0) {
            // Эффект распада
            this._spawnDeathDebris();
            // Прячем модель игрока
            if (this._modelRoot) this._modelRoot.setEnabled(false);
            if (this._onDeath) {
                try { this._onDeath(); } catch (e) {}
            }
        }
    }

    /** Распад на куски при смерти. */
    _spawnDeathDebris() {
        if (!this._pos) return;
        const cx = this._pos.x, cy = this._pos.y, cz = this._pos.z;
        const colors = [
            new Color3(0.95, 0.78, 0.6),  // кожа
            new Color3(0.7, 0.5, 0.4),
            new Color3(0.4, 0.4, 0.7),    // одежда
            new Color3(0.3, 0.25, 0.2),
        ];
        for (let i = 0; i < 10; i++) {
            const size = 0.18 + Math.random() * 0.14;
            const cube = MeshBuilder.CreateBox(`pdebris_${i}`, { size }, this.scene);
            const mat = new StandardMaterial(`pdebrisMat_${i}`, 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 + 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;
            const debris = {
                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,
            };
            if (!this._debris) this._debris = [];
            this._debris.push(debris);
        }
    }

    /** Тик debris — вызывается в _tick. */
    _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;
    }

    /** Короткий звук «ой» когда получили урон. */
    _playHurtSound() {
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();
            const t = ctx.currentTime;
            const osc = ctx.createOscillator();
            osc.type = 'sawtooth';
            osc.frequency.setValueAtTime(220, t);
            osc.frequency.exponentialRampToValueAtTime(80, t + 0.15);
            const g = ctx.createGain();
            g.gain.setValueAtTime(0.15, t);
            g.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
            osc.connect(g).connect(ctx.destination);
            osc.start(t);
            osc.stop(t + 0.22);
        } catch (e) { /* ignore */ }
    }

    /** Полное восстановление HP (например при респавне). */
    healFull() {
        this.hp = this.maxHp;
        this._lastDamageTime = performance.now() / 1000;  // i-frames на момент респавна
        // Возвращаем модель
        if (this._modelRoot) this._modelRoot.setEnabled(true);
        // Сбрасываем оставшиеся debris
        if (this._debris) {
            for (const d of this._debris) {
                try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
            }
            this._debris = [];
        }
        if (this._onHpChange) {
            try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp }); } catch (e) {}
        }
    }

    setUiCursorMode(enabled) {
        this._uiCursorMode = !!enabled;
        if (enabled) {
            // Открываем UI (меню/курсор) → сбрасываем удержание ПКМ. Иначе если
            // меню открыли при зажатой ПКМ, _rmbHeld застревает в true и orbit-
            // камера после закрытия меню «думает», что ПКМ всё ещё активна.
            this._rmbHeld = false;
            // Освобождаем мышь
            if (document.pointerLockElement === this.canvas) {
                try { document.exitPointerLock(); } catch (e) { /* ignore */ }
            }
        } else {
            // Возвращаем lock — но только если мы реально активны
            if (this._active) {
                this._requestPointerLockSafe();
            }
        }
    }
    isUiCursorMode() { return !!this._uiCursorMode; }

    /**
     * Callback, который вызывается при движении мыши в UI-режиме.
     * fn(x, y) — нормализованные координаты [0..1] относительно канваса.
     * Используется для drag-механик (Дальгона и т.д.).
     */
    setUiMouseMoveCallback(fn) {
        this._uiMouseMoveCb = (typeof fn === 'function') ? fn : null;
    }
    /** mousedown в UI-режиме. fn(x, y). */
    setUiMouseDownCallback(fn) {
        this._uiMouseDownCb = (typeof fn === 'function') ? fn : null;
    }
    /** mouseup в UI-режиме. fn(x, y). */
    setUiMouseUpCallback(fn) {
        this._uiMouseUpCb = (typeof fn === 'function') ? fn : null;
    }

    _requestPointerLockSafe(retried = false) {
        if (!this._active || !this.canvas?.requestPointerLock) return;
        // На тач-устройствах pointer-lock не нужен — управление через touch-overlay
        if (this._touchMode) return;
        // UI-режим (скрипт включил курсор через game.input.setCursorMode('ui'))
        // — не захватываем мышь.
        if (this._uiCursorMode) return;
        // Если уже есть lock на этот canvas — нечего делать
        if (document.pointerLockElement === this.canvas) return;
        // Если есть lock на ДРУГОМ элементе — ждём pointerlockchange и пробуем
        if (document.pointerLockElement && document.pointerLockElement !== this.canvas) {
            if (retried) return; // только одна попытка повтора
            const onChange = () => {
                document.removeEventListener('pointerlockchange', onChange);
                if (this._active) this._requestPointerLockSafe(true);
            };
            document.addEventListener('pointerlockchange', onChange, { once: true });
            return;
        }
        requestAnimationFrame(() => {
            if (!this._active) return;
            try {
                const p = this.canvas.requestPointerLock();
                // Promise-форма: ловим reject (SecurityError) и пробуем повтор
                if (p && typeof p.catch === 'function') {
                    p.catch((err) => {
                        if (!this._active) return;
                        // SecurityError — попробуем ещё раз через кадр (один раз)
                        if (!retried && err && err.name === 'SecurityError') {
                            setTimeout(() => this._requestPointerLockSafe(true), 50);
                        }
                    });
                }
            } catch (e) { /* legacy form, ignore */ }
        });
    }

    /**
     * Загрузить манифест R15-скинов (характеристики + overrides).
     * Кешируется в this._skinManifest. Возвращает массив skins или [].
     */
    async _loadSkinManifest() {
        if (this._skinManifest) return this._skinManifest;
        // ВАЖНО: объединяем ОБА источника, а не «или-или».
        // Баг (2026-05-30): раньше при непустом /rublox/avatars возвращался
        // ТОЛЬКО он, а статичный skins_manifest.json (где встроенные
        // non-humanoid скины — еда/машины/животные: skin_burger, squirrel-donut
        // и т.д.) НЕ подгружался. setSkin('burger') не находил entry → fallback
        // на несуществующий characters/skin_burger/body.glb → 404 → краш GLTF
        // (Unexpected magic) → старая модель уже выгружена, новая не создаётся →
        // скин исчезал. Теперь грузим статичный манифест ВСЕГДА, плюс аватары.
        let combined = [];
        // 1) Статичный JSON (встроенные скины, включая non-humanoid).
        try {
            const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
            if (resp.ok) {
                const json = await resp.json();
                if (Array.isArray(json.skins)) combined = combined.concat(json.skins);
            }
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[PlayerController] skins_manifest load failed:', e);
        }
        // 2) БД rublox_avatars (легаси + дизайнерские аватары после approve).
        try {
            const resp = await fetch(_storysApiUrl('/rublox/avatars'));
            if (resp.ok) {
                const json = await resp.json();
                const items = json.items || [];
                // Нормализуем: file уже полный путь (absolute_file=true), т.к.
                // _resolveModelSource иначе добавляет '/kubikon-assets/' префикс.
                const avatars = items.map((a) => ({
                    id: a.code,
                    name: a.name,
                    file: a.file_path,
                    overrides: a.overrides || {},
                    absolute_file: true,
                }));
                // Аватары имеют приоритет при совпадении id — кладём в начало.
                const avatarIds = new Set(avatars.map((a) => a.id));
                combined = avatars.concat(combined.filter((s) => !avatarIds.has(s.id)));
            }
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[PlayerController] /rublox/avatars failed:', e);
        }
        this._skinManifest = combined;
        return combined;
    }

    /**
     * Определить путь к GLB и overrides для текущего _modelTypeId.
     * - 'skin_*' → R15-скин из characters/<id>/body.glb + overrides из манифеста
     * - иначе    → старая Kenney-модель через getModelType()
     * Возвращает { file, isR15, overrides } или null.
     */
    async _resolveModelSource() {
        const typeId = this._modelTypeId || 'character-a';
        // eslint-disable-next-line no-console
        console.log(`[PlayerController] _resolveModelSource typeId=${typeId}`);
        // Подфаза 3.6 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md:
        // body-skin от дизайнера передаётся как 'body:<item_id>' или
        // прямой URL ('/api-storys/...' или 'http(s)://...').
        // Подтягиваем item из rublox_items.serialize, берём path.
        if (typeId.startsWith('body:')) {
            const itemId = typeId.slice(5);
            const item = await this._fetchBodySkinItem(itemId);
            if (item && item.path) {
                return {
                    file: item.path,
                    isR15: true,
                    overrides: {},  // overrides пока не поддерживаем для дизайнерских body
                };
            }
            // fallback на дефолт
            return {
                file: '/kubikon-assets/characters/skin_bacon-hair/body.glb',
                isR15: true, overrides: {},
            };
        }
        // 2026-05-27: 'designer_avatar:<id>' — preview-режим для аватара из БД
        // (rublox_avatars). Используется в /_preview-avatar/<id>.
        if (typeId.startsWith('designer_avatar:')) {
            const avId = typeId.slice('designer_avatar:'.length);
            const item = await this._fetchDesignerAvatar(avId);
            if (item && item.file_path) {
                return {
                    file: item.file_path,
                    isR15: true,
                    overrides: item.overrides || {},
                };
            }
            // Fallback на бекона — но с глобальным флагом ошибки, чтобы
            // preview-route смог показать понятный alert почему.
            try {
                window.__previewFallbackReason =
                    this._lastDesignerAvatarError
                    || `Designer avatar #${avId} не загрузился — fallback на бекона`;
            } catch (e) {}
            // eslint-disable-next-line no-console
            console.warn(
                `[PlayerController] designer_avatar:${avId} НЕ загружен. `
                + `Fallback на бекона. Причина: ${this._lastDesignerAvatarError || 'unknown'}`
            );
            return {
                file: '/kubikon-assets/characters/skin_bacon-hair/body.glb',
                isR15: true, overrides: {},
            };
        }
        if (typeId.startsWith('/') || typeId.startsWith('http')) {
            // Прямой URL (для preview-режима или тестов).
            return { file: typeId, isR15: true, overrides: {} };
        }
        // Кастомный .glb пользователя: 'customskin:<slug>'. dataUrl + метаданные
        // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs.
        if (typeId.startsWith('customskin:')) {
            const slug = typeId.slice('customskin:'.length);
            const list = this._scene3d?._skinsConfig?.customGlbs || [];
            const meta = list.find(g => g && g.slug === slug) || null;
            const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null;
            if (url) {
                return {
                    file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {},
                    scaleManifest: meta?.scale ?? 1.5,
                    hipHeight: meta?.hipHeight ?? 0.4,
                    rotationYOffset: meta?.rotationYOffset ?? 0,
                    isDataUrl: true,
                };
            }
            return null;
        }
        if (typeId.startsWith('skin_')) {
            // 2026-06-11: палитра скинов Рублокса заменена на 80 Mixamo.
            // Mixamo-скины: /character-assets/skins/<id>.glb (на rublox-site).
            // Legacy-скины (skin_bacon-hair / skin_sigma-labubu / skin_cop / ...)
            // ещё могут приходить из БД пока feature-flag в storys выключен —
            // их грузим из старого /kubikon-assets/characters/<id>/body.glb
            // (R15-скелет). После заливки 80 GLB на rublox.pro и включения
            // RUBLOX_NEW_SKINS_AVAILABLE=true legacy-ветка перестанет
            // срабатывать (бэк начнёт отдавать только новые типы).
            if (MIXAMO_SKINS.has(typeId)) {
                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, не R15
                    overrides: {},
                    isMixamo: true,
                };
            }
            // Legacy R15-скин — через старый manifest.
            const manifest = await this._loadSkinManifest();
            const entry = manifest.find((s) => s.id === typeId);
            if (entry) {
                const kind = entry.kind || 'r15';
                const file = entry.absolute_file
                    ? entry.file
                    : '/kubikon-assets/' + entry.file;
                return {
                    file,
                    isR15: kind === 'r15',
                    kind,
                    overrides: entry.overrides || {},
                    scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null,
                    hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null,
                    rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
                };
            }
            // нет ни в Mixamo, ни в manifest — пробуем прямой legacy-путь
            return {
                file: `/kubikon-assets/characters/${typeId}/body.glb`,
                isR15: true,
                kind: 'r15',
                overrides: {},
            };
        }
        const modelType = getModelType(typeId);
        if (!modelType) return null;
        return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
    }

    /** Подгрузить metadata designer-аватара по id через api-storys. */
    async _fetchDesignerAvatar(avatarId) {
        try {
            this._designerAvatarCache = this._designerAvatarCache || {};
            if (this._designerAvatarCache[avatarId]) {
                return this._designerAvatarCache[avatarId];
            }
            const jwt = (localStorage.getItem('player_jwt')
                         || localStorage.getItem('Authorization') || '');
            const cleanJwt = jwt.startsWith('Bearer ') ? jwt.slice(7) : jwt;
            const url = _storysApiUrl(`/designer/avatars/${avatarId}`);
            // eslint-disable-next-line no-console
            console.log(`[PlayerController] _fetchDesignerAvatar GET ${url}`);
            // 10-секундный таймаут — fetch без него может висеть бесконечно
            // (CORS-preflight, медленная сеть, упавший прокси).
            const ctrl = new AbortController();
            const timer = setTimeout(() => ctrl.abort(), 10000);
            let resp;
            try {
                resp = await fetch(url, {
                    headers: cleanJwt
                        ? { Authorization: 'Bearer ' + cleanJwt }
                        : {},
                    signal: ctrl.signal,
                });
            } finally { clearTimeout(timer); }
            if (!resp.ok) {
                // Получим тело для диагностики
                let detail = '';
                try {
                    const txt = await resp.text();
                    detail = txt.slice(0, 200);
                } catch (e) {}
                // eslint-disable-next-line no-console
                console.warn(
                    `[PlayerController] _fetchDesignerAvatar id=${avatarId} `
                    + `HTTP ${resp.status} ${resp.statusText}; jwt=${cleanJwt ? 'есть' : 'НЕТ'}; `
                    + `body: ${detail}`,
                );
                // Прокидываем ошибку наверх чтобы preview-route показал alert.
                const err = new Error(
                    `Не удалось получить аватар #${avatarId}: HTTP ${resp.status}. `
                    + (resp.status === 401 ? 'JWT не валиден или истёк.'
                       : resp.status === 403 ? 'Нет роли «дизайнер» в team.'
                       : resp.status === 404 ? 'Аватар не найден в БД.'
                       : 'См. консоль для деталей.')
                );
                err.status = resp.status;
                err.detail = detail;
                throw err;
            }
            const data = await resp.json();
            const item = data.item || data;
            this._designerAvatarCache[avatarId] = item;
            return item;
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[PlayerController] _fetchDesignerAvatar failed:', e);
            // Сохраняем ошибку чтобы preview-route мог её показать.
            this._lastDesignerAvatarError = e?.message || String(e);
            return null;
        }
    }

    /** Подгрузить metadata body-skin (item) по id через api-storys. */
    async _fetchBodySkinItem(itemId) {
        try {
            // Кэш на инстанс — _modelTypeId не меняется внутри одной сессии.
            this._bodySkinCache = this._bodySkinCache || {};
            if (this._bodySkinCache[itemId]) return this._bodySkinCache[itemId];
            const resp = await fetch(
                _storysApiUrl(`/designer/skins/${itemId}`),
                {
                    headers: {
                        Authorization: 'Bearer '
                            + (localStorage.getItem('player_jwt')
                               || localStorage.getItem('Authorization') || ''),
                    },
                },
            );
            if (!resp.ok) {
                // eslint-disable-next-line no-console
                console.warn('[PlayerController] _fetchBodySkinItem HTTP', resp.status);
                return null;
            }
            const data = await resp.json();
            const item = data.item || data;
            this._bodySkinCache[itemId] = item;
            return item;
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[PlayerController] _fetchBodySkinItem failed:', e);
            return null;
        }
    }

    /** Загрузить GLB-модель персонажа и его анимации. */
    async _loadPlayerModel() {
        // eslint-disable-next-line no-console
        console.log(`[PlayerController] _loadPlayerModel start, modelTypeId=${this._modelTypeId}`);
        const source = await this._resolveModelSource();
        // eslint-disable-next-line no-console
        console.log(`[PlayerController] _resolveModelSource → ${source ? source.file : 'NULL'}`);
        if (!source) return;
        if (!this._active) return;

        // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш
        // ModelManager. Если бы мы использовали тот же AssetContainer
        // что и зомби (через _loadPrototype), повторный
        // instantiateModelsToScene давал меши с битыми материалами.
        // Babylon HTTP-кэш всё равно убирает сетевые запросы.
        //
        // ВАЖНО (2026-05-27): file_path для дизайнерских аватаров приходит
        // как '/api-storys/...' — на проде player.rublox.pro это даст 404
        // (там нет такого пути). Конвертируем в абсолютный URL minecraftia.
        let absFile = source.file;
        if (absFile && absFile.startsWith('/api-storys/')) {
            const isDev = (typeof window !== 'undefined'
                && (window.location.hostname === 'localhost'
                    || window.location.hostname === '127.0.0.1'));
            if (!isDev) {
                absFile = 'https://minecraftia-school.ru' + absFile;
            }
        }
        let rootUrl, filename;
        if (source.isDataUrl) {
            // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl=''
            // и filename=data:... с подсказкой расширения через 5-й аргумент.
            rootUrl = '';
            filename = absFile;
        } else {
            const lastSlash = absFile.lastIndexOf('/');
            rootUrl = absFile.substring(0, lastSlash + 1);
            filename = absFile.substring(lastSlash + 1);
        }
        // eslint-disable-next-line no-console
        console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`);
        // Прогресс-индикатор для больших GLB (некоторые дизайнерские
        // аватары до 60 МБ — на медленной сети идут минутами, без прогресса
        // выглядит как зависание). Публикуем в глобал чтобы PreviewRoute
        // мог его показать.
        try { window.__playerLoadProgress = { loaded: 0, total: 0 }; } catch (e) {}
        const onProgress = (evt) => {
            try {
                if (evt && evt.lengthComputable) {
                    window.__playerLoadProgress = {
                        loaded: evt.loaded, total: evt.total,
                    };
                    const pct = ((evt.loaded / evt.total) * 100).toFixed(0);
                    // eslint-disable-next-line no-console
                    console.log(`[PlayerController] GLB загрузка: ${pct}% `
                        + `(${(evt.loaded / 1024 / 1024).toFixed(1)}/`
                        + `${(evt.total / 1024 / 1024).toFixed(1)} МБ)`);
                }
            } catch (e) {}
        };
        let container;
        try {
            container = await SceneLoader.LoadAssetContainerAsync(
                rootUrl, filename, this.scene, onProgress,
                source.isDataUrl ? '.glb' : undefined,
            );
            try { window.__playerLoadProgress = null; } catch (e) {}
        } catch (e) {
            try { window.__playerLoadProgress = null; } catch (e2) {}
            // eslint-disable-next-line no-console
            console.error('[PlayerController] failed to load model:', e);
            return;
        }
        try {
            if (!this._active) {
                try { container.dispose(); } catch (e) {}
                return;
            }
            // Создаём корневой узел и инстанцируем модель туда
            const root = new TransformNode('playerModel', this.scene);
            // Масштаб модели — рост ~2 блока (1.8 м, как AABB игрока).
            //   - R15-скины ('skin_*'): фиксированный 0.301 — модели
            //     нормализованы к 5.98 ед пайплайном auto_rig_bacon
            //     (1.8 / 5.98 ≈ 0.301). AABB-based scale ломается на скинах
            //     с торчащими волосами/плащами (как у bacon-hair).
            //   - Kenney-модели: старый 0.72.
            //   - overrides.scale_mult — per-skin множитель из манифеста.
            // Non-humanoid скины (животное/машина/еда) масштабируются иначе:
            // базовый размер из манифеста (scale), без фикс-0.301.
            const isNonHumanoid = source.kind === 'non-humanoid-mesh'
                || source.kind === 'non-humanoid-rigged';
            let modelScale;
            if (isNonHumanoid) {
                modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0;
            } else {
                modelScale = source.isR15 ? 0.301 : this._modelScale;
                const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
                modelScale *= scaleMult;
            }
            root.scaling = new Vector3(modelScale, modelScale, modelScale);
            if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
            const inst = container.instantiateModelsToScene(
                (name) => `player_${name}`,
                /*cloneAnimations*/ true,
                { doNotInstantiate: false }
            );
            for (const r of inst.rootNodes) {
                r.parent = root;
                // Фолбэк для GLB с face_dir_invert (старый Blender→glTF
                // конвейер экспортировал лицом в -Z). После 2026-05-27
                // запеченные через bake_avatars_rotate.sh аватары флаг не
                // требуют, но механизм оставлен для будущих кривых загрузок.
                if (source.overrides && source.overrides.face_dir_invert) {
                    r.rotation.y = (r.rotation.y || 0) + Math.PI;
                }
            }
            this._modelRoot = root;
            this._modelKind = source.kind || 'r15';
            // hipHeight: на сколько центр модели поднят от «низа ног».
            this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null;

            // Non-humanoid: нормализуем размер и опускаем модель на «ноги».
            if (isNonHumanoid) {
                this._setupNonHumanoidModel(root, modelScale, source);
            }

            // === R15-скин: детекция скелета ===
            // R15-скины приходят с встроенным скелетом Mixamo. Babylon
            // распарсил его в inst.skeletons. Создаём R15Skeleton-резолвер
            // и, если скелет валидный, помечаем _isR15 + создаём аниматор.
            this._isR15 = false;
            this._r15Skeleton = null;
            this._r15Animator = null;
            this._skinOverrides = source.overrides || {};
            // eslint-disable-next-line no-console
            console.log('[PlayerController] _loadPlayerModel: file=' + source.file
                + ' isR15=' + source.isR15
                + ' inst.skeletons=' + ((inst.skeletons || []).length)
                + ' rootNodes=' + (inst.rootNodes || []).length);
            if (source.isR15) {
                // Скелет ищем в нескольких местах: inst.skeletons (норма),
                // container.skeletons (иногда не клонируется), на мешах
                // модели (skeleton-property). Берём первый найденный.
                let sk = (inst.skeletons && inst.skeletons[0]) || null;
                if (!sk && container.skeletons && container.skeletons.length > 0) {
                    sk = container.skeletons[0];
                }
                if (!sk) {
                    const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
                    if (meshWithSkel) sk = meshWithSkel.skeleton;
                }
                if (sk) {
                    // eslint-disable-next-line no-console
                    console.log('[PlayerController] скелет найден: bones=' + (sk.bones || []).length
                        + ' имена=' + (sk.bones || []).slice(0, 24).map(b => b.name).join(','));
                    const r15 = new R15Skeleton(sk);
                    if (r15.isValidR15()) {
                        this._r15Skeleton = r15;
                        this._isR15 = true;
                        this._r15Animator = new R15Animator(r15, this._skinOverrides);
                        // eslint-disable-next-line no-console
                        console.log('[PlayerController] R15-скин загружен:',
                            this._modelTypeId, '— костей:', r15.resolvedNames().length,
                            'overrides:', JSON.stringify(this._skinOverrides));
                    } else {
                        // eslint-disable-next-line no-console
                        console.warn('[PlayerController] R15-скин', this._modelTypeId,
                            '— скелет не прошёл валидацию. Зарезолвлено:',
                            r15.resolvedNames().join(','),
                            '| все кости скелета:',
                            (sk.bones || []).map(b => b.name).join(','));
                    }
                } else {
                    // eslint-disable-next-line no-console
                    console.warn('[PlayerController] R15-скин', this._modelTypeId,
                        '— нет скелета в glb');
                }
            }

            // Собираем все mesh-чилдрены (для toggle visibility в 1st person)
            this._modelMeshes = root.getChildMeshes(false);
            // Игрок не должен ловить свой raycast → отключаем pickable
            for (const m of this._modelMeshes) {
                m.isPickable = false;
                if (m.alwaysSelectAsActiveMesh !== undefined) {
                    m.alwaysSelectAsActiveMesh = true;
                }
                // Тени: персонаж принимает тени от мира (тень дерева
                // ложится на тело игрока) и сам отбрасывает тень — caster
                // регистрируется отдельно через _scene3d.addShadowCaster.
                m.receiveShadows = true;
            }
            // Игрок ОТБРАСЫВАЕТ тень — регистрируем mesh-части как shadow casters.
            try {
                if (this._scene3d && typeof this._scene3d.addShadowCaster === 'function') {
                    for (const m of this._modelMeshes) {
                        this._scene3d.addShadowCaster(m);
                    }
                }
            } catch (e) { /* ignore */ }
            // У Kenney character имена `arm-left`/`arm-right` соответствуют
            // СОБСТВЕННОЙ стороне персонажа (его правая = arm-right).
            // Когда мы смотрим персонажу В ЛИЦО (3-rd person сзади) — его
            // правая рука у нас слева на экране.
            //
            // Берём именно arm-right (его правую) — это та рука куда логично
            // вкладывать оружие. По логу: arm-right @ x=-0.4, arm-left @ x=+0.4.
            this._rightArmMeshes = [];
            for (const m of this._modelMeshes) {
                const n = (m.name || '').toLowerCase();
                // Берём именно «right» — настоящая правая рука персонажа
                if (n === 'player_arm-right' || n.endsWith('arm-right')
                    || n.includes('right-arm') || n.includes('rightarm')
                    || n.includes('right-hand') || n.includes('hand-right')) {
                    this._rightArmMeshes.push(m);
                    break;
                }
            }
            // Fallback: если по имени не нашли — берём левую по позиции (x<0)
            if (this._rightArmMeshes.length === 0) {
                let bestMesh = null;
                let bestX = Infinity;
                for (const m of this._modelMeshes) {
                    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; // ищем X<0
                    if (px < bestX) { bestX = px; bestMesh = m; }
                }
                if (bestMesh) this._rightArmMeshes = [bestMesh];
            }
            const arm = this._rightArmMeshes[0];
            if (arm) {
                this._rightArmX = arm.position.x;
                this._rightArmY = arm.position.y;
                this._rightArmZ = arm.position.z;
            }

            // Анимации.
            // R15-скины не содержат AnimationGroups (анимируются процедурно
            // через R15Animator в _tick). Kenney-модели — наоборот, имеют
            // встроенные AnimationGroups (idle/walk/sprint/jump).
            // Mixamo-скины (kind=non-humanoid-rigged) — анимируются через
            // MixamoAnimator: 5 базовых анимаций грузятся отдельными GLB
            // из /character-assets/animations/ и ретаргетятся на скелет.
            this._animations = {};
            this._mixamoAnimator = null;
            if (source.isMixamo || source.kind === 'non-humanoid-rigged') {
                // Найдём скелет Mixamo-модели (отдельно от R15-ветки —
                // та валидацию не прошла, скелет другого формата).
                let mixSk = (inst.skeletons && inst.skeletons[0]) || null;
                if (!mixSk && container.skeletons && container.skeletons.length > 0) {
                    mixSk = container.skeletons[0];
                }
                if (!mixSk) {
                    const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
                    if (meshWithSkel) mixSk = meshWithSkel.skeleton;
                }
                if (mixSk) {
                    try {
                        // Грузим базовые анимации (singleton-кэш — после первого
                        // скина следующие переключаются мгновенно).
                        const animator = new MixamoAnimator();
                        loadMixamoAnimations(this.scene)
                            .then(() => {
                                animator.attach(this.scene, mixSk, root);
                                animator.setState('idle');
                                this._mixamoAnimator = animator;
                                // Предзагрузим climb-анимации заранее (тихо),
                                // чтобы при первом касании лестницы не было кадра
                                // walk с climb-поворотом (дёрг на 180°).
                                try {
                                    animator.preload('climb_up');
                                    animator.preload('climb_down');
                                    animator.preload('climb_to_top');
                                } catch (e) {}
                                // Глобально для отладки/скриптов:
                                //   window.__mixamo.playEmote('dance_hiphop')
                                try { window.__mixamo = animator; } catch (e) {}
                                // eslint-disable-next-line no-console
                                console.log('[PlayerController] MixamoAnimator готов, скелет=' + mixSk.bones.length + ' bones');
                            })
                            .catch((e) => {
                                // eslint-disable-next-line no-console
                                console.warn('[PlayerController] MixamoAnimator не загрузился:', e);
                            });
                    } catch (e) {
                        // eslint-disable-next-line no-console
                        console.warn('[PlayerController] MixamoAnimator init fail:', e);
                    }
                } else {
                    // eslint-disable-next-line no-console
                    console.warn('[PlayerController] Mixamo-скин', this._modelTypeId, '— скелет не найден');
                }
            } else if (!this._isR15) {
                const groups = inst.animationGroups || [];
                for (const g of groups) {
                    const name = (g.name || '').toLowerCase();
                    if (name.includes('idle')) this._animations.idle = g;
                    else if (name.includes('sprint') || name.includes('run')) this._animations.sprint = g;
                    else if (name.includes('walk')) this._animations.walk = g;
                    else if (name.includes('jump')) this._animations.jump = g;
                    g.stop();
                }
                this._playAnim('idle');
            }
            // Применяем текущий camera-mode (показать/скрыть модель)
            this._applyCameraMode();

            // Подфаза 3.3 — создаём AccessoryManager после успешной загрузки
            // тела. Если был старый (после смены скина) — его аксессуары
            // уже задиспозены вместе со старым modelRoot, просто
            // создаём заново.
            this._accessoryManager = new AccessoryManager(
                this.scene, this._r15Skeleton, this._modelRoot,
            );
        } catch (e) {
            // eslint-disable-next-line no-console
            console.error('[PlayerController] failed to load model:', e);
        }
    }

    /**
     * Настройка non-humanoid модели (животное/машина/еда): нормализация
     * размера и опускание на «низ ног». В отличие от R15 (нормализованы
     * пайплайном), эти модели произвольного размера, поэтому считаем bbox.
     *
     * Локальные координаты root: модель должна стоять так, чтобы её низ был
     * на y=0 (там «ноги»). PlayerController позиционирует root в точке
     * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю.
     */
    _setupNonHumanoidModel(root, scaleApplied, source) {
        try {
            // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ
            // применения scaling root'а. Babylon refreshBoundingInfo нужен после
            // инстансинга.
            const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0);
            if (!meshes.length) return;
            root.computeWorldMatrix(true);
            let minY = Infinity, maxY = -Infinity, maxDim = 0;
            let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
            for (const m of meshes) {
                m.computeWorldMatrix(true);
                // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного
                // скелета/морфов; без него minimumWorld у инстансов часто нулевой
                // или из исходной позы → центр считался неверно (баг пришельца/робота).
                try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} }
                const bi = m.getBoundingInfo();
                const bb = bi.boundingBox;
                const lo = bb.minimumWorld, hi = bb.maximumWorld;
                if (!lo || !hi) continue;
                minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y);
                minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x);
                minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z);
            }
            if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
            const h = maxY - minY;
            const w = maxX - minX;
            const d = maxZ - minZ;
            maxDim = Math.max(h, w, d);
            // === Центрирование модели через pivot-node ===
            // Многие Kenney-модели имеют origin НЕ в геометрическом центре
            // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого
            // origin (баг пришельца/робота). Ручной сдвиг детей с делением на
            // scaleApplied неверен если у детей свой scale/rotation. Надёжно:
            // вставляем промежуточный pivot между root и моделью и смещаем pivot
            // на -localCenter (через инверсию world-матрицы root — точно при
            // любом scale/rotation).
            const worldCenter = new Vector3(
                (minX + maxX) / 2,    // центр X
                minY,                 // низ Y (модель «садится» на ноги)
                (minZ + maxZ) / 2     // центр Z
            );
            // world-центр → локальные координаты root
            const invRoot = root.getWorldMatrix().clone().invert();
            const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot);
            const pivot = new TransformNode('playerModelPivot', this.scene);
            pivot.parent = root;
            pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z);
            // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot.
            for (const ch of root.getChildren().slice()) {
                if (ch === pivot) continue;
                ch.parent = pivot;
            }
            // Сохраняем размеры для настраиваемого AABB и камеры.
            // hipHeight из манифеста — приоритетно; иначе берём низ модели.
            this._nonHumanoidBox = { w, h, d };
            this._modelBaseHeight = h;
            // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды).
            // Ограничиваем разумными пределами чтобы не проваливаться/застревать.
            this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2));
            this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2));
            const halfH = Math.max(0.3, Math.min(1.0, h / 2));
            this.HALF_H = halfH;
            this.HALF_H_NORMAL = halfH;
            this.EYE_HEIGHT = halfH * 0.7;
            // eslint-disable-next-line no-console
            console.log('[PlayerController] non-humanoid setup:', this._modelTypeId,
                'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2),
                'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2));
        } catch (e) {
            // eslint-disable-next-line no-console
            console.warn('[PlayerController] _setupNonHumanoidModel failed:', e);
        }
    }

    /**
     * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать
     * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при
     * беге + наклон в воздухе. Вызывается каждый кадр из _tick.
     * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel).
     */
    _animateNonHumanoidMesh(dt) {
        const root = this._modelRoot;
        if (!root) return;
        const t = (typeof performance !== 'undefined' && performance.now)
            ? performance.now() / 1000 : Date.now() / 1000;
        const speed = this._lastFrameSpeed || 0;
        // Базовое вращение по yaw уже выставляет _tick (он крутит модель под
        // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt
        // поверх — храним их в отдельных полях, чтобы _tick их не перетёр.
        let bobY = 0, tiltX = 0;
        if (!this._isGrounded) {
            tiltX = 0.2;  // в воздухе — нос вверх
        } else if (speed > 0.1) {
            const bobFreq = 8 * Math.min(2, speed / 4);
            bobY = Math.sin(t * bobFreq) * 0.06;
            tiltX = Math.min(speed * 0.04, 0.13);
        } else {
            bobY = Math.sin(t * 1.5) * 0.015;  // лёгкое «дыхание» в покое
        }
        // Применяем поверх позиции, которую _tick уже выставил в root.position.y.
        root.position.y += bobY;
        // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом.
        root.rotation.x = tiltX;
    }

    // ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ──

    /**
     * Надеть аксессуар. item — RubloxItem.serialize с полем attachment.
     * Возвращает Promise<handle> (handle.dispose() снимает).
     * Если в этом слоте уже надето что-то — старое снимается автоматом.
     */
    async equipAccessory(item) {
        if (!this._accessoryManager) {
            // eslint-disable-next-line no-console
            console.warn('[PlayerController] equipAccessory: model not loaded yet');
            return null;
        }
        return this._accessoryManager.attach(item);
    }

    /** Снять аксессуар из слота (hat/tool/hair/face/...). */
    unequipSlot(slot) {
        if (this._accessoryManager) this._accessoryManager.detachSlot(slot);
    }

    /** Снять все аксессуары. */
    unequipAll() {
        if (this._accessoryManager) this._accessoryManager.detachAll();
    }

    /** Геттер для прямого доступа (used by calibration UI / DevTools). */
    getAccessoryManager() {
        return this._accessoryManager || null;
    }

    /** AABB игрока пересекает хотя бы один блок-воду. */
    _isInWater() {
        const bm = this._scene3d?.blockManager;
        if (!bm) return false;
        // FAST PATH: если на сцене нет водных блоков — точно не в воде.
        // Большинство карт (зомби-остров, любые «суховые») — без воды,
        // и тройной цикл ниже бесполезно тратит время каждый кадр.
        if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false;
        const px = this._pos.x, py = this._pos.y, pz = this._pos.z;
        const hw = this.HALF_W, hh = this.HALF_H, hd = this.HALF_D;
        // Проверяем клетки которые AABB перекрывает
        const gxMin = Math.floor(px - hw + 0.5);
        const gxMax = Math.floor(px + hw + 0.5);
        const gzMin = Math.floor(pz - hd + 0.5);
        const gzMax = Math.floor(pz + hd + 0.5);
        const gyMin = Math.floor(py - hh);
        const gyMax = Math.floor(py + hh);
        for (let gx = gxMin; gx <= gxMax; gx++) {
            for (let gy = gyMin; gy <= gyMax; gy++) {
                for (let gz = gzMin; gz <= gzMax; gz++) {
                    const m = bm.blocks.get(`${gx},${gy},${gz}`);
                    if (m?.metadata?.isWater) return true;
                }
            }
        }
        return false;
    }

    /** AABB игрока ПОЛНОСТЬЮ внутри блоков-воды (голова под водой). */
    _isSubmerged() {
        const bm = this._scene3d?.blockManager;
        if (!bm) return false;
        // FAST PATH: нет воды на сцене — не утопаем.
        if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false;
        // Проверяем «голову» — точку чуть ниже верха AABB
        const headY = this._pos.y + this.HALF_H - 0.1;
        const gx = Math.round(this._pos.x);
        const gy = Math.floor(headY);
        const gz = Math.round(this._pos.z);
        const m = bm.blocks.get(`${gx},${gy},${gz}`);
        return !!m?.metadata?.isWater;
    }

    /** Воспроизвести звук шага. Создаёт короткий burst через Web Audio. */
    _playFootstep() {
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();

            const now = ctx.currentTime;
            const duration = 0.08;

            // Источник — короткий шумовой буфер
            const sampleRate = ctx.sampleRate;
            const length = Math.floor(sampleRate * duration);
            const buffer = ctx.createBuffer(1, length, sampleRate);
            const data = buffer.getChannelData(0);
            for (let i = 0; i < length; i++) {
                data[i] = (Math.random() * 2 - 1) * (1 - i / length);   // затухающий шум
            }
            const src = ctx.createBufferSource();
            src.buffer = buffer;

            // Lowpass для тяжёлого «тук» вместо высокого «шшш»
            const lowpass = ctx.createBiquadFilter();
            lowpass.type = 'lowpass';
            lowpass.frequency.value = 350;
            lowpass.Q.value = 1.5;

            // Envelope (быстрая атака, быстрое затухание)
            const gain = ctx.createGain();
            gain.gain.setValueAtTime(0, now);
            gain.gain.linearRampToValueAtTime(0.7, now + 0.005);
            gain.gain.exponentialRampToValueAtTime(0.001, now + duration);

            src.connect(lowpass).connect(gain).connect(ctx.destination);
            src.start(now);
            src.stop(now + duration);
        } catch (e) {
            // ignore — звук не критичен
        }
    }

    /**
     * Звук прыжка — мягкий «boing» из двух слоёв:
     *  1) низкий thump (sine 90Hz, очень короткий) — «толчок ногами»
     *  2) высокий pitch-down sine (700→500 Hz) — «лёгкость подъёма»
     * Гораздо приятнее старого квадратного восходящего тона.
     */
    /**
     * Проиграть эмоцию персонажа (wave/dance/cheer/sit) — game.player.playAnimation.
     * Работает только для R15-скинов (Kenney-модели эмоций не имеют).
     */
    playEmote(name) {
        if (this._isR15 && this._r15Animator) {
            return this._r15Animator.playEmote(name);
        }
        return false;
    }

    /** Прервать текущую эмоцию персонажа. */
    stopEmote() {
        if (this._isR15 && this._r15Animator) this._r15Animator.stopEmote();
    }

    /**
     * Проиграть кастомный emote из GLB-spec (см. EmoteGlbParser).
     * Используется в preview-режиме теста дизайнерских emote.
     */
    playCustomEmote(spec) {
        if (this._isR15 && this._r15Animator) {
            return this._r15Animator.playCustomEmote(spec);
        }
        return false;
    }

    _playJumpSound() {
        // Хук для скриптов: game.onPlayerJump. Вызывается на каждый прыжок
        // (обычный / UFO / двойной) — _playJumpSound гарантированно зовётся.
        if (typeof this._onJump === 'function') {
            try { this._onJump(); } catch (e) { /* ignore */ }
        }
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();
            const now = ctx.currentTime;
            const out = ctx.destination;

            // Слой 1: низкий thump
            const thumpDur = 0.07;
            const thumpOsc = ctx.createOscillator();
            thumpOsc.type = 'sine';
            thumpOsc.frequency.setValueAtTime(110, now);
            thumpOsc.frequency.exponentialRampToValueAtTime(60, now + thumpDur);
            const thumpGain = ctx.createGain();
            thumpGain.gain.setValueAtTime(0, now);
            thumpGain.gain.linearRampToValueAtTime(0.35, now + 0.005);
            thumpGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur);
            thumpOsc.connect(thumpGain).connect(out);
            thumpOsc.start(now);
            thumpOsc.stop(now + thumpDur + 0.02);

            // Слой 2: «boing» — pitch-down sine
            const boingDur = 0.18;
            const boingOsc = ctx.createOscillator();
            boingOsc.type = 'sine';
            boingOsc.frequency.setValueAtTime(720, now + 0.005);
            boingOsc.frequency.exponentialRampToValueAtTime(440, now + 0.005 + boingDur);
            const boingGain = ctx.createGain();
            boingGain.gain.setValueAtTime(0, now + 0.005);
            boingGain.gain.linearRampToValueAtTime(0.18, now + 0.02);
            boingGain.gain.exponentialRampToValueAtTime(0.001, now + 0.005 + boingDur);
            // Лёгкое vibrato чтобы было «живее»
            const lfo = ctx.createOscillator();
            lfo.type = 'sine';
            lfo.frequency.value = 14;
            const lfoGain = ctx.createGain();
            lfoGain.gain.value = 18; // ±18 Hz
            lfo.connect(lfoGain).connect(boingOsc.frequency);
            boingOsc.connect(boingGain).connect(out);
            boingOsc.start(now + 0.005);
            lfo.start(now + 0.005);
            boingOsc.stop(now + 0.005 + boingDur + 0.02);
            lfo.stop(now + 0.005 + boingDur + 0.02);
        } catch (e) { /* ignore */ }
    }

    /** Звук «бульк» при входе/выходе из воды. */
    _playSplashSound() {
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();
            const now = ctx.currentTime;
            const duration = 0.35;

            // Шум с быстро падающим highpass — звук «всплеска»
            const length = Math.floor(ctx.sampleRate * duration);
            const buf = ctx.createBuffer(1, length, ctx.sampleRate);
            const data = buf.getChannelData(0);
            for (let i = 0; i < length; i++) {
                data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2;
            }
            const src = ctx.createBufferSource();
            src.buffer = buf;

            const bp = ctx.createBiquadFilter();
            bp.type = 'bandpass';
            bp.frequency.setValueAtTime(2000, now);
            bp.frequency.exponentialRampToValueAtTime(400, now + duration);
            bp.Q.value = 1.5;

            const g = ctx.createGain();
            g.gain.setValueAtTime(0, now);
            g.gain.linearRampToValueAtTime(0.6, now + 0.01);
            g.gain.exponentialRampToValueAtTime(0.001, now + duration);

            src.connect(bp).connect(g).connect(ctx.destination);
            src.start(now);
            src.stop(now + duration);
        } catch (e) { /* ignore */ }
    }

    _playAnim(name) {
        if (this._currentAnim === name) return;
        const target = this._animations[name];
        if (!target) {
            // если нужной нет — пробуем idle как fallback
            if (name !== 'idle' && this._animations.idle) {
                return this._playAnim('idle');
            }
            return;
        }
        // === ЛОГ ПЕРЕКЛЮЧЕНИЯ АНИМАЦИИ ===
        // Помогает дебажить вибрацию ног на склонах: если в логе видно
        // частое мерцание walk↔jump или idle↔walk — значит onGround или
        // isMoving скачет каждые несколько кадров.
        const dbg = this._lastAnimDebug || { vy: 0, og: false, sf: false, mv: false };
        console.log(`[Anim] ${this._currentAnim || '∅'} → ${name} `
            + `(onGround=${dbg.og}, surfFollow=${dbg.sf}, moving=${dbg.mv}, vy=${dbg.vy.toFixed(2)})`);
        // Стопим текущую
        if (this._currentAnim && this._animations[this._currentAnim]) {
            this._animations[this._currentAnim].stop();
        }
        target.start(/*loop*/ true, /*speed*/ 1);
        this._currentAnim = name;
    }

    /**
     * Остановить режим игры. Освобождает все ресурсы.
     */
    stop() {
        if (!this._active) return;
        this._active = false;

        if (this._beforeRender) {
            this.scene.unregisterBeforeRender(this._beforeRender);
            this._beforeRender = null;
        }
        for (const { target, type, fn, opts } of this._listeners) {
            target.removeEventListener(type, fn, opts);
        }
        this._listeners = [];

        if (document.pointerLockElement === this.canvas) {
            document.exitPointerLock();
        }

        // Останавливаем все анимации
        for (const g of Object.values(this._animations)) {
            try { g.stop(); } catch (e) { /* ignore */ }
        }
        this._animations = {};
        this._currentAnim = null;

        // Удаляем якорь оружия
        if (this._weaponAnchor) {
            try { this._weaponAnchor.dispose(); } catch (e) { /* ignore */ }
            this._weaponAnchor = null;
        }
        // Сбрасываем все override'ы вращения
        if (this._meshRotationOverrides) {
            this._meshRotationOverrides.clear();
        }
        // Сброс R15-состояния
        if (this._r15BoneOverrides) this._r15BoneOverrides.clear();
        this._r15Animator = null;
        this._r15Skeleton = null;
        this._isR15 = false;
        // Сброс MixamoAnimator
        try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {}
        this._mixamoAnimator = null;

        // Удаляем модель
        if (this._modelRoot) {
            for (const m of this._modelMeshes) {
                try { m.dispose(); } catch (e) { /* ignore */ }
            }
            try { this._modelRoot.dispose(); } catch (e) { /* ignore */ }
            this._modelRoot = null;
            this._modelMeshes = [];
        }

        if (this.camera) {
            this.camera.dispose();
            this.camera = null;
        }

        if (this._audioCtx) {
            try { this._audioCtx.close(); } catch (e) { /* ignore */ }
            this._audioCtx = null;
        }
    }

    isActive() {
        return this._active;
    }

    // === ВНУТРЕННЕЕ ===

    /** Позиция камеры в мире (зависит от режима first/third/front). */
    _computeCameraPos() {
        // Виртуальная "визуальная" Y-позиция игрока — учитывает step-up
        // оффсет. Физически игрок уже на pos.y, но мы плавно «догоняем»
        // высоту чтобы камера не дёргалась рывком при step-up.
        const visY = this._pos.y - this._stepUpVisualOffset;
        if (this._cameraMode === 'first') {
            return new Vector3(this._pos.x, visY + this.EYE_HEIGHT, this._pos.z);
        }
        if (this._cameraMode === 'sideview') {
            // Кубикон Dash: камера сбоку, фиксированный yaw на куб.
            // Игрок движется по +X, камера в -Z от него (смотрит на +Z).
            // С этого ракурса +X на экране = вправо (как в Geometry Dash).
            // Лёгкое смещение camera по X влево от куба — игрок в левой
            // трети кадра, впереди видно больше уровня.
            return new Vector3(
                this._pos.x - 1.5,
                visY + SIDEVIEW_HEIGHT,
                this._pos.z - SIDEVIEW_DIST
            );
        }
        // forward — направление «куда смотрит игрок» с учётом yaw и pitch
        const cosP = Math.cos(this._pitch);
        const fx = Math.sin(this._yaw) * cosP;
        const fy = -Math.sin(this._pitch);
        const fz = Math.cos(this._yaw) * cosP;
        const dist = this._thirdDistance;

        // Точка «глаз» игрока — отсюда пускаем луч к запланированной
        // позиции камеры и сокращаем дистанцию если упёрлись в стену.
        const eyeY = visY + this.EYE_HEIGHT + this.THIRD_HEIGHT_OFFSET;

        if (this._cameraMode === 'third') {
            const desired = new Vector3(
                this._pos.x - fx * dist,
                eyeY - fy * dist,
                this._pos.z - fz * dist
            );
            return this._clampCameraToWorld(
                this._pos.x, eyeY, this._pos.z, desired
            );
        }
        // 'front' — спереди игрока, направлена назад (на лицо)
        const desiredFront = new Vector3(
            this._pos.x + fx * dist,
            eyeY + fy * dist,
            this._pos.z + fz * dist
        );
        return this._clampCameraToWorld(
            this._pos.x, eyeY, this._pos.z, desiredFront
        );
    }

    /**
     * Не позволяет камере «проходить» сквозь стены/блоки/примитивы.
     * Пускает луч от глаз игрока до запланированной позиции камеры.
     * Если на пути есть препятствие — возвращает точку чуть ближе
     * (hit.distance - PADDING), чтобы камера прижалась к стене.
     *
     * Игнорирует:
     *  - меши без metadata (вспомогательная техника редактора),
     *  - триггеры (canCollide===false),
     *  - саму модель игрока,
     *  - debris/particles.
     */
    _clampCameraToWorld(ex, ey, ez, desired) {
        if (!this.scene) return desired;
        // Вектор от глаз до желаемой камеры.
        const dx = desired.x - ex;
        const dy = desired.y - ey;
        const dz = desired.z - ez;
        const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
        if (len < 0.05) return desired;  // камера почти в точке глаз — не уйдёт
        const dir = new Vector3(dx / len, dy / len, dz / len);
        const origin = new Vector3(ex, ey, ez);
        const ray = new Ray(origin, dir, len);

        const PADDING = 0.35;  // отступ от стены, чтобы камера не «врезалась»
        const playerRoot = this._modelRoot;

        const pickPred = (mesh) => {
            if (!mesh) return false;
            if (!mesh.isEnabled || !mesh.isEnabled()) return false;
            // Прозрачно-визуальные и technical meshes — пропускаем.
            if (mesh.isPickable === false) return false;
            const md = mesh.metadata || {};
            // Триггеры/невидимки/скриптовые маркеры — не блокируют.
            if (md.canCollide === false) return false;
            if (md._isTriggerHelper) return false;
            // Модель игрока (камера не должна цепляться за собственный меш).
            if (playerRoot) {
                let n = mesh;
                while (n) {
                    if (n === playerRoot) return false;
                    n = n.parent;
                }
            }
            // Жидкости (вода/лава) — не блокируют камеру.
            if (md._liquidProxy) return false;
            return true;
        };

        let hit = null;
        try {
            hit = this.scene.pickWithRay(ray, pickPred);
        } catch (e) {
            return desired;
        }
        if (!hit || !hit.hit || hit.distance >= len - 0.01) {
            return desired;
        }
        // Сокращаем дистанцию.
        const clampedLen = Math.max(0.3, hit.distance - PADDING);
        return new Vector3(
            ex + dir.x * clampedLen,
            ey + dir.y * clampedLen,
            ez + dir.z * clampedLen
        );
    }

    // ===== Управление камерой из скрипта (Фаза 5.7) =====

    /** Установить угол обзора камеры (FOV) в градусах. */
    setCameraFov(degrees) {
        if (!this.camera) return;
        const d = Number(degrees);
        if (!Number.isFinite(d) || d < 10 || d > 130) return;
        this.camera.fov = d * Math.PI / 180;
    }

    /**
     * Привязать камеру к объекту — она смотрит на него.
     * getTarget — функция, возвращающая {x,y,z} цели.
     * opts: { distance, height } — отступ камеры от цели.
     */
    cameraFocusOn(getTarget, opts = {}) {
        if (typeof getTarget !== 'function') return;
        this._cameraOverride = {
            mode: 'focus',
            getTarget,
            distance: Number.isFinite(opts.distance) ? opts.distance : 8,
            height: Number.isFinite(opts.height) ? opts.height : 4,
        };
    }

    /**
     * Катсцена — плавный пролёт камеры по точкам.
     * points — массив {x,y,z} позиций камеры.
     * lookAt — массив {x,y,z} точек взгляда (по одной на каждую позицию).
     * segDuration — секунд на отрезок между точками.
     * onDone — колбэк по завершении.
     */
    cameraCutscene(points, lookAt, segDuration, onDone) {
        if (!Array.isArray(points) || points.length < 2) return;
        this._cameraOverride = {
            mode: 'cutscene',
            points,
            lookAt: Array.isArray(lookAt) ? lookAt : [],
            segDuration: Number.isFinite(segDuration) && segDuration > 0 ? segDuration : 2,
            t: 0,             // время от начала
            seg: 0,           // текущий отрезок
            onDone: typeof onDone === 'function' ? onDone : null,
        };
    }

    /** Вернуть камеру под управление игрока. */
    cameraReset() {
        this._cameraOverride = null;
    }

    // ===== Задача 14: вождение машины =====
    enterVehicle(veh) {
        if (!veh) return;
        this._inVehicle = veh;
        this._vehicleCamMode = 'follow';
        veh.driver = 'player';
        if (this._codes) this._codes.clear();
        this._skinVisibleScripted = false;
        this._startEngineSound();
    }

    // Звук мотора: низкочастотный РОКОТ (бас-пила + отфильтрованный шум +
    // LFO-пульсация тактов), а не воющий тон. Парность со студией.
    _startEngineSound() {
        try {
            if (!this._audioCtx) {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                this._audioCtx = new Ctx();
            }
            const ctx = this._audioCtx;
            if (ctx.state === 'suspended') ctx.resume();
            if (this._engineNodes) return;
            const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 45;
            const bufLen = ctx.sampleRate * 1.0;
            const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
            const data = buf.getChannelData(0);
            for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.6;
            const noise = ctx.createBufferSource(); noise.buffer = buf; noise.loop = true;
            const noiseLp = ctx.createBiquadFilter(); noiseLp.type = 'lowpass'; noiseLp.frequency.value = 180; noiseLp.Q.value = 0.7;
            const noiseGain = ctx.createGain(); noiseGain.gain.value = 0.35;
            const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5;
            const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 12;
            const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.18;
            const gain = ctx.createGain(); gain.gain.value = 0.05;
            osc.connect(lp);
            noise.connect(noiseLp); noiseLp.connect(noiseGain); noiseGain.connect(lp);
            lp.connect(gain); gain.connect(ctx.destination);
            lfo.connect(lfoGain); lfoGain.connect(gain.gain);
            osc.start(); noise.start(); lfo.start();
            this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain };
        } catch (e) {}
    }
    _updateEngineSound(speedMs, maxSpeed) {
        const n = this._engineNodes; if (!n) return;
        try {
            const frac = Math.min(1, Math.abs(speedMs) / (maxSpeed || 14));
            const ctx = this._audioCtx; const t = ctx.currentTime;
            n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12);
            n.lfo.frequency.setTargetAtTime(12 + frac * 33, t, 0.12);
            n.lp.frequency.setTargetAtTime(300 + frac * 500, t, 0.12);
            n.noiseLp.frequency.setTargetAtTime(150 + frac * 350, t, 0.12);
            n.noiseGain.gain.setTargetAtTime(0.25 + frac * 0.35, t, 0.12);
            n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12);
        } catch (e) {}
    }
    _stopEngineSound() {
        const n = this._engineNodes; if (!n) return;
        try {
            const t = this._audioCtx.currentTime;
            n.gain.gain.setTargetAtTime(0, t, 0.05);
            n.osc.stop(t + 0.2); n.noise.stop(t + 0.2); n.lfo.stop(t + 0.2);
        } catch (e) {}
        this._engineNodes = null;
    }
    exitVehicle() {
        const veh = this._inVehicle;
        this._inVehicle = null;
        if (veh) {
            veh.driver = null;
            try {
                const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
                this._pos.set(veh.pos.x + side.x * (veh.half.w + 1.0), veh.pos.y + 1.0, veh.pos.z + side.z * (veh.half.w + 1.0));
                this._vy = 0;
            } catch (e) {}
        }
        this._stopEngineSound();
        this._skinVisibleScripted = true;
        if (this._modelMeshes) {
            for (const m of this._modelMeshes) {
                if (m && m.setEnabled) { try { m.setEnabled(true); } catch (e) {} }
            }
        }
    }
    cycleVehicleCamera() {
        const modes = ['follow', 'hood', 'cinematic'];
        const i = modes.indexOf(this._vehicleCamMode || 'follow');
        this._vehicleCamMode = modes[(i + 1) % modes.length];
    }
    _tickVehicle(dt) {
        const veh = this._inVehicle;
        if (!veh || !this._scene3d?.vehicleManager) return;
        if (this._modelMeshes) {
            for (const m of this._modelMeshes) {
                if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { try { m.setEnabled(false); } catch (e) {} }
            }
        }
        const c = this._codes;
        const throttle = (c.has('KeyW') || c.has('ArrowUp') ? 1 : 0) - (c.has('KeyS') || c.has('ArrowDown') ? 1 : 0);
        const steer = (c.has('KeyD') || c.has('ArrowRight') ? 1 : 0) - (c.has('KeyA') || c.has('ArrowLeft') ? 1 : 0);
        const handbrake = c.has('Space');
        this._scene3d.vehicleManager.setInput(veh, throttle, steer, handbrake);
        const _vres = this._scene3d.vehicleManager.tickVehicle(veh, dt);
        this._updateEngineSound(veh.speed, veh.params?.maxSpeed);
        if (_vres && _vres.fellOut) {
            this.exitVehicle();
            if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (e) {} }
            const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
            try { this._pos.set(sp.x, sp.y + 1.0, sp.z); this._vy = 0; } catch (e) {}
            return;
        }
        try { this._pos.set(veh.pos.x, veh.pos.y + 1.0, veh.pos.z); } catch (e) {}
        if (!this.camera) return;
        const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
        const cp = veh.pos;
        const mode = this._vehicleCamMode || 'follow';
        let camPos, camTarget;
        if (mode === 'hood') {
            camPos = new Vector3(cp.x + dir.x * (veh.half.d + 0.3), cp.y + veh.half.h + 0.6, cp.z + dir.z * (veh.half.d + 0.3));
            camTarget = new Vector3(cp.x + dir.x * 8, cp.y + 0.5, cp.z + dir.z * 8);
        } else if (mode === 'cinematic') {
            const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
            camPos = new Vector3(cp.x + side.x * 7 + dir.x * 2, cp.y + 2.5, cp.z + side.z * 7 + dir.z * 2);
            camTarget = new Vector3(cp.x, cp.y + 0.8, cp.z);
        } else {
            camPos = new Vector3(cp.x - dir.x * 8, cp.y + 3.2, cp.z - dir.z * 8);
            camTarget = new Vector3(cp.x + dir.x * 2, cp.y + 1.0, cp.z + dir.z * 2);
        }
        const k = Math.min(1, dt * 6);
        this.camera.position.set(
            this.camera.position.x + (camPos.x - this.camera.position.x) * k,
            this.camera.position.y + (camPos.y - this.camera.position.y) * k,
            this.camera.position.z + (camPos.z - this.camera.position.z) * k,
        );
        try { this.camera.setTarget(camTarget); } catch (e) {}
    }

    /** Применить активный режим камеры скрипта (вызывается в _tick). */
    _applyCameraOverride(dt) {
        const o = this._cameraOverride;
        if (!o || !this.camera) return;
        if (o.mode === 'focus') {
            const t = o.getTarget();
            if (!t) return;
            // Камера позади-сверху цели, смотрит на неё.
            this.camera.position.set(t.x, t.y + o.height, t.z + o.distance);
            this.camera.setTarget(new Vector3(t.x, t.y, t.z));
        } else if (o.mode === 'cutscene') {
            // Клампим dt: на тяжёлых кадрах (загрузка сцены, спавн GLB)
            // dt может скакнуть до 0.5-2с — тогда катсцена «проматывается»
            // за пару кадров. Ограничиваем шаг 1/30с — катсцена идёт
            // ровно свою длительность независимо от лагов.
            o.t += Math.min(dt, 1 / 30);
            const segCount = o.points.length - 1;
            // Прогресс по текущему отрезку [0..1].
            let local = o.t / o.segDuration;
            let seg = Math.floor(o.t / o.segDuration);
            if (seg >= segCount) {
                // Катсцена завершена — встаём на последнюю точку.
                const last = o.points[o.points.length - 1];
                this.camera.position.set(last.x, last.y, last.z);
                const lookLast = o.lookAt[o.lookAt.length - 1];
                if (lookLast) this.camera.setTarget(new Vector3(lookLast.x, lookLast.y, lookLast.z));
                const cb = o.onDone;
                this._cameraOverride = null;
                if (cb) { try { cb(); } catch (e) { /* ignore */ } }
                return;
            }
            local = local - seg;   // дробная часть = прогресс отрезка
            // Сглаживание (ease-in-out) — плавный пролёт.
            const k = local < 0.5
                ? 2 * local * local
                : 1 - Math.pow(-2 * local + 2, 2) / 2;
            const a = o.points[seg], b = o.points[seg + 1];
            this.camera.position.set(
                a.x + (b.x - a.x) * k,
                a.y + (b.y - a.y) * k,
                a.z + (b.z - a.z) * k,
            );
            // Точка взгляда — интерполяция между соседними lookAt.
            const la = o.lookAt[seg], lb = o.lookAt[seg + 1] || o.lookAt[seg];
            if (la && lb) {
                this.camera.setTarget(new Vector3(
                    la.x + (lb.x - la.x) * k,
                    la.y + (lb.y - la.y) * k,
                    la.z + (lb.z - la.z) * k,
                ));
            }
        }
    }

    /**
     * Применить текущий режим камеры:
     * - В 1st person скрываем модель игрока (видим только сцену)
     * - В 3rd person и front показываем
     */
    _applyCameraMode() {
        const visible = this._cameraMode !== 'first';
        for (const m of this._modelMeshes) {
            m.setEnabled(visible);
        }
        // Сообщаем оружию что режим камеры сменился — чтобы перепарентить
        // view-model (камера в 1-st, модель игрока в 3-rd).
        if (this._scene3d?.weapons?.onCameraModeChange) {
            this._scene3d.weapons.onCameraModeChange(this._cameraMode);
        }
    }

    /**
     * УНИВЕРСАЛЬНЫЙ механизм управления частями тела модели.
     *
     * Установить override-rotation для именованного меша поверх анимации.
     * Применяется КАЖДЫЙ КАДР — анимация сначала пишет rotationQuaternion,
     * потом наш _applyMeshRotationOverrides() обнуляет quaternion и пишет
     * наши углы Эйлера.
     *
     *   meshName — имя меша как в GLB ('arm-right', 'arm-left', 'head', ...).
     *              Без префикса 'player_'.
     *   rotation — Vector3 углов Эйлера (rad). null → снять override.
     *
     * Это база для будущих кастомных поз/анимаций (стрельба, IK, жесты).
     */
    setMeshRotationOverride(meshName, rotation) {
        // R15-скин: «меш руки» — это кость RightUpperArm. WeaponSystem зовёт
        // этот метод для позы/замаха — переадресуем на override кости.
        // R15Animator каждый кадр ставит rest+анимацию; override кости
        // применяется поверх в _applyR15BoneOverrides() после update().
        if (this._isR15) {
            if (!this._r15BoneOverrides) this._r15BoneOverrides = new Map();
            // Имя меша Kenney ('arm-right'/...) маппим на логическую R15-кость.
            const lower = (meshName || '').toLowerCase();
            const logical = (lower.includes('right')) ? 'RightUpperArm'
                : (lower.includes('left')) ? 'LeftUpperArm'
                : 'RightUpperArm';
            if (rotation == null) {
                this._r15BoneOverrides.delete(logical);
            } else {
                this._r15BoneOverrides.set(logical,
                    rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z));
            }
            return;
        }
        if (!this._meshRotationOverrides) this._meshRotationOverrides = new Map();
        if (rotation == null) {
            this._meshRotationOverrides.delete(meshName);
        } else {
            this._meshRotationOverrides.set(meshName,
                rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z));
        }
    }

    /**
     * Применить override-повороты костей R15 поверх процедурной анимации.
     * Вызывается после R15Animator.update(). Используется WeaponSystem
     * для позы руки с оружием / melee-замаха.
     */
    _applyR15BoneOverrides() {
        const map = this._r15BoneOverrides;
        if (!map || map.size === 0 || !this._r15Skeleton) return;
        for (const [logical, rot] of map.entries()) {
            const bone = this._r15Skeleton.resolveBone(logical);
            if (!bone) continue;
            // Override задаётся как абсолютный локальный поворот кости
            // (Эйлер). Перекрывает то, что поставил аниматор этим кадром.
            const q = Quaternion.RotationYawPitchRoll(rot.y, rot.x, rot.z);
            bone.setRotationQuaternion(q, Space.LOCAL);
        }
    }

    /** Получить меш модели по короткому имени (без префикса 'player_'). */
    getModelMesh(meshName) {
        if (!this._modelMeshes) return null;
        const target = `player_${meshName}`;
        return this._modelMeshes.find(m => m.name === target) || null;
    }

    /**
     * Применить все активные override'ы. Вызывается каждый кадр в _tick
     * ПОСЛЕ обновления анимации (registerBeforeRender срабатывает после
     * _animate). Анимация Kenney пишет в rotationQuaternion → обнуляем его
     * каждый кадр и пишем в .rotation.
     */
    _applyMeshRotationOverrides() {
        const map = this._meshRotationOverrides;
        if (!map || map.size === 0) return;
        for (const [meshName, rot] of map.entries()) {
            const mesh = this.getModelMesh(meshName);
            if (!mesh) continue;
            if (mesh.rotationQuaternion) {
                mesh.rotationQuaternion = null;
            }
            mesh.rotation.x = rot.x;
            mesh.rotation.y = rot.y;
            mesh.rotation.z = rot.z;
        }
    }

    /**
     * Включить/выключить «позу с оружием».
     * Делает 2 вещи независимо:
     *   1. Override rotation на МЕШе правой руки (поднимает реальную руку Kenney).
     *   2. Создаёт ЧИСТЫЙ TransformNode armAnchor на плече (ориентация совпадает
     *      с _modelRoot). К нему WeaponSystem парентит бластер с rotation 0,
     *      и дуло автоматически смотрит вперёд персонажа.
     *
     * Эти два механизма НЕ ЗАВИСЯТ друг от друга — мы не пытаемся вычислять
     * ориентацию повёрнутого меша руки.
     */
    _updateExtendedArm(hasWeapon) {
        // === R15-скин: якорь оружия на кости RightHand ===
        // У R15 нет меша-руки — есть кость. Якорь привязываем к кости
        // через attachToBone: оружие следует за рукой при анимации.
        if (this._isR15 && this._r15Skeleton) {
            const showWeapon = hasWeapon && this._cameraMode !== 'first';
            if (showWeapon && !this._weaponAnchor) {
                const handBone = this._r15Skeleton.resolveBone('RightHand');
                const skinMesh = this._modelMeshes?.find((m) => m.skeleton) || this._modelMeshes?.[0];
                if (handBone && skinMesh) {
                    this._weaponAnchor = new TransformNode('weaponAnchor', this.scene);
                    // attachToBone — якорь следует за костью каждый кадр.
                    this._weaponAnchor.attachToBone(handBone, skinMesh);
                    // Небольшой сдвиг чтобы оружие легло в ладонь, не в запястье.
                    this._weaponAnchor.position.set(0, 0.05, 0.1);
                }
            }
            if (this._weaponAnchor) {
                this._weaponAnchor.setEnabled(showWeapon);
            }
            return;
        }

        const armMesh = this._rightArmMeshes?.[0];
        if (!armMesh) return;
        const meshName = (armMesh.name || '').replace(/^player_/, '');
        const showWeapon = hasWeapon && this._cameraMode !== 'first';

        // 1) Поза руки через override
        if (showWeapon) {
            this.setMeshRotationOverride(meshName, new Vector3(-Math.PI / 2, 0, 0));
        } else {
            this.setMeshRotationOverride(meshName, null);
        }

        // 2) ChIstый якорь для оружия — TransformNode на плече персонажа,
        //    ориентация совпадает с _modelRoot (без поворотов).
        if (showWeapon && !this._weaponAnchor && this._modelRoot) {
            this._weaponAnchor = new TransformNode('weaponAnchor', this.scene);
            this._weaponAnchor.parent = this._modelRoot;
            // Координаты в _modelRoot имеют ОТЗЕРКАЛЕННЫЙ X относительно меша.
            // Плечо: y = origin + 0.7 (выше).
            // X: сдвигаем чуть наружу (ещё правее).
            const sx = -(armMesh.position?.x ?? -0.4) + 0.15;
            const sy = (armMesh.position?.y ?? 1.1) + 0.7;
            const sz = (armMesh.position?.z ?? 0) + 0.95;
            this._weaponAnchor.position.set(sx, sy, sz);
        }
        if (this._weaponAnchor) {
            this._weaponAnchor.setEnabled(showWeapon);
        }
    }

    getWeaponAnchor() { return this._weaponAnchor || null; }

    /** Цикл first ↔ third. */
    _toggleCameraMode() {
        const idx = CAMERA_MODES.indexOf(this._cameraMode);
        this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length];
        this._applyCameraMode();
    }

    /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус
     * всегда лицом к камере, камера через плечо).
     */
    setShiftLock(on) {
        this._shiftLock = !!on;
        if (this._shiftLock) {
            // Запросить pointer-lock — курсор в центре
            this._requestPointerLockSafe();
        } else {
            // Снять lock если он есть и нет других причин держать (first/sideview)
            const needPermLock = (
                this._cameraMode === 'first' ||
                this._cameraMode === 'lockfirst' ||
                this._cameraMode === 'sideview'
            );
            if (!needPermLock && document.pointerLockElement === this.canvas) {
                try { document.exitPointerLock(); } catch (e) {}
            }
        }
        this._applyCursorVisibility?.();
    }
    isShiftLock() { return !!this._shiftLock; }

    /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc).
     *  Не блокирует Esc/Tab/Enter (нужны для GUI).
     *  Также сбрасывает накопленные клавиши чтобы движение остановилось. */
    setInputBlocked(blocked) {
        this._inputBlocked = !!blocked;
        if (this._inputBlocked) {
            try { this._codes?.clear(); } catch (e) {}
            this._shift = false;
            // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры»
            try {
                if (document.pointerLockElement === this.canvas) document.exitPointerLock();
            } catch (e) {}
        }
    }
    isInputBlocked() { return !!this._inputBlocked; }

    /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */
    setCameraFrozen(frozen) {
        this._cameraFrozen = !!frozen;
    }
    isCameraFrozen() { return !!this._cameraFrozen; }

    /** Задача 04: снимок состояния камеры — для восстановления после модала. */
    captureCameraState() {
        return {
            yaw: this._yaw,
            pitch: this._pitch,
            cameraMode: this._cameraMode,
            thirdDistance: this._thirdDistance,
            fov: this.scene?.activeCamera?.fov,
            playerPos: this._pos ? {
                x: this._pos.x, y: this._pos.y, z: this._pos.z
            } : null,
        };
    }

    /** Задача 04: восстановить состояние камеры из снимка. */
    restoreCameraState(s) {
        if (!s) return;
        if (Number.isFinite(s.yaw)) this._yaw = s.yaw;
        if (Number.isFinite(s.pitch)) this._pitch = s.pitch;
        if (s.cameraMode) {
            this._cameraMode = s.cameraMode;
            try { this._applyCameraMode?.(); } catch (e) {}
        }
        if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance;
        if (Number.isFinite(s.fov) && this.scene?.activeCamera) {
            this.scene.activeCamera.fov = s.fov;
        }
    }

    /** Задача 04: камера-фокус на reference (cube/npc/cam-target).
     *  ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}.
     *  Использует уже существующий механизм camera.focus в GameRuntime, но
     *  здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель,
     *  и зум на distance. */
    focusOnTarget(ref, opts) {
        opts = opts || {};
        const distance = Number.isFinite(opts.distance) ? opts.distance : 8;
        const height = Number.isFinite(opts.height) ? opts.height : 3;
        const fov = Number.isFinite(opts.fov) ? opts.fov : null;
        let target = null;
        if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) {
            target = ref;
        } else {
            const m = this._resolveTargetMesh(ref);
            if (m) {
                const p = m.getAbsolutePosition?.() || m.position;
                target = { x: p.x, y: p.y, z: p.z };
            }
        }
        if (!target) return;
        // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target
        // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch.
        if (!this._pos) return;
        const dx = target.x - this._pos.x;
        const dz = target.z - this._pos.z;
        const dy = target.y - this._pos.y;
        const horiz = Math.hypot(dx, dz);
        this._yaw = Math.atan2(dx, dz);
        this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz)));
        this._thirdDistance = distance;
        if (this._cameraMode !== 'third') {
            this._cameraMode = 'third';
            try { this._applyCameraMode?.(); } catch (e) {}
        }
        if (fov && this.scene?.activeCamera) {
            this.scene.activeCamera.fov = fov * Math.PI / 180;
        }
    }

    _resolveTargetMesh(ref) {
        if (!ref) return null;
        if (ref.getScene && typeof ref.getScene === 'function') return ref;
        const sc = this._scene3d || this.scene3d;
        const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
        if (!idStr || !sc) return null;
        const tries = [
            () => sc.primitiveManager?.getMesh?.(idStr),
            () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0],
            () => sc.scene?.getMeshByName?.(idStr),
            () => sc.npcManager?.getMeshes?.(idStr)?.[0],
        ];
        for (const fn of tries) {
            try { const r = fn(); if (r) return r; } catch (e) {}
        }
        return null;
    }

    /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */
    setCameraZoom(distance) {
        const d = Number(distance);
        if (!Number.isFinite(d)) return;
        this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
            Math.min(this.THIRD_DISTANCE_MAX, d));
        // Авто-переход third↔first если пересекли порог
        if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD
                && this._cameraMode === 'third') {
            this._cameraMode = 'first';
            this._applyCameraMode?.();
            this._requestPointerLockSafe();
        } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD
                && this._cameraMode === 'first' && !this._lockFirstPerson) {
            this._cameraMode = 'third';
            this._applyCameraMode?.();
            if (!this._shiftLock && document.pointerLockElement === this.canvas) {
                try { document.exitPointerLock(); } catch (e) {}
            }
        }
    }
    /** Установить границы зума колеса. */
    setCameraZoomLimits(min, max) {
        const mn = Number(min), mx = Number(max);
        if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn;
        if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx;
        // Перекламп текущей дистанции
        this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
            Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance));
    }

    _setupInput() {
        const canvas = this.canvas;

        // Задача 02 (как в студии): хелпер — режим с ПОСТОЯННЫМ pointer-lock.
        const needPermLock = () => (
            this._cameraMode === 'first' ||
            this._cameraMode === 'lockfirst' ||
            this._cameraMode === 'sideview' ||
            this._shiftLock
        );

        const onCanvasClick = () => {
            // В UI-режиме клик не перехватывает мышь.
            if (this._uiCursorMode) return;
            if (!this._active) return;
            // Roblox-style: в third-person ЛКМ-клик НЕ лочит курсор (он остаётся
            // свободным для GUI/3D-onClick). Lock запрашиваем ТОЛЬКО для режимов
            // где курсор постоянно скрыт, и только если lock был снят.
            if (!needPermLock()) return;
            if (document.pointerLockElement !== canvas) {
                try {
                    const p = canvas.requestPointerLock?.();
                    if (p && typeof p.catch === 'function') p.catch(() => {});
                } catch (e) { /* ignore */ }
            }
        };
        canvas.addEventListener('click', onCanvasClick);

        // === ПКМ: в third-person удержание ПКМ запускает orbit-камеру ===
        // Зажал ПКМ → курсор скрыт, мышь крутит камеру. Отпустил → курсор вернулся.
        const onCanvasMouseDownGlobal = (e) => {
            if (!this._active || this._uiCursorMode) return;
            if (e.button !== 2) return; // только ПКМ
            if (needPermLock()) return; // в perma-режимах ПКМ ничего не делает
            this._rmbHeld = true;
            if (document.pointerLockElement !== canvas) {
                try {
                    const p = canvas.requestPointerLock?.();
                    if (p && typeof p.catch === 'function') p.catch(() => {});
                } catch (err) { /* ignore */ }
            }
            e.preventDefault();
        };
        const onWindowMouseUpGlobal = (e) => {
            if (e.button !== 2) return;
            if (!this._rmbHeld) return;
            this._rmbHeld = false;
            if (needPermLock()) return;
            if (document.pointerLockElement === canvas) {
                try { document.exitPointerLock(); } catch (err) { /* ignore */ }
            }
        };
        canvas.addEventListener('mousedown', onCanvasMouseDownGlobal);
        window.addEventListener('mouseup', onWindowMouseUpGlobal);
        canvas.addEventListener('contextmenu', (e) => { if (this._active) e.preventDefault(); });

        // === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
        const onCanvasMouseDown = (e) => {
            if (!this._uiCursorMode) return;
            if (typeof this._uiMouseDownCb !== 'function') return;
            const rect = canvas.getBoundingClientRect();
            const x = (e.clientX - rect.left) / rect.width;
            const y = (e.clientY - rect.top) / rect.height;
            if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
                try { this._uiMouseDownCb(x, y); } catch (err) { /* ignore */ }
            }
        };
        const onCanvasMouseUp = (e) => {
            if (!this._uiCursorMode) return;
            if (typeof this._uiMouseUpCb !== 'function') return;
            const rect = canvas.getBoundingClientRect();
            const x = (e.clientX - rect.left) / rect.width;
            const y = (e.clientY - rect.top) / rect.height;
            try { this._uiMouseUpCb(x, y); } catch (err) { /* ignore */ }
        };
        canvas.addEventListener('mousedown', onCanvasMouseDown);
        // mouseup ловим на document — мышь могла уйти за пределы канваса
        document.addEventListener('mouseup', onCanvasMouseUp);

        const onMouseMove = (e) => {
            // === UI-режим: транслируем нормализованные [0..1] координаты ===
            // подписчику (Worker через GameRuntime). Используется для drag-игр
            // типа Дальгона.
            if (this._uiCursorMode && typeof this._uiMouseMoveCb === 'function') {
                const rect = canvas.getBoundingClientRect();
                const x = (e.clientX - rect.left) / rect.width;
                const y = (e.clientY - rect.top) / rect.height;
                // Кидаем только если внутри канваса
                if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
                    try { this._uiMouseMoveCb(x, y); } catch (err) { /* ignore */ }
                }
            }
            if (document.pointerLockElement !== canvas) return;
            // Кубикон Dash: в sideview мышь не вращает камеру.
            if (this._cameraMode === 'sideview') return;
            // Задача 04: модал с freezeCamera — мышь не вращает.
            if (this._cameraFrozen) return;
            this._yaw   += e.movementX * this.MOUSE_SENSITIVITY;
            // _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y.
            const pitchSign = this._invertCamera ? -1 : 1;
            this._pitch += e.movementY * this.MOUSE_SENSITIVITY * pitchSign;
            const lim = Math.PI / 2 - 0.05;
            if (this._pitch > lim) this._pitch = lim;
            if (this._pitch < -lim) this._pitch = -lim;
        };
        document.addEventListener('mousemove', onMouseMove);

        // Задача 02: колесо = зум third-камеры с авто-переходом third↔first.
        const onWheel = (e) => {
            if (!this._active) return;
            if (this._cameraFrozen) { e.preventDefault(); return; }      // модал
            if (this._cameraMode === 'sideview') { e.preventDefault(); return; } // GD
            // В first зум наружу возвращает в third (если не lockfirst).
            if (this._cameraMode === 'first') {
                if (e.deltaY > 0 && !this._lockFirstPerson) {
                    this._cameraMode = 'third';
                    this._thirdDistance = (this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7) + 0.5;
                    if (!this._isPermaLockMode() && document.pointerLockElement === canvas) {
                        try { document.exitPointerLock(); } catch (err) { /* ignore */ }
                    }
                    this._applyCursorVisibility();
                    this._applyCameraMode?.();
                }
                e.preventDefault();
                return;
            }
            if (this._cameraMode !== 'third') { e.preventDefault(); return; }
            // Экспоненциальный шаг (плавнее вблизи).
            this._thirdDistance += Math.sign(e.deltaY) * Math.max(0.3, this._thirdDistance * 0.15);
            if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
            if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
            // Зум внутрь до порога → авто-переход в first (Roblox-style).
            const THRESH = this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7;
            if (this._thirdDistance <= THRESH) {
                this._cameraMode = 'first';
                this._requestPointerLockSafe();
                this._applyCursorVisibility();
                this._applyCameraMode?.();
            }
            e.preventDefault();
        };
        canvas.addEventListener('wheel', onWheel, { passive: false });

        let wasLocked = false;
        const onPointerLockChange = () => {
            const locked = document.pointerLockElement === canvas;
            this._applyCursorVisibility?.();   // задача 02: вернуть/скрыть курсор
            if (locked) {
                wasLocked = true;
                this._rmbHeld = true; // если попали в lock — ПКМ удерживается
            } else if (wasLocked && this._active) {
                // pointer-lock снят. Причин три:
                //  1) пользователь в UI-режиме (game.input.setCursorMode('ui'))
                //  2) ПКМ отпущена в third-person (orbit-камера завершена)
                //  3) Esc → выход из Play (если был в first/lockfirst/sideview/shift)
                wasLocked = false;
                this._rmbHeld = false;
                if (this._uiCursorMode) { this._applyCursorVisibility?.(); return; }
                if (needPermLock()) {
                    // Был режим с постоянным lock'ом и его сняли (Esc) → выход.
                    if (this._onExitRequest) this._onExitRequest();
                } else {
                    // Third-person: просто отпустили ПКМ. Остаёмся в Play,
                    // курсор вернулся — это НЕ повод открывать меню.
                    this._applyCursorVisibility?.();
                }
            }
        };
        document.addEventListener('pointerlockchange', onPointerLockChange);

        const isTypingTarget = (target) => {
            if (!target) return false;
            const tag = (target.tagName || '').toLowerCase();
            if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
            return !!target.isContentEditable;
        };
        const onKeyDown = (e) => {
            if (!this._active) return;
            if (isTypingTarget(e.target)) return;
            // Меню в игре (Roblox-style — Участники / Настройки / etc).
            //
            // ПРАВИЛО (2026-06-14):
            //   • НЕ в fullscreen — Esc открывает меню (классика)
            //   • В fullscreen     — Esc отдаётся БРАУЗЕРУ (выход из FS, hardcoded),
            //                        а меню открывается на Tab (как в CS/BF)
            //
            // Это компромисс: в fullscreen нельзя перехватить Esc — браузер
            // принудительно выкидывает в обычный режим. Поэтому добавили
            // вторую клавишу. Tab безопасен (не блокирует UI-фокус
            // потому что мы делаем preventDefault).
            const isFs = !!(typeof document !== 'undefined' && document.fullscreenElement);
            if (e.code === 'Escape' && !isFs) {
                if (this._onExitRequest) {
                    this._onExitRequest();
                    return;
                }
            }
            if (e.code === 'Tab' && isFs) {
                e.preventDefault();
                if (this._onExitRequest) {
                    this._onExitRequest();
                    return;
                }
            }
            // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.),
            // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик),
            // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах).
            if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') {
                // Глотаем preventDefault только для игровых клавиш
                if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
                return;
            }
            this._codes.add(e.code);
            // Задача 14: в машине — V камера, E выход.
            if (this._inVehicle) {
                if (e.code === 'KeyV') { this.cycleVehicleCamera(); }
                else if (e.code === 'KeyE') {
                    const veh = this._inVehicle;
                    this.exitVehicle();
                    if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (err) {} }
                }
            }
            if (e.shiftKey) this._shift = true;
            // C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
            // и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
            if (e.code === 'KeyC') {
                const inGdMode = (this._autoRunSpeed || 0) > 0
                    || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
                if (!inGdMode) this._toggleCameraMode();
            }
            // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег,
            // поэтому переназначено на L). Курсор центрируется, корпус всегда
            // лицом к камере, камера через плечо.
            if (e.code === 'KeyL') {
                this.setShiftLock(!this._shiftLock);
            }
            // B — встроенный магазин скинов (задача 07). Открывается только если
            // включён в проекте (scene.skins.shopVisible). Toggle.
            if (e.code === 'KeyB' && !this._inputBlocked) {
                try { this._scene3d?.toggleSkinShop?.(); } catch (err) {}
            }
            // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
            if (e.code === 'Tab') {
                e.preventDefault();
                this.setUiCursorMode(!this._uiCursorMode);
            }
            if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
                e.preventDefault();
            }
            // В GD-режиме блокируем Alt (открывает меню браузера + ломает фокус),
            // Ctrl (приседание), C (смена камеры). Чтобы не было неожиданных побочек.
            const inGdMode = (this._autoRunSpeed || 0) > 0
                || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
            if (inGdMode && ['AltLeft','AltRight','ControlLeft','ControlRight','KeyC'].includes(e.code)) {
                e.preventDefault();
            }
        };
        const onKeyUp = (e) => {
            if (isTypingTarget(e.target)) return;
            this._codes.delete(e.code);
            if (!e.shiftKey) this._shift = false;
        };
        window.addEventListener('keydown', onKeyDown);
        window.addEventListener('keyup', onKeyUp);

        const onBlur = () => {
            this._codes.clear();
            this._shift = false;
        };
        window.addEventListener('blur', onBlur);

        this._listeners = [
            { target: canvas,   type: 'click',              fn: onCanvasClick },
            { target: canvas,   type: 'wheel',              fn: onWheel,
              opts: { passive: false } },
            { target: document, type: 'mousemove',          fn: onMouseMove },
            { target: document, type: 'pointerlockchange',  fn: onPointerLockChange },
            { target: window,   type: 'keydown',            fn: onKeyDown },
            { target: window,   type: 'keyup',              fn: onKeyUp },
            { target: window,   type: 'blur',               fn: onBlur },
        ];
    }

    _tick() {
        // dt cap: на лагающем редакторе кадр может быть 100-300мс. Старая
        // логика 'if (dt > 0.1) return' пропускала физику целиком → персонаж
        // не двигался, прыжок «застревал» в воздухе. Теперь зажимаем в 0.1
        // (max 10 кадров/сек физики). Этого хватает чтобы движение было
        // плавным даже на 5 FPS — просто чуть рывками.
        let dt = this.scene.getEngine().getDeltaTime() / 1000;
        if (dt <= 0) return;
        if (dt > 0.1) dt = 0.1;

        // === Задача 14: режим вождения — инпут идёт в машину, не в ходьбу ===
        if (this._inVehicle) {
            try { this._tickVehicle(dt); } catch (e) { /* ignore */ }
            return;
        }

        // === Присед: по Ctrl на десктопе, или через мобильную кнопку
        // (которая шлёт keydown 'ControlLeft'). C — НЕ используется
        // (это смена вида в Babylon).
        // В GD-режиме (auto-run > 0) приседание отключено — оно ломает физику
        // (уменьшается HALF_H, игрок проваливается под коллизию и может пробить потолок в Ship).
        const inGdMode = (this._autoRunSpeed || 0) > 0
            || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
        const wantCrouch = !inGdMode && this._codes
            && (this._codes.has('ControlLeft') || this._codes.has('ControlRight'));
        if (wantCrouch && !this._crouching) {
            this._crouching = true;
            // сдвигаем центр капсулы вниз — низ ног остаётся на земле
            const dH = this.HALF_H_CROUCH - this.HALF_H;
            this.HALF_H = this.HALF_H_CROUCH;
            if (this._pos) this._pos.y += dH;
            // Помечаем: при следующем _tick mixamo-ветка проиграет
            // one-shot crouch_enter (движение присеста) ПЕРЕД зацикленным
            // crouch_idle. Без этого визуально нет "присеста" — персонаж
            // мгновенно оказывается в позе.
            this._crouchEnterPending = true;
            this._crouchTransitionUntil = Date.now() + 600; // длительность анимации Standing→Crouch ~0.6s
        } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
            this._crouching = false;
            const dH = this.HALF_H_NORMAL - this.HALF_H;
            this.HALF_H = this.HALF_H_NORMAL;
            if (this._pos) this._pos.y += dH;
            // Анимация выхода — crouch_to_stand
            this._crouchExitPending = true;
            this._crouchTransitionUntil = Date.now() + 600;
        }

        // === Горизонтальное движение ===
        const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
        const right   = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
        // Crouch имеет ПРИОРИТЕТ над sprint: если Ctrl зажат — Shift игнорится.
        // Скорость в crouch = 0.45 от walk (медленный шаг на корточках).
        const isSprinting = this._shift && !this._crouching;
        const crouchMult = this._crouching ? 0.45 : 1;
        const speedMult = (isSprinting ? this.SPRINT_MULT : 1) * crouchMult;
        const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;

        let moveX = 0, moveZ = 0;
        // c (codes) используется ниже для прыжка/Space — объявляем здесь,
        // чтобы был доступен после if/else движения.
        const c = this._codes;
        const am = this._analogMove;
        // === Кубикон Dash: авто-движение по +X, ввод заблокирован ===
        // game.player.autoRun(speed) выставляет _autoRunSpeed > 0. В sideview-режиме
        // игрок САМ движется по +X со скоростью speed (м/с), WASD/тач игнорируются.
        if (this._cameraMode === 'sideview' && this._autoRunSpeed > 0) {
            moveX = this._autoRunSpeed * dt;
            moveZ = 0;
        } else
        // === Раннер от 3-го лица: авто-бег ВПЕРЁД по +Z ===
        // В режимах third/first/front при _autoRunSpeed > 0 игрок сам бежит
        // строго по мировому +Z (subway-runner). Направление ФИКСИРОВАНО —
        // вращение камеры мышью НЕ меняет курс бега (иначе персонаж бежал
        // бы туда, куда смотрит игрок). WASD/тач НЕ двигают: смену полос
        // делает скрипт через player.teleport по X.
        if (this._cameraMode !== 'sideview' && this._autoRunSpeed > 0) {
            moveX = 0;
            moveZ = this._autoRunSpeed * dt;
        } else
        // Аналоговый ввод (тач-джойстик) имеет приоритет над клавишами:
        // позволяет двигаться в любом направлении плавно, не только в 8 секторов.
        if (am && (Math.abs(am.x) > 0.01 || Math.abs(am.y) > 0.01)) {
            // Магнитуда [0..1] — скорость (зажатый джойстик в край = sprint*).
            // *На практике мы передаём sprint через setVirtualShift.
            const mag = Math.min(1, Math.hypot(am.x, am.y));
            // Нормализуем направление, скорость = mag * speed
            const dirX = am.x / Math.max(0.0001, Math.hypot(am.x, am.y));
            const dirY = am.y / Math.max(0.0001, Math.hypot(am.x, am.y));
            const v = mag * speed;
            // y=1 → вперёд (forward), x=1 → вправо (right)
            moveX = forward.x * dirY * v + right.x * dirX * v;
            moveZ = forward.z * dirY * v + right.z * dirX * v;
        } else {
            if (c.has('KeyW') || c.has('ArrowUp'))    { moveX += forward.x * speed; moveZ += forward.z * speed; }
            if (c.has('KeyS') || c.has('ArrowDown'))  { moveX -= forward.x * speed; moveZ -= forward.z * speed; }
            if (c.has('KeyD') || c.has('ArrowRight')) { moveX += right.x   * speed; moveZ += right.z   * speed; }
            if (c.has('KeyA') || c.has('ArrowLeft'))  { moveX -= right.x   * speed; moveZ -= right.z   * speed; }
        }

        const isMoving = (moveX !== 0 || moveZ !== 0);

        // === ЛЁД: инерция движения ===
        // Если _iceFriction > 0 — игрок «скользит». Хранимая скорость
        // _iceVelX/_iceVelZ обновляется к target (текущий ввод за этот кадр)
        // с коэффициентом ускорения, и затухает с (1 - friction*dt*8).
        if (this._iceFriction > 0.001) {
            const fric = Math.min(1, this._iceFriction);
            // Чем выше friction, тем медленнее набираем скорость и тем дольше
            // затухает после отпускания клавиш.
            const accel = (1 - fric) * 0.4 + 0.05;   // 0.05..0.45
            const decay = 1 - (1 - fric) * 6 * dt;   // ~0.7..1
            // Целевая скорость = moveX/moveZ (уже содержит dt)
            // Прибавляем разницу с ускорением
            this._iceVelX += (moveX - this._iceVelX) * accel;
            this._iceVelZ += (moveZ - this._iceVelZ) * accel;
            if (!isMoving) {
                this._iceVelX *= decay;
                this._iceVelZ *= decay;
                if (Math.abs(this._iceVelX) < 0.0001) this._iceVelX = 0;
                if (Math.abs(this._iceVelZ) < 0.0001) this._iceVelZ = 0;
            }
            moveX = this._iceVelX;
            moveZ = this._iceVelZ;
        } else {
            // Сбрасываем накопленную скорость когда лёд выкл
            this._iceVelX = 0;
            this._iceVelZ = 0;
        }

        // === Плавание (если AABB пересекает блок-воду) ===
        const inWater = this._isInWater();
        const submerged = this._isSubmerged();
        if (inWater) {
            // В воде движемся в 2 раза медленнее
            moveX *= 0.5;
            moveZ *= 0.5;
        }

        // === Лестница (ladder_vertical) ===
        // Детект касания лестницы. В воде/машине/GD-режиме лестница отключена.
        let ladder = null;
        if (!inWater && !inGdMode && this.physics?.getOverlappingLadder) {
            ladder = this.physics.getOverlappingLadder(
                this._pos.x, this._pos.y, this._pos.z,
                this.HALF_W, this.HALF_H, this.HALF_D
            );
        }
        // Предзагрузка climb-анимаций при касании лестницы (ДО лазания),
        // чтобы при входе в ladder-mode climb_up уже был в кэше. Без этого
        // первый кадр играет walk с climb-поворотом → персонаж «дёргается»
        // на 180° пока climb_up асинхронно подгружается.
        if (ladder && this._mixamoAnimator && !this._climbPreloaded) {
            this._climbPreloaded = true;
            try {
                this._mixamoAnimator.preload('climb_up');
                this._mixamoAnimator.preload('climb_down');
                this._mixamoAnimator.preload('climb_to_top');
            } catch (e) {}
        }
        const wantUp   = c.has('KeyW') || c.has('ArrowUp');
        const wantDown = c.has('KeyS') || c.has('ArrowDown');
        // Фаза climb_to_top — вылезание на площадку (4с). Блокирует всё:
        // управление, физику, обычный ladder-mode. Игрок плавно перемещается
        // из _climbTopStart в _climbTopEnd (lerp), анимация climb_to_top играет.
        if (this._climbingTop) {
            const total = 4000;
            const left = this._climbingTopUntil - Date.now();
            const t = Math.max(0, Math.min(1, 1 - left / total));
            const a = this._climbTopStart, b = this._climbTopEnd;
            if (a && b) {
                this._pos.x = a.x + (b.x - a.x) * t;
                this._pos.y = a.y + (b.y - a.y) * t;
                this._pos.z = a.z + (b.z - a.z) * t;
            }
            this._vy = 0;
            if (left <= 0) {
                // Завершили вылезание — выходим в обычный режим.
                this._climbingTop = false;
                this._ladderMode = false;
                this._ladderData = null;
                this._climbTopStart = null;
                this._climbTopEnd = null;
            }
            // Пропускаем остальную ladder/движение логику в этом кадре.
            // Но позволяем анимационной ветке проиграть climb_to_top.
        }

        // Вход в ladder-mode: касаемся лестницы И жмём вверх/вниз.
        if (!this._climbingTop && ladder && !this._ladderMode && (wantUp || wantDown)) {
            this._ladderMode = true;
            this._ladderData = ladder;
            this._vy = 0;
            // Прижать игрока к плоскости лестницы и повернуть лицом к ней.
            // Лестница плоская: её фронт — вдоль локальной оси -Z, повёрнутой
            // на rotationY. Нормаль фронта = (sin(rY), 0, cos(rY)).
            const rY = (ladder.rotationY || 0) * Math.PI / 180;
            const nx = Math.sin(rY);
            const nz = Math.cos(rY);
            // Игрок стоит ПЕРЕД лестницей: позиция = центр лестницы по XZ
            // + нормаль * (полглубины лестницы + полширины игрока).
            const standOff = (ladder.sz || 0.25) / 2 + this.HALF_D + 0.05;
            this._pos.x = ladder.x + nx * standOff;
            this._pos.z = ladder.z + nz * standOff;
            // Повернуть лицом К лестнице (смотрит против нормали).
            // climb_up-клип сам разворачивает Hips на 180°, поэтому модель
            // доворачиваем на +π, чтобы персонаж смотрел на перекладины.
            const faceYaw = Math.atan2(-nx, -nz);
            this._yaw = faceYaw;                 // камера смотрит на лестницу
            this._modelYaw = faceYaw + Math.PI;  // +180° компенсация анимации
            this._ladderMoving = null;     // сброс — climb-анимация стартует заново
        }
        // Пока в ladder-mode: обновляем ссылку на лестницу если ещё касаемся.
        // (НЕ во время climb_to_top — там своя логика перемещения.)
        if (this._ladderMode && !this._climbingTop) {
            if (ladder) this._ladderData = ladder;
            const ld = this._ladderData;
            // Верх лестницы (мировая координата). Поднялись выше — выходим наверх.
            const ladderTop = ld ? (ld.y + (ld.sy || 0) / 2) : Infinity;
            // Гистерезис выхода: НЕ выходим по мгновенному !ladder (детект
            // нестабилен на грани AABB → мигание climb↔walk каждый кадр).
            // Выходим только если игрок РЕАЛЬНО отошёл по XZ от сохранённой
            // лестницы (> половины ширины + запас).
            let farFromLadder = false;
            if (ld) {
                const dx = this._pos.x - ld.x;
                const dz = this._pos.z - ld.z;
                const distXZ = Math.hypot(dx, dz);
                const exitDist = Math.max(ld.sx || 1, ld.sz || 0.25) / 2 + this.HALF_D + 0.6;
                farFromLadder = distXZ > exitDist;
            } else {
                farFromLadder = true;
            }
            // Space → отпрыг назад + выход.
            if (c.has('Space')) {
                this._ladderMode = false;
                this._ladderData = null;
                this._vy = 5;
                this._jumpHeld = true;
            } else if (farFromLadder) {
                // Реально отошли от лестницы — выходим (гравитация включится).
                this._ladderMode = false;
                this._ladderData = null;
            } else {
                // Лазание: гравитация отключена, A/D заблокированы.
                // Вертикальное движение задаём через _vy (climb-скорость),
                // чтобы moveAABB обработал коллизию корректно. Прямое
                // _pos.y += не годилось: персонаж стоит на земле, и moveAABB
                // снапил его обратно (онГраунд держал внизу).
                moveX = 0;
                moveZ = 0;
                if (wantUp)        this._vy = this.CLIMB_SPEED;
                else if (wantDown) this._vy = -this.CLIMB_SPEED;
                else               this._vy = 0;
                // Достигли верха лестницы И лезем вверх → запускаем переход
                // climb_to_top (вылезание на площадку, 4с one-shot). Управление
                // блокируется, физика замораживается, в конце игрок ставится
                // на площадку над лестницей.
                if (this._pos.y + this.HALF_H > ladderTop - 0.3 && wantUp
                    && !this._climbingTop) {
                    this._climbingTop = true;
                    this._climbingTopUntil = Date.now() + 4000;
                    this._vy = 0;
                    // Куда вылезти: вперёд (по нормали от лестницы, внутрь
                    // площадки) + на верх лестницы.
                    const ldd = this._ladderData;
                    const rY = (ldd?.rotationY || 0) * Math.PI / 180;
                    // Нормаль фронта (откуда лез) — игрок перед лестницей.
                    // Площадка — за лестницей (противоположная сторона).
                    const fnx = Math.sin(rY), fnz = Math.cos(rY);
                    const fwd = (ldd?.sz || 0.25) / 2 + this.HALF_D + 0.4;
                    this._climbTopStart = { x: this._pos.x, y: this._pos.y, z: this._pos.z };
                    this._climbTopEnd = {
                        x: ldd.x - fnx * fwd,           // на другую сторону лестницы
                        y: ladderTop + this.HALF_H,     // на верх
                        z: ldd.z - fnz * fwd,
                    };
                }
            }
        }

        // === Вертикальное ===
        if (this._ladderMode) {
            // На лестнице гравитация НЕ применяется — _vy уже выставлен
            // (=CLIMB_SPEED вверх / -CLIMB_SPEED вниз / 0 на месте) выше,
            // moveAABB применит его с коллизией.
        } else if (inWater) {
            // Плавание: лёгкая гравитация + плавучесть к поверхности
            const buoyancy = submerged ? 6 : 0;
            const swimGravity = -3;
            this._vy += (buoyancy + swimGravity) * dt;
            this._vy *= Math.max(0, 1 - 3 * dt);
            if (this._codes.has('Space')) this._vy += 14 * dt;
            if (this._vy > 4) this._vy = 4;
            if (this._vy < -4) this._vy = -4;
        } else {
            // Кубикон Dash: умеренная усиленная гравитация для коротких
            // "поппи" прыжков как в GD. ×1.35 даёт время полёта ~0.55с
            // и высоту ~2.6м при jumpPower=1.5 — хватает на шип scaleY=1.2,
            // не слишком улетает по X (~4-5м за прыжок).
            // Variable-jump (отпускание Space обрезает vy) НЕ используется —
            // в авто-беге игрок не контролирует длительность нажатия.
            //
            // gravityDir: при перевёрнутой гравитации (-1) сила тянет ВВЕРХ
            // к потолку. Кап-скорости тоже инвертируется.
            const dashGravityMul = (this._cameraMode === 'sideview') ? 1.35 : 1.0;
            const userGravityMul = this._gravityMul || 1;
            const gDir = this._gravityDir || 1;

            if (this._waveMode) {
                // WAVE-режим: жёстко ±45°. vy = ±autoRunSpeed (тангенс 45° = 1 → |vy| = |vx|).
                // Гравитация полностью игнорируется — линейное движение по диагонали.
                const speed = Math.max(1, this._autoRunSpeed || 8);
                this._vy = this._jumpHeld ? speed : -speed;
            } else {
                this._vy += this.GRAVITY * gDir * dashGravityMul * userGravityMul * dt;

                // SHIP-режим: при удержании Space даём импульс ВВЕРХ
                // (вертолёт-стиль из GD). Гравитация продолжает тянуть, баланс
                // делает плавный полёт.
                if (this._shipMode && this._jumpHeld) {
                    // SHIP_THRUST подобран так чтобы при удержании корабль медленно поднимался,
                    // а при отпускании — медленно падал. Зависит от GRAVITY (модуль ~22*1.35*1.227 ≈ 36)
                    const SHIP_THRUST = 80;  // м/с² против гравитации
                    this._vy += SHIP_THRUST * gDir * dt;
                }
                // ROBOT-режим: пока активна boost-фаза (после прыжка) и Space зажат — компенсируем
                // почти всю гравитацию, продлевая подъём. Отпустил Space → boost кончается.
                if (this._robotMode && this._robotBoostLeft > 0) {
                    if (c.has('Space')) {
                        // Компенсация 92% гравитации — игрок продолжает лететь вверх почти линейно.
                        // Это даёт ~3.5-4м высоты при полном удержании 0.45с (хватает на 3-блочную стену).
                        this._vy += -this.GRAVITY * gDir * dashGravityMul * userGravityMul * 0.92 * dt;
                        this._robotBoostLeft = Math.max(0, this._robotBoostLeft - dt);
                    } else {
                        this._robotBoostLeft = 0;
                    }
                }

                // Cap: ±50 в любом направлении (для Ship — мягче, ±25)
                const vyCap = this._shipMode ? 25 : 50;
                if (this._vy < -vyCap) this._vy = -vyCap;
                if (this._vy > vyCap)  this._vy = vyCap;
            }
        }

        const beforeX = this._pos.x, beforeZ = this._pos.z;

        // === STICK к движущейся платформе ===
        // Если в прошлом кадре стояли на примитиве/модели, и она двигается —
        // двигаем игрока вместе с ней (по дельте позиции платформы за кадр).
        if (this._lastGroundData && this._lastGroundPos) {
            const gd = this._lastGroundData;
            // Текущая позиция платформы — берём из live-data (она обновляется
            // movingPlatforms-скриптом через scene.move).
            const curX = gd.x, curY = gd.y, curZ = gd.z;
            const dPlatX = curX - this._lastGroundPos.x;
            const dPlatY = curY - this._lastGroundPos.y;
            const dPlatZ = curZ - this._lastGroundPos.z;
            // Применяем дельту только если она разумная (защита от телепорта
            // или dispose'а платформы, когда позиция вдруг становится -50 и т.п.)
            if (Math.abs(dPlatX) < 5 && Math.abs(dPlatY) < 5 && Math.abs(dPlatZ) < 5) {
                this._pos.x += dPlatX;
                this._pos.y += dPlatY;
                this._pos.z += dPlatZ;
            }
        }

        // PERF-METRICS: замер физики игрока
        const _pt0 = performance.now();
        // Во время climb_to_top физику пропускаем — _pos двигается lerp'ом
        // вручную (вылезание на площадку), коллизия не нужна.
        const result = this._climbingTop
            ? { x: this._pos.x, y: this._pos.y, z: this._pos.z,
                onGround: false, hitY: false, surfaceFollowed: false }
            : this.physics.moveAABB(
                this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
                moveX, this._vy * dt, moveZ
            );
        const _bs = this._scene3d || this.scene3d;
        if (_bs && _bs._perfMetrics) {
            _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0;
            _bs._perfMetrics.physics_count++;
        }
        this._pos.set(result.x, result.y, result.z);
        if (result.hitY) this._vy = 0;
        // Surface-follow на smooth-terrain прижал нас к склону — гравитация
        // больше не нужна на этом кадре, иначе будет вибрация от соревнования
        // (гравитация тянет вниз → следующий кадр surface поднимает обратно).
        if (result.surfaceFollowed) this._vy = 0;

        // Auto-step: накапливаем «как будто игрок ещё внизу» оффсет, чтобы
        // визуально плавно подняться. Физика уже сделала телепорт вверх,
        // а рендер на несколько кадров отстаёт. Спадание оффсета — ниже
        // в кадровой логике (раз за tick).
        if (result.steppedUpBy && result.steppedUpBy > 0) {
            this._stepUpVisualOffset += result.steppedUpBy;
            // Кэп — не более 1.2м, иначе при множественных степах подряд
            // оффсет может стать гигантским и аватар «уйдёт под землю».
            if (this._stepUpVisualOffset > 1.2) this._stepUpVisualOffset = 1.2;
        }
        // Спадание оффсета. dt — реальное время тика (секунды).
        if (this._stepUpVisualOffset > 0) {
            this._stepUpVisualOffset -= this._stepUpDecay * dt;
            if (this._stepUpVisualOffset < 0) this._stepUpVisualOffset = 0;
        }

        // Запоминаем «на чём стоим» для следующего кадра
        if (result.onGround && result.groundData?.data) {
            this._lastGroundData = result.groundData.data;
            this._lastGroundPos = {
                x: result.groundData.data.x,
                y: result.groundData.data.y,
                z: result.groundData.data.z,
            };
        } else {
            this._lastGroundData = null;
            this._lastGroundPos = null;
        }

        // === Авто-вылезание на берег из воды ===
        // Если игрок в воде, упёрся в стенку (hitX/hitZ) и пытается двигаться —
        // даём boost вверх чтобы перешагнуть на 1 блок. Имитирует «карабкание».
        if (inWater && isMoving && (result.hitX || result.hitZ)) {
            this._vy = Math.max(this._vy, 5);
        }

        // Респавн если игрок упал в пустоту (за пределы baseplate)
        if (this._pos.y < -30) {
            const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
            this._pos.set(sp.x, sp.y + this.HALF_H + 0.1, sp.z);
            this._vy = 0;
        }

        // === Push unanchored объектов при пересечении ===
        // Скорость игрока в этом кадре (для направления толчка).
        // Если игрок упёрся в объект — реальное dx/dz после физики ≈ 0,
        // поэтому также передаём «желаемое» движение (moveX/moveZ от WASD)
        // и forward камеры — DynamicsManager выберет лучшее направление.
        const playerVxReal = (this._pos.x - beforeX) / Math.max(0.0001, dt);
        const playerVzReal = (this._pos.z - beforeZ) / Math.max(0.0001, dt);
        const desiredSpeed = Math.sqrt(moveX * moveX + moveZ * moveZ);
        const realSpeed = Math.sqrt(playerVxReal * playerVxReal + playerVzReal * playerVzReal);
        // Берём "желаемое" движение если оно больше реального (игрок упёрся,
        // но WASD нажаты — значит "пытается толкать").
        const useDesired = desiredSpeed > realSpeed + 0.5;
        const pushVx = useDesired ? moveX / dt : playerVxReal;
        const pushVz = useDesired ? moveZ / dt : playerVzReal;
        if (this._scene3d?.dynamics?.isEnabled?.()) {
            this._scene3d.dynamics.applyPushFromPlayer(
                this._pos.x, this._pos.y, this._pos.z,
                this.HALF_W, this.HALF_H, this.HALF_D,
                pushVx, pushVz,
                forward.x, forward.z,
                playerVxReal, playerVzReal
            );
        }

        // === Чекпоинты — обновить точку спавна при касании ===
        if (this.physics?.getOverlappingPrimitives) {
            const overlaps = this.physics.getOverlappingPrimitives(
                this._pos.x, this._pos.y, this._pos.z,
                this.HALF_W, this.HALF_H, this.HALF_D
            );
            for (const data of overlaps) {
                if (data.type === 'checkpoint' && !this._activatedCheckpoints.has(data.id)) {
                    this._activatedCheckpoints.add(data.id);
                    if (this._scene3d) {
                        // setSpawnPoint поднимет маркер. Координата немного выше пола чекпоинта
                        // чтобы при респавне игрок не попал внутрь чекпоинта.
                        this._scene3d.setSpawnPoint(data.x, data.y + data.sy / 2 + 0.1, data.z);
                    }
                    this._playFootstep();   // звуковой фидбэк (заменим позже на «дзинь»)
                }
            }
        }

        // Шаги — копим пройденную горизонтальную дистанцию когда onGround
        if (result.onGround && isMoving) {
            const dxReal = this._pos.x - beforeX;
            const dzReal = this._pos.z - beforeZ;
            this._distanceSinceLastStep += Math.sqrt(dxReal * dxReal + dzReal * dzReal);
            const stepThreshold = isSprinting ? this.STEP_DISTANCE_SPRINT : this.STEP_DISTANCE_WALK;
            if (this._distanceSinceLastStep >= stepThreshold) {
                this._distanceSinceLastStep = 0;
                this._playFootstep();
            }
        } else {
            // В воздухе или стоит — сбрасываем чтобы первый шаг после остановки
            // не воспроизвёлся слишком рано.
            this._distanceSinceLastStep = 0;
        }

        // Coyote-time: после схода с платформы ~0.12 сек ещё можно прыгнуть.
        // Без этого Dash-таймиги слишком жёсткие — игрок жмёт прыжок чуть позже
        // края, понимает что упал в яму, бесит. С коянот-окном — прощает.
        //
        // gravityDir: при перевёрнутой гравитации (-1) потолок становится «полом»,
        // используем result.onCeiling вместо onGround.
        const gDir = this._gravityDir || 1;
        const effGround = (gDir > 0) ? result.onGround : (result.onCeiling || false);
        if (effGround) {
            this._coyoteLeft = 0.12;
        } else if (this._coyoteLeft > 0) {
            this._coyoteLeft -= dt;
        }
        const canJump = effGround || this._coyoteLeft > 0;
        // Прыжок только если стоим на земле/потолке (или coyote-окно) и НЕ в воде.
        // При gDir=-1 прыжок vy<0 (от потолка вниз = «вверх» в перевёрнутой ориентации).
        // В Ship-режиме обычный jump-импульс отключён — корабль управляется
        // только удержанием Space (см. блок _shipMode выше). _jumpHeld
        // обновляем чтобы остальная логика (release, double-jump) работала.
        if (this._waveMode) {
            // Wave не использует обычный прыжок — vy уже задано напрямую (см. apply gravity block).
            // _jumpHeld для синхронизации с состоянием Space.
            this._jumpHeld = c.has('Space');
        } else
        if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) {
            if (!this._jumpHeld) {
                // 3-фазная модель прыжка.
                // _jumpKind определяется по нажатым клавишам в момент Space:
                //   in_place — нет WASD (анимация Mixamo Jumping)
                //   forward — есть WASD (анимация Mixamo Jump = прыжок вперёд)
                const cc = this._codes;
                const wasdHeld = cc && (cc.has('KeyW') || cc.has('KeyS')
                    || cc.has('KeyA') || cc.has('KeyD')
                    || cc.has('ArrowUp') || cc.has('ArrowDown')
                    || cc.has('ArrowLeft') || cc.has('ArrowRight'));
                //   in_place — нет WASD
                //   forward  — WASD без Shift (Mixamo Jump)
                //   run      — WASD + Shift (Mixamo Running Jump)
                const sprinting = this._shift && !this._crouching;
                if (!wasdHeld) this._jumpKind = 'in_place';
                else if (sprinting) this._jumpKind = 'run';
                else this._jumpKind = 'forward';
                // anticipate-фаза разной длительности.
                const antDuration = this._jumpKind === 'in_place' ? 375
                    : this._jumpKind === 'run' ? 125 : 170;
                this._jumpHeld = true;
                this._coyoteLeft = 0;
                this._jumpAnticipateUntil = Date.now() + antDuration;
                this._jumpPendingImpulse = true;
                // Robot: запускаем boost-фазу на 0.45с
                if (this._robotMode) {
                    this._robotBoostLeft = 0.45;
                }
            }
        }
        // Запускаем физический прыжок ровно в конце anticipate-фазы.
        if (this._jumpPendingImpulse
            && this._jumpAnticipateUntil
            && Date.now() >= this._jumpAnticipateUntil
            && !inWater && !this._shipMode && !this._ufoMode) {
            this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
            this._playJumpSound();
            this._jumpPendingImpulse = false;
            // _jumpAnticipateUntil оставляем для анимационной ветки
        } else if (this._shipMode && c.has('Space')) {
            this._jumpHeld = true;
        } else if (this._ufoMode && c.has('Space') && !inWater) {
            // UFO: каждый отдельный тап = микропрыжок (даже в воздухе).
            // Используем _jumpHeld чтобы избежать повторного срабатывания пока кнопка зажата.
            if (!this._jumpHeld) {
                this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir * 0.85;
                this._playJumpSound();
                this._jumpHeld = true;
            }
        }
        // Сбрасываем флаг "второй прыжок использован" при касании земли/потолка
        if (effGround) this._doubleJumpUsed = false;
        // Двойной прыжок: в воздухе и Space нажат отдельно (после release).
        if (!effGround && !inWater && c.has('Space')
            && this._doubleJumpEnabled && !this._doubleJumpUsed && !this._jumpHeld) {
            this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
            this._playJumpSound();
            this._doubleJumpUsed = true;
            this._jumpHeld = true;
        }
        if (!c.has('Space')) this._jumpHeld = false;

        // Звук всплеска при входе/выходе из воды
        if (this._wasInWater !== inWater) {
            this._playSplashSound();
            this._wasInWater = inWater;
        }

        // Состояние игрока для game.player.state ('ground'|'air'|'water').
        this._playerState = inWater ? 'water' : (effGround ? 'ground' : 'air');
        // Хук game.onPlayerLand — был в воздухе, коснулся земли.
        if (effGround && this._wasOnGround === false && !inWater) {
            if (typeof this._onLand === 'function') {
                try { this._onLand(); } catch (e) { /* ignore */ }
            }
        }
        this._wasOnGround = effGround;

        // === Камера ===
        this.camera.position = this._computeCameraPos();
        // Управление камерой из скрипта (Фаза 5.7) — перебивает обычную.
        if (this._cameraOverride) {
            this._applyCameraOverride(dt);
        }
        // Camera shake — небольшое случайное смещение по X/Y, затухает.
        if (this._cameraShakeLeft > 0) {
            this._cameraShakeLeft -= dt;
            const t = Math.max(0, this._cameraShakeLeft);
            const amp = this._cameraShakeAmp * Math.min(1, t * 4);  // быстрая ослабка
            this.camera.position.x += (Math.random() - 0.5) * amp;
            this.camera.position.y += (Math.random() - 0.5) * amp;
        }
        // Скрипт-управление камерой (cutscene/focus) уже выставило И
        // позицию, И направление взгляда через setTarget. НЕ трогаем
        // camera.rotation — иначе setTarget затирается и катсцена
        // «смотрит прямо» вместо вращения по точкам lookAt.
        if (!this._cameraOverride) {
            if (this._cameraMode === 'front') {
                // Камера смотрит назад на игрока — yaw на 180°, pitch инверт.
                this.camera.rotation.x = -this._pitch;
                this.camera.rotation.y = this._yaw + Math.PI;
            } else if (this._cameraMode === 'sideview') {
                // Sideview: камера в -Z от игрока, смотрит на +Z.
                this.camera.rotation.x = 0;
                this.camera.rotation.y = 0;
                this.camera.rotation.z = 0;
            } else {
                this.camera.rotation.x = this._pitch;
                this.camera.rotation.y = this._yaw;
            }
            if (this._cameraMode !== 'sideview') this.camera.rotation.z = 0;
        }

        // === Модель игрока ===
        if (this._modelRoot) {
            // === Поза: в воде персонаж лежит горизонтально (плавает) ===
            // Цель наклона — 0 на суше, π/2 в воде. Плавная интерполяция.
            const targetSwimTilt = inWater ? Math.PI / 2 : 0;
            const swimTilt = this._swimTilt ?? 0;
            const tiltStep = 4 * dt;        // 4 рад/с скорость наклона
            let nextTilt = swimTilt;
            if (Math.abs(targetSwimTilt - swimTilt) <= tiltStep) {
                nextTilt = targetSwimTilt;
            } else {
                nextTilt += Math.sign(targetSwimTilt - swimTilt) * tiltStep;
            }
            this._swimTilt = nextTilt;

            // В воде наклон 90° кладёт модель горизонтально. yLift поднимает
            // root к центру AABB. Также при наклоне корень оказывается в позиции
            // ног (которые сзади головы при этом ракурсе), поэтому сдвигаем root
            // на длину модели вперёд по yaw чтобы голова была впереди.
            const tiltFrac = nextTilt / (Math.PI / 2);   // 0..1
            const yLift = inWater ? this.HALF_H * tiltFrac : 0;
            const bodyLen = this.HALF_H * 2 * 0.7;       // примерная длина тела
            const fwdShift = inWater ? bodyLen * tiltFrac : 0;
            const fx = Math.sin(this._modelYaw);
            const fz = Math.cos(this._modelYaw);
            // Crouch-смещение: разные Mixamo-клипы имеют разный hip-baseline.
            //   crouch_idle (Crouching Idle)        — hip ПРИПОДНЯТ (~0.35м над землёй)
            //   crouch_walk (Sneak Walk)            — hip нормальный, ноги стандартные
            //   crouch_enter/crouch_to_stand        — переход, плавно меняется
            // Поэтому drop зависит от текущего проигрываемого state, не от _crouching.
            let crouchYDrop = 0;
            if (this._crouching && this._mixamoAnimator) {
                const ms = this._mixamoAnimator._currentState;
                if (ms === 'crouch_idle') crouchYDrop = 0.45;
                else if (ms === 'crouch_walk') crouchYDrop = 0.25;
                else if (ms === 'crouch_enter' || ms === 'crouch_to_stand') crouchYDrop = 0.30;
                else crouchYDrop = 0.30;
            }
            this._modelRoot.position.set(
                this._pos.x + fx * fwdShift,
                this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset - crouchYDrop,
                this._pos.z + fz * fwdShift
            );

            // Поворот модели:
            //  - на лестнице: лицом К лестнице, yaw зафиксирован при входе.
            //  - на суше: направление РЕАЛЬНОГО движения (как было).
            //  - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
            //    двигает тело вбок без вращения, как на суше при first-person.
            if (this._climbingTop) {
                // climb_to_top: модель смотрит В сторону площадки (куда вылазит).
                // Эта анимация имеет другую ориентацию Hips чем climb_up,
                // поэтому БЕЗ +π компенсации — иначе развёрнута на 180°.
                if (this._climbTopStart && this._climbTopEnd) {
                    const dx = this._climbTopEnd.x - this._climbTopStart.x;
                    const dz = this._climbTopEnd.z - this._climbTopStart.z;
                    if (Math.abs(dx) > 0.001 || Math.abs(dz) > 0.001) {
                        this._modelYaw = Math.atan2(dx, dz);
                    }
                }
            } else if (this._ladderMode) {
                // _modelYaw уже выставлен при входе в ladder-mode (лицом к лестнице).
                // Анимация climb_up даёт ~180° поворот Hips → персонаж лицом к
                // перекладинам. Ничего не доворачиваем.
            } else if (inWater) {
                const targetYaw = this._yaw;
                let diff = targetYaw - this._modelYaw;
                while (diff > Math.PI)  diff -= Math.PI * 2;
                while (diff < -Math.PI) diff += Math.PI * 2;
                const maxStep = this.MODEL_TURN_SPEED * dt * 2;
                if (Math.abs(diff) <= maxStep) {
                    this._modelYaw = targetYaw;
                } else {
                    this._modelYaw += Math.sign(diff) * maxStep;
                }
            } else {
                const dxReal = this._pos.x - beforeX;
                const dzReal = this._pos.z - beforeZ;
                const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001;
                if (movedHorizontal) {
                    const targetYaw = Math.atan2(dxReal, dzReal);
                    let diff = targetYaw - this._modelYaw;
                    while (diff > Math.PI)  diff -= Math.PI * 2;
                    while (diff < -Math.PI) diff += Math.PI * 2;
                    const maxStep = this.MODEL_TURN_SPEED * dt;
                    if (Math.abs(diff) <= maxStep) {
                        this._modelYaw = targetYaw;
                    } else {
                        this._modelYaw += Math.sign(diff) * maxStep;
                    }
                }
            }
            // Применяем yaw + swim-tilt.
            // rotation.x = +π/2 кладёт модель лицом вниз; при этом голова уходит
            // НАЗАД относительно корня — компенсируем сдвигом root вперёд (см. fwdShift).
            this._modelRoot.rotation.y = this._modelYaw;
            this._modelRoot.rotation.x = nextTilt;
            // В воде также добавляем лёгкое покачивание по Z (как волна тела)
            if (inWater) {
                const wobble = Math.sin((this._scene3d?.engine?.getDeltaTime?.() || 0) * 0.001 + performance.now() * 0.004) * 0.15;
                this._modelRoot.rotation.z = wobble;
            } else if (this._cameraMode === 'sideview') {
                // Кубикон Dash: в воздухе куб крутится назад (по часовой если
                // смотреть с -Z), на земле плавно возвращается в 0.
                // Скорость подобрана так чтобы между прыжками куб успевал
                // совершить ~1 оборот.
                const SPIN_SPEED = Math.PI * 1.8;     // ~1.8π рад/с
                if (!result.onGround) {
                    this._dashSpinAngle -= SPIN_SPEED * dt;
                } else {
                    // Дотягиваем до ближайшего кратного 2π (т.е. визуально 0)
                    const TAU = Math.PI * 2;
                    const target = Math.round(this._dashSpinAngle / TAU) * TAU;
                    const diff = target - this._dashSpinAngle;
                    const snapStep = SPIN_SPEED * 1.5 * dt;
                    if (Math.abs(diff) <= snapStep) this._dashSpinAngle = target;
                    else this._dashSpinAngle += Math.sign(diff) * snapStep;
                }
                this._modelRoot.rotation.z = this._dashSpinAngle;
            } else {
                this._modelRoot.rotation.z = 0;
            }
            // Поза с оружием — обновляем флаг каждый кадр (на случай смены)
            const hasWeapon = !!this._scene3d?.weapons?._equipped;
            this._updateExtendedArm(hasWeapon);
            // Применяем все override'ы вращения мешей ПОСЛЕ всех манипуляций.
            // Анимация Kenney пишет в rotationQuaternion на меше — обнуляем
            // и пишем свои углы Эйлера. Это ключевой момент: вызывается
            // каждый кадр, поэтому override переживает анимацию.
            this._applyMeshRotationOverrides();
        }

        // Кубикон Dash: скрипт мог попросить скрыть скин (game.player.setSkinVisible(false)).
        // Применяем каждый кадр — на случай если меши только что асинхронно
        // загрузились, либо _applyCameraMode перезаписал enabled=true.
        if (!this._skinVisibleScripted && this._modelMeshes && this._modelMeshes.length > 0) {
            for (let i = 0; i < this._modelMeshes.length; i++) {
                const m = this._modelMeshes[i];
                if (m && m.isEnabled && m.isEnabled() && m.setEnabled) {
                    try { m.setEnabled(false); } catch (e) {}
                }
            }
        }

        // Тик распадающихся кусков игрока (после смерти)
        this._tickDebris(dt);

        // === Анимации ===
        // Снимок скорости/опоры для процедурной анимации non-humanoid скинов.
        this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1);
        this._isGrounded = !!result.onGround;

        // Non-humanoid single-mesh скин: костей нет — анимируем процедурно
        // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них.
        if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) {
            this._animateNonHumanoidMesh(dt);
            return;
        }

        // Mixamo-скин: AnimationGroup для каждого состояния, грузятся отдельно
        // из /character-assets/animations/*.glb. Состояния:
        //   idle/walk/run/jump/fall — базовые
        //   crouch_idle/crouch_walk — присед (Ctrl)
        // sprint → run. crouch имеет приоритет над sprint.
        if (this._mixamoAnimator) {
            let mState;
            const now = Date.now();
            // climb_to_top — вылезание на площадку (приоритет над всем).
            if (this._climbingTop) {
                this._mixamoAnimator.setState('climb_to_top');
                return;
            }
            // Лазание по лестнице имеет приоритет над всеми анимациями.
            //   climb_up — движется вверх (W), climb_down — вниз (S),
            //   на месте на лестнице — анимация продолжает играть циклично
            //   (НЕ паузим: g.pause() останавливал обновление скелета →
            //   bounding box не обновлялся → frustum culling прятал скин).
            if (this._ladderMode) {
                const climbUp = this._codes.has('KeyW') || this._codes.has('ArrowUp');
                const climbDown = this._codes.has('KeyS') || this._codes.has('ArrowDown');
                const moving = climbUp || climbDown;
                // Меняем state ТОЛЬКО при реальном движении. На месте держим
                // текущую анимацию (не дёргаем setState — это убирает мигание
                // climb_up↔climb_down и исчезание скина).
                if (climbUp) this._mixamoAnimator.setState('climb_up');
                else if (climbDown) this._mixamoAnimator.setState('climb_down');
                // play/pause трогаем ТОЛЬКО при смене режима движения (как в jump).
                if (moving !== this._ladderMoving) {
                    this._ladderMoving = moving;
                    try {
                        const g = this._mixamoAnimator._currentGroup;
                        if (g) {
                            if (moving) g.play(true);  // возобновить (снять паузу)
                            else g.pause();            // заморозить позу
                        }
                    } catch (e) {}
                }
                return;
            }
            const inCrouchTransition = this._crouchTransitionUntil
                && now < this._crouchTransitionUntil;
            // 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind:
            //   in_place: jump_*           (Mixamo Jumping)
            //   forward:  jump_fwd_*       (Mixamo Jump, прыжок с шага)
            //   run:      jump_run_*       (Mixamo Running Jump, прыжок с бега)
            const jk = this._jumpKind;
            const isAirborneJump = jk === 'forward' || jk === 'run';
            let stAnticipate, stAir, stLand, landDuration;
            if (jk === 'run') {
                stAnticipate = 'jump_run_anticipate';
                stAir = 'jump_run_air';
                stLand = 'jump_run_land';
                landDuration = 175;
            } else if (jk === 'forward') {
                stAnticipate = 'jump_fwd_anticipate';
                stAir = 'jump_fwd_air';
                stLand = 'jump_fwd_land';
                landDuration = 142;
            } else {
                stAnticipate = 'jump_anticipate';
                stAir = 'jump_air';
                stLand = 'jump_land';
                landDuration = 570;
            }
            const inAnticipate = this._jumpAnticipateUntil
                && now < this._jumpAnticipateUntil
                && this._jumpPendingImpulse;
            const inJumpLand = this._jumpLandUntil && now < this._jumpLandUntil;
            // Coyote-фильтр для микро-полётов на ступеньках. При спуске по
            // лестнице из блоков персонаж 30-700мс физически в воздухе, и
            // jump_air мигает между шагами walk. Критерий — ВЫСОТА падения
            // от последней наземной позиции (а не время — полёт может быть
            // длинным при спуске лицом к камере). Опустился <1.3 блока И не
            // прыгал → ступенька, играем walk/run.
            if (result.onGround) {
                this._lastGroundY = this._pos.y;
            }
            const dropFromGround = (this._lastGroundY != null)
                ? (this._lastGroundY - this._pos.y) : Infinity;
            const microAir = !result.onGround
                && !this._jumpHeld           // не прыжок со Space
                && !this._wasAirborne        // не продолжение реального прыжка
                && dropFromGround < 1.3      // опустился меньше 1.3 блока
                && this._vy < 4;             // не подлетает вверх (степ-ап импульс)
            if (inAnticipate) {
                mState = stAnticipate;
            } else if (microAir) {
                // Микро-полёт между ступеньками — наземная анимация.
                mState = this._crouching
                    ? (isMoving ? 'crouch_walk' : 'crouch_idle')
                    : (isMoving ? (isSprinting ? 'run' : 'walk') : 'idle');
            } else if (!result.onGround) {
                mState = stAir;
                this._wasAirborne = true;
                this._crouchEnterPending = false;
                this._crouchExitPending = false;
                this._crouchTransitionUntil = 0;
                this._jumpAnticipateUntil = 0;
            } else if (this._wasAirborne) {
                this._jumpLandUntil = now + landDuration;
                this._wasAirborne = false;
                mState = stLand;
            } else if (inJumpLand) {
                // Для forward — доигрываем land даже при движении
                // (там короткая фаза 142мс)
                if (isAirborneJump || !isMoving) mState = stLand;
            } else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
                mState = 'crouch_enter';
            } else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
                mState = 'crouch_to_stand';
            } else if (this._crouching) {
                this._crouchEnterPending = false;
                this._crouchExitPending = false;
                mState = isMoving ? 'crouch_walk' : 'crouch_idle';
            } else if (inWater) {
                mState = isMoving ? 'walk' : 'idle';
            } else if (isMoving) {
                this._crouchExitPending = false;
                this._crouchTransitionUntil = 0;
                this._jumpLandUntil = 0; // прерываем jump_land если пошли
                mState = isSprinting ? 'run' : 'walk';
            } else {
                this._crouchExitPending = false;
                mState = 'idle';
            }
            this._mixamoAnimator.setState(mState);
            return;
        }

        // R15-скин: процедурный аниматор (нет glTF AnimationGroups).
        // Состояния: idle/walk/run/jump/fall. sprint → run.
        if (this._isR15 && this._r15Animator) {
            let r15State;
            if (!result.onGround) {
                // vy > 0 — вверх (jump-поза с поджатыми ногами),
                // vy < 0 — вниз (fall, лёгкий наклон корпуса).
                r15State = (this._vy > 0.5) ? 'jump' : 'fall';
            } else if (inWater) {
                r15State = isMoving ? 'walk' : 'idle';
            } else if (isMoving) {
                r15State = isSprinting ? 'run' : 'walk';
            } else {
                r15State = 'idle';
            }
            this._r15Animator.setState(r15State);
            this._r15Animator.update(dt);
            // Override костей поверх анимации (поза руки с оружием/замах).
            this._applyR15BoneOverrides();
            return;   // R15 не использует AnimationGroups-путь ниже
        }

        let nextAnim;
        if (inWater) {
            // В воде — walk-анимация выглядит как гребки/педаляж в горизонтальной позе
            nextAnim = isMoving ? 'walk' : 'idle';
        } else if (!result.onGround) {
            nextAnim = this._animations.jump ? 'jump' : (isMoving ? 'walk' : 'idle');
        } else if (isMoving) {
            nextAnim = isSprinting ? 'sprint' : 'walk';
        } else {
            nextAnim = 'idle';
        }
        // Снимок состояния для лога в _playAnim
        this._lastAnimDebug = {
            vy: this._vy,
            og: !!result.onGround,
            sf: !!result.surfaceFollowed,
            mv: !!isMoving,
        };
        // === Периодический trace состояния (раз в 30 кадров ~0.5с) ===
        // Видим как меняются onGround/surfaceFollowed/vy даже когда анимация
        // не меняется — полезно для разбора "вибрации".
        if (!this._animTraceCnt) this._animTraceCnt = 0;
        this._animTraceCnt++;
        if (this._animTraceCnt >= 30) {
            this._animTraceCnt = 0;
            const d = this._lastAnimDebug;
            console.log(`[AnimTrace] anim=${this._currentAnim} `
                + `og=${d.og} sf=${d.sf} mv=${d.mv} vy=${d.vy.toFixed(2)}`);
        }
        this._playAnim(nextAnim);
    }
}
