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

325 lines
14 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-режим модели — мини-редактор. Фаза 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',
};