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)
325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
// Preview-режим модели — мини-редактор. Фаза 5.6 RUBLOX_DESIGNER_PLAN.md.
|
||
//
|
||
// URL: https://player.rublox.pro/_preview-model/<itemId>#team_jwt=<JWT>
|
||
//
|
||
// Что умеет:
|
||
// 1. Пустой мир, модель в центре, гизмо привязано — двигай, крути, масштабируй.
|
||
// 2. Кнопки тулбара: Select / Move / Rotate / Scale (хоткеи Q/W/E/R),
|
||
// Reset (вернуть модель в (0,0,0)), Play (запустить персонажа).
|
||
// 3. Play: спавнится персонаж рядом с моделью, WASD/Space — можно
|
||
// бегать, прыгать, проверять коллизии. Кнопка Stop → назад в editor.
|
||
//
|
||
// Используется ВСЯ инфра editor'a плеера: ModelManager.addInstance + 'url:<URL>'
|
||
// (см. ModelManager._resolveModelType, мы добавили туда case),
|
||
// SelectionManager, GizmoController, BabylonScene.enterPlayMode/exitPlayMode.
|
||
//
|
||
// Когда rublox-site editor будет готов как полноценный игровой редактор
|
||
// (RUBLOX_EDITOR_ROADMAP.md) — этот test-режим перенести туда.
|
||
|
||
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 PreviewModelRoute() {
|
||
const { itemId } = useParams();
|
||
const navigate = useNavigate();
|
||
const { isAuthenticated, isLoading: authLoading, error: authError } = useAuth();
|
||
|
||
const canvasRef = useRef(null);
|
||
const sceneRef = useRef(null);
|
||
const modelInstanceIdRef = useRef(null);
|
||
const [model, setModel] = useState(null);
|
||
const [loadErr, setLoadErr] = useState(null);
|
||
const [phase, setPhase] = useState('auth'); // auth | loading | ready | error
|
||
const [gizmoMode, setGizmoMode] = useState('move');
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
|
||
// 1) грузим item
|
||
useEffect(() => {
|
||
if (!isAuthenticated) return;
|
||
setPhase('loading');
|
||
Kubikon3DApi.getDesignerModel(itemId)
|
||
.then((r) => {
|
||
const m = r?.data?.item || r?.data || r;
|
||
if (!m || !m.file_path) throw new Error('Модель не найдена');
|
||
setModel(m);
|
||
})
|
||
.catch((e) => {
|
||
const msg = e?.response?.data?.message
|
||
|| e?.response?.data?.error
|
||
|| e?.message || 'Не удалось загрузить модель';
|
||
setLoadErr(msg);
|
||
setPhase('error');
|
||
});
|
||
}, [isAuthenticated, itemId]);
|
||
|
||
// 2) инит сцены + спавн модели + выделение + гизмо
|
||
useEffect(() => {
|
||
if (!model || !canvasRef.current) return;
|
||
if (sceneRef.current) return;
|
||
|
||
let scene;
|
||
let destroyed = false;
|
||
(async () => {
|
||
try {
|
||
scene = new BabylonScene(canvasRef.current);
|
||
sceneRef.current = scene;
|
||
scene.init();
|
||
// По умолчанию у BabylonScene activeTool='block' (ставит grass-кубы
|
||
// при клике). Для preview-режима это не нужно — переключаем на
|
||
// 'select' чтобы курсор просто выделял модель и работал с гизмо.
|
||
try { scene.setActiveTool?.('select'); } catch (e) {}
|
||
// Спавним модель в (0, 0.5, 0) через ModelManager — он умеет
|
||
// 'url:<URL>' с момента подфазы 5.6 (см. _resolveModelType).
|
||
// y=0.5 чтобы модель стояла НА земле (если её origin в центре, то 0.5)
|
||
const instanceId = await scene.modelManager?.addInstance?.(
|
||
'url:' + model.file_path, 0, 0.5, 0, 0,
|
||
);
|
||
if (destroyed) return;
|
||
if (instanceId == null) {
|
||
throw new Error('ModelManager не смог загрузить модель');
|
||
}
|
||
modelInstanceIdRef.current = instanceId;
|
||
// Выделяем модель — это автоматом привяжет гизмо через
|
||
// _updateGizmoForSelection (BabylonScene:1311).
|
||
try {
|
||
scene.selection?.selectModelByInstanceId?.(instanceId);
|
||
} catch (e) { /* selection mог не уметь — гизмо подключим вручную */ }
|
||
// Дефолтный режим — move
|
||
scene.setGizmoMode?.('move');
|
||
setGizmoMode('move');
|
||
setPhase('ready');
|
||
} catch (e) {
|
||
// eslint-disable-next-line no-console
|
||
console.error('[PreviewModel] 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) { /* ignore */ }
|
||
sceneRef.current = null;
|
||
};
|
||
}, [model]);
|
||
|
||
// 3) хоткеи Q/W/E/R + Space
|
||
useEffect(() => {
|
||
function onKey(e) {
|
||
// не перехватываем если в инпуте
|
||
if (e.target && (e.target.tagName === 'INPUT'
|
||
|| e.target.tagName === 'TEXTAREA')) return;
|
||
if (isPlaying) return; // в Play режиме плеер сам управляет клавишами
|
||
const k = e.key.toLowerCase();
|
||
if (k === 'q') changeMode('select');
|
||
else if (k === 'w') changeMode('move');
|
||
else if (k === 'e') changeMode('rotate');
|
||
else if (k === 'r') changeMode('scale');
|
||
}
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [isPlaying]);
|
||
|
||
function changeMode(m) {
|
||
setGizmoMode(m);
|
||
const scene = sceneRef.current;
|
||
if (!scene) return;
|
||
scene.setGizmoMode?.(m);
|
||
// Любое из 4 положений тулбара = курсор «select», а не «class blocks/models».
|
||
// Без этого клик на сцену ставит grass-кубы (BabylonScene._activeTool='block' по умолчанию).
|
||
try { scene.setActiveTool?.('select'); } catch (e) {}
|
||
}
|
||
|
||
function resetModel() {
|
||
const scene = sceneRef.current;
|
||
const iid = modelInstanceIdRef.current;
|
||
if (!scene || iid == null) return;
|
||
const inst = scene.modelManager?.getInstance?.(iid)
|
||
|| scene.modelManager?._instances?.get?.(iid);
|
||
if (!inst || !inst.root) return;
|
||
inst.root.position.x = 0;
|
||
inst.root.position.y = 0.5;
|
||
inst.root.position.z = 0;
|
||
inst.root.rotation.y = 0;
|
||
inst.root.scaling.set(1, 1, 1);
|
||
}
|
||
|
||
function togglePlay() {
|
||
const scene = sceneRef.current;
|
||
if (!scene) return;
|
||
if (!isPlaying) {
|
||
// Спавним персонажа в (3, 1, 0) — рядом, чтобы видеть модель в кадре
|
||
try { scene.spawnPoint = { x: 3, y: 1, z: 0 }; } catch (e) {}
|
||
try { scene.setPlayerModelType?.('skin_bacon-hair'); } catch (e) {}
|
||
try { scene.enterPlayMode?.(); } catch (e) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('enterPlayMode failed', e);
|
||
}
|
||
setIsPlaying(true);
|
||
} else {
|
||
try { scene.exitPlayMode?.(); } catch (e) {}
|
||
setIsPlaying(false);
|
||
// После exit гизмо могло слетать — переподключаем
|
||
const iid = modelInstanceIdRef.current;
|
||
if (iid != null) {
|
||
try { scene.selection?.selectModelByInstanceId?.(iid); } catch (e) {}
|
||
try { scene.setGizmoMode?.(gizmoMode); } catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── render ────────────────────────────────────────────────────────
|
||
|
||
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 }}>
|
||
Тест модели: «{model ? model.name : '...'}»
|
||
</div>
|
||
<div style={{ fontSize: 12, opacity: 0.7, marginTop: 2 }}>
|
||
{model ? `${model.category} · ${model.target_height || 1.0} м` : ''}
|
||
{model && model.status !== 'published' && (
|
||
<span style={{ color: '#ffcc66', marginLeft: 8 }}>
|
||
· {model.status === 'draft' ? 'черновик' : 'на проверке'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }}
|
||
style={closeBtnStyle}>Закрыть</button>
|
||
</div>
|
||
|
||
{/* Тулбар */}
|
||
{phase === 'ready' && (
|
||
<div style={toolbarStyle}>
|
||
{!isPlaying && (
|
||
<>
|
||
<ToolBtn active={gizmoMode === 'select'} onClick={() => changeMode('select')}
|
||
label="Курсор" hint="Q"/>
|
||
<ToolBtn active={gizmoMode === 'move'} onClick={() => changeMode('move')}
|
||
label="Move" hint="W"/>
|
||
<ToolBtn active={gizmoMode === 'rotate'} onClick={() => changeMode('rotate')}
|
||
label="Rotate" hint="E"/>
|
||
<ToolBtn active={gizmoMode === 'scale'} onClick={() => changeMode('scale')}
|
||
label="Scale" hint="R"/>
|
||
<div style={separator}/>
|
||
<ToolBtn onClick={resetModel} label="Reset"
|
||
hint="модель в (0, 0)"/>
|
||
<div style={separator}/>
|
||
</>
|
||
)}
|
||
<ToolBtn onClick={togglePlay}
|
||
primary={!isPlaying}
|
||
danger={isPlaying}
|
||
label={isPlaying ? '⏹ Стоп' : '▶ Запустить'}
|
||
hint={isPlaying ? 'выйти из игры' : 'побегать персонажем'}/>
|
||
</div>
|
||
)}
|
||
|
||
{phase === 'loading' && (
|
||
<div style={overlayStyle}><div>Загрузка модели…</div></div>
|
||
)}
|
||
{phase === 'error' && (
|
||
<div style={overlayStyle}>
|
||
<div style={{ color: '#ff8888', fontWeight: 700, marginBottom: 8 }}>
|
||
Не удалось загрузить модель
|
||
</div>
|
||
<div style={{ fontSize: 13, opacity: 0.85, maxWidth: 420, textAlign: 'center' }}>
|
||
{loadErr}
|
||
</div>
|
||
<button onClick={() => window.close()}
|
||
style={{ ...closeBtnStyle, marginTop: 12, position: 'static' }}>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ToolBtn({ active, primary, danger, onClick, label, hint }) {
|
||
const bg = active ? '#3357ff'
|
||
: primary ? '#1e9a51'
|
||
: danger ? '#a93232'
|
||
: 'rgba(255,255,255,0.08)';
|
||
const color = (active || primary || danger) ? '#fff' : '#cfd6e6';
|
||
return (
|
||
<button onClick={onClick} title={hint}
|
||
style={{
|
||
padding: '7px 12px', fontSize: 13, fontWeight: 600,
|
||
background: bg, color, border: '1px solid rgba(255,255,255,0.12)',
|
||
borderRadius: 6, cursor: 'pointer',
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
}}>
|
||
{label}
|
||
{hint && hint.length <= 3 && (
|
||
<span style={{
|
||
fontSize: 10, padding: '1px 5px', borderRadius: 3,
|
||
background: 'rgba(0,0,0,0.3)', opacity: 0.7,
|
||
}}>{hint}</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const bannerStyle = {
|
||
position: 'absolute', top: 12, left: 12,
|
||
background: 'rgba(10, 14, 26, 0.88)', color: '#f1f5fb',
|
||
// padding-right больше, чтобы текст никогда не залезал под кнопку «Закрыть»
|
||
// (она absolute в правом верхнем углу баннера, ширина ~70px).
|
||
padding: '10px 90px 10px 14px',
|
||
borderRadius: 8, border: '1px solid rgba(255,255,255,0.10)',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
maxWidth: 'calc(100vw - 40px)',
|
||
// Делаем баннер inline-block чтобы кнопка позиционировалась относительно него.
|
||
display: 'inline-block',
|
||
};
|
||
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',
|
||
};
|
||
const toolbarStyle = {
|
||
position: 'absolute', bottom: 16, left: '50%', transform: 'translateX(-50%)',
|
||
background: 'rgba(10, 14, 26, 0.92)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
borderRadius: 10, padding: 8, gap: 6,
|
||
display: 'inline-flex', alignItems: 'center',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
};
|
||
const separator = {
|
||
width: 1, height: 22, background: 'rgba(255,255,255,0.15)',
|
||
margin: '0 2px',
|
||
};
|