/** * 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 (
{spike.id}{chosenId}
)}