Files
Connect-5/multiplayer.js

737 lines
28 KiB
JavaScript
Raw Permalink 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;
// Reset scores if this is a new opponent, otherwise keep them for rematch
if (this.currentOpponentId !== data.opponentId) {
this.game.scores = { X: 0, O: 0 };
this.game.updateScores();
this.currentOpponentId = data.opponentId;
}
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
});
this.lastMyMove = { row, 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) {
this.lastOpponentMove = { row: data.row, col: data.col };
// 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(), 'latest-move');
this.game.board[data.row][data.col] = data.symbol;
// Remove brightness boost after 2 seconds
setTimeout(() => {
if (cell) cell.classList.remove('latest-move');
}, 2000);
}
// 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 session scores
if (this.game) {
let winner = null;
if (data.reason === 'win' || data.reason === 'opponent_abandoned') {
winner = this.mySymbol;
} else if (data.reason === 'loss') {
winner = this.mySymbol === 'X' ? 'O' : 'X';
}
if (winner) {
this.game.scores[winner]++;
this.game.updateScores();
}
}
// 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) {
// Highlight winning move if applicable
if (this.game && (data.reason === 'win' || data.reason === 'loss')) {
try {
const move = data.reason === 'win' ? this.lastMyMove : this.lastOpponentMove;
if (move) {
// We temporarily set gameActive to true to allow checkWin to run purely for highlighting
// (checkWin doesn't check gameActive, but just in case)
// Actually checkWin calculates and calls highlightWinningCells
this.game.checkWin(move.row, move.col);
}
} catch (e) {
console.error("Error highlighting winning move:", e);
}
}
// Delay showing the modal for 5 seconds
setTimeout(() => {
// 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');
}, 5000);
}
}
// 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;