Fix wheel
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import type { Game } from '@/types'
|
||||
import { Gamepad2, Loader2 } from 'lucide-react'
|
||||
|
||||
@@ -9,27 +9,43 @@ interface SpinWheelProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const SPIN_DURATION = 5000 // ms
|
||||
const EXTRA_ROTATIONS = 5
|
||||
const SPIN_DURATION = 6000 // ms - увеличено для более плавного замедления
|
||||
const EXTRA_ROTATIONS = 7 // больше оборотов для эффекта инерции
|
||||
|
||||
// Цветовая палитра секторов
|
||||
// Пороги для адаптивного отображения
|
||||
const TEXT_THRESHOLD = 16 // До 16 игр - показываем текст
|
||||
const LINES_THRESHOLD = 40 // До 40 игр - показываем разделители
|
||||
|
||||
// Цветовая палитра секторов (расширенная для большего количества)
|
||||
const SECTOR_COLORS = [
|
||||
{ bg: '#0d9488', border: '#14b8a6' }, // teal
|
||||
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
|
||||
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
|
||||
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
|
||||
{ bg: '#059669', border: '#10b981' }, // emerald
|
||||
{ bg: '#7c2d12', border: '#ea580c' }, // orange
|
||||
{ bg: '#ea580c', border: '#f97316' }, // orange
|
||||
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
|
||||
{ bg: '#be123c', border: '#e11d48' }, // rose
|
||||
{ bg: '#4f46e5', border: '#6366f1' }, // indigo
|
||||
{ bg: '#0284c7', border: '#0ea5e9' }, // sky
|
||||
{ bg: '#9333ea', border: '#a855f7' }, // purple
|
||||
{ bg: '#16a34a', border: '#22c55e' }, // green
|
||||
]
|
||||
|
||||
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
|
||||
const [isSpinning, setIsSpinning] = useState(false)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
const [displayedGame, setDisplayedGame] = useState<Game | null>(null)
|
||||
const [spinStartTime, setSpinStartTime] = useState<number | null>(null)
|
||||
const [startRotation, setStartRotation] = useState(0)
|
||||
const [targetRotation, setTargetRotation] = useState(0)
|
||||
|
||||
// Размеры колеса
|
||||
const wheelSize = 400
|
||||
// Определяем режим отображения
|
||||
const showText = games.length <= TEXT_THRESHOLD
|
||||
const showLines = games.length <= LINES_THRESHOLD
|
||||
|
||||
// Размеры колеса - увеличиваем для большого количества игр
|
||||
const wheelSize = games.length > 50 ? 450 : games.length > 30 ? 420 : 400
|
||||
const centerX = wheelSize / 2
|
||||
const centerY = wheelSize / 2
|
||||
const radius = wheelSize / 2 - 10
|
||||
@@ -102,11 +118,16 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
||||
const fullRotations = EXTRA_ROTATIONS * 360
|
||||
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
|
||||
|
||||
setRotation(rotation + finalAngle)
|
||||
const newRotation = rotation + finalAngle
|
||||
setStartRotation(rotation)
|
||||
setTargetRotation(newRotation)
|
||||
setSpinStartTime(Date.now())
|
||||
setRotation(newRotation)
|
||||
|
||||
// Ждём окончания анимации
|
||||
setTimeout(() => {
|
||||
setIsSpinning(false)
|
||||
setSpinStartTime(null)
|
||||
onSpinComplete(resultGame)
|
||||
}, SPIN_DURATION)
|
||||
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
|
||||
@@ -117,13 +138,67 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
||||
return text.slice(0, maxLength - 2) + '...'
|
||||
}
|
||||
|
||||
// Функция для вычисления игры под указателем по углу
|
||||
const getGameAtAngle = useCallback((currentRotation: number) => {
|
||||
if (games.length === 0) return null
|
||||
const normalizedRotation = ((currentRotation % 360) + 360) % 360
|
||||
const angleUnderPointer = (360 - normalizedRotation + 360) % 360
|
||||
const sectorIndex = Math.floor(angleUnderPointer / sectorAngle) % games.length
|
||||
return games[sectorIndex] || null
|
||||
}, [games, sectorAngle])
|
||||
|
||||
// Вычисляем игру под указателем (статическое состояние)
|
||||
const currentGameUnderPointer = useMemo(() => {
|
||||
return getGameAtAngle(rotation)
|
||||
}, [rotation, getGameAtAngle])
|
||||
|
||||
// Easing функция для имитации инерции - быстрый старт, долгое замедление
|
||||
// Аппроксимирует CSS cubic-bezier(0.12, 0.9, 0.15, 1)
|
||||
const easeOutExpo = useCallback((t: number): number => {
|
||||
// Экспоненциальное замедление - очень быстро в начале, очень медленно в конце
|
||||
return t === 1 ? 1 : 1 - Math.pow(2, -12 * t)
|
||||
}, [])
|
||||
|
||||
// Отслеживаем позицию во время вращения
|
||||
useEffect(() => {
|
||||
if (!isSpinning || spinStartTime === null) {
|
||||
// Когда не крутится - показываем текущую игру под указателем
|
||||
if (currentGameUnderPointer) {
|
||||
setDisplayedGame(currentGameUnderPointer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const totalDelta = targetRotation - startRotation
|
||||
|
||||
const updateDisplayedGame = () => {
|
||||
const elapsed = Date.now() - spinStartTime
|
||||
const progress = Math.min(elapsed / SPIN_DURATION, 1)
|
||||
const easedProgress = easeOutExpo(progress)
|
||||
|
||||
// Вычисляем текущий угол на основе прогресса анимации
|
||||
const currentAngle = startRotation + (totalDelta * easedProgress)
|
||||
const game = getGameAtAngle(currentAngle)
|
||||
|
||||
if (game) {
|
||||
setDisplayedGame(game)
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем каждые 30мс для плавности
|
||||
const interval = setInterval(updateDisplayedGame, 30)
|
||||
updateDisplayedGame() // Сразу обновляем
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isSpinning, spinStartTime, startRotation, targetRotation, getGameAtAngle, currentGameUnderPointer, easeOutExpo])
|
||||
|
||||
// Мемоизируем секторы для производительности
|
||||
const sectors = useMemo(() => {
|
||||
return games.map((game, index) => {
|
||||
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
|
||||
const path = createSectorPath(index, games.length)
|
||||
const textPos = getTextPosition(index, games.length)
|
||||
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
|
||||
const maxTextLength = games.length > 12 ? 8 : games.length > 8 ? 10 : games.length > 5 ? 14 : 18
|
||||
|
||||
return { game, color, path, textPos, maxTextLength }
|
||||
})
|
||||
@@ -213,7 +288,8 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transitionProperty: isSpinning ? 'transform' : 'none',
|
||||
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
|
||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
|
||||
// Инерционное вращение: быстрый старт, долгое плавное замедление
|
||||
transitionTimingFunction: 'cubic-bezier(0.12, 0.9, 0.15, 1)',
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
@@ -230,38 +306,42 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
||||
<path
|
||||
d={path}
|
||||
fill={color.bg}
|
||||
stroke={color.border}
|
||||
strokeWidth="2"
|
||||
stroke={showLines ? color.border : 'transparent'}
|
||||
strokeWidth={showLines ? "1" : "0"}
|
||||
filter="url(#sectorShadow)"
|
||||
/>
|
||||
|
||||
{/* Текст названия игры */}
|
||||
<text
|
||||
x={textPos.x}
|
||||
y={textPos.y}
|
||||
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
|
||||
fontWeight="bold"
|
||||
style={{
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{truncateText(game.title, maxTextLength)}
|
||||
</text>
|
||||
{/* Текст названия игры - только для небольшого количества */}
|
||||
{showText && (
|
||||
<text
|
||||
x={textPos.x}
|
||||
y={textPos.y}
|
||||
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={games.length > 12 ? "9" : games.length > 8 ? "10" : games.length > 6 ? "11" : "13"}
|
||||
fontWeight="bold"
|
||||
style={{
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{truncateText(game.title, maxTextLength)}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Разделительная линия */}
|
||||
<line
|
||||
x1={centerX}
|
||||
y1={centerY}
|
||||
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
|
||||
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{/* Разделительная линия - только для среднего количества */}
|
||||
{showLines && (
|
||||
<line
|
||||
x1={centerX}
|
||||
y1={centerY}
|
||||
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
|
||||
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
@@ -322,6 +402,21 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Название текущей игры (для большого количества) */}
|
||||
{!showText && (
|
||||
<div className="glass rounded-xl px-6 py-3 min-w-[280px] text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
{games.length} игр в колесе
|
||||
</p>
|
||||
<p className={`
|
||||
font-semibold transition-all duration-100 truncate max-w-[280px]
|
||||
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-white'}
|
||||
`}>
|
||||
{displayedGame?.title || 'Крутите колесо!'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подсказка */}
|
||||
<p className={`
|
||||
text-sm transition-all duration-300
|
||||
|
||||
Reference in New Issue
Block a user