Add game roll wheel
This commit is contained in:
209
frontend/src/components/SpinWheel.tsx
Normal file
209
frontend/src/components/SpinWheel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user