/**
 * R15Animator — процедурные анимации R15-скелета для веб-плеера.
 *
 * Порт `RubloxAnimator.gd` (Godot) / `R15Animator.kt` (Kotlin). R15-скины
 * приходят БЕЗ glTF-анимаций — позы считаются кодом покадрово.
 *
 * Принцип (как в нативных плеерах):
 *   поза кости = restRotation × Quaternion(axis, angle × intensity)
 *   intensity ∈ [-1..1] интерполируется по фазе анимации (ключевые точки).
 *
 * Состояния: idle / walk / run / jump / fall. Между состояниями —
 * cross-fade (lerp кватернионов) за BLEND_TIME.
 *
 * Babylon: bone.getRotationQuaternion(Space.LOCAL) даёт текущий поворот,
 * bone.setRotationQuaternion(q, Space.LOCAL) — ставит. rest берём один раз
 * при setup (bone.getRotationQuaternion на старте = bind-поза).
 */
import { Quaternion, Vector3, Space } from '@babylonjs/core';

const DEG = Math.PI / 180;
const BLEND_TIME = 0.18;   // сек на cross-fade между состояниями

// Логические R15-кости, которыми анимируем (резолвятся через R15Skeleton).
const B = {
    SPINE: 'UpperTorso',
    LEFT_UPLEG: 'LeftUpperLeg', LEFT_LEG: 'LeftLowerLeg',
    RIGHT_UPLEG: 'RightUpperLeg', RIGHT_LEG: 'RightLowerLeg',
    LEFT_ARM: 'LeftUpperArm', LEFT_FOREARM: 'LeftLowerArm',
    RIGHT_ARM: 'RightUpperArm', RIGHT_FOREARM: 'RightLowerArm',
    HEAD: 'Head',
};

// Оси вращения (локальные оси кости). Vector3.Right = (1,0,0) — основная ось
// сгиба конечностей в Mixamo-риге.
const AXIS_RIGHT = new Vector3(1, 0, 0);
const AXIS_UP = new Vector3(0, 1, 0);

/**
 * Линейная интерполяция intensity по массивам ключей.
 * times — отсортированные timestamps, values — значения intensity.
 * phase — текущее время внутри цикла анимации.
 */
function lerpKeys(times, values, phase) {
    if (phase <= times[0]) return values[0];
    if (phase >= times[times.length - 1]) return values[values.length - 1];
    for (let i = 0; i < times.length - 1; i++) {
        if (phase >= times[i] && phase <= times[i + 1]) {
            const t = (phase - times[i]) / (times[i + 1] - times[i]);
            return values[i] + (values[i + 1] - values[i]) * t;
        }
    }
    return values[values.length - 1];
}

/**
 * Описание одной анимации: длина + список треков костей.
 * Трек: { bone, axis, angleDeg, times, values }.
 * times/values — ключи intensity. loop — зацикливать ли фазу.
 */
function makeAnim(length, loop, tracks) {
    return { length, loop, tracks };
}

// Стандартные анимации (для всех скинов кроме bacon-hair-подобных).
// Углы калиброваны под Mixamo-bind как в Godot RubloxAnimator.
const ANIMS_STD = {
    idle: makeAnim(3.0, true, [
        // Лёгкое «дыхание» — корпус чуть качается вокруг X.
        { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 3,
          times: [0.0, 1.5, 3.0], values: [0.0, 1.0, 0.0] },
    ]),
    walk: makeAnim(0.7, true, [
        // Бёдра шагают попеременно, голени сгибаются назад при подъёме.
        { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: 45,
          times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.0, 1.0, 0.0, -1.0, 0.0] },
        { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: 45,
          times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.0, -1.0, 0.0, 1.0, 0.0] },
        { bone: B.LEFT_LEG, axis: AXIS_RIGHT, angleDeg: 80,
          times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [1.0, 0.1, 0.4, 0.6, 1.0] },
        { bone: B.RIGHT_LEG, axis: AXIS_RIGHT, angleDeg: 80,
          times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.4, 0.6, 1.0, 0.1, 0.4] },
        // Руки swing вперёд-назад по X.
        { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: 25,
          times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.0, -1.0, 0.0, 1.0, 0.0] },
        { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: 25,
          times: [0.0, 0.175, 0.35, 0.525, 0.7], values: [0.0, 1.0, 0.0, -1.0, 0.0] },
    ]),
    run: makeAnim(0.45, true, [
        { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: 45,
          times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.0, 1.0, 0.0, -1.0, 0.0] },
        { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: 45,
          times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.0, -1.0, 0.0, 1.0, 0.0] },
        { bone: B.LEFT_LEG, axis: AXIS_RIGHT, angleDeg: 65,
          times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.3, 0.7, 0.8, 0.3, 0.3] },
        { bone: B.RIGHT_LEG, axis: AXIS_RIGHT, angleDeg: 65,
          times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.8, 0.3, 0.3, 0.7, 0.8] },
        { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: 45,
          times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.0, -1.0, 0.0, 1.0, 0.0] },
        { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: 45,
          times: [0.0, 0.1125, 0.225, 0.3375, 0.45], values: [0.0, 1.0, 0.0, -1.0, 0.0] },
        // Локти согнуты ~75° (поза бегуна).
        { bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -75,
          times: [0.0, 0.45], values: [1.0, 1.0] },
        { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -75,
          times: [0.0, 0.45], values: [1.0, 1.0] },
        // Лёгкий наклон корпуса вперёд.
        { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -10,
          times: [0.0, 0.45], values: [1.0, 1.0] },
    ]),
    jump: makeAnim(0.5, true, [
        // Бёдра вперёд, голени поджаты назад, руки вверх.
        { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: -35,
          times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] },
        { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: -35,
          times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] },
        { bone: B.LEFT_LEG, axis: AXIS_RIGHT, angleDeg: 80,
          times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] },
        { bone: B.RIGHT_LEG, axis: AXIS_RIGHT, angleDeg: 80,
          times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] },
        { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -110,
          times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] },
        { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -110,
          times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] },
        { bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -25,
          times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] },
        { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -25,
          times: [0.0, 0.25, 0.5], values: [0.0, 1.0, 1.0] },
    ]),
    fall: makeAnim(0.8, true, [
        // Лёгкий наклон корпуса вперёд.
        { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
          times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
    ]),
    // Удар правой рукой вперёд (для враждебных NPC). loop=true — постоянно
    // машет, пока NPC в режиме атаки.
    attack: makeAnim(0.5, true, [
        // Правая рука выбрасывается вперёд (замах назад → удар).
        { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -95,
          times: [0.0, 0.15, 0.3, 0.5], values: [0.3, 1.0, 0.2, 0.3] },
        { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -50,
          times: [0.0, 0.15, 0.3, 0.5], values: [1.0, 0.2, 1.0, 1.0] },
        // Левая рука тоже в боевой стойке.
        { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -45,
          times: [0.0, 0.5], values: [1.0, 1.0] },
        { bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -70,
          times: [0.0, 0.5], values: [1.0, 1.0] },
        // Корпус подаётся в удар.
        { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -12,
          times: [0.0, 0.15, 0.3, 0.5], values: [0.0, 1.0, 0.3, 0.0] },
    ]),

    // === ЭМОЦИИ (game.player.playAnimation) ===
    // Разовые анимации поверх авто-состояния. loop=false — играют один раз,
    // потом аниматор возвращается к idle/walk/...

    // Помахать правой рукой — рука поднята вверх-вбок, кисть качается.
    wave: makeAnim(1.6, false, [
        // Правая рука вверх (поднимается, держится, опускается).
        { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -150,
          times: [0.0, 0.3, 1.3, 1.6], values: [0.0, 1.0, 1.0, 0.0] },
        // Предплечье качается из стороны в сторону (само махание).
        { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -40,
          times: [0.0, 0.3, 0.55, 0.8, 1.05, 1.3, 1.6],
          values:  [0.0, 1.0, -1.0, 1.0, -1.0, 1.0, 0.0] },
    ]),

    // Танец — корпус качается, обе руки двигаются попеременно вверх.
    dance: makeAnim(2.4, false, [
        { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 14,
          times: [0.0, 0.6, 1.2, 1.8, 2.4],
          values:  [0.0, 1.0, -0.6, 1.0, 0.0] },
        { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -120,
          times: [0.0, 0.6, 1.2, 1.8, 2.4],
          values:  [0.0, 1.0, 0.2, 1.0, 0.0] },
        { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -120,
          times: [0.0, 0.6, 1.2, 1.8, 2.4],
          values:  [0.0, 0.2, 1.0, 0.2, 0.0] },
        // Бёдра чуть пружинят — топчется на месте.
        { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: 20,
          times: [0.0, 0.6, 1.2, 1.8, 2.4],
          values:  [0.0, 1.0, 0.0, 1.0, 0.0] },
        { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: 20,
          times: [0.0, 0.6, 1.2, 1.8, 2.4],
          values:  [0.0, 0.0, 1.0, 0.0, 0.0] },
    ]),

    // Радость/победа — обе руки резко вверх и держатся.
    cheer: makeAnim(1.8, false, [
        { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -160,
          times: [0.0, 0.25, 1.5, 1.8], values: [0.0, 1.0, 1.0, 0.0] },
        { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -160,
          times: [0.0, 0.25, 1.5, 1.8], values: [0.0, 1.0, 1.0, 0.0] },
        // Корпус чуть назад от энтузиазма.
        { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -8,
          times: [0.0, 0.25, 1.5, 1.8], values: [0.0, 1.0, 1.0, 0.0] },
    ]),

    // Присесть — бёдра и голени согнуты, держится в позе сидя.
    sit: makeAnim(3.0, false, [
        { bone: B.LEFT_UPLEG, axis: AXIS_RIGHT, angleDeg: -90,
          times: [0.0, 0.4, 2.6, 3.0], values: [0.0, 1.0, 1.0, 0.0] },
        { bone: B.RIGHT_UPLEG, axis: AXIS_RIGHT, angleDeg: -90,
          times: [0.0, 0.4, 2.6, 3.0], values: [0.0, 1.0, 1.0, 0.0] },
        { bone: B.LEFT_LEG, axis: AXIS_RIGHT, angleDeg: 90,
          times: [0.0, 0.4, 2.6, 3.0], values: [0.0, 1.0, 1.0, 0.0] },
        { bone: B.RIGHT_LEG, axis: AXIS_RIGHT, angleDeg: 90,
          times: [0.0, 0.4, 2.6, 3.0], values: [0.0, 1.0, 1.0, 0.0] },
    ]),

    // Рисование баллончиком — правая рука ПОДНЯТА вперёд к «стене»
    // и интенсивно ТРЯСЁТСЯ (мазки). Длительность 4с — на всю фазу
    // граффити в кат-сцене раннера. Рука держится поднятой всё время.
    paint: makeAnim(4.0, false, [
        // правая рука поднята вперёд-вверх и ДЕРЖИТСЯ так почти всё время
        { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -100,
          times: [0.0, 0.3, 3.7, 4.0], values: [0.0, 1.0, 1.0, 0.0] },
        // предплечье быстро ходит вверх-вниз — мазки баллончиком
        { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -60,
          times: [0.0, 0.3, 0.6, 0.9, 1.2, 1.5, 1.8, 2.1, 2.4,
                  2.7, 3.0, 3.3, 3.6, 4.0],
          values:  [0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0,
                    1.0, 0.0, 1.0, 0.0, 0.0] },
        // вся рука покачивается вбок — горизонтальные мазки
        { bone: B.RIGHT_ARM, axis: AXIS_UP, angleDeg: 32,
          times: [0.0, 0.5, 1.1, 1.7, 2.3, 2.9, 3.5, 4.0],
          values:  [0.0, 1.0, -0.7, 1.0, -0.7, 1.0, -0.4, 0.0] },
        // корпус повёрнут к стене и слегка качается в такт
        { bone: B.SPINE, axis: AXIS_UP, angleDeg: 16,
          times: [0.0, 0.3, 3.7, 4.0], values: [0.0, 1.0, 1.0, 0.0] },
        { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 7,
          times: [0.0, 0.7, 1.4, 2.1, 2.8, 3.5, 4.0],
          values:  [0.0, 1.0, 0.2, 1.0, 0.2, 1.0, 0.0] },
    ]),
};

// Имена клипов-эмоций — для playEmote (отличить от состояний движения).
const EMOTE_NAMES = ['wave', 'dance', 'cheer', 'sit', 'paint'];

// Диагональные оси для F-pose плеч (руки в bind под 45°).
// Калибровано в Godot для bacon-hair: bind-dir LeftArm ≈ (-0.71,0,-0.71).
const FPOSE_LEFT_AXIS = new Vector3(0.71, 0, -0.71);
const FPOSE_RIGHT_AXIS = new Vector3(0.71, 0, 0.71);

/**
 * Построить набор анимаций для F-pose скинов: как ANIMS_STD, но swing
 * плеч идёт по диагональным осям вместо чистого X. Глубокая копия треков
 * с подменой оси для LEFT_ARM / RIGHT_ARM.
 */
function buildFPoseAnims() {
    const out = {};
    for (const key in ANIMS_STD) {
        const src = ANIMS_STD[key];
        out[key] = makeAnim(src.length, src.loop, src.tracks.map((tr) => {
            let axis = tr.axis;
            if (tr.bone === B.LEFT_ARM) axis = FPOSE_LEFT_AXIS;
            else if (tr.bone === B.RIGHT_ARM) axis = FPOSE_RIGHT_AXIS;
            return { ...tr, axis };
        }));
    }
    return out;
}

export class R15Animator {
    /**
     * @param {R15Skeleton} r15  — резолвер костей.
     * @param {object} overrides — per-skin overrides из манифеста.
     */
    constructor(r15, overrides = {}) {
        this.r15 = r15;
        this.overrides = overrides || {};
        // Аниматор отключён для скинов со сломанным ригом.
        this.disabled = !!this.overrides.disable_animator;

        // Кеш: логическое имя кости → { bone, restQuat }.
        // restQuat — поворот кости в bind-позе (снимаем один раз СЕЙЧАС,
        // пока ничего не анимировано).
        this._bones = {};
        const resolvedList = [];
        const missingList = [];
        let withTNode = 0;
        for (const logical of Object.values(B)) {
            const bone = r15.resolveBone(logical);
            if (bone) {
                // Если у кости есть привязанный TransformNode (glTF-нода) —
                // Babylon синхронизирует кость ОТ него. Тогда анимировать
                // надо TransformNode, а не саму кость.
                const tNode = bone.getTransformNode ? bone.getTransformNode() : null;
                if (tNode) withTNode++;
                // rest-поворот: с tNode берём из него, иначе из кости.
                let restQ;
                if (tNode) {
                    restQ = tNode.rotationQuaternion
                        ? tNode.rotationQuaternion.clone()
                        : Quaternion.FromEulerAngles(
                            tNode.rotation.x, tNode.rotation.y, tNode.rotation.z);
                } else {
                    restQ = bone.getRotationQuaternion(Space.LOCAL);
                    if (!restQ) {
                        restQ = bone.rotationQuaternion
                            ? bone.rotationQuaternion.clone()
                            : Quaternion.Identity();
                    } else {
                        restQ = restQ.clone();
                    }
                }
                this._bones[logical] = { bone, tNode, restQ };
                resolvedList.push(`${logical}→${bone.name}`);
            } else {
                missingList.push(logical);
            }
        }
        // eslint-disable-next-line no-console
        console.log('[R15Animator] костей с TransformNode: ' + withTNode
            + ' / ' + resolvedList.length);
        // eslint-disable-next-line no-console
        console.log('[R15Animator] создан. disabled=' + this.disabled
            + ' костей зарезолвлено=' + resolvedList.length
            + ' / нет=' + missingList.length
            + (missingList.length ? ' (отсутствуют: ' + missingList.join(',') + ')' : ''));
        // eslint-disable-next-line no-console
        console.log('[R15Animator] кости: ' + resolvedList.join(' | '));

        // Per-skin override: F-pose рук.
        // Некоторые скины (классический Roblox-стиль, bacon-hair) имеют руки
        // в bind-позе под ~45° в стороны, а не вертикально. Для них ось
        // swing'а плеча — не чистый X, а диагональ. Включается флагом
        // overrides.arm_fpose в манифесте (подбирается на устройстве).
        // Если флаг есть — собираем модифицированный набор анимаций с
        // диагональными осями для плеч.
        if (this.overrides.arm_fpose) {
            this._anims = buildFPoseAnims();
        } else {
            this._anims = ANIMS_STD;
        }

        // Текущее и предыдущее состояние для cross-fade.
        this._curState = 'idle';
        this._prevState = 'idle';
        this._blendT = BLEND_TIME;   // >= BLEND_TIME → блендинг завершён
        this._curPhase = 0;
        this._prevPhase = 0;

        // Активная эмоция (game.player.playAnimation). null = нет эмоции.
        //   { name, phase, length } — играет поверх авто-состояния, не loop.
        this._emote = null;

        // Переиспользуемые временные кватернионы (без аллокаций в кадре).
        this._tmpA = new Quaternion();
        this._tmpB = new Quaternion();
        this._tmpOut = new Quaternion();
    }

    /** Сменить состояние (idle/walk/run/jump/fall). Запускает cross-fade. */
    setState(state) {
        if (!this._anims[state]) state = 'idle';
        if (state === this._curState) return;
        this._prevState = this._curState;
        this._prevPhase = this._curPhase;
        this._curState = state;
        this._curPhase = 0;
        this._blendT = 0;
    }

    /**
     * Проиграть эмоцию (wave/dance/cheer/sit) один раз поверх движения.
     * Эмоция перебивает авто-анимацию пока играет, потом аниматор
     * возвращается к idle/walk/... Возвращает true если эмоция найдена.
     */
    playEmote(name) {
        if (!EMOTE_NAMES.includes(name) || !this._anims[name]) return false;
        this._emote = { name, phase: 0, length: this._anims[name].length };
        return true;
    }

    /** Прервать текущую эмоцию досрочно. */
    stopEmote() {
        this._emote = null;
    }

    /** Список доступных эмоций. */
    static getEmoteNames() {
        return EMOTE_NAMES.slice();
    }

    /**
     * Применить анимацию за кадр. dt — секунды.
     * Вычисляет позу для curState (и prevState если идёт блендинг),
     * ставит кватернионы на кости.
     */
    update(dt) {
        if (this.disabled) return;

        // === ДИАГНОСТИКА: раз в ~120 вызовов ===
        if (this._dbgCnt === undefined) this._dbgCnt = 0;
        this._dbgCnt++;
        if (this._dbgCnt % 120 === 1) {
            // Снимок: какую позу СЕЙЧАС имеет кость RightUpperLeg
            // (если анимация работает — её rotationQuaternion меняется).
            const probe = this._bones.RightUpperLeg || this._bones.UpperTorso;
            let curQ = '?';
            let via = '?';
            if (probe) {
                if (probe.tNode) {
                    via = 'tNode';
                    const q = probe.tNode.rotationQuaternion;
                    curQ = q ? `(${q.x.toFixed(3)},${q.y.toFixed(3)},${q.z.toFixed(3)},${q.w.toFixed(3)})` : 'null';
                } else if (probe.bone) {
                    via = 'bone';
                    const q = probe.bone.getRotationQuaternion
                        ? probe.bone.getRotationQuaternion(Space.LOCAL)
                        : null;
                    curQ = q ? `(${q.x.toFixed(3)},${q.y.toFixed(3)},${q.z.toFixed(3)},${q.w.toFixed(3)})` : 'null';
                }
            }
            // eslint-disable-next-line no-console
            console.log('[R15Animator] update: state=' + this._curState
                + ' dt=' + dt.toFixed(4)
                + ' phase=' + this._curPhase.toFixed(3)
                + ' bones=' + Object.keys(this._bones).length
                + ' probeBone=' + (probe ? probe.bone.name : 'НЕТ')
                + ' via=' + via
                + ' curRotQ=' + curQ);
        }

        // === ЭМОЦИЯ ===
        // Если играет эмоция — она перебивает авто-анимацию. Продвигаем её
        // фазу; когда доиграла (не loop) — снимаем, аниматор вернётся к idle/walk.
        if (this._emote) {
            this._emote.phase += dt;
            if (this._emote.phase >= this._emote.length) {
                this._emote = null;   // эмоция закончилась
            } else {
                const emAnim = this._anims[this._emote.name];
                const emPoses = {};
                this._collectPose(emAnim, this._emote.phase, emPoses, 1);
                // Применяем позу эмоции; кости вне её треков — rest.
                for (const logical in this._bones) {
                    const rec = this._bones[logical];
                    const target = emPoses[logical] || rec.restQ;
                    if (rec.tNode) {
                        if (!rec.tNode.rotationQuaternion) {
                            rec.tNode.rotationQuaternion = target.clone();
                        } else {
                            rec.tNode.rotationQuaternion.copyFrom(target);
                        }
                    } else {
                        rec.bone.setRotationQuaternion(target, Space.LOCAL);
                    }
                }
                return;   // в этот кадр обычную анимацию не считаем
            }
        }

        // Продвигаем фазы.
        const curAnim = this._anims[this._curState];
        this._curPhase += dt;
        if (curAnim.loop && this._curPhase > curAnim.length) {
            this._curPhase %= curAnim.length;
        } else if (this._curPhase > curAnim.length) {
            this._curPhase = curAnim.length;
        }

        let blendAlpha = 1.0;
        let prevAnim = null;
        if (this._blendT < BLEND_TIME) {
            this._blendT += dt;
            blendAlpha = Math.min(1.0, this._blendT / BLEND_TIME);
            prevAnim = this._anims[this._prevState];
            this._prevPhase += dt;
            if (prevAnim.loop && this._prevPhase > prevAnim.length) {
                this._prevPhase %= prevAnim.length;
            }
        }

        // Поза для каждой анимированной кости: сначала собираем target-
        // повороты из curAnim (и prevAnim при блендинге), затем ставим.
        // Кости, которых нет в треках анимации, остаются в rest.
        const poses = {};   // logical → Quaternion (target поворот)

        this._collectPose(curAnim, this._curPhase, poses, blendAlpha < 1 ? null : 1);

        if (prevAnim && blendAlpha < 1) {
            // Блендинг: сначала prev-поза, потом lerp к cur-позе.
            const prevPoses = {};
            this._collectPose(prevAnim, this._prevPhase, prevPoses, null);
            const curPoses = {};
            this._collectPose(curAnim, this._curPhase, curPoses, null);
            // Объединяем ключи обеих поз.
            const keys = new Set([
                ...Object.keys(prevPoses), ...Object.keys(curPoses),
            ]);
            for (const logical of keys) {
                const rec = this._bones[logical];
                if (!rec) continue;
                const from = prevPoses[logical] || rec.restQ;
                const to = curPoses[logical] || rec.restQ;
                Quaternion.SlerpToRef(from, to, blendAlpha, this._tmpOut);
                poses[logical] = this._tmpOut.clone();
            }
        }

        // Применяем позы. Кости без позы → rest.
        // Если у кости есть привязанный TransformNode (glTF-нода) — Babylon
        // синхронизирует кость ОТ него, поэтому пишем поворот в TransformNode.
        // Иначе — напрямую в кость через setRotationQuaternion.
        for (const logical in this._bones) {
            const rec = this._bones[logical];
            const target = poses[logical] || rec.restQ;
            if (rec.tNode) {
                if (!rec.tNode.rotationQuaternion) {
                    rec.tNode.rotationQuaternion = target.clone();
                } else {
                    rec.tNode.rotationQuaternion.copyFrom(target);
                }
            } else {
                rec.bone.setRotationQuaternion(target, Space.LOCAL);
            }
        }
    }

    /**
     * Собрать target-повороты костей для анимации в фазе phase.
     * Записывает в out: logical → Quaternion (rest × Δ).
     */
    _collectPose(anim, phase, out, _unused) {
        for (const track of anim.tracks) {
            const rec = this._bones[track.bone];
            if (!rec) continue;
            const intensity = lerpKeys(track.times, track.values, phase);
            const angle = track.angleDeg * DEG * intensity;
            // Δ = Quaternion вокруг локальной оси кости.
            Quaternion.RotationAxisToRef(track.axis, angle, this._tmpA);
            // target = restQ × Δ
            rec.restQ.multiplyToRef(this._tmpA, this._tmpB);
            // несколько треков на одну кость складываются (редко, но
            // run имеет ARM и FOREARM раздельно — разные кости, ок).
            if (out[track.bone]) {
                out[track.bone].multiplyInPlace(this._tmpA);
            } else {
                out[track.bone] = this._tmpB.clone();
            }
        }
    }

    /** Вернуть все кости в bind-позу (при выгрузке/смене скина). */
    resetToRest() {
        for (const logical in this._bones) {
            const rec = this._bones[logical];
            rec.bone.setRotationQuaternion(rec.restQ, Space.LOCAL);
        }
    }
}
