- MixamoAnimator обновлён: jump_anticipate/air/land, jump_fwd_*, jump_run_* - PlayerController: _jumpKind (in_place/forward/run), anticipate-фаза с отложенным импульсом, coyote-фильтр спуска по лестнице (microAir) - студия теперь анимирует прыжки 1-в-1 как плеер Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
582 lines
30 KiB
JavaScript
582 lines
30 KiB
JavaScript
/**
|
||
* 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", "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<state, AnimationGroup> — кастомные группы для ЭТОГО скелета */
|
||
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",
|
||
"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.
|
||
const BLEND_MS = 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);
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
}
|