Большой консолидирующий коммит после поднятия 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>
348 lines
15 KiB
JavaScript
348 lines
15 KiB
JavaScript
/**
|
||
* 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" /> Назад
|
||
</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,
|
||
};
|