Большой консолидирующий коммит после поднятия 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>
259 lines
11 KiB
JavaScript
259 lines
11 KiB
JavaScript
/**
|
||
* GdSpikesPreview — превью 100 вариантов шипов (10 эпох × 10).
|
||
*
|
||
* Маршрут: /admin-preview/gd-spikes
|
||
*
|
||
* Юзер выбирает по одному шипу на каждую эпоху (радио-кнопка), нажимает
|
||
* «Сохранить» — выборы пишутся в kubikon3d_savegame
|
||
* (project_id=295, namespace='gd_spike_choices') как { '1': 'forest_wood', ... }.
|
||
*
|
||
* После сохранения Claude может прочитать БД и подставить выбранный шип
|
||
* в GdSpikes.js для соответствующих 10 уровней.
|
||
*/
|
||
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 { SPIKE_CATALOG, EPOCH_INFO, getSpikesByEpoch } from './gdSpikes/spikeFactories';
|
||
|
||
const CHOICES_PID = 295; // sandbox-проект для хранения админ-настроек
|
||
const CHOICES_NS = 'gd_spike_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 SpikeCard({ spike, isChosen, onChoose }) {
|
||
const wrapRef = useRef(null);
|
||
const canvasRef = useRef(null);
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
|
||
// 1) IntersectionObserver — отслеживаем когда карточка в viewport
|
||
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();
|
||
}, []);
|
||
|
||
// 2) Babylon-сцена — только пока карточка видима
|
||
useEffect(() => {
|
||
if (!isVisible || !canvasRef.current) return;
|
||
let engine = null, scene = null;
|
||
try {
|
||
engine = new Engine(canvasRef.current, true, {
|
||
stencil: false, preserveDrawingBuffer: 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, 3.6, new Vector3(0, 0.7, 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.6;
|
||
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), scene);
|
||
sun.intensity = 0.8;
|
||
|
||
const floor = MeshBuilder.CreateGround('floor', { width: 3, height: 3 }, scene);
|
||
const fmat = new StandardMaterial('fmat', scene);
|
||
fmat.diffuseColor = new Color3(0.18, 0.22, 0.25);
|
||
floor.material = fmat;
|
||
|
||
const handle = spike.make(scene, `prev_${spike.id}`);
|
||
if (handle && handle.root) handle.root.position.y = 0;
|
||
|
||
scene.onBeforeRenderObservable.add(() => {
|
||
if (handle && handle.root) handle.root.rotation.y += 0.012;
|
||
});
|
||
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('[SpikeCard] init failed', e);
|
||
return () => {};
|
||
}
|
||
}, [isVisible, spike]);
|
||
|
||
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: 180, display: 'block', background: '#0a1020' }} />
|
||
: <div style={{ width: '100%', height: 180, 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 }}>{spike.title}</div>
|
||
<div style={{ fontSize: 11, color: '#888' }}><code>{spike.id}</code></div>
|
||
</div>
|
||
{isChosen && <span style={{ color: '#22ff66', fontSize: 20 }}>✓</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function GdSpikesPreview() {
|
||
const { userRole, isLoading } = useAuth();
|
||
const navigate = useNavigate();
|
||
// choices: { 1: 'e1_v1', 2: 'e2_v3', ... }
|
||
const [choices, setChoices] = useState({});
|
||
const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'saved' | 'error'
|
||
|
||
// Загрузка сохранённых выборов
|
||
useEffect(() => {
|
||
const uid = getUserId();
|
||
if (!uid) return;
|
||
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
|
||
.then((r) => {
|
||
const data = r.data?.data || {};
|
||
setChoices(data);
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const choose = (epoch, spikeId) => {
|
||
setChoices(prev => ({ ...prev, [epoch]: spikeId }));
|
||
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('[GdSpikesPreview] 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 — Шипы по эпохам ({SPIKE_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 spikes = getSpikesByEpoch(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(220px, 1fr))',
|
||
gap: 16,
|
||
}}>
|
||
{spikes.map(spike => (
|
||
<SpikeCard
|
||
key={spike.id}
|
||
spike={spike}
|
||
isChosen={chosenId === spike.id}
|
||
onChoose={() => choose(epoch.n, spike.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,
|
||
};
|