caseRoulette 50/50
This commit is contained in:
@ -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',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
|
||||
Reference in New Issue
Block a user