Files
SerpentRace/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx
T
2025-11-18 00:09:08 +01:00

674 lines
25 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useCallback } from "react"
import { getVerticalOffset } from "../../utils/randomUtils"
import Dice from "../../utils/dice/Dice"
import { useGameWebSocketContext } from "../../contexts/GameWebSocketContext"
import JokerApprovalModal from "./JokerApprovalModal"
import CardDisplayModal from "./CardDisplayModal"
import ConsequenceModal from "./ConsequenceModal"
import StepPredictionModal from "./StepPredictionModal"
// Constants - outside component to prevent recreation
const PLAYER_STYLES = [
{ color: "bg-blue-600", emoji: "🐍" },
{ color: "bg-green-600", emoji: "🐢" },
{ color: "bg-purple-600", emoji: "🐇" },
{ color: "bg-yellow-600", emoji: "🦊" },
{ color: "bg-red-600", emoji: "🦁" },
{ color: "bg-pink-600", emoji: "🐷" },
{ color: "bg-orange-600", emoji: "🐯" },
{ color: "bg-indigo-600", emoji: "🐺" },
]
const BOARD_CONFIG = {
rows: 5,
cols: 20,
cellSize: 40,
cellMargin: 2.5,
rowSpacing: 70,
}
// Helper functions outside component
const mapFieldType = (backendType) => {
switch (backendType) {
case 'positive': return 'good'
case 'negative': return 'bad'
case 'luck': return 'clover'
default: return 'regular'
}
}
const getDefaultFieldType = (count) => {
if (count % 17 === 0) return "clover"
if (count % 13 === 0) return "bad"
if ((count + 5) % 13 === 0) return "good"
return "regular"
}
const GameScreen = () => {
// WebSocket connection from context (maintains connection across navigation)
const {
isConnected,
gameState,
players: backendPlayers,
boardData: websocketBoardData,
currentTurn,
error,
rollDice,
approveJoker,
rejectJoker,
submitAnswer,
submitPositionGuess,
addEventListener,
removeEventListener
} = useGameWebSocketContext()
// Try to get boardData from WebSocket, fallback to localStorage
const boardData = useMemo(() => {
if (websocketBoardData) return websocketBoardData
try {
const stored = localStorage.getItem('boardData')
if (stored) {
console.log('📦 Loading boardData from localStorage')
return JSON.parse(stored)
}
} catch (err) {
console.error('Failed to parse boardData from localStorage:', err)
}
return null
}, [websocketBoardData])
const [path, setPath] = useState([])
const [players, setPlayers] = useState([])
// Joker approval modal state
const [isJokerModalOpen, setIsJokerModalOpen] = useState(false)
const [currentJokerRequest, setCurrentJokerRequest] = useState(null)
// Card display modal state
const [isCardModalOpen, setIsCardModalOpen] = useState(false)
const [currentCard, setCurrentCard] = useState(null)
// Consequence modal state
const [isConsequenceModalOpen, setIsConsequenceModalOpen] = useState(false)
const [currentConsequence, setCurrentConsequence] = useState(null)
// Step prediction modal state
const [isPredictionModalOpen, setIsPredictionModalOpen] = useState(false)
const [currentPredictionData, setCurrentPredictionData] = useState(null)
// Memoized board dimensions
const { rows, cols, totalCells, cellSize, cellMargin, rowSpacing, topOffset, width, height } = useMemo(() => {
const { rows, cols, cellSize, cellMargin, rowSpacing } = BOARD_CONFIG
const topOffset = rowSpacing * 0.5
const bottomOffset = rowSpacing * 0.5
const totalCells = rows * cols
return {
rows,
cols,
totalCells,
cellSize,
cellMargin,
rowSpacing,
topOffset,
bottomOffset,
width: cols * (cellSize + cellMargin * 2),
height: rows * (cellSize + cellMargin * 2 + rowSpacing) + topOffset + bottomOffset - rowSpacing,
}
}, [])
// Memoized path generator - Snake pattern with proper turn handling
const generateWindingPath = useCallback((backendFields = null) => {
const path = []
const hasBackendData = backendFields && Array.isArray(backendFields)
let currentNum = 1
// Generate all 100 positions
while (currentNum <= totalCells) {
const row = Math.floor((currentNum - 1) / cols)
const posInRow = (currentNum - 1) % cols
const isLeftToRight = row % 2 === 0
// Calculate column based on direction
const col = isLeftToRight ? posInRow : (cols - 1 - posInRow)
// Base Y position for this row
let baseYPosition = topOffset + row * (cellSize + cellMargin * 2 + rowSpacing)
// Apply vertical offset for wave effect
let yOffset = getVerticalOffset(currentNum - 1)
// Special handling for turn positions (21, 41, 61, 81)
// These should be positioned between rows to show the turn
if (currentNum % cols === 1 && currentNum > 1) {
// This is the first element of a new row (21, 41, 61, 81)
// Position it halfway between the previous row and current row
baseYPosition = topOffset + (row - 0.5) * (cellSize + cellMargin * 2 + rowSpacing)
yOffset = 0 // Reset wave offset for turn positions
}
const backendField = hasBackendData ? backendFields.find(f => f.position === currentNum) : null
path.push({
number: currentNum,
x: col * (cellSize + cellMargin * 2),
y: baseYPosition + yOffset,
type: backendField ? mapFieldType(backendField.type) : getDefaultFieldType(currentNum - 1),
stepValue: backendField?.stepValue || 0,
})
currentNum++
}
return path
}, [rows, cols, totalCells, cellSize, cellMargin, rowSpacing, topOffset])
// Update path when boardData changes
useEffect(() => {
if (boardData?.fields) {
setPath(generateWindingPath(boardData.fields))
} else if (path.length === 0) {
setPath(generateWindingPath())
}
}, [boardData, generateWindingPath])
// Update players from backend - memoized mapping
useEffect(() => {
if (!backendPlayers?.length) return
const mappedPlayers = backendPlayers.map((player, index) => ({
id: player.playerId || player.id || index,
name: player.playerName || player.name || `Player ${index + 1}`,
position: player.boardPosition || 0,
score: player.score || 0,
color: PLAYER_STYLES[index % PLAYER_STYLES.length].color,
emoji: PLAYER_STYLES[index % PLAYER_STYLES.length].emoji,
isOnline: player.isOnline !== undefined ? player.isOnline : true,
isReady: player.isReady || false,
}))
setPlayers(mappedPlayers)
}, [backendPlayers])
// Listen to player movement - optimized to update only moved player
useEffect(() => {
if (!addEventListener) return
const handlePlayerMoved = (moveData) => {
setPlayers(prev =>
prev.map(p =>
p.id === moveData.playerId
? { ...p, position: moveData.newPosition }
: p
)
)
}
addEventListener('game:player-moved', handlePlayerMoved)
return () => removeEventListener('game:player-moved')
}, [addEventListener, removeEventListener])
// Listen to Joker card events (csak Gamemaster számára)
useEffect(() => {
if (!addEventListener) return
const handleJokerDrawn = (jokerData) => {
console.log('🃏 Joker kártya húzva:', jokerData)
// Joker approval modal megjelenítése
setCurrentJokerRequest({
playerId: jokerData.playerId,
playerName: jokerData.playerName,
playerEmoji: jokerData.playerEmoji || "🎭",
cardTitle: jokerData.cardTitle || jokerData.jokerCard?.question,
cardDescription: jokerData.cardDescription || jokerData.jokerCard?.consequence?.description,
points: jokerData.points || jokerData.jokerCard?.consequence?.value,
cardId: jokerData.cardId || jokerData.jokerCard?.id,
requestId: jokerData.requestId, // Important: requestId from backend
timestamp: Date.now()
})
setIsJokerModalOpen(true)
}
// Listen for gamemaster decision request (correct event name per docs)
addEventListener('game:joker-drawn', handleJokerDrawn)
addEventListener('game:gamemaster-decision-request', handleJokerDrawn)
return () => {
removeEventListener('game:joker-drawn')
removeEventListener('game:gamemaster-decision-request')
}
}, [addEventListener, removeEventListener])
// Listen to card drawn events (kártya megjelenítés)
useEffect(() => {
if (!addEventListener) return
const handleCardDrawn = (cardData) => {
console.log('🎴 Kártya húzva:', cardData)
setCurrentCard({
id: cardData.cardId || cardData.id,
type: cardData.cardType || cardData.type,
question: cardData.question || cardData.text,
answerOptions: cardData.answerOptions || cardData.options || [],
correctAnswer: cardData.correctAnswer,
points: cardData.points || 0,
timeLimit: cardData.timeLimit || 60
})
setIsCardModalOpen(true)
}
// Listen for both generic and self-specific events
addEventListener('game:card-drawn', handleCardDrawn)
addEventListener('game:card-drawn-self', handleCardDrawn)
return () => {
removeEventListener('game:card-drawn')
removeEventListener('game:card-drawn-self')
}
}, [addEventListener, removeEventListener])
// Listen to answer validation (következmény megjelenítés)
useEffect(() => {
if (!addEventListener) return
const handleAnswerValidated = (resultData) => {
console.log('✅ Válasz kiértékelve:', resultData)
// Close card modal first
setIsCardModalOpen(false)
// Show consequence modal
setCurrentConsequence({
isCorrect: resultData.isCorrect || resultData.correct,
playerAnswer: resultData.playerAnswer || resultData.answer,
correctAnswer: resultData.correctAnswer,
explanation: resultData.explanation || '',
consequenceType: resultData.consequenceType || resultData.consequence?.type,
consequenceValue: resultData.consequenceValue || resultData.consequence?.value || 0,
points: resultData.pointsEarned || resultData.points || 0
})
setIsConsequenceModalOpen(true)
}
// Also listen for luck consequences (instant consequences from luck cards)
const handleLuckConsequence = (luckData) => {
console.log('🍀 Szerencse kártya következménye:', luckData)
setCurrentConsequence({
isCorrect: true, // Luck cards don't have right/wrong answers
consequenceType: luckData.consequenceType,
consequenceValue: luckData.value || luckData.consequenceValue || 0,
explanation: luckData.message || 'Szerencse kártya!',
playerAnswer: null,
correctAnswer: null
})
setIsConsequenceModalOpen(true)
}
addEventListener('game:answer-validated', handleAnswerValidated)
addEventListener('game:luck-consequence', handleLuckConsequence)
return () => {
removeEventListener('game:answer-validated')
removeEventListener('game:luck-consequence')
}
}, [addEventListener, removeEventListener])
// Listen to position guess requests (lépés tippelés)
useEffect(() => {
if (!addEventListener) return
const handlePositionGuessRequest = (predictionData) => {
console.log('🎯 Pozíció tippelés kérés:', predictionData)
setCurrentPredictionData({
currentPosition: predictionData.currentPosition,
diceRoll: predictionData.diceRoll || predictionData.dice,
fieldStepValue: predictionData.fieldStepValue || predictionData.fieldStep || 0,
patternModifier: predictionData.patternModifier || predictionData.zoneModifier || 0,
cardText: predictionData.cardText || predictionData.text || 'Tippeld meg, hova fogsz lépni!',
timeLimit: predictionData.timeLimit || 30
})
setIsPredictionModalOpen(true)
}
addEventListener('game:position-guess-request', handlePositionGuessRequest)
return () => removeEventListener('game:position-guess-request')
}, [addEventListener, removeEventListener])
// Joker jóváhagyás
const handleApproveJoker = useCallback(async (jokerRequest) => {
console.log('✅ Joker feladat jóváhagyva:', jokerRequest)
// WebSocket üzenet a backend felé
approveJoker(jokerRequest.playerId, jokerRequest.cardId, jokerRequest.requestId)
// Modal bezárása
setIsJokerModalOpen(false)
}, [approveJoker])
// Joker elutasítás
const handleRejectJoker = useCallback(async (jokerRequest) => {
console.log('❌ Joker feladat elutasítva:', jokerRequest)
// WebSocket üzenet a backend felé
rejectJoker(jokerRequest.playerId, jokerRequest.cardId, jokerRequest.requestId)
// Modal bezárása
setIsJokerModalOpen(false)
}, [rejectJoker])
// Kártya válasz beküldése
const handleSubmitAnswer = useCallback((answer) => {
console.log('📝 Válasz beküldve:', answer)
// WebSocket emit a backend felé
if (currentCard?.id) {
submitAnswer(currentCard.id, answer)
}
// A consequence modal automatikusan megnyílik a 'game:answer-validated' event hatására
}, [currentCard?.id, submitAnswer])
// Pozíció tippelés beküldése
const handleSubmitPrediction = useCallback((predictedPosition) => {
console.log('🎯 Pozíció tippelés beküldve:', predictedPosition)
// WebSocket emit a backend felé
submitPositionGuess(predictedPosition)
// Modal bezárása
setIsPredictionModalOpen(false)
}, [submitPositionGuess])
// Sorted players - memoized
const sortedPlayers = useMemo(
() => [...players].sort((a, b) => b.position - a.position),
[players]
)
// Handle dice roll
const handleDiceRoll = useCallback((value) => {
console.log('🎲 Dobás:', value)
const success = rollDice(value)
if (success) {
console.log('✅ Kockadobás elküldve a szervernek')
} else {
console.warn('⚠️ Kockadobás sikertelen - nincs kapcsolat vagy nem te következel')
}
}, [rollDice])
// Get field style - memoized
const getFieldStyle = useCallback((type) => {
switch (type) {
case "clover":
return "bg-teal-700 border-teal-500 shadow-teal-800"
case "bad":
return "bg-red-800 border-red-600 shadow-red-900"
case "good":
return "bg-blue-800 border-blue-600 shadow-blue-900"
default:
return "bg-gray-800 border-gray-600 shadow-gray-900"
}
}, [])
// Get player position - memoized
const getPlayerPosition = useCallback((playerPosition) => {
const field = path.find((p) => p.number === playerPosition)
return field ? { top: `${field.y}px`, left: `${field.x}px` } : { top: 0, left: 0 }
}, [path])
// Get medal style - memoized
const getMedalStyle = useCallback((rank) => {
switch (rank) {
case 1:
return "bg-yellow-400 text-yellow-900 border-yellow-500 shadow-yellow-600"
case 2:
return "bg-gray-400 text-gray-900 border-gray-500 shadow-gray-600"
case 3:
return "bg-orange-500 text-orange-900 border-orange-600 shadow-orange-700"
default:
return "bg-gray-700 text-gray-300 border-gray-600 shadow-gray-800"
}
}, [])
return (
<div className="p-4 bg-gradient-to-br from-gray-900 via-gray-800 to-teal-900 min-h-screen flex items-center justify-center">
<div className="w-full">
{/* Connection Status Indicator */}
<div className="fixed top-4 right-4 z-50">
<div className={`px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 ${
isConnected
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'
}`}>
<div className={`w-3 h-3 rounded-full ${
isConnected ? 'bg-green-300 animate-pulse' : 'bg-red-300'
}`}></div>
<span className="text-sm font-medium">
{isConnected ? '🟢 Csatlakozva' : '🔴 Kapcsolódás...'}
</span>
</div>
{error && !error.includes('Game not found') && !error.includes('token invalid') && (
<div className="mt-2 px-4 py-2 rounded-lg shadow-lg bg-red-600 text-white text-xs">
{error}
</div>
)}
</div> {/* Game Info Bar */}
{gameState && (
<div className="fixed top-4 left-4 z-50">
<div className="bg-gray-800 border border-teal-700 px-4 py-2 rounded-lg shadow-lg">
<div className="text-teal-300 text-sm font-medium">
🎮 Játék kód: <span className="font-bold text-white">{gameState.gameCode || 'N/A'}</span>
</div>
{currentTurn && (
<div className="text-gray-400 text-xs mt-1">
🎯 Köron: <span className="text-white">{players.find(p => p.id === currentTurn)?.name || 'Betöltés...'}</span>
</div>
)}
</div>
</div>
)}
<div className="flex flex-col md:flex-row gap-6 justify-center">
{/* Game Board */}
<div className="relative bg-gray-800 p-6 rounded-2xl shadow-xl border border-teal-700 flex flex-col items-center justify-center overflow-hidden">
{/* Background decoration */}
<div className="absolute w-full h-full opacity-10 pointer-events-none overflow-hidden">
{[...Array(35)].map((_, i) => (
<div
key={i}
className="absolute rounded-full bg-teal-600 animate-pulse"
style={{
width: Math.random() * 120 + 40 + "px",
height: Math.random() * 120 + 40 + "px",
top: Math.random() * 100 + "%",
left: Math.random() * 100 + "%",
transform: "translate(-50%, -50%)",
}}
></div>
))}
</div>
<div className="relative" style={{ height: `${height}px`, width: `${width}px` }}>
{/* Fields */}
{path.map((field) => (
<div
key={field.number}
className={`absolute w-10 h-10 border-2 flex items-center justify-center transition-all shadow-md hover:scale-110 ${getFieldStyle(
field.type
)}`}
style={{
top: `${field.y}px`,
left: `${field.x}px`,
width: `${cellSize}px`,
height: `${cellSize}px`,
margin: `${cellMargin}px`,
}}
>
<span className="text-gray-300 text-sm font-bold">{field.number}</span>
</div>
))}
{/* Player tokens */}
{players.map((player) => (
<div
key={player.id}
className={`absolute w-6 h-6 ${player.color} rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold z-10 animate-bounce`}
style={{
...getPlayerPosition(player.position),
transform: "translate(17px, 17px)",
}}
>
{player.emoji}
</div>
))}
</div>
</div>
{/* Right sidebar */}
<div className="flex-1 max-w-md">
<div className="bg-gray-800 rounded-xl p-4 shadow-lg mb-4 border border-teal-700">
<h2 className="text-xl font-semibold mb-3 text-teal-300">Játékosok</h2>
{/* Empty state */}
{players.length === 0 && (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">👥</div>
<p className="text-sm">Várakozás játékosokra...</p>
</div>
)}
{/* Players list */}
{sortedPlayers.map((player, index) => (
<div
key={player.id}
className="flex items-center mb-3 p-2 bg-gray-900 rounded-lg hover:bg-gray-700 transition-colors relative"
>
{/* Online indicator */}
{player.isOnline && (
<div className="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
)}
<div
className={`w-8 h-8 ${player.color} rounded-full mr-3 flex items-center justify-center text-white text-sm font-bold shadow-md`}
>
{player.emoji}
</div>
<div className="flex-1">
<div className="font-medium text-sm text-gray-300 flex items-center gap-2 flex-wrap">
{player.name}
{/* Ready indicator */}
{player.isReady && (
<span className="px-2 py-0.5 bg-green-600 text-white text-xs rounded-full">
Kész
</span>
)}
{/* Current turn indicator */}
{currentTurn === player.id && (
<span className="px-2 py-0.5 bg-yellow-500 text-gray-900 text-xs rounded-full font-bold animate-pulse">
Köre
</span>
)}
{/* Rank medal */}
<span
className={`ml-auto px-2 py-1 rounded-full border text-xs font-bold shadow-md ${getMedalStyle(
index + 1
)}`}
>
{index + 1 === 1
? "🥇 1st"
: index + 1 === 2
? "🥈 2nd"
: index + 1 === 3
? "🥉 3rd"
: `${index + 1}th`}
</span>
</div>
<div className="text-xs text-gray-500">
Pozíció: {player.position} Pontszám: {player.score}
</div>
</div>
</div>
))}
</div>
{/* Dice Container */}
<div className="bg-gray-800 rounded-xl p-4 shadow-lg border border-teal-700 text-center">
<h2 className="text-xl font-semibold mb-3 text-teal-300">Dobókocka</h2>
<p className="text-gray-300 text-sm mb-4">
Kattints a kockára dobáshoz!
</p>
<Dice onRoll={handleDiceRoll} />
{/* Connection warning */}
{!isConnected && (
<div className="mt-3 text-xs text-red-400">
Nincs kapcsolat a szerverrel
</div>
)}
</div>
{/* Debug Info Panel (Development only) */}
{import.meta.env.DEV && (
<div className="bg-gray-900 rounded-xl p-4 shadow-lg border border-gray-700 text-left mt-4">
<h3 className="text-sm font-semibold mb-2 text-gray-400">🔧 Debug Info</h3>
<div className="text-xs text-gray-500 space-y-1">
<div>📡 Connected: {isConnected ? '✅' : '❌'}</div>
<div>🎮 Game Code: {gameState?.gameCode || 'N/A'}</div>
<div>👥 Players: {backendPlayers?.length || 0}</div>
<div>🎲 Board Fields: {boardData?.fields?.length || 0}</div>
<div>🏁 Current Turn: {currentTurn || 'N/A'}</div>
{/* <div>🔑 Token: {gameToken ? '✅' : '❌'}</div> */}
</div>
</div>
)}
</div>
</div>
</div>
{/* Joker Approval Modal - csak Gamemaster számára */}
<JokerApprovalModal
isOpen={isJokerModalOpen}
onClose={() => setIsJokerModalOpen(false)}
jokerRequest={currentJokerRequest}
onApprove={handleApproveJoker}
onReject={handleRejectJoker}
playerName={currentJokerRequest?.playerName}
playerEmoji={currentJokerRequest?.playerEmoji}
/>
{/* Card Display Modal - kártya megjelenítés */}
<CardDisplayModal
isOpen={isCardModalOpen}
onClose={() => setIsCardModalOpen(false)}
card={currentCard}
onSubmitAnswer={handleSubmitAnswer}
/>
{/* Consequence Modal - következmények megjelenítése */}
<ConsequenceModal
isOpen={isConsequenceModalOpen}
onClose={() => setIsConsequenceModalOpen(false)}
consequence={currentConsequence}
/>
{/* Step Prediction Modal - pozíció tippelés */}
<StepPredictionModal
isOpen={isPredictionModalOpen}
onClose={() => setIsPredictionModalOpen(false)}
predictionData={currentPredictionData}
onSubmitPrediction={handleSubmitPrediction}
/>
</div>
)
}
export default GameScreen