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