player/src/PreviewSkin/PreviewAvatarRoute.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

207 lines
8.8 KiB
JavaScript
Raw Permalink 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-режим аватара (полное тело R15). 2026-05-27.
//
// URL: https://player.rublox.pro/_preview-avatar/<itemId>#team_jwt=<JWT>
//
// Пустой мир + персонаж с указанным аватаром (вместо дефолтного bacon-hair).
// Можно бегать WASD, прыгать, крутить камеру — проверить как выглядит
// и анимируется новый аватар.
//
// Реализация: scene.setPlayerModelType('designer_avatar:<id>') →
// _resolveModelSource подтянет item.file_path из БД и подгрузит body.glb.
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/PlayerAuth';
import { BabylonScene } from '../engine/BabylonScene';
import * as Kubikon3DApi from '../api/Kubikon3DService';
import LoadingScreen from '../LoadingScreen';
export default function PreviewAvatarRoute() {
const { itemId } = useParams();
const navigate = useNavigate();
const { isAuthenticated, isLoading: authLoading, error: authError } = useAuth();
const canvasRef = useRef(null);
const sceneRef = useRef(null);
const [avatar, setAvatar] = useState(null);
const [loadErr, setLoadErr] = useState(null);
const [phase, setPhase] = useState('auth');
// 1) грузим метаданные аватара
useEffect(() => {
if (!isAuthenticated) return;
setPhase('loading');
Kubikon3DApi.getDesignerAvatar(itemId)
.then((r) => {
const a = r?.data?.item || r?.data || r;
if (!a || !a.file_path) throw new Error('Аватар не найден');
setAvatar(a);
})
.catch((e) => {
const msg = e?.response?.data?.message
|| e?.response?.data?.error
|| e?.message || 'Не удалось загрузить аватара';
setLoadErr(msg);
setPhase('error');
});
}, [isAuthenticated, itemId]);
// 2) сцена + спавн персонажа с этим аватаром
useEffect(() => {
if (!avatar || !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) {}
// Главное — задать modelType ДО enterPlayMode чтобы PlayerController
// подхватил наш designer_avatar.
// Сбрасываем глобальный флаг ошибки fallback'а перед загрузкой —
// если PlayerController не сможет дозагрузить, флаг будет установлен.
try { window.__previewFallbackReason = null; } catch (e) {}
try { scene.setPlayerModelType?.(`designer_avatar:${avatar.id}`); } catch (e) {}
scene.enterPlayMode?.();
// Подождём короткое время и проверим — был ли silent-fallback на бекона
await new Promise(r => setTimeout(r, 1500));
if (destroyed) return;
try {
const reason = window.__previewFallbackReason;
if (reason) {
setLoadErr(
'Аватар не загрузился — показан стандартный бекон.\n\n'
+ 'Причина: ' + reason + '\n\n'
+ 'Возможные причины:\n'
+ '• Роль «дизайнер» ещё не пробросилась (подожди до 60 секунд после назначения)\n'
+ '• JWT истёк — открой через кнопку «Тестировать» в кабинете заново\n'
+ '• Аватар удалён из БД'
);
setPhase('error');
return;
}
} catch (e) {}
if (!destroyed) setPhase('ready');
} catch (e) {
console.error('[PreviewAvatar] scene init failed', e);
if (!destroyed) {
setLoadErr(e?.message || 'Ошибка инициализации сцены');
setPhase('error');
}
}
})();
return () => {
destroyed = true;
try {
if (scene && typeof scene.dispose === 'function') scene.dispose();
else if (scene && scene.engine) scene.engine.dispose();
} catch (e) {}
sceneRef.current = null;
};
}, [avatar]);
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 }}>
Тест аватара: «{avatar ? avatar.name : '...'}»
</div>
<div style={{ fontSize: 12, opacity: 0.7, marginTop: 2 }}>
{avatar ? `код ${avatar.code}` : ''}
{avatar && avatar.status !== 'published' && (
<span style={{ color: '#ffcc66', marginLeft: 8 }}>
· {avatar.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%)',
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',
}}>
WASD движение · Space прыжок · мышь камера
</div>
{phase === 'loading' && (
<div style={overlayStyle}><div>Загрузка аватара</div></div>
)}
{phase === 'error' && (
<div style={overlayStyle}>
<div style={{ color: '#ff8888', fontWeight: 700, marginBottom: 8,
fontSize: 16 }}>
Не удалось загрузить аватар
</div>
<div style={{ fontSize: 13, opacity: 0.9, maxWidth: 540,
textAlign: 'left', whiteSpace: 'pre-wrap',
lineHeight: 1.55,
padding: '12px 16px',
background: 'rgba(255, 100, 100, 0.06)',
border: '1px solid rgba(255, 100, 100, 0.25)',
borderRadius: 6 }}>
{loadErr}
</div>
<button onClick={() => window.location.reload()}
style={{ marginTop: 12, padding: '6px 16px', fontSize: 13,
background: '#3357ff', color: '#fff',
border: 'none', borderRadius: 6, cursor: 'pointer' }}>
Попробовать снова
</button>
<button onClick={() => window.close()}
style={{ ...closeBtnStyle, marginTop: 8, position: 'static' }}>
Закрыть
</button>
</div>
)}
</div>
);
}
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 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',
};