620 lines
31 KiB
JavaScript
620 lines
31 KiB
JavaScript
/**
|
||
* 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] },
|
||
]),
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Проиграть кастомный emote, спарсенный из GLB-файла дизайнера.
|
||
* spec — результат EmoteGlbParser.parseEmoteGlbToSpec():
|
||
* { length, loop, perBoneFrames: { logical: { restQ, times, deltas } } }
|
||
*
|
||
* При apply: target_bone = rest_bacon × delta. Это корректный retargeting
|
||
* (так как delta уже относительно rest исходного скелета, а локальные
|
||
* оси костей одинаково ориентированы в Mixamo и в r15-бекона).
|
||
*/
|
||
playCustomEmote(spec) {
|
||
if (!spec || !spec.perBoneFrames) return false;
|
||
this._customEmoteSpec = spec;
|
||
this._emote = {
|
||
name: '__custom__',
|
||
phase: 0,
|
||
length: spec.length || 1.0,
|
||
loop: !!spec.loop,
|
||
custom: true,
|
||
};
|
||
return true;
|
||
}
|
||
|
||
/** Прервать текущую эмоцию досрочно. */
|
||
stopEmote() {
|
||
this._emote = null;
|
||
this._customEmoteSpec = 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;
|
||
const done = this._emote.phase >= this._emote.length;
|
||
if (done) {
|
||
if (this._emote.loop) {
|
||
this._emote.phase = this._emote.phase % this._emote.length;
|
||
} else {
|
||
this._emote = null;
|
||
this._customEmoteSpec = null;
|
||
}
|
||
}
|
||
if (this._emote) {
|
||
let emPoses;
|
||
if (this._emote.custom && this._customEmoteSpec) {
|
||
// Кастомный emote из GLB-файла дизайнера. Каждая кость
|
||
// имеет свои keyframes (delta-quaternion от rest-исходника).
|
||
// target_bacon = rest_bacon × delta_interpolated.
|
||
emPoses = this._collectCustomEmotePose(
|
||
this._customEmoteSpec, this._emote.phase);
|
||
} else {
|
||
const emAnim = this._anims[this._emote.name];
|
||
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-повороты для кастомного emote из GLB.
|
||
* spec.perBoneFrames[logical] = { restQ (исходного), times, deltas }.
|
||
* Интерполируем deltas (Slerp) → умножаем на rest_target_skeleton.
|
||
*/
|
||
_collectCustomEmotePose(spec, phase) {
|
||
const out = {};
|
||
const pbf = spec.perBoneFrames || {};
|
||
for (const logical of Object.keys(pbf)) {
|
||
const rec = this._bones[logical];
|
||
if (!rec) continue;
|
||
const slot = pbf[logical];
|
||
const times = slot.times;
|
||
const deltas = slot.deltas;
|
||
if (!times || !deltas || times.length === 0) continue;
|
||
// Интерполируем delta-кватернионы через Slerp
|
||
let delta;
|
||
if (phase <= times[0]) {
|
||
delta = deltas[0];
|
||
} else if (phase >= times[times.length - 1]) {
|
||
delta = deltas[deltas.length - 1];
|
||
} else {
|
||
let i = 0;
|
||
while (i < times.length - 1 && phase > times[i + 1]) i++;
|
||
const t = (phase - times[i]) / (times[i + 1] - times[i]);
|
||
delta = Quaternion.Slerp(deltas[i], deltas[i + 1], t);
|
||
}
|
||
// target = rest_бекона × delta
|
||
out[logical] = rec.restQ.multiply(delta);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Собрать 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);
|
||
}
|
||
}
|
||
}
|