Add game roll wheel

This commit is contained in:
2025-12-14 21:41:49 +07:00
parent 5db2f9c48d
commit 1a882fb2e0
2 changed files with 237 additions and 22 deletions

View File

@@ -0,0 +1,209 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { Game } from '@/types'
interface SpinWheelProps {
games: Game[]
onSpin: () => Promise<Game | null>
onSpinComplete: (game: Game) => void
disabled?: boolean
}
const ITEM_HEIGHT = 100
const VISIBLE_ITEMS = 5
const SPIN_DURATION = 4000
const EXTRA_ROTATIONS = 3
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
const [isSpinning, setIsSpinning] = useState(false)
const [offset, setOffset] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<number | null>(null)
// Create extended list for seamless looping
const extendedGames = [...games, ...games, ...games, ...games, ...games]
const handleSpin = useCallback(async () => {
if (isSpinning || disabled || games.length === 0) return
setIsSpinning(true)
// Get result from API first
const resultGame = await onSpin()
if (!resultGame) {
setIsSpinning(false)
return
}
// Find target index
const targetIndex = games.findIndex(g => g.id === resultGame.id)
if (targetIndex === -1) {
setIsSpinning(false)
onSpinComplete(resultGame)
return
}
// Calculate animation
const totalItems = games.length
const fullRotations = EXTRA_ROTATIONS * totalItems
const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT
// Animate
const startTime = Date.now()
const startOffset = offset % (totalItems * ITEM_HEIGHT)
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / SPIN_DURATION, 1)
// Easing function - starts fast, slows down at end
const easeOut = 1 - Math.pow(1 - progress, 4)
const currentOffset = startOffset + (finalPosition - startOffset) * easeOut
setOffset(currentOffset)
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate)
} else {
setIsSpinning(false)
onSpinComplete(resultGame)
}
}
animationRef.current = requestAnimationFrame(animate)
}, [isSpinning, disabled, games, offset, onSpin, onSpinComplete])
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [])
if (games.length === 0) {
return (
<div className="text-center py-12 text-gray-400">
Нет доступных игр для прокрутки
</div>
)
}
const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT
const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length
// Calculate opacity based on distance from center
const getItemOpacity = (itemIndex: number) => {
const itemPosition = itemIndex * ITEM_HEIGHT - offset
const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2
const distanceFromCenter = Math.abs(itemPosition - centerPosition)
const maxDistance = containerHeight / 2
const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8)
return opacity
}
return (
<div className="flex flex-col items-center gap-6">
{/* Wheel container */}
<div className="relative w-full max-w-md">
{/* Selection indicator */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] border-2 border-primary-500 rounded-lg bg-primary-500/10 z-20 pointer-events-none">
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-t-transparent border-b-transparent border-r-primary-500" />
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-l-8 border-t-transparent border-b-transparent border-l-primary-500" />
</div>
{/* Items container */}
<div
ref={containerRef}
className="relative overflow-hidden"
style={{ height: containerHeight }}
>
<div
className="absolute w-full transition-none"
style={{
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`,
}}
>
{extendedGames.map((game, index) => {
const realIndex = index % games.length
const isSelected = !isSpinning && realIndex === currentIndex
const opacity = getItemOpacity(index)
return (
<div
key={`${game.id}-${index}`}
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${
isSelected ? 'scale-105' : ''
}`}
style={{ height: ITEM_HEIGHT, opacity }}
>
{/* Game cover */}
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0">
{game.cover_url ? (
<img
src={game.cover_url}
alt={game.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">
🎮
</div>
)}
</div>
{/* Game info */}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate text-lg">
{game.title}
</h3>
{game.genre && (
<p className="text-sm text-gray-400 truncate">{game.genre}</p>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
{/* Spin button */}
<button
onClick={handleSpin}
disabled={isSpinning || disabled}
className={`
relative px-12 py-4 text-xl font-bold rounded-full
transition-all duration-300 transform
${isSpinning || disabled
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
: 'bg-gradient-to-r from-primary-500 to-primary-600 text-white hover:scale-105 hover:shadow-lg hover:shadow-primary-500/30 active:scale-95'
}
`}
>
{isSpinning ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-6 h-6" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Крутится...
</span>
) : (
'КРУТИТЬ!'
)}
</button>
</div>
)
}