studio/src/gd-shop/GdMenu.jsx
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия 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>
2026-05-28 05:01:13 +03:00

348 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* GdMenu — главное меню Geometry Dash 2.0.
*
* Маршрут: /gd
* Карточки L1-L10 с прогрессом, замки на непройденных, кнопки в магазин.
*
* Данные из gd_progress (savegame namespace, project_id=296):
* level_<N>_passed: bool
* level_<N>_best_ms: number
* level_<N>_coin_<i>: 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 <CenteredCard text={error} onBack={() => navigate('/')} />;
if (!progress) return <CenteredCard text="Загрузка..." />;
// Звёзды — единый источник истины (gdStars.js)
const totalStars = availableStars(progress); // earned spent
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(180deg, #0a1428 0%, #1a2a4a 100%)',
color: '#fff',
padding: '24px 28px',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}>
<Header
stars={totalStars}
onBack={() => 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 (
<div key={epoch.num}>
<h2 style={{
fontSize: 28, color: epochUnlocked ? epoch.color : '#666',
marginTop: epochIdx === 0 ? 32 : 48, marginBottom: 16,
borderBottom: '2px solid #2a3a5a', paddingBottom: 12,
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}>
<epoch.Icon size={32} color={epochUnlocked ? epoch.iconColor : '#666'} />
<span>Эпоха {romanize(epoch.num)} {epoch.name}</span>
{!epochUnlocked && (
<span style={{ fontSize: 14, color: '#888', fontWeight: 400, marginLeft: 4 }}>
(откроется после L{prevBossNum})
</span>
)}
</h2>
{epochUnlocked ? (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 16,
}}>
{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 (
<LevelCard
key={l.id}
level={l}
unlocked={unlocked}
passed={passed}
bestMs={bestMs}
coins={coins}
onPlay={() => navigate(`/play/${l.id}`)}
/>
);
})}
</div>
) : (
<div style={{ color: '#666', padding: '20px 0', fontStyle: 'italic' }}>
Пройди босса предыдущей эпохи чтобы открыть.
</div>
)}
</div>
);
})}
</div>
);
}
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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<button onClick={onBack} style={btnSecondary}>
<IconArrowLeft size={18} color="#cdd4e0" />&nbsp; Назад
</button>
<h1 style={{
margin: 0, fontSize: 32, color: '#22ff66', flex: 1, fontWeight: 800,
textShadow: '0 0 12px rgba(34,255,102,0.4)',
display: 'flex', alignItems: 'center', gap: 12,
}}>
<IconGamepad size={36} color="#22ff66" />
GEOMETRY DASH
</h1>
<div style={{
fontSize: 22, color: '#ffe44a', fontWeight: 700,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<IconStar size={24} color="#ffe44a" /> {stars}
</div>
<button onClick={toggleQuality} style={{
...btnSecondary, padding: '10px 16px', fontSize: 14,
background: quality === 'high' ? '#22aa44' : '#3a4156',
display: 'flex', alignItems: 'center', gap: 6,
}} title="Качество графики">
<IconSettings size={18} color="#cdd4e0" />
{quality === 'high' ? 'Высокое' : 'Низкое'}
</button>
<button onClick={onRules} style={{
...btnPrimary, padding: '12px 22px', fontSize: 15,
background: 'linear-gradient(135deg, #88ccff, #4488dd)',
color: '#0a1428',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<IconBook size={20} color="#0a1428" />
Правила
</button>
<button onClick={onShop} style={{
...btnPrimary, padding: '12px 22px', fontSize: 15,
background: 'linear-gradient(135deg, #22ff66, #44aaff)',
color: '#0a1428',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<IconShop size={20} color="#0a1428" />
Магазин
</button>
</div>
);
}
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 (
<div style={{
padding: 16, background: bgColor, borderRadius: 12,
border: `3px solid ${borderColor}`,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
opacity: unlocked ? 1 : 0.5,
position: 'relative',
}}>
{!unlocked && (
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
opacity: 0.6,
}}>
<IconLock size={64} color="#888" />
</div>
)}
<div style={{
fontSize: 18, color: isBoss ? '#ffaa44' : '#cdd4e0',
fontWeight: 700, textTransform: 'uppercase',
display: 'flex', alignItems: 'center', gap: 6,
}}>
{isBoss ? (<><IconBoss size={22} color="#ffaa44" /> БОСС</>) : `L${level.num}`}
</div>
<div style={{ fontSize: 16, fontWeight: 600, textAlign: 'center', color: '#fff' }}>
{level.name}
</div>
<div style={{ fontSize: 13, color: '#888' }}>
{level.duration}с
</div>
{unlocked && (
<>
{/* Звёзды (заливка по числу собранных монет) */}
<div style={{ display: 'flex', gap: 4 }}>
{[1, 2, 3].map((i) => (
<IconStar
key={i}
size={22}
color={passed && coins >= i ? '#ffe44a' : '#444'}
outline={!passed || coins < i}
/>
))}
</div>
{bestMs > 0 && (
<div style={{ fontSize: 12, color: '#aaa' }}>
Best: {(bestMs / 1000).toFixed(2)}с
</div>
)}
<button onClick={onPlay} style={{
...btnPrimary, marginTop: 4, width: '100%',
background: passed ? '#3a4156' : (isBoss ? '#ffaa44' : '#22ff66'),
color: passed ? '#cdd4e0' : '#000',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
{passed ? (
<><IconReplay size={16} color="#cdd4e0" /> Пройти снова</>
) : (
<><IconPlay size={16} color="#000" /> Играть</>
)}
</button>
</>
)}
</div>
);
}
function CenteredCard({ text, onBack }) {
return (
<div style={{
minHeight: '100vh', background: '#070a14', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 16,
}}>
<div style={{ fontSize: 22 }}>{text}</div>
{onBack && (
<button onClick={onBack} style={{
...btnSecondary, display: 'flex', alignItems: 'center', gap: 6,
}}>
<IconArrowLeft size={18} color="#cdd4e0" /> Назад
</button>
)}
</div>
);
}
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,
};