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)
175 lines
7.6 KiB
JavaScript
175 lines
7.6 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
}
|