// Preview-режим модели — мини-редактор. Фаза 5.6 RUBLOX_DESIGNER_PLAN.md. // // URL: https://player.rublox.pro/_preview-model/#team_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:' // (см. 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:' с момента подфазы 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 ; if (authError === 'no_jwt_local') { return ; } if (!isAuthenticated) { return ; } return (
{/* Верхний баннер */}
Тест модели: «{model ? model.name : '...'}»
{model ? `${model.category} · ${model.target_height || 1.0} м` : ''} {model && model.status !== 'published' && ( · {model.status === 'draft' ? 'черновик' : 'на проверке'} )}
{/* Тулбар */} {phase === 'ready' && (
{!isPlaying && ( <> changeMode('select')} label="Курсор" hint="Q"/> changeMode('move')} label="Move" hint="W"/> changeMode('rotate')} label="Rotate" hint="E"/> changeMode('scale')} label="Scale" hint="R"/>
)}
)} {phase === 'loading' && (
Загрузка модели…
)} {phase === 'error' && (
Не удалось загрузить модель
{loadErr}
)}
); } 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 ( ); } 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', };