player/src/engine/EmoteGlbParser.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

175 lines
7.6 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.

/**
* EmoteGlbParser — конвертирует Babylon AnimationGroup (из GLB-файла) в
* R15Animator-spec формат: { length, loop, perBoneFrames }.
*
* perBoneFrames: { <logicalR15Name>: { times: [...], deltas: [Quaternion, ...] } }
* delta — это quaternion от rest-pose ИСХОДНОГО скелета.
*
* При проигрывании на бекона: target = rest_bacon × delta. Это даёт
* корректный retargeting если локальные оси костей одинаково ориентированы
* (Mixamo → R15-bacon: да, обе используют ту же конвенцию).
*
* Использование:
* const spec = parseEmoteGlbToSpec(animationGroup, importedSkeleton);
* r15Animator.playCustomEmote(spec);
*/
import { Quaternion, Vector3 } from '@babylonjs/core';
// R15-логическое имя → подстроки имени Mixamo-кости (приоритет сверху-вниз).
// Должно совпадать с R15Skeleton.R15_TO_MIXAMO, но мы здесь работаем с именами
// нод GLB напрямую (а не через R15Skeleton-резолвер).
const R15_KEYWORDS = {
HumanoidRootPart: ['hips', 'humanoidrootpart', 'root'],
LowerTorso: ['spine1', 'spine', 'lowertorso'],
UpperTorso: ['spine2', 'uppertorso'],
Head: ['head'],
Neck: ['neck'],
LeftUpperArm: ['leftarm', 'leftupperarm', 'leftshoulder'],
LeftLowerArm: ['leftforearm', 'leftlowerarm'],
LeftHand: ['lefthand'],
RightUpperArm: ['rightarm', 'rightupperarm', 'rightshoulder'],
RightLowerArm: ['rightforearm', 'rightlowerarm'],
RightHand: ['righthand'],
LeftUpperLeg: ['leftupleg', 'leftupperleg'],
LeftLowerLeg: ['leftleg', 'leftlowerleg'],
LeftFoot: ['leftfoot'],
RightUpperLeg: ['rightupleg', 'rightupperleg'],
RightLowerLeg: ['rightleg', 'rightlowerleg'],
RightFoot: ['rightfoot'],
};
function normName(raw) {
return String(raw || '')
.toLowerCase()
.replace(/mixamorig/g, '')
.replace(/[:_\s.\-]/g, '');
}
function resolveLogicalR15(boneName) {
const norm = normName(boneName);
for (const logical of Object.keys(R15_KEYWORDS)) {
const kws = R15_KEYWORDS[logical];
for (const kw of kws) {
if (norm.includes(kw)) return logical;
}
}
return null;
}
/**
* Конвертировать AnimationGroup в delta-spec.
*
* @param {AnimationGroup} group — из импортированного GLB
* @returns {object} spec = { length, loop, perBoneFrames }
*/
export function parseEmoteGlbToSpec(group, loop = false) {
if (!group) return null;
const length = group.to / 60; // glTF samplers времена в секундах,
// но Babylon хранит их в frames (60 FPS) — group.from/to это frames.
// ИСПРАВЛЕНО: используем длительность из targetedAnimations[0].animation
// если есть, иначе через group.to / 60.
// Берём реальную длительность из первой анимации (там times в секундах).
let realLength = 0;
if (group.targetedAnimations && group.targetedAnimations.length > 0) {
for (const ta of group.targetedAnimations) {
const anim = ta.animation;
const keys = anim?.getKeys?.() || [];
if (keys.length > 0) {
const lastFrame = keys[keys.length - 1].frame;
const fps = anim.framePerSecond || 60;
realLength = Math.max(realLength, lastFrame / fps);
}
}
}
if (realLength === 0) realLength = (group.to - group.from) / 60;
// Для каждой target-ноды: собираем keyframes её rotation_local + rest
const perBone = {}; // logical → { restRotQ, times, rots }
for (const ta of group.targetedAnimations || []) {
const target = ta.target;
if (!target) continue;
const tgtName = target.name || target.id || '';
const logical = resolveLogicalR15(tgtName);
if (!logical) continue;
const anim = ta.animation;
if (!anim) continue;
const propName = anim.targetProperty;
// Нас интересуют только rotation/rotationQuaternion-каналы.
// Position-каналы (translation корня Hips) пропускаем — у бекона
// позиция корня управляется движком (физика), нельзя её рулить.
const isRot = propName === 'rotationQuaternion' || propName === 'rotation';
if (!isRot) continue;
const keys = anim.getKeys() || [];
if (keys.length === 0) continue;
const fps = anim.framePerSecond || 60;
if (!perBone[logical]) {
// rest = текущее значение target до анимации (= bind pose)
const restQ = target.rotationQuaternion
? target.rotationQuaternion.clone()
: (target.rotation
? Quaternion.FromEulerAngles(
target.rotation.x, target.rotation.y, target.rotation.z)
: Quaternion.Identity());
perBone[logical] = {
restQ,
times: [],
rots: [],
};
}
const slot = perBone[logical];
// Складываем keyframes. Если есть несколько каналов на одну кость
// (например, rotation_x + rotation_y отдельно — для anim_type=Euler)
// — приходится их сэмплировать на общей сетке. Для простоты:
// если propName === 'rotation' (Vector3), конвертируем каждый кадр.
for (const k of keys) {
const t = k.frame / fps;
let q;
if (propName === 'rotationQuaternion') {
q = (k.value && k.value.clone) ? k.value.clone() : null;
} else {
// rotation (Vector3 euler XYZ)
if (k.value && typeof k.value.x === 'number') {
q = Quaternion.FromEulerAngles(k.value.x, k.value.y, k.value.z);
}
}
if (!q) continue;
// Если в этом времени уже есть запись — мерджим (умножаем).
// На практике одна кость → один канал rotation, мердж не нужен.
const existingIdx = slot.times.indexOf(t);
if (existingIdx >= 0) {
slot.rots[existingIdx] = slot.rots[existingIdx].multiply(q);
} else {
slot.times.push(t);
slot.rots.push(q);
}
}
}
// Сортируем keyframes по времени внутри каждой кости
for (const logical of Object.keys(perBone)) {
const slot = perBone[logical];
const pairs = slot.times.map((t, i) => [t, slot.rots[i]]);
pairs.sort((a, b) => a[0] - b[0]);
slot.times = pairs.map(p => p[0]);
slot.rots = pairs.map(p => p[1]);
// Конвертируем абсолютные rot в delta: delta = restQ⁻¹ × rot.
const restInv = Quaternion.Inverse(slot.restQ);
slot.deltas = slot.rots.map(q => restInv.multiply(q));
delete slot.rots;
}
return {
length: realLength || 1.0,
loop: !!loop,
perBoneFrames: perBone,
};
}