player/src/PreviewSkin/PreviewAvatarRoute.jsx
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

207 lines
8.8 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-режим аватара (полное тело 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',
};