/** * 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 (
{isVisible ? :
•••
}
{spike.title}
{spike.id}
{isChosen && }
); } 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
Загрузка...
; if (userRole !== 'admin') { return (

Доступ только для администратора

); } const chosenCount = Object.keys(choices).length; return (

GD — Шипы по эпохам ({SPIKE_CATALOG.length})

Выбрано: {chosenCount}/10 эпох
{status === 'error' && Ошибка сохранения}
{EPOCH_INFO.map(epoch => { const spikes = getSpikesByEpoch(epoch.n); const chosenId = choices[epoch.n]; return (

{epoch.emoji} Эпоха {epoch.n} — {epoch.name} L{(epoch.n - 1) * 10 + 1} – L{epoch.n * 10} {chosenId && ( ✓ выбран: {chosenId} )}

{spikes.map(spike => ( choose(epoch.n, spike.id)} /> ))}
); })}
); } 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, };