1087 lines
42 KiB
React
1087 lines
42 KiB
React
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,
|
||
playerPositions, // NEW: Get dedicated position tracking state
|
||
boardData: websocketBoardData,
|
||
currentTurn,
|
||
currentTurnName,
|
||
isMyTurn,
|
||
playerIdentifier,
|
||
isGamemaster,
|
||
error,
|
||
playerDiceRolls,
|
||
rollDice,
|
||
approveJoker,
|
||
rejectJoker,
|
||
submitAnswer,
|
||
submitPositionGuess,
|
||
submitJokerPositionGuess,
|
||
leaveGame,
|
||
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)
|
||
const [isMyCardTurn, setIsMyCardTurn] = useState(false) // Track if I'm the one answering
|
||
|
||
// 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)
|
||
|
||
// End game modal state
|
||
const [isEndGameModalOpen, setIsEndGameModalOpen] = useState(false)
|
||
const [endGameData, setEndGameData] = useState(null)
|
||
|
||
// Animation state management
|
||
const [animatingPlayers, setAnimatingPlayers] = useState({}) // { playerId: { from, to, startTime, duration } }
|
||
const [animatedPositions, setAnimatedPositions] = useState({}) // { playerId: currentAnimatedPosition }
|
||
|
||
// 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 (UI properties only, no position)
|
||
useEffect(() => {
|
||
if (!backendPlayers?.length) return
|
||
|
||
const mappedPlayers = backendPlayers.map((player, index) => {
|
||
const playerName = player.playerName || player.name || `Player ${index + 1}`;
|
||
|
||
return {
|
||
id: player.playerId || player.id || index,
|
||
name: playerName,
|
||
// NO position stored here - always read from context playerPositions
|
||
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])
|
||
|
||
// Debug: Log playerPositions changes
|
||
useEffect(() => {
|
||
console.log('🔍 [GameScreen] playerPositions changed:', JSON.stringify(playerPositions));
|
||
players.forEach(p => {
|
||
const pos = playerPositions?.[p.name];
|
||
console.log(`🔍 [GameScreen] Player ${p.name} position from context: ${pos}`);
|
||
});
|
||
}, [playerPositions, players]);
|
||
|
||
// Animation loop using requestAnimationFrame
|
||
useEffect(() => {
|
||
let animationFrameId
|
||
|
||
const animate = () => {
|
||
const now = Date.now()
|
||
const updates = {}
|
||
let hasActiveAnimations = false
|
||
|
||
Object.entries(animatingPlayers).forEach(([playerId, animation]) => {
|
||
const elapsed = now - animation.startTime
|
||
const progress = Math.min(elapsed / animation.duration, 1)
|
||
|
||
// Easing function (ease-in-out)
|
||
const eased = progress < 0.5
|
||
? 2 * progress * progress
|
||
: 1 - Math.pow(-2 * progress + 2, 2) / 2
|
||
|
||
// Interpolate position
|
||
const currentPos = Math.round(
|
||
animation.from + (animation.to - animation.from) * eased
|
||
)
|
||
|
||
updates[playerId] = currentPos
|
||
|
||
if (progress < 1) {
|
||
hasActiveAnimations = true
|
||
}
|
||
// Animation complete - no local position update needed
|
||
// Position always comes from context playerPositions
|
||
})
|
||
|
||
if (Object.keys(updates).length > 0) {
|
||
setAnimatedPositions(updates)
|
||
}
|
||
|
||
// Clean up completed animations
|
||
if (!hasActiveAnimations && Object.keys(animatingPlayers).length > 0) {
|
||
setAnimatingPlayers({})
|
||
setAnimatedPositions({})
|
||
} else if (hasActiveAnimations) {
|
||
animationFrameId = requestAnimationFrame(animate)
|
||
}
|
||
}
|
||
|
||
if (Object.keys(animatingPlayers).length > 0) {
|
||
animationFrameId = requestAnimationFrame(animate)
|
||
}
|
||
|
||
return () => {
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId)
|
||
}
|
||
}
|
||
}, [animatingPlayers])
|
||
|
||
// Listen to player-moving event to start animation
|
||
useEffect(() => {
|
||
if (!addEventListener) return
|
||
|
||
const handlePlayerMoving = (moveData) => {
|
||
const duration = Math.abs(moveData.toPosition - moveData.fromPosition) * 50 // 50ms per position
|
||
const clampedDuration = Math.max(500, Math.min(duration, 2000)) // Between 0.5s and 2s
|
||
|
||
// Backend sends 1-based positions (1-100), use directly
|
||
setAnimatingPlayers(prev => ({
|
||
...prev,
|
||
[moveData.playerId]: {
|
||
from: moveData.fromPosition,
|
||
to: moveData.toPosition,
|
||
startTime: Date.now(),
|
||
duration: clampedDuration
|
||
}
|
||
}))
|
||
}
|
||
|
||
addEventListener('game:player-moving', handlePlayerMoving)
|
||
return () => removeEventListener('game:player-moving')
|
||
}, [addEventListener, removeEventListener])
|
||
|
||
// Listen to errors and close modals
|
||
useEffect(() => {
|
||
if (!addEventListener) return
|
||
|
||
const handleGameError = (errorData) => {
|
||
console.error('❌ Game error:', errorData)
|
||
// Close any open modals on error
|
||
setIsCardModalOpen(false)
|
||
setIsPredictionModalOpen(false)
|
||
setIsJokerModalOpen(false)
|
||
// Show error in consequence modal if severe
|
||
if (errorData.message && errorData.message.includes('card')) {
|
||
setCurrentConsequence({
|
||
isCorrect: false,
|
||
explanation: errorData.message || 'An error occurred',
|
||
consequenceType: null,
|
||
consequenceValue: 0
|
||
})
|
||
setIsConsequenceModalOpen(true)
|
||
}
|
||
}
|
||
|
||
addEventListener('game:error', handleGameError)
|
||
return () => removeEventListener('game:error')
|
||
}, [addEventListener, removeEventListener])
|
||
|
||
// Listen to player-arrived event (trigger animation for position changes)
|
||
useEffect(() => {
|
||
const handlePlayerArrived = (event) => {
|
||
const arrivalData = event.detail
|
||
|
||
// Context manager already updated playerPositions
|
||
// Just set animation flag for visual animation
|
||
const player = players.find(p => p.id === arrivalData.playerId || p.name === arrivalData.playerName)
|
||
if (player) {
|
||
setAnimatingPlayers(prevAnimating => ({
|
||
...prevAnimating,
|
||
[player.id]: true
|
||
}))
|
||
|
||
// Clear animation flag after animation completes (2 seconds)
|
||
setTimeout(() => {
|
||
setAnimatingPlayers(prevAnimating => {
|
||
const newAnimating = { ...prevAnimating }
|
||
delete newAnimating[player.id]
|
||
return newAnimating
|
||
})
|
||
}, 2000)
|
||
}
|
||
}
|
||
|
||
// Listen to window CustomEvent instead of socket event (context already handles socket)
|
||
window.addEventListener('game:player-arrived', handlePlayerArrived)
|
||
return () => window.removeEventListener('game:player-arrived', handlePlayerArrived)
|
||
}, [players])
|
||
|
||
// Listen to Joker card events
|
||
useEffect(() => {
|
||
if (!addEventListener) return
|
||
|
||
const handleJokerDrawn = (jokerData) => {
|
||
console.log('🃏 Joker kártya húzva:', jokerData)
|
||
|
||
if (isGamemaster) {
|
||
// Gamemaster sees approval modal with approve/deny buttons
|
||
console.log('👑 Gamemaster látja a jóváhagyási modal-t')
|
||
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)
|
||
} else {
|
||
// Other players see the joker card as a read-only card display
|
||
console.log('👥 Játékosok látják a joker kártyát (csak olvasás)')
|
||
setCurrentCard({
|
||
type: 'JOKER',
|
||
question: jokerData.jokerCard?.question || jokerData.cardTitle || 'Joker Kártya Feladat',
|
||
consequence: jokerData.jokerCard?.consequence?.description || jokerData.cardDescription,
|
||
playerName: jokerData.playerName,
|
||
playerEmoji: jokerData.playerEmoji || "🎭",
|
||
isReadOnly: true,
|
||
waitingForGamemaster: true
|
||
})
|
||
setIsCardModalOpen(true)
|
||
setIsMyCardTurn(false) // Not my turn to answer
|
||
}
|
||
}
|
||
|
||
// 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, isGamemaster])
|
||
|
||
// Listen to card drawn events (kártya megjelenítés)
|
||
useEffect(() => {
|
||
if (!addEventListener) return
|
||
|
||
// Handle card drawn FOR ME (I need to answer)
|
||
const handleCardDrawnSelf = (data) => {
|
||
console.log('🎴 Kártya húzva NEKEM:', data)
|
||
const cardData = data.cardData || data;
|
||
console.log('📦 Card data structure:', cardData)
|
||
setCurrentCard({
|
||
id: cardData.cardid || cardData.id,
|
||
type: cardData.type,
|
||
question: cardData.question || cardData.text || cardData.statement,
|
||
answerOptions: cardData.answerOptions || [],
|
||
sentencePairs: cardData.sentencePairs || [],
|
||
words: cardData.words || [],
|
||
acceptableAnswers: cardData.acceptableAnswers || [],
|
||
correctAnswer: cardData.correctAnswer,
|
||
hint: cardData.hint,
|
||
points: cardData.points || 0,
|
||
timeLimit: data.timeLimit || cardData.timeLimit || 60
|
||
})
|
||
setIsMyCardTurn(true) // I need to answer
|
||
setIsCardModalOpen(true)
|
||
}
|
||
|
||
// Handle card drawn by ANOTHER PLAYER (spectator mode)
|
||
const handleCardDrawn = (data) => {
|
||
console.log('👀 Kártya húzva más játékos által:', data)
|
||
const cardData = data.cardData || data;
|
||
setCurrentCard({
|
||
id: cardData.cardid || cardData.id,
|
||
type: cardData.type,
|
||
question: cardData.question || cardData.text || cardData.statement,
|
||
answerOptions: cardData.answerOptions || [],
|
||
sentencePairs: cardData.sentencePairs || [],
|
||
words: cardData.words || [],
|
||
timeLimit: data.timeLimit || cardData.timeLimit || 60
|
||
})
|
||
setIsMyCardTurn(false) // Spectator mode
|
||
setIsCardModalOpen(true)
|
||
}
|
||
|
||
addEventListener('game:card-drawn-self', handleCardDrawnSelf)
|
||
addEventListener('game:card-drawn', handleCardDrawn)
|
||
|
||
return () => {
|
||
removeEventListener('game:card-drawn-self')
|
||
removeEventListener('game:card-drawn')
|
||
}
|
||
}, [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)
|
||
|
||
// Close card modal if it's open (shouldn't be for luck, but just in case)
|
||
setIsCardModalOpen(false)
|
||
|
||
setCurrentConsequence({
|
||
isCorrect: true, // Luck cards don't have right/wrong answers
|
||
consequenceType: luckData.consequenceType,
|
||
consequenceValue: luckData.value || luckData.consequenceValue || 0,
|
||
explanation: luckData.description || luckData.message || 'Szerencse kártya!',
|
||
playerAnswer: null,
|
||
correctAnswer: null
|
||
})
|
||
setIsConsequenceModalOpen(true)
|
||
}
|
||
|
||
const handleCardResult = (resultData) => {
|
||
console.log('🎴 Card result (luck):', resultData)
|
||
// This is for luck cards that use game:card-result event
|
||
setIsCardModalOpen(false)
|
||
|
||
setCurrentConsequence({
|
||
isCorrect: true,
|
||
consequenceType: resultData.consequence?.type,
|
||
consequenceValue: resultData.consequence?.value || 0,
|
||
explanation: resultData.description || 'Szerencse kártya!',
|
||
playerAnswer: null,
|
||
correctAnswer: null
|
||
})
|
||
setIsConsequenceModalOpen(true)
|
||
}
|
||
|
||
addEventListener('game:answer-validated', handleAnswerValidated)
|
||
addEventListener('game:luck-consequence', handleLuckConsequence)
|
||
addEventListener('game:card-result', handleCardResult)
|
||
|
||
return () => {
|
||
removeEventListener('game:answer-validated')
|
||
removeEventListener('game:luck-consequence')
|
||
removeEventListener('game:card-result')
|
||
}
|
||
}, [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)
|
||
}
|
||
|
||
const handleJokerPositionGuessRequest = (predictionData) => {
|
||
console.log('🃏🎯 Joker után 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.message || 'Tippeld meg a végső pozíciódat joker után!',
|
||
timeLimit: predictionData.timeLimit || 30,
|
||
isJoker: true // Mark this as joker guess
|
||
})
|
||
setIsPredictionModalOpen(true)
|
||
}
|
||
|
||
const handleGuessResult = (resultData) => {
|
||
console.log('✅ Tippelés eredménye:', resultData)
|
||
// Close prediction modal
|
||
setIsPredictionModalOpen(false)
|
||
|
||
// Backend already emits game:player-arrived before this event
|
||
// Position is handled by context manager, no need for pendingPositionUpdate
|
||
setCurrentConsequence({
|
||
isCorrect: resultData.guessCorrect,
|
||
playerAnswer: resultData.guessedPosition,
|
||
correctAnswer: resultData.actualPosition,
|
||
explanation: resultData.message,
|
||
consequenceType: resultData.penaltyApplied ? 'penalty' : 'success',
|
||
consequenceValue: resultData.penaltyApplied ? -2 : 0
|
||
})
|
||
setIsConsequenceModalOpen(true)
|
||
}
|
||
|
||
const handleJokerComplete = (resultData) => {
|
||
console.log('🃏✅ Joker tippelés eredménye:', resultData)
|
||
// Close prediction modal
|
||
setIsPredictionModalOpen(false)
|
||
|
||
// Backend already emits game:player-arrived before this event (if moved)
|
||
// Position is handled by context manager, no need for pendingPositionUpdate
|
||
setCurrentConsequence({
|
||
isCorrect: resultData.guessCorrect,
|
||
playerAnswer: resultData.guessedPosition,
|
||
correctAnswer: resultData.actualPosition,
|
||
explanation: resultData.message,
|
||
consequenceType: resultData.penaltyApplied ? 'penalty' : (resultData.moved ? 'success' : 'neutral'),
|
||
consequenceValue: resultData.penaltyApplied ? -2 : 0
|
||
})
|
||
setIsConsequenceModalOpen(true)
|
||
}
|
||
|
||
addEventListener('game:position-guess-request', handlePositionGuessRequest)
|
||
addEventListener('game:joker-position-guess-request', handleJokerPositionGuessRequest)
|
||
addEventListener('game:guess-result', handleGuessResult)
|
||
addEventListener('game:joker-complete', handleJokerComplete)
|
||
return () => {
|
||
removeEventListener('game:position-guess-request')
|
||
removeEventListener('game:joker-position-guess-request')
|
||
removeEventListener('game:guess-result')
|
||
removeEventListener('game:joker-complete')
|
||
}
|
||
}, [addEventListener, removeEventListener])
|
||
|
||
// Listen to game end event
|
||
useEffect(() => {
|
||
if (!addEventListener) return
|
||
|
||
const handleGameEnded = (endData) => {
|
||
console.log('🏆 Játék vége:', endData)
|
||
setEndGameData({
|
||
winnerName: endData.winnerName,
|
||
winnerId: endData.winner,
|
||
finalPositions: endData.finalPositions || [],
|
||
message: endData.message
|
||
})
|
||
setIsEndGameModalOpen(true)
|
||
}
|
||
|
||
const handleGamemasterDecision = (decisionData) => {
|
||
console.log('👑 Gamemaster döntés:', decisionData)
|
||
// Close joker card modal for non-gamemaster players when decision is made
|
||
if (!isGamemaster) {
|
||
setIsCardModalOpen(false)
|
||
}
|
||
}
|
||
|
||
addEventListener('game:ended', handleGameEnded)
|
||
addEventListener('game:gamemaster-decision-result', handleGamemasterDecision)
|
||
return () => {
|
||
removeEventListener('game:ended')
|
||
removeEventListener('game:gamemaster-decision-result')
|
||
}
|
||
}, [addEventListener, removeEventListener, isGamemaster])
|
||
|
||
// Joker jóváhagyás
|
||
const handleApproveJoker = useCallback(async (jokerRequest) => {
|
||
console.log('✅ Joker feladat jóváhagyva:', jokerRequest)
|
||
|
||
// WebSocket üzenet a backend felé - csak requestId kell
|
||
approveJoker(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é - csak requestId kell
|
||
rejectJoker(jokerRequest.requestId, 'Joker rejected by gamemaster')
|
||
|
||
// 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, 'Card ID:', currentCard?.id)
|
||
|
||
// WebSocket emit a backend felé - uses context method with card ID
|
||
submitAnswer(answer, currentCard?.id)
|
||
|
||
// A consequence modal automatikusan megnyílik a 'game:answer-validated' event hatására
|
||
}, [submitAnswer, currentCard])
|
||
|
||
// Consequence modal bezárása
|
||
const handleConsequenceClose = useCallback(() => {
|
||
// Position updates are handled by game:player-arrived event in context
|
||
// No need to manually update positions here
|
||
setIsConsequenceModalOpen(false)
|
||
}, [])
|
||
|
||
// 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é (különböző event joker-nél)
|
||
if (currentPredictionData?.isJoker) {
|
||
console.log('🃏 Joker pozíció tipp beküldése')
|
||
submitJokerPositionGuess(predictedPosition)
|
||
} else {
|
||
submitPositionGuess(predictedPosition)
|
||
}
|
||
|
||
// Modal bezárása
|
||
setIsPredictionModalOpen(false)
|
||
}, [submitPositionGuess, submitJokerPositionGuess, currentPredictionData])
|
||
|
||
// Sorted players - memoized (sort by context position)
|
||
const sortedPlayers = useMemo(
|
||
() => [...players].sort((a, b) => {
|
||
const posA = playerPositions?.[a.name] || 0
|
||
const posB = playerPositions?.[b.name] || 0
|
||
return posB - posA
|
||
}),
|
||
[players, playerPositions]
|
||
)
|
||
|
||
// 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">
|
||
{/* Exit Game Button - Top Right Corner */}
|
||
<div className="fixed top-4 right-4 z-50">
|
||
<button
|
||
onClick={() => {
|
||
if (window.confirm('Biztosan ki szeretnél lépni a játékból?')) {
|
||
leaveGame()
|
||
window.location.href = '/'
|
||
}
|
||
}}
|
||
className="bg-red-600 hover:bg-red-700 text-white font-semibold py-2 px-4 rounded-lg shadow-lg transition-colors duration-200 flex items-center gap-2 cursor-pointer"
|
||
title="Kilépés a játékból"
|
||
>
|
||
🚪 Kilépés
|
||
</button>
|
||
</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>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<div className={`w-2 h-2 rounded-full ${
|
||
isConnected ? 'bg-green-400 animate-pulse' : 'bg-red-400'
|
||
}`}></div>
|
||
<span className={`text-xs ${
|
||
isConnected ? 'text-green-400' : 'text-red-400'
|
||
}`}>
|
||
{isConnected ? 'Csatlakozva' : 'Kapcsolódás...'}
|
||
</span>
|
||
</div>
|
||
{currentTurn && (
|
||
<div className="text-gray-400 text-xs mt-1">
|
||
🎯 Köron: <span className={`font-bold ${isMyTurn ? 'text-green-400' : 'text-white'}`}>
|
||
{currentTurnName || players.find(p => p.id === currentTurn || p.playerName === currentTurn || p.name === currentTurn)?.name || currentTurn || 'Betöltés...'}
|
||
</span>
|
||
{isMyTurn && <span className="ml-2 text-green-400 animate-pulse">← Te vagy!</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) => {
|
||
// ALWAYS read position from context playerPositions, not local state
|
||
// Backend uses 1-based indexing (1-100)
|
||
const contextPosition = playerPositions?.[player.name] ?? 1
|
||
// Use animated position if player is currently animating, otherwise use context position
|
||
const displayPosition = animatedPositions[player.id] ?? contextPosition
|
||
const isAnimating = animatingPlayers[player.id] !== undefined
|
||
|
||
return (
|
||
<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 transition-transform ${isAnimating ? 'scale-110' : ''}`}
|
||
style={{
|
||
...getPlayerPosition(displayPosition),
|
||
transform: "translate(17px, 17px)",
|
||
transition: isAnimating ? 'none' : 'all 0.3s ease'
|
||
}}
|
||
>
|
||
{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ó: {playerPositions?.[player.name] ?? 1} • 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>
|
||
{isMyTurn ? (
|
||
<>
|
||
<p className="text-green-400 text-sm mb-4 font-bold animate-pulse">
|
||
🎯 A te köröd! Kattints a kockára dobáshoz!
|
||
</p>
|
||
<Dice onRoll={handleDiceRoll} />
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="text-gray-500 text-sm mb-4">
|
||
⏳ Várd meg a köröd...
|
||
</p>
|
||
<div className="opacity-50 pointer-events-none">
|
||
<Dice onRoll={handleDiceRoll} />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 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: {currentTurnName || currentTurn || 'N/A'}</div>
|
||
<div>🆔 My ID: {playerIdentifier || 'N/A'}</div>
|
||
<div>✅ Is My Turn: {isMyTurn ? 'YES' : 'NO'}</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}
|
||
isMyTurn={isMyCardTurn}
|
||
/>
|
||
|
||
{/* Consequence Modal - következmények megjelenítése */}
|
||
<ConsequenceModal
|
||
isOpen={isConsequenceModalOpen}
|
||
onClose={handleConsequenceClose}
|
||
isCorrect={currentConsequence?.isCorrect}
|
||
consequenceType={currentConsequence?.consequenceType}
|
||
consequenceValue={currentConsequence?.consequenceValue}
|
||
playerAnswer={currentConsequence?.playerAnswer}
|
||
correctAnswer={currentConsequence?.correctAnswer}
|
||
explanation={currentConsequence?.explanation}
|
||
/>
|
||
|
||
{/* End Game Modal */}
|
||
{isEndGameModalOpen && endGameData && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/90">
|
||
<div className="relative bg-gradient-to-br from-yellow-900 via-yellow-800 to-yellow-900 rounded-2xl shadow-2xl border-4 border-yellow-500 max-w-2xl w-full p-8 text-center">
|
||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-pulse" />
|
||
|
||
<div className="relative">
|
||
<div className="text-8xl mb-4 animate-bounce">🏆</div>
|
||
<h1 className="text-5xl font-bold text-white mb-4">
|
||
Játék vége!
|
||
</h1>
|
||
<div className="bg-black/30 rounded-xl p-6 mb-6">
|
||
<p className="text-6xl font-bold text-yellow-300 mb-2">
|
||
{endGameData.winnerName}
|
||
</p>
|
||
<p className="text-2xl text-yellow-100">
|
||
🎉 Nyert! 🎉
|
||
</p>
|
||
</div>
|
||
|
||
{endGameData.finalPositions && endGameData.finalPositions.length > 0 && (
|
||
<div className="bg-black/30 rounded-xl p-4 mb-6">
|
||
<h3 className="text-xl font-semibold text-yellow-300 mb-3">Végső állás:</h3>
|
||
<div className="space-y-2">
|
||
{endGameData.finalPositions
|
||
.sort((a, b) => b.boardPosition - a.boardPosition)
|
||
.map((player, index) => (
|
||
<div key={player.playerId} className="flex items-center justify-between bg-yellow-900/30 rounded-lg p-3">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-2xl">
|
||
{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🎮'}
|
||
</span>
|
||
<span className="text-white font-semibold">{player.playerName}</span>
|
||
</div>
|
||
<span className="text-yellow-300 font-bold">Pozíció: {player.boardPosition}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => window.location.href = '/'}
|
||
className="bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white font-bold py-4 px-8 rounded-xl shadow-lg transform transition-all duration-200 hover:scale-105 text-xl"
|
||
>
|
||
Vissza a főoldalra
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step Prediction Modal - pozíció tippelés */}
|
||
<StepPredictionModal
|
||
isOpen={isPredictionModalOpen}
|
||
onClose={() => setIsPredictionModalOpen(false)}
|
||
currentPosition={currentPredictionData?.currentPosition || 0}
|
||
diceRoll={currentPredictionData?.diceRoll || 0}
|
||
fieldStepValue={currentPredictionData?.fieldStepValue || 0}
|
||
patternModifier={currentPredictionData?.patternModifier || 0}
|
||
cardText={currentPredictionData?.cardText || "Tippeld meg, melyik pozícióra fogsz lépni!"}
|
||
hints={currentPredictionData?.hints || []}
|
||
timeLimit={currentPredictionData?.timeLimit || 30}
|
||
isJoker={currentPredictionData?.isJoker || false}
|
||
onSubmitPrediction={handleSubmitPrediction}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default GameScreen
|