523 lines
16 KiB
JavaScript
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,
|
|
};
|
|
};
|