add cases to Shop (roulette don't work :( )

This commit is contained in:
2025-12-07 02:44:25 +05:00
parent 3ddcda2cec
commit c14315b078
3 changed files with 696 additions and 11 deletions

View File

@ -207,6 +207,94 @@ export interface MeResponse {
is_admin: boolean;
}
export interface CaseItemMeta {
display_name?: string | null;
lore?: string[] | null;
}
export interface CaseItem {
id: string;
name?: string;
material: string;
amount: number;
weight?: number;
meta?: CaseItemMeta;
}
export interface Case {
id: string;
name: string;
description?: string;
price: number;
image_url?: string;
server_ids?: string[];
items_count?: number;
items?: CaseItem[];
}
export interface OpenCaseResponse {
status: string;
message: string;
operation_id: string;
balance: number;
reward: CaseItem;
}
// ===== КЕЙСЫ =====
export async function fetchCases(): Promise<Case[]> {
const response = await fetch(`${API_BASE_URL}/cases`);
if (!response.ok) {
throw new Error('Не удалось получить список кейсов');
}
return await response.json();
}
// Если у тебя есть отдельный эндпоинт деталей кейса, можно использовать это:
export async function fetchCase(case_id: string): Promise<Case> {
const response = await fetch(`${API_BASE_URL}/cases/${case_id}`);
if (!response.ok) {
throw new Error('Не удалось получить информацию о кейсе');
}
return await response.json();
}
export async function openCase(
case_id: string,
username: string,
server_id: string,
): Promise<OpenCaseResponse> {
// Формируем URL с query-параметрами, как любит текущий бэкенд
const url = new URL(`${API_BASE_URL}/cases/${case_id}/open`);
url.searchParams.append('username', username);
url.searchParams.append('server_id', server_id);
const response = await fetch(url.toString(), {
method: 'POST',
});
if (!response.ok) {
let msg = 'Не удалось открыть кейс';
try {
const errorData = await response.json();
if (errorData.message) {
msg = errorData.message;
} else if (Array.isArray(errorData.detail)) {
msg = errorData.detail.map((d: any) => d.msg).join(', ');
} else if (typeof errorData.detail === 'string') {
msg = errorData.detail;
}
} catch {
// если бэкенд вернул не-JSON, оставляем дефолтное сообщение
}
throw new Error(msg);
}
return await response.json();
}
export async function fetchMe(): Promise<MeResponse> {
const { accessToken, clientToken } = getAuthTokens();

View File

@ -0,0 +1,254 @@
import { Box, Typography, Button, Dialog, DialogContent } from '@mui/material';
import { useEffect, useState } from 'react';
import { CaseItem } from '../api';
type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
interface CaseRouletteProps {
open: boolean;
onClose: () => void;
caseName?: string;
items: CaseItem[];
reward: CaseItem | null; // что реально выпало с бэка
}
// --- настройки рулетки ---
const ITEM_WIDTH = 110;
const ITEM_GAP = 8;
const VISIBLE_ITEMS = 21;
const CENTER_INDEX = Math.floor(VISIBLE_ITEMS / 2);
// ширина видимой области и позиция линии
const CONTAINER_WIDTH = 800;
const LINE_X = CONTAINER_WIDTH / 2;
// редкость по weight (только фронт)
function getRarityByWeight(weight?: number): Rarity {
if (weight === undefined || weight === null) return 'common';
if (weight <= 5) return 'legendary';
if (weight <= 20) return 'epic';
if (weight <= 50) return 'rare';
return 'common';
}
function getRarityColor(weight?: number): string {
const rarity = getRarityByWeight(weight);
switch (rarity) {
case 'legendary':
return 'rgba(255, 215, 0, 1)';
case 'epic':
return 'rgba(186, 85, 211, 1)';
case 'rare':
return 'rgba(65, 105, 225, 1)';
case 'common':
default:
return 'rgba(255, 255, 255, 0.25)';
}
}
export default function CaseRoulette({
open,
onClose,
caseName,
items,
reward,
}: CaseRouletteProps) {
const [sequence, setSequence] = useState<CaseItem[]>([]);
const [offset, setOffset] = useState(0);
const [animating, setAnimating] = useState(false);
const winningName =
reward?.meta?.display_name || reward?.name || reward?.material || '';
// генерируем полосу предметов и запускаем анимацию,
// когда диалог открыт и есть reward
useEffect(() => {
if (!open || !reward || !items || items.length === 0) return;
// 1. генерим последовательность
const seq: CaseItem[] = [];
for (let i = 0; i < VISIBLE_ITEMS; i++) {
const randomItem = items[Math.floor(Math.random() * items.length)];
seq.push(randomItem);
}
// 2. подменяем центр на тот предмет, который реально выпал
const fromCase =
items.find((i) => i.material === reward.material) || reward;
seq[CENTER_INDEX] = fromCase;
setSequence(seq);
// 3. считаем финальный offset, при котором CENTER_INDEX оказывается под линией
const centerItemCenter =
CENTER_INDEX * (ITEM_WIDTH + ITEM_GAP) + ITEM_WIDTH / 2;
const finalOffset = Math.max(0, centerItemCenter - LINE_X);
// стартуем анимацию
setAnimating(false);
setOffset(0);
// маленькая задержка, чтобы браузер применил начальное состояние
const id = setTimeout(() => {
setAnimating(true);
setOffset(finalOffset);
}, 50);
return () => {
clearTimeout(id);
};
}, [open, reward, items]);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
bgcolor: 'rgba(15, 15, 20, 0.95)',
borderRadius: '1vw',
},
}}
>
<DialogContent>
<Typography
variant="h6"
color="white"
sx={{ textAlign: 'center', mb: 2 }}
>
Открытие кейса {caseName}
</Typography>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderRadius: '1vw',
border: '2px solid rgba(255, 255, 255, 0.1)',
px: 2,
py: 3,
width: `${CONTAINER_WIDTH}px`,
maxWidth: '100%',
mx: 'auto',
}}
>
{/* Линия центра */}
<Box
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${LINE_X}px`,
transform: 'translateX(-1px)',
width: '2px',
bgcolor: 'rgba(255, 77, 77, 0.8)',
zIndex: 2,
}}
/>
{/* Лента с предметами */}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: `${ITEM_GAP}px`,
transform: `translateX(-${offset}px)`,
transition: animating
? 'transform 3s cubic-bezier(0.1, 0.8, 0.2, 1)'
: 'none',
}}
>
{sequence.map((item, index) => {
const color = getRarityColor(item.weight);
return (
<Box
key={index}
sx={{
width: `120px`,
height: '120px',
borderRadius: '0.7vw',
bgcolor: 'rgba(255, 255, 255, 0.04)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border:
index === CENTER_INDEX
? `2px solid ${color}`
: `1px solid ${color}`,
}}
>
<Box
component="img"
src={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
alt={item.material}
sx={{
width: '48px',
height: '48px',
objectFit: 'contain',
imageRendering: 'pixelated',
mb: 1,
}}
/>
<Typography
variant="body2"
sx={{
color: 'white',
textAlign: 'center',
fontSize: '0.7rem',
}}
>
{item.meta?.display_name ||
item.name ||
item.material
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (l) => l.toUpperCase())}
</Typography>
</Box>
);
})}
</Box>
</Box>
{winningName && (
<Typography
variant="body1"
color="white"
sx={{ textAlign: 'center', mt: 2 }}
>
Вам выпало: <b>{winningName}</b>
</Typography>
)}
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mt: 3,
}}
>
<Button
variant="contained"
onClick={onClose}
sx={{
borderRadius: '20px',
p: '0.5vw 2.5vw',
color: 'white',
backgroundColor: 'rgb(255, 77, 77)',
'&:hover': {
backgroundColor: 'rgba(255, 77, 77, 0.5)',
},
fontFamily: 'Benzin-Bold',
}}
>
Закрыть
</Button>
</Box>
</DialogContent>
</Dialog>
);
}

View File

@ -1,5 +1,16 @@
import { Box } from '@mui/material';
import { Typography } from '@mui/material';
import {
Box,
Typography,
Button,
Grid,
Card,
CardMedia,
CardContent,
Snackbar,
Alert,
Dialog,
DialogContent,
} from '@mui/material';
import CapeCard from '../components/CapeCard';
import {
Cape,
@ -7,9 +18,43 @@ import {
fetchCapesStore,
purchaseCape,
StoreCape,
Case,
CaseItem,
fetchCases,
fetchCase,
openCase,
Server,
} from '../api';
import { useEffect, useState } from 'react';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { getPlayerServer } from '../utils/playerOnlineCheck';
import CaseRoulette from '../components/CaseRoulette';
function getRarityByWeight(
weight?: number,
): 'common' | 'rare' | 'epic' | 'legendary' {
if (weight === undefined || weight === null) return 'common';
if (weight <= 5) return 'legendary';
if (weight <= 20) return 'epic';
if (weight <= 50) return 'rare';
return 'common';
}
function getRarityColor(weight?: number): string {
const rarity = getRarityByWeight(weight);
switch (rarity) {
case 'legendary':
return 'rgba(255, 215, 0, 1)'; // золотой
case 'epic':
return 'rgba(186, 85, 211, 1)'; // фиолетовый
case 'rare':
return 'rgba(65, 105, 225, 1)'; // синий
case 'common':
default:
return 'rgba(255, 255, 255, 0.25)'; // сероватый
}
}
export default function Shop() {
const [storeCapes, setStoreCapes] = useState<StoreCape[]>([]);
@ -18,6 +63,39 @@ export default function Shop() {
const [uuid, setUuid] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
// Кейсы
const [cases, setCases] = useState<Case[]>([]);
const [casesLoading, setCasesLoading] = useState<boolean>(false);
// Онлайн/сервер (по аналогии с Marketplace)
const [isOnline, setIsOnline] = useState<boolean>(false);
const [playerServer, setPlayerServer] = useState<Server | null>(null);
const [onlineCheckLoading, setOnlineCheckLoading] = useState<boolean>(true);
// Рулетка
const [isOpening, setIsOpening] = useState<boolean>(false);
const [selectedCase, setSelectedCase] = useState<Case | null>(null);
const [rouletteOpen, setRouletteOpen] = useState(false);
const [rouletteCaseItems, setRouletteCaseItems] = useState<CaseItem[]>([]);
const [rouletteReward, setRouletteReward] = useState<CaseItem | null>(null);
// Уведомления
const [notification, setNotification] = useState<{
open: boolean;
message: string;
type: 'success' | 'error';
}>({
open: false,
message: '',
type: 'success',
});
const ITEM_WIDTH = 110; // ширина "карточки" предмета в рулетке
const ITEM_GAP = 8;
const VISIBLE_ITEMS = 21; // сколько элементов в линии
const CENTER_INDEX = Math.floor(VISIBLE_ITEMS / 2);
// Функция для загрузки плащей из магазина
const loadStoreCapes = async () => {
try {
@ -44,12 +122,55 @@ export default function Shop() {
try {
await purchaseCape(username, cape_id);
await loadUserCapes(username);
setNotification({
open: true,
message: 'Плащ успешно куплен!',
type: 'success',
});
} catch (error) {
console.error('Ошибка при покупке плаща:', error);
setNotification({
open: true,
message:
error instanceof Error ? error.message : 'Ошибка при покупке плаща',
type: 'error',
});
}
};
// Загружаем данные при монтировании
// Загрузка кейсов
const loadCases = async () => {
try {
setCasesLoading(true);
const casesData = await fetchCases();
setCases(casesData);
} catch (error) {
console.error('Ошибка при получении кейсов:', error);
setCases([]);
} finally {
setCasesLoading(false);
}
};
// Проверка онлайна игрока (по аналогии с Marketplace.tsx)
const checkPlayerStatus = async () => {
if (!username) return;
try {
setOnlineCheckLoading(true);
const { online, server } = await getPlayerServer(username);
setIsOnline(online);
setPlayerServer(server || null);
} catch (error) {
console.error('Ошибка при проверке онлайн-статуса:', error);
setIsOnline(false);
setPlayerServer(null);
} finally {
setOnlineCheckLoading(false);
}
};
// Загружаем базовые данные при монтировании
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
@ -60,22 +181,118 @@ export default function Shop() {
setLoading(true);
// Загружаем оба списка плащей
Promise.all([loadStoreCapes(), loadUserCapes(config.username)]).finally(
() => {
Promise.all([
loadStoreCapes(),
loadUserCapes(config.username),
loadCases(),
])
.catch((err) => console.error(err))
.finally(() => {
setLoading(false);
},
);
});
}
}
}, []);
// Проверяем онлайн после того, как знаем username
useEffect(() => {
if (username) {
checkPlayerStatus();
}
}, [username]);
// Фильтруем плащи, которые уже куплены пользователем
const availableCapes = storeCapes.filter(
(storeCape) =>
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
);
// Генерация массива предметов для рулетки
const generateRouletteItems = (
allItems: Case['items'],
winningItemMaterial: string,
): Case['items'] => {
if (!allItems || allItems.length === 0) return [];
const result: Case['items'] = [];
for (let i = 0; i < VISIBLE_ITEMS; i++) {
const randomItem = allItems[Math.floor(Math.random() * allItems.length)];
result.push(randomItem);
}
// Принудительно ставим выигрышный предмет в центр
const winningSource =
allItems.find((i) => i.material === winningItemMaterial) || allItems[0];
result[CENTER_INDEX] = winningSource;
return result;
};
const handleOpenCase = async (caseData: Case) => {
if (!username) {
setNotification({
open: true,
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
type: 'error',
});
return;
}
if (!isOnline || !playerServer) {
setNotification({
open: true,
message: 'Для открытия кейсов необходимо находиться на сервере в игре.',
type: 'error',
});
return;
}
if (isOpening) return;
try {
setIsOpening(true);
// 1. получаем полный кейс
const fullCase = await fetchCase(caseData.id);
const caseItems: CaseItem[] = fullCase.items || [];
setSelectedCase(fullCase);
// 2. открываем кейс на бэке
const result = await openCase(fullCase.id, username, playerServer.id);
// 3. сохраняем данные для рулетки
setRouletteCaseItems(caseItems);
setRouletteReward(result.reward);
setRouletteOpen(true);
// 4. уведомление
setNotification({
open: true,
message: result.message || 'Кейс открыт!',
type: 'success',
});
setIsOpening(false);
} catch (error) {
console.error('Ошибка при открытии кейса:', error);
setNotification({
open: true,
message:
error instanceof Error ? error.message : 'Ошибка при открытии кейса',
type: 'error',
});
setIsOpening(false);
}
};
const handleCloseNotification = () => {
setNotification((prev) => ({ ...prev, open: false }));
};
const handleCloseRoulette = () => {
setRouletteOpen(false);
};
return (
<Box
sx={{
@ -88,9 +305,11 @@ export default function Shop() {
height: '100%',
}}
>
{loading ? (
<FullScreenLoader message="Загрузка..." />
) : (
{(loading || onlineCheckLoading) && (
<FullScreenLoader message="Загрузка магазина..." />
)}
{!loading && !onlineCheckLoading && (
<Box
sx={{
display: 'flex',
@ -102,6 +321,106 @@ export default function Shop() {
gap: '2vw',
}}
>
{/* Блок кейсов */}
<Typography variant="h6" sx={{ mb: 1 }}>
Кейсы
</Typography>
{!isOnline && (
<Typography
variant="body1"
color="error"
sx={{ mb: 2, maxWidth: '600px' }}
>
Для открытия кейсов вам необходимо находиться на одном из серверов
игры. Зайдите в игру и обновите страницу.
</Typography>
)}
{casesLoading ? (
<FullScreenLoader fullScreen={false} message="Загрузка кейсов..." />
) : cases.length > 0 ? (
<Grid container spacing={2} sx={{ mb: 4 }}>
{cases.map((c) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={c.id}>
<Card
sx={{
bgcolor: 'rgba(255, 255, 255, 0.05)',
borderRadius: '1vw',
}}
>
{c.image_url && (
<CardMedia
component="img"
image={c.image_url}
alt={c.name}
sx={{
minWidth: '10vw',
minHeight: '10vw',
maxHeight: '10vw',
objectFit: 'cover',
p: '0.5vw',
borderRadius: '1vw 1vw 0 0',
}}
/>
)}
<CardContent>
<Typography variant="h6" color="white">
{c.name}
</Typography>
{c.description && (
<Typography
variant="body2"
color="white"
sx={{ opacity: 0.7 }}
>
{c.description}
</Typography>
)}
<Typography variant="body2" color="white" sx={{ mt: 1 }}>
Цена: {c.price} монет
</Typography>
{typeof c.items_count === 'number' && (
<Typography
variant="body2"
color="white"
sx={{ opacity: 0.7 }}
>
Предметов в кейсе: {c.items_count}
</Typography>
)}
<Button
variant="contained"
fullWidth
sx={{
mt: '1vw',
borderRadius: '20px',
p: '0.3vw 0vw',
color: 'white',
backgroundColor: 'rgb(255, 77, 77)',
'&:hover': {
backgroundColor: 'rgba(255, 77, 77, 0.5)',
},
fontFamily: 'Benzin-Bold',
fontSize: '1vw',
}}
disabled={!isOnline || isOpening}
onClick={() => handleOpenCase(c)}
>
{isOpening && selectedCase?.id === c.id
? 'Открываем...'
: 'Открыть кейс'}
</Button>
</CardContent>
</Card>
</Grid>
))}
</Grid>
) : (
<Typography>Кейсы временно недоступны.</Typography>
)}
{/* Блок плащей (как был) */}
<Typography variant="h6">Доступные плащи</Typography>
{availableCapes.length > 0 ? (
<Box
@ -126,6 +445,30 @@ export default function Shop() {
)}
</Box>
)}
{/* Компонент с анимацией рулетки */}
<CaseRoulette
open={rouletteOpen}
onClose={handleCloseRoulette}
caseName={selectedCase?.name}
items={rouletteCaseItems}
reward={rouletteReward}
/>
{/* Уведомления */}
<Snackbar
open={notification.open}
autoHideDuration={6000}
onClose={handleCloseNotification}
>
<Alert
onClose={handleCloseNotification}
severity={notification.type}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
</Box>
);
}