add cape preview in Shop

This commit is contained in:
2025-12-07 19:59:57 +05:00
parent c6cceaf299
commit 3e03c1024d
5 changed files with 536 additions and 276 deletions

View File

@ -207,6 +207,8 @@ export interface MeResponse {
is_admin: boolean;
}
// ===== КЕЙСЫ =====
export interface CaseItemMeta {
display_name?: string | null;
lore?: string[] | null;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Box } from '@mui/material';
interface CapePreviewProps {
imageUrl: string;
alt?: string;
}
export const CapePreview: React.FC<CapePreviewProps> = ({
imageUrl,
alt = 'Плащ',
}) => {
return (
<Box
sx={{
position: 'relative',
width: '100%',
height: 140, // фиксированная область под плащ
overflow: 'hidden',
}}
>
<Box
component="img"
src={imageUrl}
alt={alt}
sx={{
width: '100%',
height: '100%',
imageRendering: 'pixelated',
// Берём старый "зум" из CapeCard — плащ становится большим,
// а лишнее обрезается контейнером.
transform: 'scale(2.9) translateX(0px) translateY(0px)',
transformOrigin: 'top left',
}}
/>
</Box>
);
};

View File

@ -0,0 +1,79 @@
// src/renderer/components/CapePreviewModal.tsx
import React from 'react';
import {
Dialog,
DialogContent,
IconButton,
Box,
Typography,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import SkinViewer from './SkinViewer';
interface CapePreviewModalProps {
open: boolean;
onClose: () => void;
capeUrl: string;
skinUrl?: string;
}
const CapePreviewModal: React.FC<CapePreviewModalProps> = ({
open,
onClose,
capeUrl,
skinUrl,
}) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogContent
sx={{
bgcolor: 'rgba(5, 5, 15, 0.96)',
position: 'relative',
p: 2,
}}
>
<IconButton
onClick={onClose}
sx={{
position: 'absolute',
top: 8,
right: 8,
color: 'white',
}}
>
<CloseIcon />
</IconButton>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
fontSize: '1.1rem',
}}
>
Предпросмотр плаща
</Typography>
<SkinViewer
width={350}
height={450}
capeUrl={capeUrl} // скин возьмётся дефолтный из SkinViewer
skinUrl={skinUrl}
autoRotate={true}
walkingSpeed={0.5}
/>
</Box>
</DialogContent>
</Dialog>
);
};
export default CapePreviewModal;

View File

@ -0,0 +1,296 @@
// src/renderer/components/ShopItem.tsx
import React, { useState } from 'react';
import {
Card,
CardMedia,
CardContent,
Box,
Typography,
Button,
IconButton,
} from '@mui/material';
import CoinsDisplay from './CoinsDisplay';
import { CapePreview } from './CapePreview';
import VisibilityIcon from '@mui/icons-material/Visibility';
import CapePreviewModal from './PlayerPreviewModal';
export type ShopItemType = 'case' | 'cape';
export interface ShopItemProps {
type: ShopItemType;
id: string;
name: string;
description?: string;
imageUrl?: string;
price?: number;
// только для кейсов
itemsCount?: number;
isOpening?: boolean;
// для препросмотра плаща
playerSkinUrl?: string;
// для обоих
disabled?: boolean;
onClick: () => void;
}
export default function ShopItem({
type,
name,
description,
imageUrl,
price,
itemsCount,
isOpening,
disabled,
playerSkinUrl,
onClick,
}: ShopItemProps) {
const badgeLabel = type === 'case' ? 'Кейс' : 'Плащ';
const buttonText =
type === 'case' ? (isOpening ? 'Открываем...' : 'Открыть кейс') : 'Купить';
const [previewOpen, setPreviewOpen] = useState(false);
return (
<Card
sx={{
position: 'relative',
width: '100%',
maxWidth: 280,
height: 420, // ✅ ФИКСИРОВАННАЯ ВЫСОТА
display: 'flex',
flexDirection: 'column',
bgcolor: 'rgba(5, 5, 15, 0.96)',
borderRadius: '20px',
border: '1px solid rgba(255, 255, 255, 0.08)',
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.8)',
overflow: 'hidden',
transition:
'transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease',
'&:hover': {
transform: 'translateY(-6px)',
boxShadow: '0 26px 60px rgba(0, 0, 0, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.18)',
},
}}
>
{/* верхний “свет” */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background:
'radial-gradient(circle at top, rgba(255,255,255,0.13), transparent 55%)',
}}
/>
{imageUrl && (
<Box
sx={{
position: 'relative',
p: type === 'case' ? '0.9vw' : 0,
pb: 0,
overflow: 'hidden',
}}
>
{type === 'case' ? (
/* как было для кейсов */
<Box
sx={{
borderRadius: '16px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.12)',
background:
'linear-gradient(135deg, rgba(40,40,80,0.9), rgba(15,15,35,0.9))',
}}
>
<CardMedia
component="img"
image={imageUrl}
alt={name}
sx={{
width: '100%',
height: '11vw',
minHeight: '140px',
objectFit: 'cover',
filter: 'saturate(1.1)',
}}
/>
</Box>
) : (
// ✅ здесь используем CapePreview
<CapePreview imageUrl={imageUrl} alt={name} />
)}
{/* кнопка предпросмотра плаща */}
{type === 'cape' && (
<Box
sx={{
position: 'absolute',
top: type === 'case' ? '1.2vw' : 8,
right: type === 'case' ? '1.6vw' : 8,
px: 1.2,
py: 0.4,
borderRadius: '999px',
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: 0.6,
bgcolor: 'rgba(0, 0, 0, 0.6)',
border: '1px solid rgba(255, 255, 255, 0.4)',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
}}
>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setPreviewOpen(true);
}}
sx={{
p: 0.3,
color: 'white',
bgcolor: 'rgba(0,0,0,0.4)',
'&:hover': {
bgcolor: 'rgba(0,0,0,0.7)',
},
}}
>
<VisibilityIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
)}
<CardContent
sx={{
position: 'relative',
zIndex: 1,
pt: imageUrl ? '0.9vw' : '1.4vw',
pb: '1.3vw',
flexGrow: 1, // ✅ растягивает середину
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between', // ✅ кнопка уезжает вниз
}}
>
<Typography
variant="h6"
color="white"
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.05rem',
mb: 0.8,
}}
>
{name}
</Typography>
{description && (
<Typography
variant="body2"
color="white"
sx={{
opacity: 0.75,
fontSize: '0.85rem',
mb: 1.5,
minHeight: 40,
maxHeight: 40, // ✅ фикс высоты
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{description}
</Typography>
)}
{typeof price === 'number' && (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
}}
>
<Typography
variant="body2"
color="white"
sx={{ opacity: 0.8, fontSize: '0.85rem' }}
>
Цена
</Typography>
<CoinsDisplay
value={price}
size="small"
autoUpdate={false}
showTooltip={true}
/>
</Box>
)}
{type === 'case' && typeof itemsCount === 'number' && (
<Typography
variant="body2"
color="white"
sx={{ opacity: 0.6, fontSize: '0.8rem', mb: 1.4 }}
>
Предметов в кейсе: {itemsCount}
</Typography>
)}
<Button
variant="contained"
fullWidth
sx={{
mt: 0.5,
borderRadius: '999px',
py: '0.45vw',
color: 'white',
background:
type === 'case'
? 'linear-gradient(135deg, rgb(255, 77, 77), rgb(255, 120, 100))'
: 'linear-gradient(135deg, rgb(0, 160, 90), rgb(0, 200, 140))',
'&:hover': {
background:
type === 'case'
? 'linear-gradient(135deg, rgba(255, 77, 77, 0.85), rgba(255, 120, 100, 0.9))'
: 'linear-gradient(135deg, rgba(0, 160, 90, 0.85), rgba(0, 200, 140, 0.9))',
},
fontFamily: 'Benzin-Bold',
fontSize: '0.9rem',
textTransform: 'uppercase',
letterSpacing: 0.8,
}}
disabled={disabled}
onClick={onClick}
>
{buttonText}
</Button>
</CardContent>
{type === 'cape' && imageUrl && (
<CapePreviewModal
open={previewOpen}
onClose={() => setPreviewOpen(false)}
capeUrl={imageUrl}
skinUrl={playerSkinUrl}
/>
)}
</Card>
);
}

View File

@ -24,12 +24,14 @@ import {
fetchCase,
openCase,
Server,
fetchPlayer,
} from '../api';
import { useEffect, useState } from 'react';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { getPlayerServer } from '../utils/playerOnlineCheck';
import CaseRoulette from '../components/CaseRoulette';
import CoinsDisplay from '../components/CoinsDisplay';
import ShopItem from '../components/ShopItem';
function getRarityByWeight(
weight?: number,
@ -64,6 +66,8 @@ export default function Shop() {
const [uuid, setUuid] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
// Кейсы
const [cases, setCases] = useState<Case[]>([]);
const [casesLoading, setCasesLoading] = useState<boolean>(false);
@ -92,8 +96,15 @@ export default function Shop() {
type: 'success',
});
const VISIBLE_ITEMS = 21; // сколько элементов в линии
const CENTER_INDEX = Math.floor(VISIBLE_ITEMS / 2);
const loadPlayerSkin = async (uuid: string) => {
try {
const player = await fetchPlayer(uuid);
setPlayerSkinUrl(player.skin_url);
} catch (error) {
console.error('Ошибка при получении скина игрока:', error);
setPlayerSkinUrl('');
}
};
// Функция для загрузки плащей из магазина
const loadStoreCapes = async () => {
@ -184,6 +195,7 @@ export default function Shop() {
loadStoreCapes(),
loadUserCapes(config.username),
loadCases(),
loadPlayerSkin(config.uuid),
])
.catch((err) => console.error(err))
.finally(() => {
@ -206,27 +218,6 @@ export default function Shop() {
!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({
@ -296,12 +287,11 @@ export default function Shop() {
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '2vw',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
}}
>
{(loading || onlineCheckLoading) && (
@ -313,269 +303,123 @@ export default function Shop() {
sx={{
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
alignContent: 'flex-start',
width: '90%',
height: '80%',
gap: '2vw',
overflow: 'auto',
paddingBottom: '7vh',
paddingLeft: '5vw',
paddingRight: '5vw',
}}
>
{/* Блок кейсов */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Typography variant="h6">Кейсы</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 2,
mb: 1,
flexDirection: 'column',
}}
>
<Box sx={{ display: 'flex', gap: 2 }}>
<Typography variant="h6">Кейсы</Typography>
{!isOnline && (
<Button
variant="outlined"
size="small"
sx={{
width: '9em',
height: '3em',
borderRadius: '2.5vw',
fontFamily: 'Benzin-Bold',
fontSize: '0.7em',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
color: 'white',
border: 'none',
transition: 'transform 0.3s ease',
'&:hover': {
{!isOnline && (
<Button
variant="outlined"
size="small"
sx={{
width: '9em',
height: '3em',
borderRadius: '2.5vw',
fontFamily: 'Benzin-Bold',
fontSize: '0.7em',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
transform: 'scale(1.05)',
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
},
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
}}
onClick={() => {
checkPlayerStatus(); // обновляем онлайн-статус
loadCases(); // обновляем ТОЛЬКО кейсы
}}
>
Обновить
</Button>
color: 'white',
border: 'none',
transition: 'transform 0.3s ease',
'&:hover': {
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
transform: 'scale(1.05)',
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
},
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
}}
onClick={() => {
checkPlayerStatus(); // обновляем онлайн-статус
loadCases(); // обновляем ТОЛЬКО кейсы
}}
>
Обновить
</Button>
)}
</Box>
{!isOnline ? (
<Typography variant="body1" color="error" sx={{ mb: 2 }}>
Для открытия кейсов вам необходимо находиться на одном из
серверов игры. Зайдите в игру и нажмите кнопку «Обновить».
</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}>
<ShopItem
type="case"
id={c.id}
name={c.name}
description={c.description}
imageUrl={c.image_url}
price={c.price}
itemsCount={c.items_count}
isOpening={isOpening && selectedCase?.id === c.id}
disabled={!isOnline || isOpening}
onClick={() => handleOpenCase(c)}
/>
</Grid>
))}
</Grid>
) : (
<Typography>Кейсы временно недоступны.</Typography>
)}
</Box>
{!isOnline ? (
<Typography variant="body1" color="error" sx={{ mb: 2 }}>
Для открытия кейсов вам необходимо находиться на одном из серверов
игры. Зайдите в игру и нажмите кнопку «Обновить».
</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={{
position: 'relative',
bgcolor: 'rgba(5, 5, 15, 0.96)',
borderRadius: '20px',
border: '1px solid rgba(255, 255, 255, 0.08)',
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.8)',
overflow: 'hidden',
transition:
'transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease',
'&:hover': {
transform: 'translateY(-6px)',
boxShadow: '0 26px 60px rgba(0, 0, 0, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.18)',
},
}}
>
{/* верхний “свет” */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background:
'radial-gradient(circle at top, rgba(255,255,255,0.13), transparent 55%)',
}}
/>
{c.image_url && (
<Box
sx={{
position: 'relative',
p: '0.9vw',
pb: 0,
}}
>
<Box
sx={{
borderRadius: '16px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.12)',
background:
'linear-gradient(135deg, rgba(40,40,80,0.9), rgba(15,15,35,0.9))',
}}
>
<CardMedia
component="img"
image={c.image_url}
alt={c.name}
sx={{
width: '100%',
height: '11vw',
minHeight: '140px',
objectFit: 'cover',
filter: 'saturate(1.1)',
}}
/>
</Box>
{/* маленький бейдж сверху картинки */}
<Box
sx={{
position: 'absolute',
top: '1.2vw',
left: '1.6vw',
px: 1.2,
py: 0.4,
borderRadius: '999px',
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: 0.6,
bgcolor: 'rgba(0, 0, 0, 0.6)',
border: '1px solid rgba(255, 255, 255, 0.4)',
color: 'white',
}}
>
Кейс
</Box>
</Box>
)}
<CardContent
sx={{
position: 'relative',
zIndex: 1,
pt: c.image_url ? '0.9vw' : '1.4vw',
pb: '1.3vw',
}}
>
<Typography
variant="h6"
color="white"
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.05rem',
mb: 0.8,
}}
>
{c.name}
</Typography>
{c.description && (
<Typography
variant="body2"
color="white"
sx={{
opacity: 0.75,
fontSize: '0.85rem',
mb: 1.5,
minHeight: '40px',
}}
>
{c.description}
</Typography>
)}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
}}
>
<Typography
variant="body2"
color="white"
sx={{ opacity: 0.8, fontSize: '0.85rem' }}
>
Цена
</Typography>
<CoinsDisplay
value={c.price}
size="small"
autoUpdate={false}
showTooltip={true}
/>
</Box>
{typeof c.items_count === 'number' && (
<Typography
variant="body2"
color="white"
sx={{ opacity: 0.6, fontSize: '0.8rem', mb: 1.4 }}
>
Предметов в кейсе: {c.items_count}
</Typography>
)}
<Button
variant="contained"
fullWidth
sx={{
mt: 0.5,
borderRadius: '999px',
py: '0.45vw',
color: 'white',
background:
'linear-gradient(135deg, rgb(255, 77, 77), rgb(255, 120, 100))',
'&:hover': {
background:
'linear-gradient(135deg, rgba(255, 77, 77, 0.85), rgba(255, 120, 100, 0.9))',
},
fontFamily: 'Benzin-Bold',
fontSize: '0.9rem',
textTransform: 'uppercase',
letterSpacing: 0.8,
}}
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
sx={{
display: 'flex',
flexDirection: 'row',
gap: '2vw',
flexWrap: 'wrap',
}}
>
{availableCapes.map((cape) => (
<CapeCard
key={cape.id}
cape={cape}
mode="shop"
onAction={handlePurchaseCape}
/>
))}
</Box>
) : (
<Typography>У вас уже есть все доступные плащи!</Typography>
)}
{/* Блок плащей */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h6">Доступные плащи</Typography>
{availableCapes.length > 0 ? (
<Grid container spacing={2}>
{availableCapes.map((cape) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={cape.id}>
<ShopItem
type="cape"
id={cape.id}
name={cape.name}
description={cape.description}
imageUrl={cape.image_url}
price={cape.price}
disabled={false}
playerSkinUrl={playerSkinUrl}
onClick={() => handlePurchaseCape(cape.id)}
/>
</Grid>
))}
</Grid>
) : (
<Typography>У вас уже есть все доступные плащи!</Typography>
)}
</Box>
</Box>
)}