From c14315b0781f58df89d6341a47a88a5490017463 Mon Sep 17 00:00:00 2001 From: DIKER0K Date: Sun, 7 Dec 2025 02:44:25 +0500 Subject: [PATCH] add cases to Shop (roulette don't work :( ) --- src/renderer/api.ts | 88 ++++++ src/renderer/components/CaseRoulette.tsx | 254 ++++++++++++++++ src/renderer/pages/Shop.tsx | 365 ++++++++++++++++++++++- 3 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 src/renderer/components/CaseRoulette.tsx diff --git a/src/renderer/api.ts b/src/renderer/api.ts index 98bbda7..9ea5c24 100644 --- a/src/renderer/api.ts +++ b/src/renderer/api.ts @@ -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 { + 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 { + 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 { + // Формируем 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 { const { accessToken, clientToken } = getAuthTokens(); diff --git a/src/renderer/components/CaseRoulette.tsx b/src/renderer/components/CaseRoulette.tsx new file mode 100644 index 0000000..634f637 --- /dev/null +++ b/src/renderer/components/CaseRoulette.tsx @@ -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([]); + 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 ( + + + + Открытие кейса {caseName} + + + + {/* Линия центра */} + + + {/* Лента с предметами */} + + {sequence.map((item, index) => { + const color = getRarityColor(item.weight); + + return ( + + + + {item.meta?.display_name || + item.name || + item.material + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase())} + + + ); + })} + + + + {winningName && ( + + Вам выпало: {winningName} + + )} + + + + + + + ); +} diff --git a/src/renderer/pages/Shop.tsx b/src/renderer/pages/Shop.tsx index 65d29e1..0750fe9 100644 --- a/src/renderer/pages/Shop.tsx +++ b/src/renderer/pages/Shop.tsx @@ -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([]); @@ -18,6 +63,39 @@ export default function Shop() { const [uuid, setUuid] = useState(''); const [loading, setLoading] = useState(false); + // Кейсы + const [cases, setCases] = useState([]); + const [casesLoading, setCasesLoading] = useState(false); + + // Онлайн/сервер (по аналогии с Marketplace) + const [isOnline, setIsOnline] = useState(false); + const [playerServer, setPlayerServer] = useState(null); + const [onlineCheckLoading, setOnlineCheckLoading] = useState(true); + + // Рулетка + const [isOpening, setIsOpening] = useState(false); + const [selectedCase, setSelectedCase] = useState(null); + + const [rouletteOpen, setRouletteOpen] = useState(false); + const [rouletteCaseItems, setRouletteCaseItems] = useState([]); + const [rouletteReward, setRouletteReward] = useState(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 ( - {loading ? ( - - ) : ( + {(loading || onlineCheckLoading) && ( + + )} + + {!loading && !onlineCheckLoading && ( + {/* Блок кейсов */} + + Кейсы + + + {!isOnline && ( + + Для открытия кейсов вам необходимо находиться на одном из серверов + игры. Зайдите в игру и обновите страницу. + + )} + + {casesLoading ? ( + + ) : cases.length > 0 ? ( + + {cases.map((c) => ( + + + {c.image_url && ( + + )} + + + {c.name} + + {c.description && ( + + {c.description} + + )} + + Цена: {c.price} монет + + {typeof c.items_count === 'number' && ( + + Предметов в кейсе: {c.items_count} + + )} + + + + + ))} + + ) : ( + Кейсы временно недоступны. + )} + + {/* Блок плащей (как был) */} Доступные плащи {availableCapes.length > 0 ? ( )} + + {/* Компонент с анимацией рулетки */} + + + {/* Уведомления */} + + + {notification.message} + + ); }