Files
SerpentRace/SerpentRace_Frontend/src/hooks/useGameWebSocket.js
T

523 lines
16 KiB
JavaScript

import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { io } from 'socket.io-client';
import { API_CONFIG } from '../api/userApi';
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);
/**
* Optimized WebSocket hook for game connection
* @param {string} gameToken - JWT token from game join
* @returns {Object} WebSocket state and methods
*/
export const useGameWebSocket = (gameToken) => {
const socketRef = useRef(null);
const [isConnected, setIsConnected] = useState(false);
const [gameState, setGameState] = useState(null);
const [boardData, setBoardData] = useState(null);
const [error, setError] = useState(null);
const [isGamemaster, setIsGamemaster] = useState(false);
const [gameStarted, setGameStarted] = useState(false);
const [pendingPlayers, setPendingPlayers] = useState([]); // Players waiting for approval
const [approvalStatus, setApprovalStatus] = useState(null); // 'pending' | 'approved' | 'denied' | null
const eventListenersRef = useRef(new Map());
// Memoized derived values - no extra state needed
const players = useMemo(() => {
// Backend sends different player fields depending on game state
// connectedPlayers: array of player names (strings) who are connected via WebSocket
// players: array of player IDs (UUIDs) - NOT USEFUL for display
// currentPlayers: full player objects with game data (positions, etc.)
const connectedPlayers = gameState?.connectedPlayers || [];
const currentPlayers = gameState?.currentPlayers || [];
// Debug: log what we received
if (import.meta.env.DEV) {
console.log('🎮 Computing players list:');
console.log(' - connectedPlayers:', connectedPlayers);
console.log(' - currentPlayers:', currentPlayers);
}
// If we have full player objects with positions, use those (during game)
if (currentPlayers.length > 0) {
console.log('✅ Using currentPlayers');
return currentPlayers;
}
// Otherwise, use connectedPlayers (player names from Redis)
if (connectedPlayers.length > 0) {
console.log('✅ Mapping connectedPlayers to player objects');
return connectedPlayers.map((nameOrObj, index) => {
// Handle both string names and objects
const playerName = typeof nameOrObj === 'string'
? nameOrObj
: (nameOrObj.playerName || nameOrObj.name || `Player ${index + 1}`);
return {
id: `player-${index}`,
name: playerName,
isOnline: true,
isReady: gameState?.readyPlayers?.includes(playerName) || false,
};
});
}
console.log('⚠️ No players found');
return [];
}, [gameState?.connectedPlayers, gameState?.currentPlayers, gameState?.readyPlayers]);
const currentTurn = useMemo(() => gameState?.currentPlayer || null, [gameState?.currentPlayer]);
// Connect to game WebSocket - only once per token
useEffect(() => {
if (!gameToken) return;
log('🔌 Connecting to game WebSocket...');
// 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
const handleConnect = () => {
log('✅ Connected to game WebSocket');
setIsConnected(true);
setError(null);
socket.emit('game:join', { gameToken });
};
const handleConnectError = (err) => {
error('❌ Connection error:', err);
setIsConnected(false);
setError(err.message);
};
const handleDisconnect = (reason) => {
log('🔌 Disconnected:', reason);
setIsConnected(false);
};
// Game state handlers - batch updates
const handleGameState = (state) => {
log('📊 Game state received:', state);
log(' - connectedPlayers:', state?.connectedPlayers);
log(' - players:', state?.players);
log(' - currentPlayers:', state?.currentPlayers);
log(' - isGamemaster in state:', state?.isGamemaster);
// EXTRA DEBUG: Show full state structure
if (import.meta.env.DEV) {
console.log('🔍 FULL STATE OBJECT:', JSON.stringify(state, null, 2));
}
// If state contains isGamemaster flag, update it
if (state?.isGamemaster !== undefined) {
log('✅ Setting isGamemaster from state:', state.isGamemaster);
setIsGamemaster(state.isGamemaster);
}
setGameState(state);
};
const handleGameJoined = (data) => {
log('✅ Joined game:', data);
// EXTRA DEBUG: Show full joined data
if (import.meta.env.DEV) {
console.log('🔍 FULL JOINED DATA:', JSON.stringify(data, null, 2));
}
// Store if this user is the gamemaster
if (data.isGamemaster !== undefined) {
log('✅ Setting isGamemaster from joined event:', data.isGamemaster);
setIsGamemaster(data.isGamemaster);
} else {
log('⚠️ No isGamemaster flag in joined event');
}
// Backend will send game:state next
};
const handlePlayerJoined = (data) => {
log('👤 Player joined:', data.playerName);
// EXTRA DEBUG
if (import.meta.env.DEV) {
console.log('🔍 PLAYER JOINED EVENT:', JSON.stringify(data, null, 2));
}
// Update game state to add the new player to connectedPlayers
setGameState(prev => {
if (!prev) {
log('⚠️ No previous game state, cannot add player');
return prev;
}
const currentConnected = prev.connectedPlayers || [];
// Only add if not already in the list
if (!currentConnected.includes(data.playerName)) {
log('✅ Adding player to connectedPlayers:', data.playerName);
log(' - Current list:', currentConnected);
log(' - New list:', [...currentConnected, data.playerName]);
return {
...prev,
connectedPlayers: [...currentConnected, data.playerName]
};
}
log('⚠️ Player already in connectedPlayers:', data.playerName);
return prev;
});
};
const handleGameStarted = (data) => {
log('🎮 Game started:', data);
// EXTRA DEBUG
if (import.meta.env.DEV) {
console.log('🔍 GAME STARTED EVENT:', JSON.stringify(data, null, 2));
}
// Signal that game has started
setGameStarted(true);
// Request updated game state from server (includes boardData and currentPlayers)
const socket = socketRef.current;
if (socket && socket.connected) {
log('📡 Requesting updated game state after game start');
// The server will send game:state event with full data
}
};
const handlePlayerMoved = (moveData) => {
log('🏃 Player moved:', moveData.playerName);
// Update only the moved player
setGameState(prev => {
if (!prev?.currentPlayers) return prev;
return {
...prev,
currentPlayers: prev.currentPlayers.map(p =>
p.playerId === moveData.playerId
? { ...p, boardPosition: moveData.newPosition }
: p
),
};
});
};
const handleTurnChanged = (data) => {
log('🔄 Turn changed to:', data.currentPlayerName);
setGameState(prev => prev ? { ...prev, currentPlayer: data.currentPlayer } : prev);
};
const handleError = (err) => {
logError('❌ Game error:', err);
setError(err.message);
};
// Approval system handlers (PRIVATE games only)
const handlePendingApproval = (data) => {
log('⏳ Pending gamemaster approval:', data);
setApprovalStatus('pending');
setError('Waiting for gamemaster approval...');
};
const handlePlayerRequestingJoin = (data) => {
log('🔔 Player requesting to join:', data.playerName);
// Add to pending players list (for gamemaster)
setPendingPlayers(prev => {
if (prev.some(p => p.playerName === data.playerName)) return prev;
return [...prev, {
playerName: data.playerName,
isAuthenticated: data.isAuthenticated,
timestamp: data.timestamp
}];
});
};
const handleApprovalGranted = (data) => {
log('✅ Join request approved:', data);
setApprovalStatus('approved');
setError(null);
// Player should now join the game rooms
const socket = socketRef.current;
if (socket && data.gameRoomName) {
// Emit join-approved to notify backend
socket.emit('game:join-approved', { gameToken });
}
};
const handleApprovalDenied = (data) => {
error('❌ Join request denied:', data.reason || data.message);
setApprovalStatus('denied');
setError(data.reason || 'Your request to join was denied');
};
const handlePlayerApproved = (data) => {
log('✅ Player approved by gamemaster:', data.playerName);
// Remove from pending players list
setPendingPlayers(prev => prev.filter(p => p.playerName !== data.playerName));
// Add to connected players
setGameState(prev => {
if (!prev) return prev;
const currentConnected = prev.connectedPlayers || [];
if (!currentConnected.includes(data.playerName)) {
return {
...prev,
connectedPlayers: [...currentConnected, data.playerName]
};
}
return prev;
});
};
// Register all handlers
socket.on('connect', handleConnect);
socket.on('connect_error', handleConnectError);
socket.on('disconnect', handleDisconnect);
socket.on('game:state', handleGameState);
socket.on('game:state-update', handleGameState);
socket.on('game:joined', handleGameJoined);
socket.on('game:player-joined', handlePlayerJoined);
socket.on('game:started', handleGameStarted);
socket.on('game:player-moved', handlePlayerMoved);
socket.on('game:turn-changed', handleTurnChanged);
socket.on('game:error', handleError);
// Approval system events (PRIVATE games)
socket.on('game:pending-approval', handlePendingApproval);
socket.on('game:player-requesting-join', handlePlayerRequestingJoin);
socket.on('game:approval-granted', handleApprovalGranted);
socket.on('game:approval-denied', handleApprovalDenied);
socket.on('game:player-approved', handlePlayerApproved);
// Cleanup
return () => {
log('🧹 Cleaning up WebSocket connection');
socket.removeAllListeners();
socket.disconnect();
};
}, [gameToken]);
// Optimized event listener management
const addEventListener = useCallback((event, handler) => {
const socket = socketRef.current;
if (!socket) return;
socket.on(event, handler);
eventListenersRef.current.set(event, handler);
}, []);
const removeEventListener = useCallback((event) => {
const socket = socketRef.current;
if (!socket) return;
const handler = eventListenersRef.current.get(event);
if (handler) {
socket.off(event, handler);
eventListenersRef.current.delete(event);
}
}, []);
// Memoized action methods - stable references
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) {
warn('⚠️ Cannot send message: not connected');
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) {
warn('⚠️ Cannot set ready: not connected');
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) {
warn('⚠️ Cannot leave game: not connected');
return false;
}
socket.emit('game:leave', {
gameCode: gameState?.gameCode,
});
return true;
}, [isConnected, gameState?.gameCode]);
// Joker approval methods
const approveJoker = useCallback((playerId, cardId, requestId) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot approve joker: not connected');
return false;
}
log('✅ Approving joker for player:', playerId);
socket.emit('game:gamemaster-decision', {
gameCode: gameState?.gameCode,
requestId: requestId || `joker-${playerId}-${cardId}`,
decision: 'approve',
});
return true;
}, [isConnected, gameState?.gameCode]);
const rejectJoker = useCallback((playerId, cardId, requestId) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot reject joker: not connected');
return false;
}
log('❌ Rejecting joker for player:', playerId);
socket.emit('game:gamemaster-decision', {
gameCode: gameState?.gameCode,
requestId: requestId || `joker-${playerId}-${cardId}`,
decision: 'reject',
});
return true;
}, [isConnected, gameState?.gameCode]);
// Card answer submission
const submitAnswer = useCallback((cardId, answer) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot submit answer: not connected');
return false;
}
log('📝 Submitting answer:', answer);
socket.emit('game:card-answer', {
gameCode: gameState?.gameCode,
cardId,
answer,
});
return true;
}, [isConnected, gameState?.gameCode]);
// Position guess submission
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]);
// Approve player (gamemaster only, PRIVATE games)
const approvePlayer = useCallback((playerName) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot approve player: not connected');
return false;
}
if (!isGamemaster) {
warn('⚠️ Only gamemaster can approve players');
return false;
}
log('✅ Approving player:', playerName);
socket.emit('game:approve-player', {
gameCode: gameState?.gameCode,
playerName,
});
return true;
}, [isConnected, isGamemaster, gameState?.gameCode]);
// Reject player (gamemaster only, PRIVATE games)
const rejectPlayer = useCallback((playerName, reason = 'Join request denied') => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot reject player: not connected');
return false;
}
if (!isGamemaster) {
warn('⚠️ Only gamemaster can reject players');
return false;
}
log('❌ Rejecting player:', playerName);
socket.emit('game:reject-player', {
gameCode: gameState?.gameCode,
playerName,
reason,
});
return true;
}, [isConnected, isGamemaster, gameState?.gameCode]);
return {
socket: socketRef.current,
isConnected,
gameState,
players,
boardData,
currentTurn,
error,
isGamemaster,
gameStarted,
pendingPlayers,
approvalStatus,
// Methods
rollDice,
sendMessage,
setReady,
leaveGame,
approveJoker,
rejectJoker,
submitAnswer,
submitPositionGuess,
approvePlayer,
rejectPlayer,
addEventListener,
removeEventListener,
};
};