// Multiplayer Client Manager class MultiplayerClient { constructor(game) { this.game = game; this.socket = null; this.playerId = null; this.username = null; this.currentGameId = null; this.isMultiplayer = false; this.activePlayers = []; this.pendingChallenges = new Map(); this.selectedBoardSize = 15; // Default board size for multiplayer this.opponent = null; // Track opponent for rematch this.opponentId = null; this.lastGameResult = null; } // Connect to server async connect() { if (typeof io === 'undefined') { this.showMessage('Socket.io library not loaded. Please check your internet connection.', 'error'); return; } // Show username modal immediately if not saved const savedUsername = await window.gameStorage.getItem('connect5_username'); if (!savedUsername) { this.showUsernameModal(); } else { this.username = savedUsername; // Pre-load username } // Dynamically construct proxy URLs based on current origin const targetUrl = window.location.origin; const servers = [ targetUrl, // Primary (Production/Local) 'http://localhost:3000' // Failover for local dev ]; let connected = false; const loadingEl = document.querySelector('.loading'); for (const serverUrl of servers) { if (connected) break; try { if (loadingEl) loadingEl.textContent = `Connecting to ${serverUrl}...`; console.log(`Attempting connection to: ${serverUrl}`); await this.tryConnect(serverUrl); connected = true; console.log(`✅ Successfully connected to: ${serverUrl}`); if (loadingEl) loadingEl.textContent = 'Connected! verifying functionality...'; } catch (error) { console.warn(`❌ Failed to connect to ${serverUrl}:`, error); } } if (!connected) { this.showMessage('Failed to connect to any multiplayer server. Please try again later.', 'error'); const loading = document.querySelector('.loading'); if (loading) loading.textContent = 'Connection failed.'; } // Setup board size selector buttons document.querySelectorAll('.size-btn-mp').forEach(btn => { btn.addEventListener('click', (e) => { document.querySelectorAll('.size-btn-mp').forEach(b => b.classList.remove('active')); e.target.classList.add('active'); this.selectedBoardSize = parseInt(e.target.dataset.size); }); }); } tryConnect(url) { return new Promise((resolve, reject) => { const socketOptions = { reconnection: false, // We handle reconnection manually for failover timeout: 5000, transports: ['websocket', 'polling'] }; const tempSocket = io(url, socketOptions); // Set up ALL listeners BEFORE the connection completes // This prevents race conditions this.setupSocketListeners(tempSocket); const timeout = setTimeout(() => { if (!tempSocket.connected) { tempSocket.close(); reject(new Error('Connection timed out')); } }, 5000); tempSocket.on('connect', async () => { clearTimeout(timeout); this.socket = tempSocket; console.log('✅ Socket connected, listeners ready'); // Now that listeners are set up, handle auto-registration const savedUsername = await window.gameStorage.getItem('connect5_username') || this.username; if (savedUsername) { console.log('Auto-registering with saved username:', savedUsername); this.registerPlayer(savedUsername); } resolve(); }); tempSocket.on('connect_error', (err) => { clearTimeout(timeout); tempSocket.close(); reject(err); }); }); } setupSocketListeners(socket) { if (!socket) return; // Safety timeout: If we are connected but don't get a player list or login prompt within 5 seconds, warn the user. setTimeout(() => { const loading = document.querySelector('.loading'); if (loading && loading.textContent.includes('Connecting')) { loading.textContent = 'Connection successful, but server response is slow...'; } else if (loading && loading.textContent === 'Loading players...') { loading.innerHTML = 'Server not responding. Retry'; } }, 5000); socket.on('disconnect', () => { console.log('❌ Disconnected from server'); this.handleDisconnect(); }); socket.on('registration_result', (data) => { console.log('📥 Received registration_result:', data); this.handleRegistration(data); }); socket.on('active_players_update', (players) => { this.updateActivePlayers(players); }); socket.on('challenge_received', (data) => { this.showChallengeNotification(data); }); socket.on('challenge_result', (data) => { this.handleChallengeResult(data); }); socket.on('challenge_declined', (data) => { this.showMessage(`${data.by} declined your challenge`, 'error'); }); socket.on('game_started', (data) => { this.startMultiplayerGame(data); }); socket.on('opponent_move', (data) => { this.handleOpponentMove(data); }); socket.on('move_result', (data) => { this.handleMoveResult(data); }); socket.on('game_ended', (data) => { this.handleGameEnded(data); }); socket.on('rematch_request', (data) => { this.handleRematchRequest(data); }); socket.on('rematch_accepted', (data) => { this.startMultiplayerGame(data); }); socket.on('rematch_declined', (data) => { this.showMessage(`${data.by} declined the rematch`, 'error'); document.getElementById('gameOverModal').classList.remove('active'); this.returnToLobby(); }); socket.on('opponent_disconnected', (data) => { this.showMessage(data.message + '. Waiting for reconnection...', 'warning'); }); socket.on('opponent_reconnected', (data) => { this.showMessage(data.message, 'success'); }); // Send heartbeat every 30 seconds setInterval(() => { if (this.socket && this.socket.connected) { this.socket.emit('heartbeat'); } }, 30000); } // Show username input modal showUsernameModal() { const modal = document.getElementById('usernameModal'); if (modal) { modal.classList.add('active'); } } // Register player async registerPlayer(username) { this.username = username; await window.gameStorage.setItem('connect5_username', username); // Hide username modal immediately for better UX const modal = document.getElementById('usernameModal'); if (modal) { modal.classList.remove('active'); } // Show loading state const loading = document.querySelector('.loading'); if (loading) { loading.textContent = 'Registering...'; } if (!this.socket || !this.socket.connected) { console.log('Socket not ready yet, username saved and will be sent on connect.'); if (loading) { loading.textContent = 'Connecting to server...'; } return; } console.log('Emitting register_player for:', username); this.socket.emit('register_player', { username }); } // Handle registration response async handleRegistration(data) { if (data.success) { this.playerId = data.player.id; this.username = data.player.username; await window.gameStorage.setItem('connect5_username', this.username); console.log('Username saved to IndexedDB'); // Hide username modal (if visible) const modal = document.getElementById('usernameModal'); if (modal) { modal.classList.remove('active'); } // Show multiplayer panel const multiplayerPanel = document.getElementById('multiplayerPanel'); if (multiplayerPanel) { multiplayerPanel.style.display = 'block'; } // Update player stats display document.getElementById('playerUsername').textContent = this.username; document.getElementById('playerWins').textContent = data.player.stats.wins; document.getElementById('playerLosses').textContent = data.player.stats.losses; document.getElementById('playerDraws').textContent = data.player.stats.draws; this.showMessage(`Welcome back, ${this.username}!`, 'success'); // Request active players this.socket.emit('request_active_players'); // Check for active game restoration if (data.activeGame) { console.log(' restoring active game:', data.activeGame); this.startMultiplayerGame(data.activeGame); } } else { // Registration failed - clear saved username and show modal localStorage.removeItem('connect5_username'); this.showMessage(data.error, 'error'); this.showUsernameModal(); } } // Update active players list updateActivePlayers(players) { this.activePlayers = players.filter(p => p.username !== this.username); const container = document.getElementById('activePlayersList'); if (!container) return; if (this.activePlayers.length === 0) { container.innerHTML = '
No other players online
'; return; } container.innerHTML = this.activePlayers.map(player => `
${player.username} ${player.total_wins}W - ${player.total_losses}L - ${player.total_draws}D
`).join(''); } // Send challenge sendChallenge(targetUsername) { this.socket.emit('send_challenge', { targetUsername, boardSize: this.selectedBoardSize }); this.showMessage(`Challenge sent to ${targetUsername} (${this.selectedBoardSize}×${this.selectedBoardSize})`, 'info'); } // Handle challenge result handleChallengeResult(data) { if (!data.success) { this.showMessage(data.error, 'error'); } } // Show challenge notification showChallengeNotification(data) { this.pendingChallenges.set(data.challengeId, data); const notification = document.createElement('div'); notification.className = 'challenge-notification'; notification.innerHTML = `

Challenge Received!

${data.from} wants to play

Board size: ${data.boardSize}×${data.boardSize}

`; document.body.appendChild(notification); setTimeout(() => notification.classList.add('active'), 10); } // Accept challenge acceptChallenge(challengeId) { this.socket.emit('accept_challenge', { challengeId }); this.pendingChallenges.delete(challengeId); // Remove notification const notifications = document.querySelectorAll('.challenge-notification'); notifications.forEach(n => n.remove()); } // Decline challenge declineChallenge(challengeId) { this.socket.emit('decline_challenge', { challengeId }); this.pendingChallenges.delete(challengeId); // Remove notification const notifications = document.querySelectorAll('.challenge-notification'); notifications.forEach(n => n.remove()); } // Start multiplayer game startMultiplayerGame(data) { console.log('🎮 Starting multiplayer game:', data); this.isMultiplayer = true; this.currentGameId = data.gameId; this.mySymbol = data.yourSymbol; this.opponent = data.opponent; this.opponentId = data.opponentId; // Store for rematch this.myTurn = data.yourTurn; // Show surrender button const surrenderBtn = document.getElementById('surrenderBtn'); if (surrenderBtn) { surrenderBtn.style.display = 'flex'; } // Update UI - hide multiplayer lobby, show game board document.getElementById('multiplayerPanel').style.display = 'none'; document.getElementById('gameControls').style.display = 'grid'; document.querySelector('.board-wrapper').style.display = 'flex'; // Status bar is part of gameControls // Set board size document.querySelectorAll('.size-btn').forEach(btn => { btn.classList.toggle('active', parseInt(btn.dataset.size) === data.boardSize); }); // Reset game board this.game.boardSize = data.boardSize; this.game.currentPlayer = this.mySymbol; // Set to our symbol this.game.gameActive = true; this.game.initializeBoard(); // Update player identity display (Right Side) const identitySection = document.getElementById('playerIdentitySection'); const identityMarker = document.getElementById('playerIdentity'); if (identitySection && identityMarker) { identitySection.style.display = 'flex'; identityMarker.textContent = this.mySymbol; // Set color for identity marker if (this.mySymbol === 'O') { identityMarker.style.color = 'hsl(195, 70%, 55%)'; identityMarker.parentElement.style.borderColor = 'hsl(195, 70%, 55%)'; } else { identityMarker.style.color = 'hsl(270, 70%, 60%)'; identityMarker.parentElement.style.borderColor = 'hsl(270, 70%, 60%)'; } } // Ensure Turn label is correct (Left Side) const turnLabel = document.getElementById('turnLabel'); if (turnLabel) turnLabel.textContent = 'Current Turn:'; // Set initial color for Current Turn display (will be updated by game.js but good to init) const currentPlayerDisplay = document.getElementById('currentPlayer'); // Update status text (Left Side generic message) const statusMessage = document.getElementById('statusMessage'); if (this.myTurn) { statusMessage.textContent = `VS ${this.opponent} | YOUR TURN`; statusMessage.className = 'status-text-small success'; } else { statusMessage.textContent = `VS ${this.opponent} | Waiting for opponent`; statusMessage.className = 'status-text-small info'; } console.log(`✅ Game started! You are ${this.mySymbol}, ${this.myTurn ? 'your turn' : 'waiting'}`); // Restore board state if provided (reconnection) if (data.board) { this.game.board = data.board; // Re-render board const cells = this.game.boardElement.children; for (let r = 0; r < data.boardSize; r++) { for (let c = 0; c < data.boardSize; c++) { const symbol = data.board[r][c]; if (symbol) { const index = r * data.boardSize + c; const cell = cells[index]; if (cell) { cell.classList.add('occupied', symbol.toLowerCase()); cell.textContent = ''; // CSS handles the X/O appearance } } } } // Update current player for game logic if (data.currentTurnSymbol) { this.game.currentPlayer = data.currentTurnSymbol; const currentPlayerDisplay = document.getElementById('currentPlayer'); if (currentPlayerDisplay) { currentPlayerDisplay.textContent = this.game.currentPlayer; } } } } // Make move in multiplayer game makeMove(row, col) { if (!this.isMultiplayer || !this.myTurn) return false; this.socket.emit('make_move', { gameId: this.currentGameId, row: row, col: col }); return true; } // Handle move result from server handleMoveResult(data) { if (data.success) { this.myTurn = false; if (!data.gameOver) { const statusMessage = document.getElementById('statusMessage'); if (statusMessage) { statusMessage.textContent = `Playing against ${this.opponent} - Waiting for opponent...`; statusMessage.className = 'status-text-small info'; } } } else { this.showMessage(data.error, 'error'); } } // Handle opponent move handleOpponentMove(data) { // Place opponent's piece on board this.game.currentPlayer = data.symbol; // Update turn indicator immediately to show X or O const currentPlayerDisplay = document.getElementById('currentPlayer'); if (currentPlayerDisplay) { currentPlayerDisplay.textContent = data.symbol; } const cellIndex = data.row * this.game.boardSize + data.col; const cell = this.game.boardElement.children[cellIndex]; if (cell) { cell.classList.add('occupied', data.symbol.toLowerCase()); this.game.board[data.row][data.col] = data.symbol; } // It's now my turn this.myTurn = true; this.game.currentPlayer = this.mySymbol; const statusMessage = document.getElementById('statusMessage'); if (statusMessage) { statusMessage.textContent = `Playing against ${this.opponent} - Your turn`; statusMessage.className = 'status-text-small success'; } } // Handle game ended handleGameEnded(data) { this.isMultiplayer = false; this.currentGameId = null; let message = ''; if (data.reason === 'win') { message = '🎉 You won!'; } else if (data.reason === 'loss') { message = '😔 You lost!'; } else if (data.reason === 'draw') { message = '🤝 It\'s a draw!'; } else if (data.reason === 'opponent_abandoned') { message = '🏆 You won! Opponent disconnected'; } // Update stats if (data.stats) { document.getElementById('playerWins').textContent = data.stats.wins; document.getElementById('playerLosses').textContent = data.stats.losses; document.getElementById('playerDraws').textContent = data.stats.draws; } // Hide surrender button const surrenderBtn = document.getElementById('surrenderBtn'); if (surrenderBtn) { surrenderBtn.style.display = 'none'; } // Show game-over modal with stats const modal = document.getElementById('gameOverModal'); const icon = document.getElementById('gameOverIcon'); const title = document.getElementById('gameOverTitle'); const subtitle = document.getElementById('gameOverMessage'); if (modal && icon && title && subtitle) { // Set icon and title based on result if (data.reason === 'win' || data.reason === 'opponent_abandoned') { icon.textContent = '🏆'; title.textContent = 'Victory!'; subtitle.textContent = data.reason === 'opponent_abandoned' ? 'Opponent disconnected' : 'Great game!'; } else if (data.reason === 'loss' || data.reason === 'surrender') { icon.textContent = '😔'; title.textContent = 'Defeat'; subtitle.textContent = data.reason === 'surrender' ? 'You surrendered' : 'Better luck next time!'; } else { icon.textContent = '🤝'; title.textContent = "It's a Draw!"; subtitle.textContent = 'Well played!'; } // Update stats in modal if (data.stats) { document.getElementById('gameOverWins').textContent = data.stats.wins; document.getElementById('gameOverLosses').textContent = data.stats.losses; document.getElementById('gameOverDraws').textContent = data.stats.draws; } // Show modal modal.classList.add('active'); } } // Handle disconnect handleDisconnect() { this.showMessage('Disconnected from server. Reconnecting...', 'error'); // Try to reconnect setTimeout(() => { if (!this.socket || !this.socket.connected) { this.connect(); } }, 2000); } // Return to lobby returnToLobby() { this.isMultiplayer = false; this.currentGameId = null; document.getElementById('multiplayerPanel').style.display = 'block'; document.getElementById('gameControls').style.display = 'block'; // Or grid/none depending on logic, but block/grid usually toggled by logic // Hide identity section in local play const identitySection = document.getElementById('playerIdentitySection'); if (identitySection) identitySection.style.display = 'none'; this.socket.emit('request_active_players'); } // Show message to user showMessage(text, type = 'info') { const messageEl = document.getElementById('statusMessage'); if (messageEl) { messageEl.textContent = text; messageEl.className = `status-text-small ${type}`; } } // Handle rematch request from opponent handleRematchRequest(data) { const notification = document.createElement('div'); notification.className = 'challenge-notification'; notification.innerHTML = `

Rematch Request!

${data.from} wants a rematch

Board size: ${data.boardSize}×${data.boardSize}

`; document.body.appendChild(notification); setTimeout(() => notification.classList.add('active'), 10); } } // Global helper functions for rematch from notifications function acceptRematchFromNotification(rematchId) { if (window.multiplayerClient && window.multiplayerClient.socket) { window.multiplayerClient.socket.emit('accept_rematch', { rematchId }); // Hide game over modal if visible const modal = document.getElementById('gameOverModal'); if (modal) { modal.classList.remove('active'); } // Remove notification const notifications = document.querySelectorAll('.challenge-notification'); notifications.forEach(n => n.remove()); } } function declineRematchFromNotification(rematchId) { if (window.multiplayerClient && window.multiplayerClient.socket) { window.multiplayerClient.socket.emit('decline_rematch', { rematchId }); // Remove notification const notifications = document.querySelectorAll('.challenge-notification'); notifications.forEach(n => n.remove()); } } // Initialize multiplayer client (will be used by game.js) // Initialize multiplayer client (will be used by game.js) window.multiplayerClient = null;