player/src/PreviewSkin/PreviewEmoteRoute.jsx
МИН 9c79da4ce5
All checks were successful
CI / Lint (pull_request) Successful in 58s
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 2m28s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
fix(lint): устранить 8 eslint-ошибок (предсущ., всплыли после починки конфига)
- no-dupe-keys: дубль ключа эмодзи '🟧' в Icon.jsx
- no-useless-escape: лишний \- в regex (ticketExchange, EmoteGlbParser)
- no-extra-semi: висячие ; в PreviewSkin-route (auto-fix)
Лок. eslint: 0 errors, 118 warnings (< max-warnings 200).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:30:50 +03:00

313 lines
13 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.

// Preview-режим emote-анимации. 2026-05-27.
//
// URL: https://player.rublox.pro/_preview-emote/<itemId>#team_jwt=<JWT>
//
// Грузит дефолтного бекона + указанный GLB-emote, retargeting на скелет
// бекона по именам костей (Mixamo). Запускает анимацию в цикле.
// Кнопка «Проиграть ещё раз» — рестартует.
import { useEffect, useRef, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/PlayerAuth';
import { BabylonScene } from '../engine/BabylonScene';
import LoadingScreen from '../LoadingScreen';
import { SceneLoader } from '@babylonjs/core/Loading/sceneLoader';
import '@babylonjs/loaders/glTF';
import { parseEmoteGlbToSpec } from '../engine/EmoteGlbParser';
export default function PreviewEmoteRoute() {
const { itemId } = useParams();
const navigate = useNavigate();
const { isAuthenticated, isLoading: authLoading, error: authError } = useAuth();
const canvasRef = useRef(null);
const sceneRef = useRef(null);
const animGroupRef = useRef(null);
const [emote, setEmote] = useState(null);
const [loadErr, setLoadErr] = useState(null);
const [phase, setPhase] = useState('auth');
// 1) Грузим метаданные emote через публичный эндпоинт.
// Используем JWT (если есть) — для draft/testing.
useEffect(() => {
if (!isAuthenticated) return;
setPhase('loading');
// JWT плеера лежит под ключом 'player_jwt' (см. ticketExchange.js).
// team_jwt из hash тоже сохраняется в этот же ключ через saveJWT.
const headers = {};
try {
const jwt = localStorage.getItem('player_jwt');
if (jwt) headers['Authorization'] = 'Bearer ' + jwt;
} catch (e) {}
fetch('/api-storys/rublox/emotes/' + itemId, { headers })
.then((r) => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then((e) => {
if (!e || !e.file_path) throw new Error('Emote не найден');
setEmote(e);
})
.catch((e) => {
setLoadErr(e?.message || 'Не удалось загрузить emote');
setPhase('error');
});
}, [isAuthenticated, itemId]);
// 2) Сцена + бекон + retargeting emote на его скелет
useEffect(() => {
if (!emote || !canvasRef.current) return;
if (sceneRef.current) return;
let scene, destroyed = false;
(async () => {
try {
scene = new BabylonScene(canvasRef.current);
sceneRef.current = scene;
scene.init();
try { scene.setActiveTool?.('select'); } catch (e) {}
try { scene.spawnPoint = { x: 0, y: 1, z: 0 }; } catch (e) {}
// Бекон как дефолт
try { scene.setPlayerModelType?.('skin_bacon-hair'); } catch (e) {}
scene.enterPlayMode?.();
// Дожидаемся появления PlayerController с инициализированным R15Animator
const playerController = await waitForR15Animator(scene, 5000);
if (destroyed) return;
if (!playerController) {
throw new Error('R15Animator не инициализировался у игрока — бекон не загрузился?');
}
// Грузим GLB-emote (только armature + AnimationGroup; меши скрываем).
// Babylon SceneLoader не умеет читать API-эндпоинты, которые могут
// требовать JWT — сначала загружаем blob через fetch, кладём в
// memory File и подсовываем SceneLoader через blob-URL.
const authHeader = (() => {
try {
const jwt = localStorage.getItem('player_jwt');
return jwt ? 'Bearer ' + jwt : null;
} catch (e) { return null; }
})();
const glbResp = await fetch(emote.file_path, {
headers: authHeader ? { 'Authorization': authHeader } : {},
});
if (!glbResp.ok) {
throw new Error(`Не удалось скачать GLB: HTTP ${glbResp.status}`);
}
const glbBlob = await glbResp.blob();
const blobUrl = URL.createObjectURL(glbBlob);
let result;
try {
// 4-аргументная форма: (rootUrl, sceneFilename, scene, onProgress).
// sceneFilename начинается с 'http' / 'blob:' / 'data:' → SceneLoader
// берёт его как абсолютный URL целиком. Для glTF-плагина нужно явное
// pluginExtension ('.glb') — иначе он не понимает расширение
// у blob://uuid URL.
result = await SceneLoader.ImportMeshAsync(
'', // мешами не фильтруем — берём все
'', // rootUrl пустой (URL в sceneFilename)
blobUrl, // sceneFilename = blob-URL
scene._scene || scene.scene,
null, // onProgress
'.glb', // pluginExtension — обязательно для blob
);
} finally {
URL.revokeObjectURL(blobUrl);
}
if (destroyed) return;
for (const m of result.meshes || []) {
if (m.setEnabled) m.setEnabled(false);
}
const groups = result.animationGroups || [];
if (groups.length === 0) {
throw new Error('В GLB нет анимаций (animationGroups[] пустой)');
}
const group = groups[0];
group.stop();
// Парсим AnimationGroup → delta-spec (одна запись на кость).
// restQ берём ИЗ ТЕКУЩИХ значений target (= bind pose исходника).
const spec = parseEmoteGlbToSpec(group, emote.is_loop);
if (!spec || !spec.perBoneFrames) {
throw new Error('Парсер не извлёк ни одного канала из GLB');
}
const boneCount = Object.keys(spec.perBoneFrames).length;
console.log(`[PreviewEmote] parsed: ${boneCount} R15-bones, length=${spec.length.toFixed(2)}s, loop=${spec.loop}`);
if (boneCount === 0) {
throw new Error(
'Ни одна кость GLB не сопоставилась с R15-скелетом. ' +
'Ожидаются Mixamo-имена (Hips/Spine/RightArm/...).',
);
}
// Удаляем импортированный armature чтобы он не «дёргался» сам
// (его AnimationGroup мы парсенули и больше не нужен).
try { group.stop(); group.dispose(); } catch (e) {}
try {
for (const m of result.meshes || []) {
if (m.dispose) m.dispose();
}
for (const s of result.skeletons || []) {
if (s.dispose) s.dispose();
}
} catch (e) {}
// Запускаем кастомный emote через R15Animator
const ok = playerController.playCustomEmote(spec);
if (!ok) throw new Error('playCustomEmote вернул false');
// Запомним spec чтобы кнопка «Проиграть ещё раз» работала
animGroupRef.current = { spec, controller: playerController };
if (!destroyed) setPhase('ready');
} catch (e) {
console.error('[PreviewEmote] failed', e);
if (!destroyed) {
setLoadErr(e?.message || 'Ошибка инициализации');
setPhase('error');
}
}
})();
return () => {
destroyed = true;
try { animGroupRef.current?.stop(); } catch (e) {}
try {
if (scene && typeof scene.dispose === 'function') scene.dispose();
else if (scene && scene.engine) scene.engine.dispose();
} catch (e) {}
sceneRef.current = null;
};
}, [emote]);
const replay = useCallback(() => {
const ref = animGroupRef.current;
if (ref && ref.controller && ref.spec) {
ref.controller.stopEmote?.();
ref.controller.playCustomEmote(ref.spec);
}
}, []);
if (authLoading) return <LoadingScreen text="Подключение" />;
if (authError === 'no_jwt_local') {
return <LoadingScreen text="Нужен JWT"
subText="Открой из team.rublox.pro кнопкой «Тестировать»." />;
}
if (!isAuthenticated) {
return <LoadingScreen text="Сессия истекла"
subText="Вернись в team.rublox.pro и попробуй снова." />;
}
return (
<div style={{ position: 'fixed', inset: 0, background: '#000' }}>
<canvas ref={canvasRef}
style={{ width: '100%', height: '100%', display: 'block',
touchAction: 'none', outline: 'none' }}/>
<div style={bannerStyle}>
<div style={{ fontWeight: 700, fontSize: 14 }}>
Тест emote: «{emote ? emote.name : '...'}»
</div>
<div style={{ fontSize: 12, opacity: 0.7, marginTop: 2 }}>
{emote ? `код ${emote.code} · ${emote.is_loop ? 'loop' : 'однократно'}` : ''}
{emote && emote.status !== 'published' && (
<span style={{ color: '#ffcc66', marginLeft: 8 }}>
· {emote.status === 'draft' ? 'черновик' : 'на проверке'}
</span>
)}
</div>
<button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
style={closeBtnStyle}>Закрыть</button>
</div>
<div style={{
position: 'absolute', bottom: 16, left: '50%',
transform: 'translateX(-50%)',
display: 'flex', gap: 8, alignItems: 'center',
}}>
<button onClick={replay} style={primaryBtnStyle}>
Проиграть ещё раз
</button>
<div style={hintStyle}>
WASD двигать камеру · мышь повернуть
</div>
</div>
{phase === 'loading' && (
<div style={overlayStyle}><div>Загрузка emote</div></div>
)}
{phase === 'error' && (
<div style={overlayStyle}>
<div style={{ color: '#ff8888', fontWeight: 700, marginBottom: 8 }}>
Ошибка
</div>
<div style={{ fontSize: 13, opacity: 0.85, maxWidth: 480,
textAlign: 'center', lineHeight: 1.5 }}>
{loadErr}
</div>
<button onClick={() => window.close()}
style={{ ...closeBtnStyle, marginTop: 12, position: 'static' }}>
Закрыть
</button>
</div>
)}
</div>
);
}
/**
* Ждать пока у PlayerController появится инициализированный R15Animator.
* Возвращает PlayerController или null по таймауту.
*/
async function waitForR15Animator(scene, timeoutMs) {
const start = Date.now();
return new Promise((resolve) => {
const tick = () => {
try {
// PlayerController хранится в scene.player (см. BabylonScene.js:196)
const pc = scene.player || scene._playerController;
if (pc && pc._r15Animator && pc._isR15) {
return resolve(pc);
}
} catch (e) {}
if (Date.now() - start > timeoutMs) return resolve(null);
requestAnimationFrame(tick);
};
tick();
});
}
const bannerStyle = {
position: 'absolute', top: 12, left: 12,
background: 'rgba(10, 14, 26, 0.88)', color: '#f1f5fb',
padding: '10px 90px 10px 14px',
borderRadius: 8, border: '1px solid rgba(255,255,255,0.10)',
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
display: 'inline-block',
maxWidth: 'calc(100vw - 40px)',
};
const closeBtnStyle = {
position: 'absolute', top: 10, right: 10,
background: 'rgba(255, 111, 122, 0.18)', color: '#ff6f7a',
border: '1px solid rgba(255, 111, 122, 0.40)',
borderRadius: 6, padding: '4px 10px',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
};
const primaryBtnStyle = {
background: '#3357FF', color: '#fff',
border: 'none', borderRadius: 8,
padding: '8px 16px', fontSize: 13, fontWeight: 700,
cursor: 'pointer',
};
const hintStyle = {
background: 'rgba(10, 14, 26, 0.88)', color: '#f1f5fb',
padding: '8px 14px', borderRadius: 8,
border: '1px solid rgba(255,255,255,0.10)',
fontSize: 12,
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
};
const overlayStyle = {
position: 'absolute', inset: 0,
background: 'rgba(0,0,0,0.65)', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', fontFamily: 'system-ui, sans-serif',
};