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)
207 lines
8.8 KiB
JavaScript
207 lines
8.8 KiB
JavaScript
// 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',
|
||
};
|