Files
Connect-5/multiplayer.js
DeNNiiInc 69dc70ee06 Reliability: Fix disconnect logic, add game restoration, and prevent race conditions
- gameManager.js:
  - Added disconnectTimeouts Map to track/clear pending timeouts correctly
  - Updated handleDisconnect to not abandon game if player reconnects
  - Updated registerPlayer to clear timeouts and restore active game state
  - Added race condition checks to handleSurrender and acceptRematch

- multiplayer.js:
  - Updated handleRegistration to resume active game if data provided
  - Updated startMultiplayerGame to restore board state from server data
2025-12-22 19:25:15 +11:00

688 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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. <a href="#" onclick="window.multiplayerClient.connect()">Retry</a>';
}
}, 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 = '<div class="no-players">No other players online</div>';
return;
}
container.innerHTML = this.activePlayers.map(player => `
<div class="player-item">
<div class="player-info">
<span class="player-name">${player.username}</span>
<span class="player-stats">${player.total_wins}W - ${player.total_losses}L - ${player.total_draws}D</span>
</div>
<button class="challenge-btn" onclick="multiplayerClient.sendChallenge('${player.username}')">
Challenge
</button>
</div>
`).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 = `
<div class="challenge-content">
<h3>Challenge Received!</h3>
<p><strong>${data.from}</strong> wants to play</p>
<p>Board size: ${data.boardSize}×${data.boardSize}</p>
<div class="challenge-actions">
<button class="accept-btn" onclick="multiplayerClient.acceptChallenge('${data.challengeId}')">
Accept
</button>
<button class="decline-btn" onclick="multiplayerClient.declineChallenge('${data.challengeId}')">
Decline
</button>
</div>
</div>
`;
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 = `
<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)
// Initialize multiplayer client (will be used by game.js)
window.multiplayerClient = null;