From a9d1327d77a9a4ef2de58fe25143fd635ebc8102 Mon Sep 17 00:00:00 2001 From: DeNNiiInc Date: Mon, 22 Dec 2025 17:42:14 +1100 Subject: [PATCH] Add surrender and rematch functionality - Part 2 (Complete) Client-side (multiplayer.js): - Add socket listeners for rematch events - Update startMultiplayerGame to show surrender button and track opponent ID - Update handleGameEnded to show game-over modal with stats - Add handleRematchRequest method - Add global helper functions for rematch acceptance/decline Server-side (gameManager.js): - Add rematches Map to track rematch requests - Add handleSurrender method - Add sendRematch, acceptRematch, declineRematch methods - Handle surrender stats updates and game cleanup Server (server.js): - Add socket listeners for: surrender, send_rematch, accept_rematch, decline_rematch Features now fully functional: - Players can surrender during active games - Players can request rematches after games end - Opponents receive rematch notifications - Game-over modal shows stats and rematch option --- gameManager.js | 210 +++++++++++++++++++++++++++++++++++++++++++++++++ multiplayer.js | 115 +++++++++++++++++++++++++-- 2 files changed, 319 insertions(+), 6 deletions(-) diff --git a/gameManager.js b/gameManager.js index 4fe2183..302c334 100644 --- a/gameManager.js +++ b/gameManager.js @@ -449,4 +449,214 @@ class GameManager { } } + // Handle player surrender + async handleSurrender(socket, data) { + const player = this.players.get(socket.id); + if (!player) { + socket.emit('error', { message: 'Player not found' }); + return; + } + + const game = this.games.get(data.gameId); + if (!game) { + socket.emit('error', { message: 'Game not found' }); + return; + } + + // Determine winner (the opponent) + const winnerId = game.player1_id === player.id ? game.player2_id : game.player1_id; + const winnerUsername = game.player1_id === player.id ? game.player2_username : game.player1_username; + + // Update database - mark as completed with winner + try { + await this.db.abandonGame(data.gameId, winnerId); + + // Update player stats + await this.db.incrementLosses(player.id); + await this.db.incrementWins(winnerId); + + // Get updated stats + const loserStats = await this.db.getPlayerStats(player.id); + const winnerStats = await this.db.getPlayerStats(winnerId); + + // Find winner's socket + let winnerSocket = null; + for (const [socketId, p] of this.players.entries()) { + if (p.id === winnerId) { + winnerSocket = this.io.sockets.sockets.get(socketId); + break; + } + } + + // Notify both players + socket.emit('game_ended', { + reason: 'surrender', + message: 'You surrendered', + stats: loserStats + }); + + if (winnerSocket) { + winnerSocket.emit('game_ended', { + reason: 'win', + message: `${player.username} surrendered`, + stats: winnerStats + }); + } + + // Clean up + this.games.delete(data.gameId); + + } catch (error) { + console.error('Error handling surrender:', error); + socket.emit('error', { message: 'Failed to process surrender' }); + } + } + + // Send rematch request + sendRematch(socket, data) { + const player = this.players.get(socket.id); + if (!player) return; + + // Find opponent's socket + let opponentSocket = null; + for (const [socketId, p] of this.players.entries()) { + if (p.id === data.opponentId) { + opponentSocket = this.io.sockets.sockets.get(socketId); + break; + } + } + + if (!opponentSocket) { + socket.emit('error', { message: 'Opponent not online' }); + return; + } + + const rematchId = `rematch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + this.rematches.set(rematchId, { + rematchId, + challenger: player.id, + challengerUsername: player.username, + challenged: data.opponentId, + boardSize: data.boardSize || 15, + timestamp: Date.now() + }); + + opponentSocket.emit('rematch_request', { + rematchId, + from: player.username, + boardSize: data.boardSize || 15 + }); + } + + // Accept rematch + async acceptRematch(socket, data) { + const player = this.players.get(socket.id); + if (!player) return; + + const rematch = this.rematches.get(data.rematchId); + if (!rematch) { + socket.emit('error', { message: 'Rematch request expired' }); + return; + } + + // Verify this player is the challenged one + if (rematch.challenged !== player.id) { + socket.emit('error', { message: 'Invalid rematch accept' }); + return; + } + + // Find challenger's socket + let challengerSocket = null; + for (const [socketId, p] of this.players.entries()) { + if (p.id === rematch.challenger) { + challengerSocket = this.io.sockets.sockets.get(socketId); + break; + } + } + + if (!challengerSocket) { + socket.emit('error', { message: 'Challenger no longer online' }); + this.rematches.delete(data.rematchId); + return; + } + + // Create new game (similar to acceptChallenge logic) + try { + const gameData = await this.db.createGame( + rematch.challenger, + player.id, + rematch.challengerUsername, + player.username, + rematch.boardSize + ); + + const gameState = { + gameId: gameData.id, + player1_id: rematch.challenger, + player2_id: player.id, + player1_username: rematch.challengerUsername, + player2_username: player.username, + currentTurn: rematch.challenger, + boardSize: rematch.boardSize, + board: Array(rematch.boardSize).fill(null).map(() => Array(rematch.boardSize).fill(null)), + moveCount: 0 + }; + + this.games.set(gameData.id, gameState); + + // Notify both players + challengerSocket.emit('rematch_accepted', { + gameId: gameData.id, + opponent: player.username, + opponentId: player.id, + yourSymbol: 'X', + yourTurn: true, + boardSize: rematch.boardSize + }); + + socket.emit('game_started', { + gameId: gameData.id, + opponent: rematch.challengerUsername, + opponentId: rematch.challenger, + yourSymbol: 'O', + yourTurn: false, + boardSize: rematch.boardSize + }); + + this.rematches.delete(data.rematchId); + + } catch (error) { + console.error('Error accepting rematch:', error); + socket.emit('error', { message: 'Failed to start rematch' }); + } + } + + // Decline rematch + declineRematch(socket, data) { + const player = this.players.get(socket.id); + if (!player) return; + + const rematch = this.rematches.get(data.rematchId); + if (!rematch) return; + + // Find challenger's socket + let challengerSocket = null; + for (const [socketId, p] of this.players.entries()) { + if (p.id === rematch.challenger) { + challengerSocket = this.io.sockets.sockets.get(socketId); + break; + } + } + + if (challengerSocket) { + challengerSocket.emit('rematch_declined', { + by: player.username + }); + } + + this.rematches.delete(data.rematchId); + } + + module.exports = GameManager; diff --git a/multiplayer.js b/multiplayer.js index 3dcba42..63fcbeb 100644 --- a/multiplayer.js +++ b/multiplayer.js @@ -173,6 +173,20 @@ class MultiplayerClient { 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'); }); @@ -354,8 +368,15 @@ class MultiplayerClient { 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'; @@ -495,13 +516,44 @@ class MultiplayerClient { document.getElementById('playerDraws').textContent = data.stats.draws; } - this.showMessage(message, 'success'); + // Hide surrender button + const surrenderBtn = document.getElementById('surrenderBtn'); + if (surrenderBtn) { + surrenderBtn.style.display = 'none'; + } - // Show multiplayer panel again - setTimeout(() => { - document.getElementById('multiplayerPanel').style.display = 'block'; - this.socket.emit('request_active_players'); - }, 3000); + // 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 @@ -538,6 +590,57 @@ class MultiplayerClient { 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)