836 lines
30 KiB
React
836 lines
30 KiB
React
import React, { createContext, useContext, useRef, useState, useCallback, useMemo, useEffect } from 'react';
|
|
import { io } from 'socket.io-client';
|
|
import { API_CONFIG } from '../api/userApi';
|
|
|
|
const GameWebSocketContext = createContext(null);
|
|
|
|
const isDev = import.meta.env.DEV;
|
|
const log = (...args) => isDev && console.log(...args);
|
|
const warn = (...args) => isDev && console.warn(...args);
|
|
const logError = (...args) => console.error(...args);
|
|
|
|
/**
|
|
* Provider that maintains WebSocket connection across page navigation
|
|
*/
|
|
export const GameWebSocketProvider = ({ children }) => {
|
|
const socketRef = useRef(null);
|
|
const gameTokenRef = useRef(null);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
|
|
// Single game object containing all game data
|
|
const [gameState, setGameState] = useState(null);
|
|
// Structure: {
|
|
// gameCode: string,
|
|
// boardData: object,
|
|
// turnInfo: { currentPlayer, currentPlayerName, turnNumber },
|
|
// players: [{ playerId, playerName, isOnline, isReady }],
|
|
// playerPositions: { playerName: position },
|
|
// connectedPlayers: [],
|
|
// readyPlayers: [],
|
|
// state: string,
|
|
// isGamemaster: boolean
|
|
// }
|
|
|
|
const [error, setError] = useState(null);
|
|
const [isGamemaster, setIsGamemaster] = useState(false);
|
|
const [gameStarted, setGameStarted] = useState(false);
|
|
const [pendingPlayers, setPendingPlayers] = useState([]);
|
|
const [approvalStatus, setApprovalStatus] = useState(null);
|
|
const [playerIdentifier, setPlayerIdentifier] = useState(null);
|
|
const [isMyTurnFlag, setIsMyTurnFlag] = useState(false); // Directly controlled by game:your-turn
|
|
const [playerDiceRolls, setPlayerDiceRolls] = useState({});
|
|
|
|
// Memoized derived values - extract from single game object
|
|
const players = useMemo(() => {
|
|
return gameState?.players || [];
|
|
}, [gameState?.players]);
|
|
|
|
const playerPositions = useMemo(() => {
|
|
return gameState?.playerPositions || {};
|
|
}, [gameState?.playerPositions]);
|
|
|
|
const boardData = useMemo(() => {
|
|
return gameState?.boardData || null;
|
|
}, [gameState?.boardData]);
|
|
|
|
const currentTurn = useMemo(() => gameState?.turnInfo?.currentPlayer || null, [gameState?.turnInfo?.currentPlayer]);
|
|
const currentTurnName = useMemo(() => gameState?.turnInfo?.currentPlayerName || null, [gameState?.turnInfo?.currentPlayerName]);
|
|
|
|
// isMyTurn is simply the flag set by game:your-turn event
|
|
const isMyTurn = isMyTurnFlag;
|
|
|
|
/**
|
|
* Connect to game WebSocket with a game token
|
|
* This maintains the connection even when navigating between pages
|
|
*/
|
|
const connect = useCallback((gameToken) => {
|
|
if (!gameToken) {
|
|
warn('⚠️ Cannot connect without game token');
|
|
return;
|
|
}
|
|
|
|
// If already connected with same token, don't reconnect
|
|
if (socketRef.current?.connected && gameTokenRef.current === gameToken) {
|
|
log('✅ Already connected with same token');
|
|
return;
|
|
}
|
|
|
|
// Disconnect old socket if exists
|
|
if (socketRef.current) {
|
|
log('🔌 Disconnecting old socket');
|
|
socketRef.current.removeAllListeners();
|
|
socketRef.current.disconnect();
|
|
}
|
|
|
|
log('🔌 Connecting to game WebSocket...');
|
|
gameTokenRef.current = gameToken;
|
|
|
|
// Decode token to get player identifier
|
|
try {
|
|
const payload = JSON.parse(atob(gameToken.split('.')[1]));
|
|
const identifier = payload.userId || payload.playerName;
|
|
setPlayerIdentifier(identifier);
|
|
log('🎮 Player identifier:', identifier);
|
|
} catch (err) {
|
|
logError('Failed to decode game token:', err);
|
|
}
|
|
|
|
// Connect to /game namespace
|
|
socketRef.current = io(`${API_CONFIG.wsURL}/game`, {
|
|
transports: ['websocket'],
|
|
reconnection: true,
|
|
reconnectionAttempts: 5,
|
|
reconnectionDelay: 1000,
|
|
timeout: 5000,
|
|
});
|
|
|
|
const socket = socketRef.current;
|
|
|
|
// Connection handlers
|
|
socket.on('connect', () => {
|
|
log('✅ Connected to game WebSocket');
|
|
setIsConnected(true);
|
|
setError(null);
|
|
socket.emit('game:join', { gameToken });
|
|
});
|
|
|
|
socket.on('connect_error', (err) => {
|
|
logError('❌ Connection error:', err);
|
|
setIsConnected(false);
|
|
setError(err.message);
|
|
|
|
// If reconnection fails completely, navigate to home
|
|
if (err.message?.includes('timeout') || err.message?.includes('xhr poll error')) {
|
|
setTimeout(() => {
|
|
if (!socketRef.current?.connected) {
|
|
logError('⚠️ Connection failed - redirecting to home');
|
|
window.location.href = '/';
|
|
}
|
|
}, 10000); // Give 10 seconds for reconnection attempts
|
|
}
|
|
});
|
|
|
|
socket.on('disconnect', (reason) => {
|
|
log('🔌 Disconnected:', reason);
|
|
setIsConnected(false);
|
|
setIsMyTurnFlag(false); // Clear turn flag on disconnect
|
|
});
|
|
|
|
// Game state handlers
|
|
socket.on('game:state', (state) => {
|
|
log('📊 Game state received:', state);
|
|
if (state?.isGamemaster !== undefined) {
|
|
setIsGamemaster(state.isGamemaster);
|
|
}
|
|
// Merge state into single game object
|
|
setGameState(prev => {
|
|
// Build players array from various sources
|
|
let playersArray = prev?.players || [];
|
|
|
|
// If we have currentPlayers or players from backend, use them (already have proper structure)
|
|
if (state.currentPlayers && Array.isArray(state.currentPlayers) && state.currentPlayers.length > 0) {
|
|
playersArray = state.currentPlayers.map(p => ({
|
|
playerId: p.playerId || p.id,
|
|
playerName: p.playerName || p.name,
|
|
name: p.playerName || p.name, // Add name for compatibility
|
|
isOnline: p.isOnline !== undefined ? p.isOnline : true,
|
|
isReady: p.isReady || false
|
|
}));
|
|
} else if (state.players && Array.isArray(state.players) && state.players.length > 0) {
|
|
playersArray = state.players.map(p => ({
|
|
playerId: p.playerId || p.id,
|
|
playerName: p.playerName || p.name,
|
|
name: p.playerName || p.name, // Add name for compatibility
|
|
isOnline: p.isOnline !== undefined ? p.isOnline : true,
|
|
isReady: p.isReady || false
|
|
}));
|
|
}
|
|
// If players array is still empty but we have connectedPlayers (array of strings), convert them
|
|
else if (playersArray.length === 0 && state.connectedPlayers && Array.isArray(state.connectedPlayers) && state.connectedPlayers.length > 0) {
|
|
playersArray = state.connectedPlayers.map((playerName, index) => ({
|
|
playerId: `player-${index}`, // Temporary ID until we get the real one
|
|
playerName: playerName,
|
|
name: playerName, // Add name for compatibility
|
|
isOnline: true,
|
|
isReady: false
|
|
}));
|
|
}
|
|
|
|
// Initialize playerPositions from backend data if joining game in progress
|
|
let playerPositions = prev?.playerPositions || {};
|
|
// If we don't have positions yet but backend sent players with positions, initialize from them
|
|
if (Object.keys(playerPositions).length === 0 && state.players && Array.isArray(state.players)) {
|
|
const positionsFromBackend = {};
|
|
state.players.forEach(p => {
|
|
const playerName = p.playerName || p.name;
|
|
if (playerName && p.position !== undefined) {
|
|
positionsFromBackend[playerName] = p.position;
|
|
}
|
|
});
|
|
if (Object.keys(positionsFromBackend).length > 0) {
|
|
playerPositions = positionsFromBackend;
|
|
}
|
|
}
|
|
|
|
const merged = {
|
|
...prev,
|
|
gameCode: state.gameCode || prev?.gameCode,
|
|
boardData: state.boardData || prev?.boardData,
|
|
state: state.state || prev?.state,
|
|
connectedPlayers: state.connectedPlayers || prev?.connectedPlayers || [],
|
|
readyPlayers: state.readyPlayers || prev?.readyPlayers || [],
|
|
// Preserve turn info - only turn-changed updates it
|
|
turnInfo: prev?.turnInfo || { currentPlayer: null, currentPlayerName: null, turnNumber: 0 },
|
|
// Use the built players array
|
|
players: playersArray,
|
|
// Initialize playerPositions from backend if joining in progress, otherwise preserve
|
|
playerPositions: playerPositions
|
|
};
|
|
return merged;
|
|
});
|
|
});
|
|
|
|
socket.on('game:state-update', (state) => {
|
|
log('📊 Game state update:', state);
|
|
if (state?.isGamemaster !== undefined) {
|
|
setIsGamemaster(state.isGamemaster);
|
|
}
|
|
// Merge state update into single game object
|
|
setGameState(prev => ({
|
|
...prev,
|
|
gameCode: state.gameCode || prev?.gameCode,
|
|
boardData: state.boardData || prev?.boardData,
|
|
state: state.state || prev?.state,
|
|
connectedPlayers: state.connectedPlayers || prev?.connectedPlayers,
|
|
readyPlayers: state.readyPlayers || prev?.readyPlayers,
|
|
// Preserve turn info and positions
|
|
turnInfo: prev?.turnInfo,
|
|
players: prev?.players,
|
|
playerPositions: prev?.playerPositions
|
|
}));
|
|
});
|
|
|
|
socket.on('game:joined', (data) => {
|
|
log('✅ Joined game:', data);
|
|
if (data.isGamemaster !== undefined) {
|
|
setIsGamemaster(data.isGamemaster);
|
|
}
|
|
// Initialize or update gameState with gameCode and gameId from join confirmation
|
|
setGameState(prev => {
|
|
if (prev && prev.gameCode) {
|
|
// If already has gameCode, just add gameId if missing
|
|
return {
|
|
...prev,
|
|
gameId: data.gameId || prev.gameId,
|
|
gameCode: data.gameCode,
|
|
playerName: data.playerName,
|
|
isAuthenticated: data.isAuthenticated
|
|
};
|
|
}
|
|
return {
|
|
...prev,
|
|
gameId: data.gameId, // Store gameId from backend
|
|
gameCode: data.gameCode,
|
|
playerName: data.playerName,
|
|
isAuthenticated: data.isAuthenticated
|
|
};
|
|
});
|
|
});
|
|
|
|
socket.on('game:player-joined', (data) => {
|
|
log('👤 Player joined:', data.playerName);
|
|
setGameState(prev => {
|
|
if (!prev) return prev;
|
|
const currentConnected = prev.connectedPlayers || [];
|
|
const currentPlayers = prev.players || [];
|
|
|
|
// Add to connectedPlayers if not already there
|
|
const updatedConnected = currentConnected.includes(data.playerName)
|
|
? currentConnected
|
|
: [...currentConnected, data.playerName];
|
|
|
|
// Add to players array if not already there (without position - that's in playerPositions)
|
|
const playerExists = currentPlayers.some(p =>
|
|
p.playerName === data.playerName || p.name === data.playerName
|
|
);
|
|
|
|
const updatedPlayers = playerExists
|
|
? currentPlayers
|
|
: [...currentPlayers, {
|
|
playerId: data.playerId,
|
|
playerName: data.playerName,
|
|
name: data.playerName, // Add name for compatibility with Lobby display
|
|
isOnline: true,
|
|
isReady: false
|
|
}];
|
|
|
|
return {
|
|
...prev,
|
|
connectedPlayers: updatedConnected,
|
|
players: updatedPlayers
|
|
};
|
|
});
|
|
window.dispatchEvent(new CustomEvent('game:player-joined', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:player-left', (data) => {
|
|
log('👋 Player left:', data.playerName);
|
|
setGameState(prev => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
connectedPlayers: (prev.connectedPlayers || []).filter(p => p !== data.playerName)
|
|
};
|
|
});
|
|
window.dispatchEvent(new CustomEvent('game:player-left', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:player-ready', (data) => {
|
|
log('✅ Player ready:', data.playerName);
|
|
setGameState(prev => {
|
|
if (!prev) return prev;
|
|
const readyPlayers = prev.readyPlayers || [];
|
|
if (data.ready && !readyPlayers.includes(data.playerName)) {
|
|
return { ...prev, readyPlayers: [...readyPlayers, data.playerName] };
|
|
} else if (!data.ready) {
|
|
return { ...prev, readyPlayers: readyPlayers.filter(p => p !== data.playerName) };
|
|
}
|
|
return prev;
|
|
});
|
|
window.dispatchEvent(new CustomEvent('game:player-ready', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:all-ready', (data) => {
|
|
log('✅ All players ready! Game can start.');
|
|
window.dispatchEvent(new CustomEvent('game:all-ready', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:start', (data) => {
|
|
log('🎮 Game started:', data);
|
|
setGameStarted(true);
|
|
|
|
// Update game state with position initialization
|
|
setGameState(prev => {
|
|
// Initialize all player positions to 0 at game start
|
|
const initialPositions = {};
|
|
|
|
// Use existing players from prev (already set by game:joined/game:state)
|
|
const existingPlayers = prev?.players || [];
|
|
|
|
// Initialize positions for all existing players
|
|
existingPlayers.forEach((player) => {
|
|
const playerName = player.playerName || player.name;
|
|
if (playerName) {
|
|
// Initialize ALL positions to 0 - only game:player-arrived will change them
|
|
initialPositions[playerName] = 0;
|
|
}
|
|
});
|
|
|
|
const updated = {
|
|
...prev,
|
|
gameCode: data.gameCode || prev?.gameCode,
|
|
boardData: data.boardData || prev?.boardData,
|
|
state: data.gamePhase || data.state || 'playing',
|
|
boardSize: data.boardSize || prev?.boardSize,
|
|
// Keep existing players list, initialize positions to 0
|
|
players: existingPlayers,
|
|
playerPositions: initialPositions,
|
|
// Preserve turn info and other data
|
|
turnInfo: prev?.turnInfo || { currentPlayer: null, currentPlayerName: null, turnNumber: 0 },
|
|
connectedPlayers: prev?.connectedPlayers || [],
|
|
readyPlayers: prev?.readyPlayers || []
|
|
};
|
|
return updated;
|
|
});
|
|
});
|
|
|
|
socket.on('game:turn-changed', (data) => {
|
|
log('🔄 Turn changed to:', data.currentPlayerName);
|
|
|
|
// Turn changed means it's NOT my turn anymore
|
|
setIsMyTurnFlag(false);
|
|
|
|
// This is the ONLY place turnInfo should be set
|
|
setGameState(prev => ({
|
|
...prev,
|
|
turnInfo: {
|
|
currentPlayer: data.currentPlayer,
|
|
currentPlayerName: data.currentPlayerName,
|
|
turnNumber: data.turnNumber || prev?.turnInfo?.turnNumber || 0
|
|
}
|
|
}));
|
|
|
|
// Force a re-render by logging after state update
|
|
setTimeout(() => {
|
|
console.log('🔄 [game:turn-changed] State should be updated now');
|
|
}, 100);
|
|
});
|
|
|
|
socket.on('game:error', (err) => {
|
|
logError('❌ Game error:', err);
|
|
setError(err.message);
|
|
});
|
|
|
|
// Approval system handlers
|
|
socket.on('game:pending-approval', (data) => {
|
|
log('⏳ Pending gamemaster approval:', data);
|
|
setApprovalStatus('pending');
|
|
setError('Waiting for gamemaster approval...');
|
|
});
|
|
|
|
socket.on('game:player-requesting-join', (data) => {
|
|
log('🔔 Player requesting to join:', data.playerName);
|
|
setPendingPlayers(prev => {
|
|
if (prev.some(p => p.playerName === data.playerName)) return prev;
|
|
return [...prev, {
|
|
playerName: data.playerName,
|
|
isAuthenticated: data.isAuthenticated,
|
|
timestamp: data.timestamp
|
|
}];
|
|
});
|
|
});
|
|
|
|
socket.on('game:approval-granted', (data) => {
|
|
log('✅ Join request approved:', data);
|
|
setApprovalStatus('approved');
|
|
setError(null);
|
|
socket.emit('game:join-approved', { gameToken });
|
|
});
|
|
|
|
socket.on('game:approval-denied', (data) => {
|
|
logError('❌ Join request denied:', data.reason || data.message);
|
|
setApprovalStatus('denied');
|
|
setError(data.reason || 'Your request to join was denied');
|
|
});
|
|
|
|
socket.on('game:player-approved', (data) => {
|
|
log('✅ Player approved by gamemaster:', data.playerName);
|
|
setPendingPlayers(prev => prev.filter(p => p.playerName !== data.playerName));
|
|
setGameState(prev => {
|
|
if (!prev) return prev;
|
|
const currentConnected = prev.connectedPlayers || [];
|
|
if (!currentConnected.includes(data.playerName)) {
|
|
return {
|
|
...prev,
|
|
connectedPlayers: [...currentConnected, data.playerName]
|
|
};
|
|
}
|
|
return prev;
|
|
});
|
|
});
|
|
|
|
socket.on('game:your-turn', (data) => {
|
|
log('🎯 Your turn!', data);
|
|
console.log('🎯 [game:your-turn] Received - enabling dice');
|
|
|
|
// Set flag to true - dice is now enabled
|
|
setIsMyTurnFlag(true);
|
|
|
|
// Emit custom event for GameScreen to display turn notification
|
|
window.dispatchEvent(new CustomEvent('game:your-turn', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:dice-rolled', (data) => {
|
|
log('🎲 Dice rolled:', data.diceValue, 'by', data.playerName);
|
|
// Track the dice roll for this player
|
|
setPlayerDiceRolls(prev => ({
|
|
...prev,
|
|
[data.playerName]: data.diceValue
|
|
}));
|
|
window.dispatchEvent(new CustomEvent('game:dice-rolled', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:player-moving', (data) => {
|
|
log('🚶 Player moving:', data.playerName, 'from', data.fromPosition, 'to', data.toPosition);
|
|
window.dispatchEvent(new CustomEvent('game:player-moving', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:player-arrived', (data) => {
|
|
log('🎯 Player arrived:', data.playerName, 'at position', data.position, '(' + data.fieldType + ')');
|
|
|
|
// Update playerPositions in game object - THIS IS THE ONLY PLACE POSITIONS ARE MODIFIED
|
|
setGameState(prev => {
|
|
if (!prev) return prev;
|
|
const updatedPositions = { ...prev.playerPositions, [data.playerName]: data.position };
|
|
|
|
return {
|
|
...prev,
|
|
playerPositions: updatedPositions
|
|
};
|
|
});
|
|
|
|
window.dispatchEvent(new CustomEvent('game:player-arrived', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:card-drawn', (data) => {
|
|
log('🃏 Card drawn:', data.cardType, 'by', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:card-drawn', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:card-result', (data) => {
|
|
log('🎴 Card result:', data.playerName, data.description);
|
|
window.dispatchEvent(new CustomEvent('game:card-result', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:answer-timeout', (data) => {
|
|
log('⏰ Answer timeout:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:answer-timeout', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:answer-result', (data) => {
|
|
log('📝 Answer result:', data.correct ? '✅ Correct' : '❌ Wrong', '-', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:answer-result', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:gamemaster-decision-request', (data) => {
|
|
log('👨⚖️ Gamemaster decision request:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:gamemaster-decision-request', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:gamemaster-decision-result', (data) => {
|
|
log('⚖️ Gamemaster decision result:', data.decision, '-', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:gamemaster-decision-result', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:card-drawn-self', (data) => {
|
|
log('🃏 You drew a card:', data.cardType);
|
|
window.dispatchEvent(new CustomEvent('game:card-drawn-self', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:joker-activated', (data) => {
|
|
log('🃏 Joker activated:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:joker-activated', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:extra-turn', (data) => {
|
|
log('⭐ Extra turn:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:extra-turn', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:turn-lost', (data) => {
|
|
log('😞 Turn lost:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:turn-lost', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:no-movement', (data) => {
|
|
log('⛔ No movement:', data.playerName, '-', data.reason);
|
|
window.dispatchEvent(new CustomEvent('game:no-movement', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:penalty-avoided', (data) => {
|
|
log('🛡️ Penalty avoided:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:penalty-avoided', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:guess-timeout', (data) => {
|
|
log('⏰ Guess timeout:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:guess-timeout', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:player-guessing', (data) => {
|
|
log('🤔 Player guessing:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:player-guessing', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:secondary-landing', (data) => {
|
|
log('🎯 Secondary landing:', data.playerName, 'on', data.fieldType);
|
|
window.dispatchEvent(new CustomEvent('game:secondary-landing', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:player-disconnected', (data) => {
|
|
log('⚠️ Player disconnected:', data.playerName);
|
|
setGameState(prev => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
connectedPlayers: (prev.connectedPlayers || []).filter(p => p !== data.playerName)
|
|
};
|
|
});
|
|
window.dispatchEvent(new CustomEvent('game:player-disconnected', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:player-disconnected-during-turn', (data) => {
|
|
log('⚠️ Player disconnected during turn:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:player-disconnected-during-turn', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:player-disconnected-during-card', (data) => {
|
|
log('⚠️ Player disconnected during card:', data.playerName);
|
|
window.dispatchEvent(new CustomEvent('game:player-disconnected-during-card', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:guess-result', (data) => {
|
|
log('🎯 Guess result:', data);
|
|
// Position update will come from game:player-arrived event
|
|
window.dispatchEvent(new CustomEvent('game:guess-result', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:joker-complete', (data) => {
|
|
log('🃏 Joker complete:', data);
|
|
// Position update will come from game:player-arrived event
|
|
window.dispatchEvent(new CustomEvent('game:joker-complete', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:luck-consequence', (data) => {
|
|
log('🍀 Luck consequence:', data);
|
|
// Position update will come from game:player-arrived event
|
|
window.dispatchEvent(new CustomEvent('game:luck-consequence', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:ended', (data) => {
|
|
log('🏁 Game ended! Winner:', data.winner);
|
|
setGameState(prev => ({
|
|
...prev,
|
|
status: 'finished',
|
|
winner: data.winner,
|
|
finalScores: data.scores
|
|
}));
|
|
window.dispatchEvent(new CustomEvent('game:ended', { detail: data }));
|
|
|
|
// Don't auto-navigate - let the player close the winner modal manually
|
|
// They can use the "Vissza a főoldalra" button when ready
|
|
});
|
|
|
|
socket.on('game:extra-turn-remaining', (data) => {
|
|
log('⭐ Extra turn remaining:', data);
|
|
window.dispatchEvent(new CustomEvent('game:extra-turn-remaining', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:players-skipped', (data) => {
|
|
log('⏭️ Players skipped:', data.skippedPlayers);
|
|
window.dispatchEvent(new CustomEvent('game:players-skipped', { detail: data }));
|
|
});
|
|
|
|
socket.on('game:cleanup-complete', (data) => {
|
|
log('🧹 Cleanup complete:', data);
|
|
window.dispatchEvent(new CustomEvent('game:cleanup-complete', { detail: data }));
|
|
|
|
// Navigate to home after cleanup
|
|
setTimeout(() => {
|
|
log('🏠 Navigating to home after cleanup');
|
|
window.location.href = '/';
|
|
}, 2000);
|
|
});
|
|
}, []);
|
|
|
|
/**
|
|
* Disconnect from WebSocket
|
|
*/
|
|
const disconnect = useCallback(() => {
|
|
if (socketRef.current) {
|
|
log('🔌 Disconnecting from game WebSocket');
|
|
socketRef.current.removeAllListeners();
|
|
socketRef.current.disconnect();
|
|
socketRef.current = null;
|
|
gameTokenRef.current = null;
|
|
setIsConnected(false);
|
|
setGameState(null); // Clear entire game object
|
|
setError(null);
|
|
setIsGamemaster(false);
|
|
setGameStarted(false);
|
|
setPendingPlayers([]);
|
|
setApprovalStatus(null);
|
|
}
|
|
}, []);
|
|
|
|
// Action methods
|
|
const rollDice = useCallback((diceValue) => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected) {
|
|
warn('⚠️ Cannot roll dice: not connected');
|
|
return false;
|
|
}
|
|
log('🎲 Rolling dice:', diceValue);
|
|
socket.emit('game:dice-roll', { gameCode: gameState?.gameCode, diceValue });
|
|
return true;
|
|
}, [isConnected, gameState?.gameCode]);
|
|
|
|
const sendMessage = useCallback((message) => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected) return false;
|
|
socket.emit('game:chat', { gameCode: gameState?.gameCode, message });
|
|
return true;
|
|
}, [isConnected, gameState?.gameCode]);
|
|
|
|
const setReady = useCallback((ready = true) => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected) return false;
|
|
socket.emit('game:ready', { gameCode: gameState?.gameCode, ready });
|
|
return true;
|
|
}, [isConnected, gameState?.gameCode]);
|
|
|
|
const leaveGame = useCallback(() => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected) return false;
|
|
socket.emit('game:leave', { gameCode: gameState?.gameCode });
|
|
return true;
|
|
}, [isConnected, gameState?.gameCode]);
|
|
|
|
const approvePlayer = useCallback((playerName) => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected || !isGamemaster) return false;
|
|
socket.emit('game:approve-player', { gameCode: gameState?.gameCode, playerName });
|
|
return true;
|
|
}, [isConnected, isGamemaster, gameState?.gameCode]);
|
|
|
|
const rejectPlayer = useCallback((playerName, reason = 'Join request denied') => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected || !isGamemaster) return false;
|
|
socket.emit('game:reject-player', { gameCode: gameState?.gameCode, playerName, reason });
|
|
return true;
|
|
}, [isConnected, isGamemaster, gameState?.gameCode]);
|
|
|
|
const submitAnswer = useCallback((answer, cardId = null) => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected) {
|
|
warn('⚠️ Cannot submit answer: not connected');
|
|
return false;
|
|
}
|
|
log('📝 Submitting answer:', answer, 'for card:', cardId);
|
|
socket.emit('game:card-answer', {
|
|
gameCode: gameState?.gameCode,
|
|
answer,
|
|
cardId
|
|
});
|
|
return true;
|
|
}, [isConnected, gameState?.gameCode]);
|
|
|
|
const submitPositionGuess = useCallback((guessedPosition) => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected) {
|
|
warn('⚠️ Cannot submit position guess: not connected');
|
|
return false;
|
|
}
|
|
log('🎯 Submitting position guess:', guessedPosition);
|
|
socket.emit('game:position-guess', { gameCode: gameState?.gameCode, guessedPosition });
|
|
return true;
|
|
}, [isConnected, gameState?.gameCode]);
|
|
|
|
const submitJokerPositionGuess = useCallback((guessedPosition) => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected) {
|
|
warn('⚠️ Cannot submit joker position guess: not connected');
|
|
return false;
|
|
}
|
|
log('🃏🎯 Submitting joker position guess:', guessedPosition);
|
|
socket.emit('game:joker-position-guess', { gameCode: gameState?.gameCode, guessedPosition });
|
|
return true;
|
|
}, [isConnected, gameState?.gameCode]);
|
|
|
|
const approveJoker = useCallback((requestId) => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected || !isGamemaster) {
|
|
warn('⚠️ Cannot approve joker: not gamemaster or not connected');
|
|
return false;
|
|
}
|
|
log('✅ Approving joker request:', requestId);
|
|
socket.emit('game:gamemaster-decision', {
|
|
gameCode: gameState?.gameCode,
|
|
requestId,
|
|
decision: 'approve'
|
|
});
|
|
return true;
|
|
}, [isConnected, isGamemaster, gameState?.gameCode]);
|
|
|
|
const rejectJoker = useCallback((requestId, reason = 'Joker answer rejected') => {
|
|
const socket = socketRef.current;
|
|
if (!socket || !isConnected || !isGamemaster) {
|
|
warn('⚠️ Cannot reject joker: not gamemaster or not connected');
|
|
return false;
|
|
}
|
|
log('❌ Rejecting joker request:', requestId, 'Reason:', reason);
|
|
socket.emit('game:gamemaster-decision', {
|
|
gameCode: gameState?.gameCode,
|
|
requestId,
|
|
decision: 'reject',
|
|
reason
|
|
});
|
|
return true;
|
|
}, [isConnected, isGamemaster, gameState?.gameCode]);
|
|
|
|
const addEventListener = useCallback((event, handler) => {
|
|
const socket = socketRef.current;
|
|
if (socket) {
|
|
socket.on(event, handler);
|
|
}
|
|
}, []);
|
|
|
|
const removeEventListener = useCallback((event, handler) => {
|
|
const socket = socketRef.current;
|
|
if (socket) socket.off(event, handler);
|
|
}, []);
|
|
|
|
const value = {
|
|
socket: socketRef.current,
|
|
isConnected,
|
|
gameState, // Single game object with all data
|
|
players, // Memoized from gameState.players
|
|
playerPositions, // Memoized from gameState.playerPositions (SINGLE SOURCE OF TRUTH for positions)
|
|
boardData, // Memoized from gameState.boardData
|
|
currentTurn, // Memoized from gameState.turnInfo.currentPlayer
|
|
currentTurnName, // Memoized from gameState.turnInfo.currentPlayerName
|
|
isMyTurn,
|
|
playerIdentifier,
|
|
error,
|
|
isGamemaster,
|
|
gameStarted,
|
|
pendingPlayers,
|
|
approvalStatus,
|
|
playerDiceRolls,
|
|
// Connection management
|
|
connect,
|
|
disconnect,
|
|
// Methods
|
|
rollDice,
|
|
sendMessage,
|
|
setReady,
|
|
leaveGame,
|
|
approvePlayer,
|
|
rejectPlayer,
|
|
submitAnswer,
|
|
submitPositionGuess,
|
|
submitJokerPositionGuess,
|
|
approveJoker,
|
|
rejectJoker,
|
|
addEventListener,
|
|
removeEventListener,
|
|
};
|
|
|
|
return (
|
|
<GameWebSocketContext.Provider value={value}>
|
|
{children}
|
|
</GameWebSocketContext.Provider>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Hook to access the shared WebSocket connection
|
|
*/
|
|
export const useGameWebSocketContext = () => {
|
|
const context = useContext(GameWebSocketContext);
|
|
if (!context) {
|
|
throw new Error('useGameWebSocketContext must be used within GameWebSocketProvider');
|
|
}
|
|
return context;
|
|
};
|