All checks were successful
- 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>
313 lines
13 KiB
JavaScript
313 lines
13 KiB
JavaScript
// 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',
|
||
};
|