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>
)
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { marathonsApi, wheelApi } from '@/api' import { marathonsApi, wheelApi, gamesApi } from '@/api'
import type { Marathon, Assignment, SpinResult } from '@/types' import type { Marathon, Assignment, SpinResult, Game } from '@/types'
import { Button, Card, CardContent } from '@/components/ui' import { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { Loader2, Upload, X } from 'lucide-react' import { Loader2, Upload, X } from 'lucide-react'
export function PlayPage() { export function PlayPage() {
@@ -11,11 +12,9 @@ export function PlayPage() {
const [marathon, setMarathon] = useState<Marathon | null>(null) const [marathon, setMarathon] = useState<Marathon | null>(null)
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null) const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
const [spinResult, setSpinResult] = useState<SpinResult | null>(null) const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
const [games, setGames] = useState<Game[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
// Spin state
const [isSpinning, setIsSpinning] = useState(false)
// Complete state // Complete state
const [proofFile, setProofFile] = useState<File | null>(null) const [proofFile, setProofFile] = useState<File | null>(null)
const [proofUrl, setProofUrl] = useState('') const [proofUrl, setProofUrl] = useState('')
@@ -34,12 +33,14 @@ export function PlayPage() {
const loadData = async () => { const loadData = async () => {
if (!id) return if (!id) return
try { try {
const [marathonData, assignment] = await Promise.all([ const [marathonData, assignment, gamesData] = await Promise.all([
marathonsApi.get(parseInt(id)), marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'),
]) ])
setMarathon(marathonData) setMarathon(marathonData)
setCurrentAssignment(assignment) setCurrentAssignment(assignment)
setGames(gamesData)
} catch (error) { } catch (error) {
console.error('Failed to load data:', error) console.error('Failed to load data:', error)
} finally { } finally {
@@ -47,24 +48,27 @@ export function PlayPage() {
} }
} }
const handleSpin = async () => { const handleSpin = async (): Promise<Game | null> => {
if (!id) return if (!id) return null
setIsSpinning(true)
setSpinResult(null)
try { try {
const result = await wheelApi.spin(parseInt(id)) const result = await wheelApi.spin(parseInt(id))
setSpinResult(result) setSpinResult(result)
// Reload to get assignment return result.game
await loadData()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось крутить') alert(error.response?.data?.detail || 'Не удалось крутить')
} finally { return null
setIsSpinning(false)
} }
} }
const handleSpinComplete = async () => {
// Small delay then reload data to show the assignment
setTimeout(async () => {
await loadData()
}, 500)
}
const handleComplete = async () => { const handleComplete = async () => {
if (!currentAssignment) return if (!currentAssignment) return
if (!proofFile && !proofUrl) { if (!proofFile && !proofUrl) {
@@ -162,17 +166,19 @@ export function PlayPage() {
</Card> </Card>
</div> </div>
{/* No active assignment - show spin */} {/* No active assignment - show spin wheel */}
{!currentAssignment && ( {!currentAssignment && (
<Card className="text-center"> <Card>
<CardContent className="py-12"> <CardContent className="py-8">
<h2 className="text-2xl font-bold text-white mb-4">Крутите колесо!</h2> <h2 className="text-2xl font-bold text-white mb-2 text-center">Крутите колесо!</h2>
<p className="text-gray-400 mb-8"> <p className="text-gray-400 mb-6 text-center">
Получите случайную игру и задание для выполнения Получите случайную игру и задание для выполнения
</p> </p>
<Button size="lg" onClick={handleSpin} isLoading={isSpinning}> <SpinWheel
{isSpinning ? 'Крутим...' : 'КРУТИТЬ'} games={games}
</Button> onSpin={handleSpin}
onSpinComplete={handleSpinComplete}
/>
</CardContent> </CardContent>
</Card> </Card>
)} )}