caseRoulette 50/50

This commit is contained in:
2025-12-07 03:17:29 +05:00
parent c14315b078
commit 39f8ec875b

View File

@ -1,5 +1,5 @@
import { Box, Typography, Button, Dialog, DialogContent } from '@mui/material';
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { CaseItem } from '../api';
type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
@ -9,7 +9,7 @@ interface CaseRouletteProps {
onClose: () => void;
caseName?: string;
items: CaseItem[];
reward: CaseItem | null; // что реально выпало с бэка
reward: CaseItem | null;
}
// --- настройки рулетки ---
@ -17,12 +17,9 @@ 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';
@ -56,48 +53,124 @@ export default function CaseRoulette({
const [sequence, setSequence] = useState<CaseItem[]>([]);
const [offset, setOffset] = useState(0);
const [animating, setAnimating] = useState(false);
const [animationFinished, setAnimationFinished] = useState(false);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationTimeoutRef = useRef<NodeJS.Timeout>();
const finishTimeoutRef = useRef<NodeJS.Timeout>();
const winningName =
reward?.meta?.display_name || reward?.name || reward?.material || '';
// генерируем полосу предметов и запускаем анимацию,
// когда диалог открыт и есть reward
// Измеряем реальные ширины элементов
const measureItemWidths = useCallback((): number[] => {
return itemRefs.current.map((ref) =>
ref ? ref.getBoundingClientRect().width : ITEM_WIDTH,
);
}, []);
// Основной эффект для инициализации
useEffect(() => {
if (!open || !reward || !items || items.length === 0) return;
// 1. генерим последовательность
// Очистка предыдущих таймеров
if (animationTimeoutRef.current) clearTimeout(animationTimeoutRef.current);
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
// Сброс состояний
setAnimating(false);
setAnimationFinished(false);
setOffset(0);
itemRefs.current = [];
// 1. Генерируем последовательность
const totalItems = VISIBLE_ITEMS * 3;
const seq: CaseItem[] = [];
for (let i = 0; i < VISIBLE_ITEMS; i++) {
for (let i = 0; i < totalItems; i++) {
const randomItem = items[Math.floor(Math.random() * items.length)];
seq.push(randomItem);
}
// 2. подменяем центр на тот предмет, который реально выпал
const winPosition = Math.floor(totalItems / 2);
const fromCase =
items.find((i) => i.material === reward.material) || reward;
seq[CENTER_INDEX] = fromCase;
seq[winPosition] = fromCase;
setSequence(seq);
}, [open, reward, items]);
// 3. считаем финальный offset, при котором CENTER_INDEX оказывается под линией
const centerItemCenter =
CENTER_INDEX * (ITEM_WIDTH + ITEM_GAP) + ITEM_WIDTH / 2;
const finalOffset = Math.max(0, centerItemCenter - LINE_X);
// Эффект для запуска анимации после рендера элементов
useEffect(() => {
if (sequence.length === 0 || !open) return;
// стартуем анимацию
setAnimating(false);
setOffset(0);
const startAnimation = () => {
const widths = measureItemWidths();
if (widths.length === 0 || widths.length !== sequence.length) {
// Если не удалось измерить, используем базовую ширину
const winPosition = Math.floor(sequence.length / 2);
const centerItemCenter =
winPosition * (ITEM_WIDTH + ITEM_GAP) + ITEM_WIDTH / 2;
const initialOffset = centerItemCenter - CONTAINER_WIDTH;
const finalOffset = centerItemCenter - LINE_X;
// маленькая задержка, чтобы браузер применил начальное состояние
const id = setTimeout(() => {
setAnimating(true);
setOffset(finalOffset);
}, 50);
setOffset(initialOffset);
animationTimeoutRef.current = setTimeout(() => {
setAnimating(true);
setOffset(finalOffset);
}, 200);
finishTimeoutRef.current = setTimeout(() => {
setAnimationFinished(true);
}, 7000);
return;
}
const winPosition = Math.floor(sequence.length / 2);
let cumulativeOffset = 0;
for (let i = 0; i < winPosition; i++) {
cumulativeOffset += widths[i] + ITEM_GAP;
}
const centerItemCenter = cumulativeOffset + widths[winPosition] / 2;
const initialOffset = centerItemCenter - CONTAINER_WIDTH;
const finalOffset = centerItemCenter - LINE_X;
setOffset(initialOffset);
animationTimeoutRef.current = setTimeout(() => {
setAnimating(true);
setOffset(finalOffset);
}, 100);
finishTimeoutRef.current = setTimeout(() => {
setAnimationFinished(true);
}, 3100);
};
// Даем время на рендер элементов
const renderTimeout = setTimeout(startAnimation, 100);
return () => {
clearTimeout(id);
clearTimeout(renderTimeout);
if (animationTimeoutRef.current)
clearTimeout(animationTimeoutRef.current);
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
};
}, [open, reward, items]);
}, [sequence, open, measureItemWidths]);
// Очистка при закрытии
useEffect(() => {
if (!open) {
if (animationTimeoutRef.current)
clearTimeout(animationTimeoutRef.current);
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
setSequence([]);
setAnimating(false);
setAnimationFinished(false);
setOffset(0);
}
}, [open]);
return (
<Dialog
@ -155,19 +228,24 @@ export default function CaseRoulette({
flexDirection: 'row',
gap: `${ITEM_GAP}px`,
transform: `translateX(-${offset}px)`,
willChange: 'transform',
transition: animating
? 'transform 3s cubic-bezier(0.1, 0.8, 0.2, 1)'
? 'transform 7s cubic-bezier(0.1, 0.8, 0.2, 1)'
: 'none',
}}
>
{sequence.map((item, index) => {
const color = getRarityColor(item.weight);
const isWinningItem =
animationFinished && index === Math.floor(sequence.length / 2);
return (
<Box
key={index}
ref={(el) => (itemRefs.current[index] = el)}
sx={{
width: `120px`,
minWidth: `${ITEM_WIDTH}px`,
width: 'auto',
height: '120px',
borderRadius: '0.7vw',
bgcolor: 'rgba(255, 255, 255, 0.04)',
@ -175,10 +253,12 @@ export default function CaseRoulette({
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border:
index === CENTER_INDEX
? `2px solid ${color}`
: `1px solid ${color}`,
border: isWinningItem
? `3px solid ${color}`
: `1px solid ${color}`,
boxShadow: isWinningItem ? `0 0 20px ${color}` : 'none',
transition: 'all 0.3s ease',
padding: '0 10px',
}}
>
<Box
@ -199,6 +279,10 @@ export default function CaseRoulette({
color: 'white',
textAlign: 'center',
fontSize: '0.7rem',
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.meta?.display_name ||
@ -214,13 +298,20 @@ export default function CaseRoulette({
</Box>
</Box>
{winningName && (
{animationFinished && winningName && (
<Typography
variant="body1"
color="white"
sx={{ textAlign: 'center', mt: 2 }}
sx={{
textAlign: 'center',
mt: 2,
animation: 'fadeIn 0.5s ease',
}}
>
Вам выпало: <b>{winningName}</b>
Вам выпало:{' '}
<b style={{ color: getRarityColor(reward?.weight) }}>
{winningName}
</b>
</Typography>
)}
@ -243,6 +334,8 @@ export default function CaseRoulette({
backgroundColor: 'rgba(255, 77, 77, 0.5)',
},
fontFamily: 'Benzin-Bold',
opacity: animationFinished ? 1 : 0.5,
pointerEvents: animationFinished ? 'auto' : 'none',
}}
>
Закрыть