Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026). Содержит изменения которые делались в процессе подготовки прод-окружения: Фиксы импортов после выноса из minecraftia: - Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/) - Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/ - API.js скопирован из минки целиком (было 8 экспортов, стало 312) - Добавлены PLAYER_URL, MyButton_1, недостающие компоненты - Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require) Структура ассетов: - public/kubikon-templates/ → public/assets/kubikon-templates/ - public/kubikon-learn/ → public/assets/kubikon-learn/ - (код искал в /assets/, файлы лежали без /assets/) Навигация роутов внутри студии: - /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced) - /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X UI: - Новый компонент StudioHeader (61px, как в минке) + копия favicon - WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера - SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке) - Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык) Документация: - docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR - docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API - API_USAGE.md — список эндпоинтов backend - README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/ .gitignore: - public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
180 lines
9.6 KiB
JavaScript
180 lines
9.6 KiB
JavaScript
/**
|
||
* GdFinishesPreview — превью финишных ворот (10 эпох × 5 = 50).
|
||
* Маршрут: /admin-preview/gd-finishes
|
||
* Выбор юзера → kubikon3d_savegame (project_id=295, namespace='gd_finish_choices').
|
||
*/
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../auth/AuthContext.jsx';
|
||
import { jwtDecode } from 'jwt-decode';
|
||
import axios from 'axios';
|
||
import { STORYS_addres } from '../api/API';
|
||
|
||
import { Engine } from '@babylonjs/core/Engines/engine';
|
||
import { Scene } from '@babylonjs/core/scene';
|
||
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
|
||
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
|
||
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
|
||
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
|
||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||
|
||
import { FINISH_CATALOG, EPOCH_INFO, getFinishesByEpoch } from './gdFinishes/finishFactories';
|
||
|
||
const CHOICES_PID = 295;
|
||
const CHOICES_NS = 'gd_finish_choices';
|
||
function getUserId() {
|
||
try { const t = localStorage.getItem('Authorization'); if (!t) return 0; return Number(jwtDecode(t).id) || 0; } catch (e) { return 0; }
|
||
}
|
||
const api = axios.create({ baseURL: STORYS_addres, timeout: 15000 });
|
||
api.interceptors.request.use((cfg) => {
|
||
try { const token = localStorage.getItem('Authorization'); if (token) cfg.headers.Authorization = token; } catch (e) {}
|
||
return cfg;
|
||
});
|
||
|
||
function FinCard({ fin, isChosen, onChoose }) {
|
||
const wrapRef = useRef(null);
|
||
const canvasRef = useRef(null);
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
useEffect(() => {
|
||
if (!wrapRef.current) return;
|
||
const io = new IntersectionObserver((entries) => {
|
||
for (const e of entries) setIsVisible(e.isIntersecting);
|
||
}, { rootMargin: '200px', threshold: 0.01 });
|
||
io.observe(wrapRef.current);
|
||
return () => io.disconnect();
|
||
}, []);
|
||
useEffect(() => {
|
||
if (!isVisible || !canvasRef.current) return;
|
||
let engine = null, scene = null;
|
||
try {
|
||
engine = new Engine(canvasRef.current, true, { stencil: false, antialias: true });
|
||
scene = new Scene(engine);
|
||
scene.clearColor = new Color4(0.05, 0.07, 0.12, 1);
|
||
const camera = new ArcRotateCamera('cam', -Math.PI / 2.5, Math.PI / 2.6, 8.5, new Vector3(0, 2.2, 0), scene);
|
||
camera.attachControl(canvasRef.current, false);
|
||
camera.minZ = 0.1; camera.maxZ = 50;
|
||
new HemisphericLight('hemi', new Vector3(0, 1, 0), scene).intensity = 0.7;
|
||
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), scene);
|
||
sun.intensity = 0.8;
|
||
const floor = MeshBuilder.CreateGround('floor', { width: 7, height: 4 }, scene);
|
||
const fmat = new StandardMaterial('fmat', scene);
|
||
fmat.diffuseColor = new Color3(0.15, 0.20, 0.18);
|
||
floor.material = fmat;
|
||
const handle = fin.make(scene, `prev_${fin.id}`);
|
||
if (handle && handle.root) handle.root.position.y = 0;
|
||
scene.onBeforeRenderObservable.add(() => {
|
||
if (handle && handle.root) handle.root.rotation.y += 0.005;
|
||
});
|
||
engine.runRenderLoop(() => scene.render());
|
||
return () => {
|
||
try { engine && engine.stopRenderLoop(); } catch (e) {}
|
||
try { handle && handle.dispose && handle.dispose(); } catch (e) {}
|
||
try { scene && scene.dispose(); } catch (e) {}
|
||
try { engine && engine.dispose(); } catch (e) {}
|
||
};
|
||
} catch (e) { console.warn('[FinCard] init failed', e); return () => {}; }
|
||
}, [isVisible, fin]);
|
||
return (
|
||
<div ref={wrapRef} onClick={onChoose}
|
||
style={{
|
||
...cardStyle,
|
||
border: isChosen ? '3px solid #22ff66' : '2px solid #2a3146',
|
||
boxShadow: isChosen ? '0 0 16px rgba(34,255,102,0.4)' : 'none',
|
||
cursor: 'pointer',
|
||
}}>
|
||
{isVisible
|
||
? <canvas ref={canvasRef} style={{ width: '100%', height: 260, display: 'block', background: '#0a1020' }} />
|
||
: <div style={{ width: '100%', height: 260, background: '#0a1020', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#444' }}>•••</div>}
|
||
<div style={{ padding: '10px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#fff', marginBottom: 2 }}>{fin.title}</div>
|
||
<div style={{ fontSize: 11, color: '#888' }}><code>{fin.id}</code></div>
|
||
</div>
|
||
{isChosen && <span style={{ color: '#22ff66', fontSize: 20 }}>✓</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function GdFinishesPreview() {
|
||
const { userRole, isLoading } = useAuth();
|
||
const navigate = useNavigate();
|
||
const [choices, setChoices] = useState({});
|
||
const [status, setStatus] = useState('idle');
|
||
useEffect(() => {
|
||
const uid = getUserId();
|
||
if (!uid) return;
|
||
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
|
||
.then((r) => setChoices(r.data?.data || {}))
|
||
.catch(() => {});
|
||
}, []);
|
||
const choose = (epoch, finId) => { setChoices(prev => ({ ...prev, [epoch]: finId })); setStatus('idle'); };
|
||
const save = async () => {
|
||
const uid = getUserId();
|
||
if (!uid) { setStatus('error'); return; }
|
||
setStatus('loading');
|
||
try {
|
||
await api.post(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`, { data: choices });
|
||
setStatus('saved');
|
||
} catch (e) { console.warn('[GdFinishesPreview] save failed', e); setStatus('error'); }
|
||
};
|
||
if (isLoading) return <div style={{ padding: 24, color: '#fff' }}>Загрузка...</div>;
|
||
if (userRole !== 'admin') {
|
||
return (
|
||
<div style={{ padding: 24, color: '#fff', background: '#070a14', minHeight: '100vh' }}>
|
||
<h2>Доступ только для администратора</h2>
|
||
<button onClick={() => navigate('/')} style={btnStyle}>На главную</button>
|
||
</div>
|
||
);
|
||
}
|
||
const chosenCount = Object.keys(choices).length;
|
||
return (
|
||
<div style={{ minHeight: '100vh', background: '#070a14', color: '#fff', padding: '20px 24px' }}>
|
||
<div style={{
|
||
position: 'sticky', top: 0, background: '#070a14', zIndex: 10,
|
||
padding: '12px 0', marginBottom: 16, borderBottom: '1px solid #2a3146',
|
||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||
}}>
|
||
<button onClick={() => navigate('/')} style={btnStyle}>← Назад</button>
|
||
<h1 style={{ margin: 0, fontSize: 24, color: '#22ff66' }}>GD — Финишные ворота ({FINISH_CATALOG.length})</h1>
|
||
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
|
||
Выбрано: <span style={{ color: '#fff', fontWeight: 700 }}>{chosenCount}/10</span> эпох
|
||
</div>
|
||
<button onClick={save} disabled={status === 'loading' || chosenCount === 0} style={{
|
||
...btnPrimary, opacity: status === 'loading' || chosenCount === 0 ? 0.5 : 1,
|
||
}}>
|
||
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
|
||
</button>
|
||
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка</span>}
|
||
</div>
|
||
{EPOCH_INFO.map(epoch => {
|
||
const fins = getFinishesByEpoch(epoch.n);
|
||
const chosenId = choices[epoch.n];
|
||
return (
|
||
<div key={epoch.n} style={{ marginBottom: 32 }}>
|
||
<h2 style={{ fontSize: 22, color: epoch.color, marginBottom: 12, borderBottom: `2px solid ${epoch.color}33`, paddingBottom: 8 }}>
|
||
{epoch.emoji} Эпоха {epoch.n} — {epoch.name}
|
||
<span style={{ fontSize: 14, color: '#666', marginLeft: 12 }}>L{(epoch.n - 1) * 10 + 1} – L{epoch.n * 10}</span>
|
||
{chosenId && (<span style={{ fontSize: 14, color: '#22ff66', marginLeft: 12 }}>✓ выбран: <code>{chosenId}</code></span>)}
|
||
</h2>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||
gap: 16,
|
||
}}>
|
||
{fins.map(fin => (
|
||
<FinCard key={fin.id} fin={fin} isChosen={chosenId === fin.id} onChoose={() => choose(epoch.n, fin.id)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const cardStyle = { background: '#0e1525', borderRadius: 10, overflow: 'hidden', transition: 'all 0.15s' };
|
||
const btnStyle = { padding: '10px 18px', background: '#2a3146', color: '#cdd4e0', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14 };
|
||
const btnPrimary = { padding: '10px 24px', background: 'linear-gradient(135deg, #22ff66, #44aaff)', color: '#000', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 700, fontSize: 15 };
|