/** * MixamoAnimator — проигрывает Mixamo-анимации на скелете персонажа. * * Mixamo-скины (skin_y-bot, skin_x-bot, и ещё 78) приходят БЕЗ * AnimationGroups в их собственном GLB. Анимации лежат отдельными * GLB-файлами в /character-assets/animations/: * * idle.glb, walk.glb, run.glb, jump.glb, fall.glb * emote_capoeira.glb, emote_defeated.glb, emote_shoved.glb, emote_taunt.glb * * Каждый GLB содержит ровно одну AnimationGroup, нацеленную на bones * с именами `mixamorig:Hips`, `mixamorig:Spine` и т.д. * * Что делает этот класс: * 1. Загружает 5 базовых GLB параллельно и кэширует AnimationGroup'ы * (singleton — один loader на сессию). * 2. Для конкретного скина РЕТАРГЕТИТ AnimationGroup на его кости. * Mixamo-скины разных вышедших времён имеют префикс `mixamorig:`, * `mixamorig9:` или вообще без префикса — детектим автоматически. * 3. Управление: `setState('idle'|'walk'|'run'|'jump'|'fall')` + * плавный кросс-фейд (blending) между состояниями. * 4. `playEmote(name, onDone)` — одноразово проиграть эмоцию поверх, * после конца автоматически вернуться в текущее состояние. * * Bone-имена которые ретаргетим (24 обязательных): * Hips, Spine, Spine1, Spine2, Neck, Head, * LeftShoulder, LeftArm, LeftForeArm, LeftHand, * RightShoulder, RightArm, RightForeArm, RightHand, * LeftUpLeg, LeftLeg, LeftFoot, LeftToeBase, * RightUpLeg, RightLeg, RightFoot, RightToeBase * * Использование: * const anim = new MixamoAnimator(); * await anim.load(); // один раз на сессию * anim.attach(scene, skeleton, modelRoot); // на каждую загрузку скина * anim.setState('idle'); * // каждый кадр в _tick (необязательно — Babylon сам тикает groups): * anim.update(dt); * // эмоция: * anim.playEmote('emote_taunt'); * // при смене скина: * anim.detach(); */ import { SceneLoader, AnimationGroup, Animation } from "@babylonjs/core"; import "@babylonjs/loaders/glTF"; // Базовые состояния — соответствуют файлам *.glb в animations/. // Базовые (всегда грузятся при старте — нужны для движения): const BASE_STATES = ["idle", "walk", "run", "jump", "fall"]; // Дополнительные движения (грузятся лениво при первом setState): const EXTRA_STATES = [ "jump_anticipate", "jump_air", "jump_land", "jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land", "jump_run_anticipate", "jump_run_air", "jump_run_land", "walk_backward", "run_backward", "run_to_stop", "run_slide", "jump_forward", "jump_backward", "jump_down", "crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand", "climb_up", "climb_down", "climb_to_top", "sit_idle", "lie_idle", "sleeping", "hit_react", "die_forward", "die_back", "punch_left", "kick_low", "kick_high", "gun_fire", "gun_reload", "rifle_walk", "sword_idle", "sword_slash", "push_button", "open_door", "throw_action", ]; // Эмоции (вызываются через playEmote()): const EMOTES = [ "emote_capoeira", "emote_defeated", "emote_shoved", "emote_taunt", "emote_salute", "emote_pointing", "emote_no", "dance_hiphop", "dance_rumba", "dance_breakdance", ]; // Все известные анимации (для опциональной полной предзагрузки) const ALL_ANIMATIONS = [...BASE_STATES, ...EXTRA_STATES, ...EMOTES]; // Кэш сырых данных анимаций между инстансами (singleton-ish): // один раз загрузили — используем для всех аватаров. let _cachedRawTargets = null; // { idle: [{boneName, animations:[Anim]}], walk: [...] , ... } let _loadPromise = null; /** * Строит абсолютный URL для статики Mixamo-анимаций. * Локально — localhost:3000 (rublox-site dev-server), * на проде — rublox.pro/character-assets/. */ function _assetsBase() { if (typeof window === "undefined") return ""; const isLocal = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"; return isLocal ? "http://localhost:3000" : "https://rublox.pro"; } /** * Нормализует имя кости: убирает префикс `mixamorig:`, `mixamorig9:`, * `mixamorig_` и т.п. Возвращает чистое имя типа `Hips`, `Spine`, `LeftArm`. */ function _normalizeBone(name) { if (!name) return ""; // mixamorig:Hips, mixamorig9:Hips, mixamorig_Hips, Armature|mixamorig:Hips, etc let n = name; const colon = n.lastIndexOf(":"); if (colon >= 0) n = n.slice(colon + 1); n = n.replace(/^mixamorig\d*[_:.]?/i, ""); n = n.replace(/^Armature\|/, ""); return n; } /** * Загружает один GLB-файл с анимациями. Возвращает массив * { boneName, animations: [Babylon.Animation] } — сырые треки, * привязанные к именам костей (без префикса). */ async function _loadAnimGlb(scene, url) { // ImportAnimations не годится — он сразу target-ит конкретный // скелет. Нам нужны сырые animations[], чтобы потом каждому // скину пристёгивать отдельно. const result = await SceneLoader.LoadAssetContainerAsync( url.substring(0, url.lastIndexOf("/") + 1), url.substring(url.lastIndexOf("/") + 1), scene, ); const out = []; // В GLB от Mixamo каждая кость — это TransformNode (или Bone), // содержит свои keyframe animations. После загрузки они на // result.transformNodes / result.skeletons[].bones. const allNodes = [ ...(result.transformNodes || []), ...((result.skeletons || []).flatMap(sk => sk.bones || [])), ]; for (const node of allNodes) { if (!node.animations || node.animations.length === 0) continue; const cleanName = _normalizeBone(node.name); if (!cleanName) continue; out.push({ boneName: cleanName, animations: node.animations.slice() }); } // Освободим геометрию (если случайно приехала — у анимаций мешей нет) result.dispose(); return out; } /** * Загрузить базовые анимации (idle/walk/run/jump/fall) один раз. * Дополнительные анимации (extra + эмоции) грузятся лениво в _ensureLoaded * при первом обращении — это экономит трафик: юзер качает только то что * реально использует в игре. */ export async function loadMixamoAnimations(scene) { if (_loadPromise) return _loadPromise; _cachedRawTargets = _cachedRawTargets || {}; _loadPromise = (async () => { const base = _assetsBase(); const entries = await Promise.all( BASE_STATES.map(async (name) => { try { const tracks = await _loadAnimGlb( scene, `${base}/character-assets/animations/${name}.glb`); return [name, tracks]; } catch (e) { console.warn(`[MixamoAnimator] не загрузилась '${name}':`, e?.message || e); return [name, []]; } }) ); for (const [k, v] of entries) _cachedRawTargets[k] = v; // eslint-disable-next-line no-console console.log("[MixamoAnimator] базовые анимации загружены:", Object.entries(_cachedRawTargets).map(([k, v]) => `${k}=${v.length}tracks`).join(", ")); return _cachedRawTargets; })(); return _loadPromise; } /** * Ленивая подгрузка одной анимации по имени (если ещё не в кэше). * Возвращает массив tracks или null если не удалось. */ async function _ensureLoaded(scene, name) { if (!_cachedRawTargets) _cachedRawTargets = {}; if (_cachedRawTargets[name]) return _cachedRawTargets[name]; const base = _assetsBase(); try { const tracks = await _loadAnimGlb( scene, `${base}/character-assets/animations/${name}.glb`); _cachedRawTargets[name] = tracks; // eslint-disable-next-line no-console console.log(`[MixamoAnimator] lazy-load '${name}': ${tracks.length} tracks`); return tracks; } catch (e) { console.warn(`[MixamoAnimator] не удалось загрузить '${name}':`, e?.message || e); _cachedRawTargets[name] = []; return null; } } export class MixamoAnimator { constructor() { this.scene = null; this.skeleton = null; this.modelRoot = null; /** Map — кастомные группы для ЭТОГО скелета */ this._groups = new Map(); this._currentState = null; this._currentGroup = null; this._currentEmote = null; this._emoteOnDone = null; this._blendInProgress = false; } /** * Пристёгивает аниматор к конкретному скелету (после загрузки модели). * scene — Babylon Scene, skeleton — Babylon Skeleton, modelRoot — TransformNode. */ attach(scene, skeleton, modelRoot) { this.scene = scene; this.skeleton = skeleton; this.modelRoot = modelRoot; // Резолвим маппинг "clean name" → Bone (из текущего скелета). this._cleanToBone = new Map(); for (const b of (skeleton.bones || [])) { const clean = _normalizeBone(b.name); if (clean && !this._cleanToBone.has(clean)) { this._cleanToBone.set(clean, b); } } // Также детектим target-property: TransformNode? linkedTransformNode? // Mixamo-анимации обычно нацелены на linkedTransformNode'ы (если есть), // потому что в glTF skin'ы делают joints через nodes, не через Bones. // Для каждой кости берём её _linkedTransformNode (Babylon API). this._cleanToTarget = new Map(); for (const [name, bone] of this._cleanToBone) { const tnode = bone.getTransformNode ? bone.getTransformNode() : null; this._cleanToTarget.set(name, tnode || bone); } // Запомним bind-pose позиции (особенно Hips) — нужны для нормализации // Hips.position в jump_air/jump_land и для сброса после анимаций. this._restPositions = new Map(); for (const [name, target] of this._cleanToTarget) { if (target && target.position) { this._restPositions.set(name, { x: target.position.x, y: target.position.y, z: target.position.z, }); } } } /** Создать (или достать из кэша) AnimationGroup для конкретного состояния. */ _ensureGroup(state) { if (this._groups.has(state)) return this._groups.get(state); if (!_cachedRawTargets || !_cachedRawTargets[state]) return null; const raw = _cachedRawTargets[state]; const group = new AnimationGroup(`mixamo_${state}`, this.scene); let attached = 0; for (const t of raw) { const target = this._cleanToTarget.get(t.boneName); if (!target) continue; for (const anim of t.animations) { // Клонируем анимацию (одна Babylon.Animation не может // быть в двух разных AnimationGroup одновременно). const cloned = anim.clone(); // Mixamo всегда грузит Hips.position — это сдвигает // персонажа по сцене. В in-place анимациях должно быть // близко к нулю, но иногда сдвиг есть. Для базовых // движений (walk/run/jump) фильтруем targetProperty=position // у кости с именем Hips — её двигает наш PlayerController. if (t.boneName === "Hips" && cloned.targetProperty === "position") { // 3-фазная модель прыжка: // jump_anticipate — присед перед прыжком. baseY = первый кадр // (стоячая поза → опускается ниже). // jump_air — физика поднимает _modelRoot, Hips.Y не используем. // jump_land — приземление с амортизацией. baseY = МИНИМУМ // (самая низкая точка приседа), так первый кадр будет Y > 0 // (только что приземлились, ноги пружинят к bind), // середина = 0 (присед на полу), конец = выпрямление. // Для всех остальных — фильтруем (физика двигает _modelRoot). const PHASES = new Set([ 'jump_anticipate', 'jump_land', 'jump_fwd_anticipate', 'jump_fwd_land', 'jump_run_anticipate', 'jump_run_land', ]); if (!PHASES.has(state)) { continue; } const rest = this._restPositions?.get('Hips'); try { const keys = cloned.getKeys(); if (keys && keys.length > 0 && keys[0].value) { // baseY = МАКСИМУМ Y по клипу. Тогда delta = k.Y - max // всегда ≤ 0 → Hips только опускается ниже bind. // jump_land: персонаж приземлился (ноги на полу = bind), // потом корпус опускается = присед амортизации, // потом возвращается обратно к bind (выпрямление). // jump_anticipate: то же — корпус опускается из стоячей. let maxY = -Infinity; for (const k of keys) { const y = k.value.y || 0; if (y > maxY) maxY = y; } const baseY = Number.isFinite(maxY) ? maxY : (keys[0].value.y || 0); const newKeys = keys.map(k => ({ frame: k.frame, value: new (k.value.constructor)( rest ? rest.x : 0, (rest ? rest.y : 0) + ((k.value.y || 0) - baseY), rest ? rest.z : 0, ), inTangent: k.inTangent, outTangent: k.outTangent, interpolation: k.interpolation, })); cloned.setKeys(newKeys); } } catch (e) { continue; } } group.addTargetedAnimation(cloned, target); attached++; } } if (attached === 0) { group.dispose(); // eslint-disable-next-line no-console console.warn(`[MixamoAnimator] state='${state}' — 0 целей зарезолвлено, skip`); return null; } // Зацикливаем базовые состояния, кроме jump (он one-shot). // ВАЖНО: для AnimationGroup нужно ставить loopAnimation=true НА // САМОМ GROUP до start(). Параметр loop в start() игнорируется в // некоторых версиях Babylon 7.x. // One-shot анимации (играются один раз, не зацикливаются): // jump, crouch_enter, crouch_to_stand, crouch_exit + все эмоции и // эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.) const ONE_SHOT = new Set([ "jump", "jump_forward", "jump_backward", "jump_down", "jump_anticipate", "jump_land", "jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land", "jump_run_anticipate", "jump_run_air", "jump_run_land", "crouch_enter", "crouch_to_stand", "climb_to_top", "hit_react", "die_forward", "die_back", "throw_action", "pickup", "push_button", "open_door", "gun_fire", "gun_reload", "sword_slash", "kick_low", "kick_high", "punch_left", ]); // emote_* — one-shot (один жест), dance_* — лупим (танцы должны крутиться) const loopable = !ONE_SHOT.has(state) && !state.startsWith("emote_"); group.loopAnimation = loopable; group.normalize(); // Safety-net: если Babylon всё равно по какой-то причине отыграл // клип до конца И не зациклил (что бывает с короткими "still pose" // клипами от Mixamo вроде Crouched Idle ~0.5s) — перезапускаем // принудительно. Это даёт стабильно зацикленную анимацию. if (loopable) { group.onAnimationGroupEndObservable.add(() => { if (this._currentGroup === group && !this._currentEmote) { try { group.reset(); group.start(true, 1.0, group.from, group.to, false); } catch (_) {} } }); } // eslint-disable-next-line no-console console.log(`[MixamoAnimator] group '${state}': ${attached} tracks, loop=${loopable}, duration=${((group.to - group.from) / 60).toFixed(2)}s`); this._groups.set(state, group); return group; } /** Установить состояние с плавным кросс-фейдом 150 мс. * Если анимация ещё не подгружена — стартует lazy-load, при этом * setState вернётся синхронно (без ожидания) — анимация подхватится * на следующем тике после успешной загрузки. * * Anti-flicker: между переключениями требуется минимальная задержка * 120мс (кроме переходов в воздух/идл из приземления). Это убирает * «дрожание» crouch_walk ↔ crouch_idle когда игрок едет по диагонали * и одно из направлений физически дёргается между кадрами. */ setState(state) { if (this._currentEmote) return; // эмоция блокирует смену состояния if (state === this._currentState) return; // Сброс Hips.position в bind-pose при выходе из jump-фаз. // Иначе последний keyframe анимации остаётся на Hips и idle/walk // подхватывает смещённую позицию → персонаж проседает. const JUMP_STATES = new Set([ 'jump_air', 'jump_land', 'jump_in_place', 'jump_anticipate', 'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land', 'jump_run_anticipate', 'jump_run_air', 'jump_run_land', ]); if (JUMP_STATES.has(this._currentState) && !JUMP_STATES.has(state) && this._restPositions) { const rest = this._restPositions.get('Hips'); const hips = this._cleanToTarget?.get('Hips'); if (rest && hips && hips.position) { try { hips.position.x = rest.x; hips.position.y = rest.y; hips.position.z = rest.z; } catch (_) {} } } const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()); // Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс, // КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость // и one-shot crouch_enter/crouch_to_stand (они короткие). const JUMP_VITAL = new Set([ 'jump', 'fall', 'jump_air', 'jump_land', 'jump_anticipate', 'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land', 'jump_run_anticipate', 'jump_run_air', 'jump_run_land', ]); const isVitalSwitch = JUMP_VITAL.has(state) || JUMP_VITAL.has(this._currentState) || state === 'crouch_enter' || state === 'crouch_to_stand'; if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) { // Запомним последний запрошенный state — если он не изменится за // окно debounce, тогда применим, иначе отбросим вспышку. this._pendingState = state; if (!this._debounceTimer) { const delay = Math.max(0, 120 - (now - this._lastSwitchAt)); this._debounceTimer = setTimeout(() => { this._debounceTimer = null; const s = this._pendingState; this._pendingState = null; if (s && s !== this._currentState) this.setState(s); }, delay); } return; } this._lastSwitchAt = now; // Если ещё не загружено — стартуем lazy-load, но ТЕКУЩУЮ анимацию // НЕ останавливаем (иначе в момент Ctrl-on/off персонаж зависает // в bind-pose пока crouch_idle асинхронно качается). if (!_cachedRawTargets || !_cachedRawTargets[state]) { if (!this._pendingLoads) this._pendingLoads = new Set(); if (!this._pendingLoads.has(state)) { this._pendingLoads.add(state); _ensureLoaded(this.scene, state).then(() => { this._pendingLoads.delete(state); }); } return; // подхватится при следующем setState когда tracks будут } const next = this._ensureGroup(state); if (!next) return; const prev = this._currentGroup; // Loop-флаг берём напрямую с group — _ensureGroup уже разрулил // (one-shot list + emote_* → не лупим). const loop = next.loopAnimation; // Лог переключений (только если изменилось — иначе спам) // eslint-disable-next-line no-console console.log(`[MixamoAnimator] setState: ${this._currentState || 'none'} → ${state} (loop=${loop})`); // Per-state speedRatio: подгоняем длительность под физику. // jump_fwd_air: Mixamo Jump полёт = 0.43с, физика = 0.73с // → speedRatio = 0.59 (замедлить чтобы клип не зациклился). // jump_fwd_air: Mixamo Jump полёт 0.43с, физика 0.73с → 0.59 // jump_run_air: Mixamo Running Jump полёт 0.52с, физика 0.73с → 0.71 const SPEED_RATIO = { jump_fwd_air: 0.59, jump_run_air: 0.71, }; const speedRatio = SPEED_RATIO[state] || 1.0; // Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop // в start() иногда игнорится — дублируем через loopAnimation // (выставлен в _ensureGroup). try { next.reset(); next.start(loop, speedRatio, next.from, next.to, false); } catch (e) { try { next.play(loop); } catch (_) {} } // Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS. // Climb-состояния переключаем МГНОВЕННО (0мс) — при blend'е персонаж // на доли секунды виден в промежуточном развороте (старая поза + новый // _modelYaw), что выглядит как «дёрг разворота» при входе/выходе с лестницы. const CLIMB_STATES = new Set(['climb_up', 'climb_down', 'climb_to_top']); const BLEND_MS = (CLIMB_STATES.has(state) || CLIMB_STATES.has(this._currentState)) ? 0 : 150; try { next.setWeightForAllAnimatables(0); } catch (_) {} // Снимаем ВСЕ предыдущие blend-observers — rapid-switching // (Ctrl on/off с интервалом 50ms) оставлял несколько ticker'ов. if (this._blendObservers && this._blendObservers.length) { for (const o of this._blendObservers) { try { this.scene.onBeforeRenderObservable.remove(o); } catch (_) {} } } this._blendObservers = []; // КРИТИЧНО: при ЛЮБОМ setState останавливаем ВСЕ остальные группы // кроме новой. Это убирает кейсы когда rapid-switching между // prev/next/третий оставляет висящую группу из позапрошлого setState // (и она «крутится» дальше в фоне с весом 1). for (const g of this._groups.values()) { if (g !== next) { // Не стопим текущую blend-исходную — она нужна для фейда. if (g !== prev) { try { g.stop(); g.setWeightForAllAnimatables(0); } catch (_) {} } } } if (prev && prev !== next) { const startedAt = (typeof performance !== 'undefined' ? performance.now() : Date.now()); const prevGroup = prev; const nextGroup = next; const obs = this.scene.onBeforeRenderObservable.add(() => { const nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now()); const t = Math.min(1, (nowMs - startedAt) / BLEND_MS); // Если за это время _currentGroup сменилась ещё раз — // прекращаем blend (новый setState уже разрулил). if (this._currentGroup !== nextGroup) { try { this.scene.onBeforeRenderObservable.remove(obs); } catch (_) {} return; } try { prevGroup.setWeightForAllAnimatables(1 - t); nextGroup.setWeightForAllAnimatables(t); } catch (_) {} if (t >= 1) { try { prevGroup.stop(); prevGroup.setWeightForAllAnimatables(0); } catch (_) {} try { nextGroup.setWeightForAllAnimatables(1); } catch (_) {} this.scene.onBeforeRenderObservable.remove(obs); } }); this._blendObservers.push(obs); } else { try { next.setWeightForAllAnimatables(1); } catch (_) {} } this._currentState = state; this._currentGroup = next; } /** Проиграть эмоцию (one-shot), потом вернуться в idle. * Если эмоция ещё не подгружена — подгружает на лету и стартует. */ async playEmote(name, onDone) { const tracks = await _ensureLoaded(this.scene, name); if (!tracks || tracks.length === 0) { console.warn(`[MixamoAnimator] эмоция '${name}' не загружена`); if (onDone) onDone(); return; } const group = this._ensureGroup(name); if (!group) { if (onDone) onDone(); return; } // Стоп текущего состояния if (this._currentGroup) { try { this._currentGroup.stop(); } catch (_) {} } this._currentEmote = name; this._emoteOnDone = onDone || null; const savedState = this._currentState; try { group.start(false, 1.0, group.from, group.to, false); } catch (e) { try { group.play(false); } catch (_) {} } const onEnd = () => { this._currentEmote = null; this._currentState = null; // принудим setState заново запустить this.setState(savedState || "idle"); if (this._emoteOnDone) { const cb = this._emoteOnDone; this._emoteOnDone = null; try { cb(); } catch (_) {} } }; group.onAnimationGroupEndObservable.addOnce(onEnd); } /** Тихая предзагрузка анимации в кэш (БЕЗ проигрывания). Нужно чтобы * при первом setState анимация уже была готова (нет дёрга от walk). */ preload(name) { try { _ensureLoaded(this.scene, name); } catch (e) {} } /** Babylon сам тикает AnimationGroup, но оставим для интерфейса. */ // eslint-disable-next-line no-unused-vars update(dt) { /* noop */ } /** Остановить и освободить все группы для этого скелета. */ detach() { if (this._currentGroup) { try { this._currentGroup.stop(); } catch (_) {} } for (const g of this._groups.values()) { try { g.dispose(); } catch (_) {} } this._groups.clear(); this._currentGroup = null; this._currentState = null; this._currentEmote = null; this.scene = null; this.skeleton = null; this.modelRoot = null; } }