// Preview-режим аватара (полное тело R15). 2026-05-27. // // URL: https://player.rublox.pro/_preview-avatar/#team_jwt= // // Пустой мир + персонаж с указанным аватаром (вместо дефолтного bacon-hair). // Можно бегать WASD, прыгать, крутить камеру — проверить как выглядит // и анимируется новый аватар. // // Реализация: scene.setPlayerModelType('designer_avatar:') → // _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 ; if (authError === 'no_jwt_local') { return ; } if (!isAuthenticated) { return ; } return (
Тест аватара: «{avatar ? avatar.name : '...'}»
{avatar ? `код ${avatar.code}` : ''} {avatar && avatar.status !== 'published' && ( · {avatar.status === 'draft' ? 'черновик' : 'на проверке'} )}
WASD — движение · Space — прыжок · мышь — камера
{phase === 'loading' && (
Загрузка аватара…
)} {phase === 'error' && (
Не удалось загрузить аватар
{loadErr}
)}
); } 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', };