player/src/engine/R15Animator.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

608 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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] },
]),
// === ЭМОЦИИ (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);
}
}
}