/** * GdMenu — главное меню Geometry Dash 2.0. * * Маршрут: /gd * Карточки L1-L10 с прогрессом, замки на непройденных, кнопки в магазин. * * Данные из gd_progress (savegame namespace, project_id=296): * level__passed: bool * level__best_ms: number * level__coin_: bool (i = 1,2,3) * total_stars: number */ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { jwtDecode } from 'jwt-decode'; import axios from 'axios'; import { STORYS_addres } from '../api/API'; import { availableStars } from './gdStars'; import { IconGamepad, IconStar, IconShop, IconSettings, IconArrowLeft, IconReplay, IconLock, IconBoss, IconPlay, IconTree, IconMountain, IconBook, } from './GdIcons'; const GD_PROJECT_ID = 296; // === ЭПОХИ === const EPOCH_1_LEVELS = [ { num: 1, id: 296, name: 'Первый шаг', duration: 90 }, { num: 2, id: 297, name: 'Через пропасть', duration: 95 }, { num: 3, id: 298, name: 'Пружина', duration: 100 }, { num: 4, id: 299, name: 'Быстрее ветра', duration: 100 }, { num: 5, id: 300, name: 'Лестница', duration: 110 }, { num: 6, id: 301, name: 'Танец', duration: 110 }, { num: 7, id: 302, name: 'Парящий', duration: 115 }, { num: 8, id: 303, name: 'Ритм-секция', duration: 115 }, { num: 9, id: 304, name: 'Гонка', duration: 120 }, { num: 10, id: 305, name: 'Стражник леса', duration: 240, boss: true }, ]; // Эпоха II — Горы (L11 = pid 306, L12-L20 = pid 350-358) const EPOCH_2_LEVELS = [ { num: 11, id: 306, name: 'Каменная тропа', duration: 120 }, { num: 12, id: 350, name: 'Пружинистые скалы', duration: 120 }, { num: 13, id: 351, name: 'Ступени в горах', duration: 120 }, { num: 14, id: 352, name: 'Парящие платформы', duration: 120 }, { num: 15, id: 353, name: 'Ледяная шахта', duration: 120 }, { num: 16, id: 354, name: 'Двойная шахта', duration: 140 }, { num: 17, id: 355, name: 'Лабиринт пещер', duration: 160 }, { num: 18, id: 356, name: 'Извилистая шахта', duration: 150 }, { num: 19, id: 357, name: 'Снежный марафон', duration: 140 }, { num: 20, id: 358, name: 'Король горы', duration: 240, boss: true }, ]; const EPOCHS = [ { num: 1, name: 'Лес', Icon: IconTree, iconColor: '#22ff66', color: '#22ff66', levels: EPOCH_1_LEVELS }, { num: 2, name: 'Горы', Icon: IconMountain, iconColor: '#88ccff', color: '#88ccff', levels: EPOCH_2_LEVELS }, ]; const ALL_LEVELS = [...EPOCH_1_LEVELS, ...EPOCH_2_LEVELS]; 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: 30000 }); api.interceptors.request.use((cfg) => { try { const token = localStorage.getItem('Authorization'); if (token) cfg.headers.Authorization = token; } catch (e) {} return cfg; }); export default function GdMenu() { const navigate = useNavigate(); const [progress, setProgress] = useState(null); const [error, setError] = useState(null); useEffect(() => { const userId = getUserId(); if (!userId) { setError('Войдите чтобы играть в Geometry Dash 2.0'); return; } // Читаем прогресс из ВСЕХ уровней и сливаем (каждый level пишет в свой project_id) const projectIds = ALL_LEVELS.map((l) => l.id); Promise.allSettled(projectIds.map((pid) => api.get(`/kubikon3d/savegame/${pid}/${userId}/gd_progress`) .then((res) => res.data?.data || {}) .catch(() => ({})) )).then((results) => { const merged = {}; for (const r of results) { if (r.status === 'fulfilled' && r.value) Object.assign(merged, r.value); } setProgress(merged); }); }, []); if (error) return navigate('/')} />; if (!progress) return ; // Звёзды — единый источник истины (gdStars.js) const totalStars = availableStars(progress); // earned − spent return (
navigate('/')} onShop={() => navigate('/gd/shop')} onRules={() => navigate('/gd/rules')} /> {EPOCHS.map((epoch, epochIdx) => { // Эпоха открыта если предыдущая полностью пройдена (босс эпохи пройден) const prevEpoch = epochIdx === 0 ? null : EPOCHS[epochIdx - 1]; const prevBossNum = prevEpoch ? prevEpoch.levels[prevEpoch.levels.length - 1].num : 0; const epochUnlocked = epochIdx === 0 || !!progress[`level_${prevBossNum}_passed`]; return (

Эпоха {romanize(epoch.num)} — {epoch.name} {!epochUnlocked && ( (откроется после L{prevBossNum}) )}

{epochUnlocked ? (
{epoch.levels.map((l, idx) => { const prevLvl = idx === 0 ? { passed: true } : { passed: !!progress[`level_${epoch.levels[idx - 1].num}_passed`] }; const unlocked = idx === 0 || prevLvl.passed; const passed = !!progress[`level_${l.num}_passed`]; const bestMs = progress[`level_${l.num}_best_ms`] || 0; const coins = [1, 2, 3].filter((i) => progress[`level_${l.num}_coin_${i}`]).length; return ( navigate(`/play/${l.id}`)} /> ); })}
) : (
Пройди босса предыдущей эпохи чтобы открыть.
)}
); })}
); } function romanize(n) { const ROMAN = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X' }; return ROMAN[n] || String(n); } function Header({ stars, onBack, onShop, onRules }) { const [quality, setQuality] = React.useState( () => (localStorage.getItem('gd_graphics_quality') || 'high') ); const toggleQuality = () => { const next = quality === 'high' ? 'low' : 'high'; setQuality(next); localStorage.setItem('gd_graphics_quality', next); }; return (

GEOMETRY DASH

{stars}
); } function LevelCard({ level, unlocked, passed, bestMs, coins, onPlay }) { const isBoss = !!level.boss; const bgColor = !unlocked ? '#1a2238' : isBoss ? '#3a1a20' : '#0e1525'; const borderColor = !unlocked ? '#2a3146' : passed ? '#22ff66' : isBoss ? '#ffaa44' : '#3a4156'; return (
{!unlocked && (
)}
{isBoss ? (<> БОСС) : `L${level.num}`}
{level.name}
{level.duration}с
{unlocked && ( <> {/* Звёзды (заливка по числу собранных монет) */}
{[1, 2, 3].map((i) => ( = i ? '#ffe44a' : '#444'} outline={!passed || coins < i} /> ))}
{bestMs > 0 && (
Best: {(bestMs / 1000).toFixed(2)}с
)} )}
); } function CenteredCard({ text, onBack }) { return (
{text}
{onBack && ( )}
); } const btnPrimary = { padding: '10px 16px', border: 'none', borderRadius: 8, fontWeight: 700, fontSize: 14, cursor: 'pointer', }; const btnSecondary = { padding: '10px 18px', background: '#2a3146', color: '#cdd4e0', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14, };