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
This commit is contained in:
2025-12-22 17:42:14 +11:00
parent 622a7e4094
commit a9d1327d77
2 changed files with 319 additions and 6 deletions

View File

@@ -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;

View File

@@ -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 = `
<div class="challenge-content">
<h3>Rematch Request!</h3>
<p><strong>${data.from}</strong> wants a rematch</p>
<p>Board size: ${data.boardSize}×${data.boardSize}</p>
<div class="challenge-actions">
<button class="accept-btn" onclick="acceptRematchFromNotification('${data.rematchId}')">
Accept
</button>
<button class="decline-btn" onclick="declineRematchFromNotification('${data.rematchId}')">
Decline
</button>
</div>
</div>
`;
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)