Большой консолидирующий коммит после поднятия 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>
218 lines
11 KiB
JavaScript
218 lines
11 KiB
JavaScript
/**
|
||
* GdDecoPreview — превью декораций ландшафта (10 эпох × 10 = 100).
|
||
* Маршрут: /admin-preview/gd-deco
|
||
* Multi-select: до 5 моделей на эпоху. Выбор → kubikon3d_savegame
|
||
* (project_id=295, namespace='gd_deco_choices'). data = { 1: ['d1_v1','d1_v3',...] }.
|
||
*/
|
||
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 { DECO_CATALOG, EPOCH_INFO, getDecoByEpoch } from './gdDeco/decoFactories';
|
||
|
||
const CHOICES_PID = 295;
|
||
const CHOICES_NS = 'gd_deco_choices';
|
||
const MAX_PER_EPOCH = 5;
|
||
|
||
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 DecoCard({ deco, isChosen, onToggle, disabled }) {
|
||
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.7, 5, new Vector3(0, 1.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: 4, height: 4 }, scene);
|
||
const fmat = new StandardMaterial('fmat', scene);
|
||
fmat.diffuseColor = new Color3(0.18, 0.22, 0.20);
|
||
floor.material = fmat;
|
||
const handle = deco.make(scene, `prev_${deco.id}`);
|
||
if (handle && handle.root) handle.root.position.y = 0;
|
||
scene.onBeforeRenderObservable.add(() => {
|
||
if (handle && handle.root) handle.root.rotation.y += 0.008;
|
||
});
|
||
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('[DecoCard]', e); return () => {}; }
|
||
}, [isVisible, deco]);
|
||
|
||
return (
|
||
<div
|
||
ref={wrapRef}
|
||
onClick={() => { if (!disabled || isChosen) onToggle(); }}
|
||
style={{
|
||
...cardStyle,
|
||
border: isChosen ? '3px solid #22ff66' : '2px solid #2a3146',
|
||
boxShadow: isChosen ? '0 0 16px rgba(34,255,102,0.4)' : 'none',
|
||
cursor: disabled && !isChosen ? 'not-allowed' : 'pointer',
|
||
opacity: disabled && !isChosen ? 0.45 : 1,
|
||
}}
|
||
>
|
||
{isVisible
|
||
? <canvas ref={canvasRef} style={{ width: '100%', height: 200, display: 'block', background: '#0a1020' }} />
|
||
: <div style={{ width: '100%', height: 200, background: '#0a1020', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#444' }}>•••</div>}
|
||
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff' }}>{deco.title}</div>
|
||
<div style={{ fontSize: 11, color: '#888' }}><code>{deco.id}</code></div>
|
||
</div>
|
||
{isChosen && <span style={{ color: '#22ff66', fontSize: 20 }}>✓</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function GdDecoPreview() {
|
||
const { userRole, isLoading } = useAuth();
|
||
const navigate = useNavigate();
|
||
// choices: { '1': ['d1_v1','d1_v4',...], '2': [...], ... }
|
||
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 toggle = (epoch, decoId) => {
|
||
setChoices(prev => {
|
||
const arr = prev[epoch] || [];
|
||
const has = arr.includes(decoId);
|
||
let next;
|
||
if (has) {
|
||
next = arr.filter(x => x !== decoId);
|
||
} else {
|
||
if (arr.length >= MAX_PER_EPOCH) return prev;
|
||
next = [...arr, decoId];
|
||
}
|
||
return { ...prev, [epoch]: next };
|
||
});
|
||
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('[GdDecoPreview] 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 totalChosen = Object.values(choices).reduce((s, arr) => s + (arr?.length || 0), 0);
|
||
|
||
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: 22, color: '#22ff66' }}>GD — Декорации ландшафта ({DECO_CATALOG.length})</h1>
|
||
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
|
||
Выбрано: <span style={{ color: '#fff', fontWeight: 700 }}>{totalChosen}</span> (до 5 на эпоху)
|
||
</div>
|
||
<button onClick={save} disabled={status === 'loading' || totalChosen === 0} style={{
|
||
...btnPrimary, opacity: status === 'loading' || totalChosen === 0 ? 0.5 : 1,
|
||
}}>
|
||
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
|
||
</button>
|
||
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка</span>}
|
||
</div>
|
||
{EPOCH_INFO.map(epoch => {
|
||
const items = getDecoByEpoch(epoch.n);
|
||
const arr = choices[epoch.n] || [];
|
||
const fullyChosen = arr.length >= MAX_PER_EPOCH;
|
||
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>
|
||
<span style={{ fontSize: 14, color: fullyChosen ? '#22ff66' : '#888', marginLeft: 12 }}>
|
||
выбрано {arr.length}/{MAX_PER_EPOCH}
|
||
</span>
|
||
</h2>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||
gap: 16,
|
||
}}>
|
||
{items.map(deco => (
|
||
<DecoCard
|
||
key={deco.id}
|
||
deco={deco}
|
||
isChosen={arr.includes(deco.id)}
|
||
onToggle={() => toggle(epoch.n, deco.id)}
|
||
disabled={fullyChosen}
|
||
/>
|
||
))}
|
||
</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 };
|