// Preview-режим emote-анимации. 2026-05-27. // // URL: https://player.rublox.pro/_preview-emote/#team_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 ; if (authError === 'no_jwt_local') { return ; } if (!isAuthenticated) { return ; } return (
Тест emote: «{emote ? emote.name : '...'}»
{emote ? `код ${emote.code} · ${emote.is_loop ? 'loop' : 'однократно'}` : ''} {emote && emote.status !== 'published' && ( · {emote.status === 'draft' ? 'черновик' : 'на проверке'} )}
WASD — двигать камеру · мышь — повернуть
{phase === 'loading' && (
Загрузка emote…
)} {phase === 'error' && (
Ошибка
{loadErr}
)}
); } /** * Ждать пока у 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', };