mirror of
https://github.com/DeNNiiInc/Connect-5.git
synced 2026-04-17 20:36:00 +00:00
Add complete multiplayer system with real-time gameplay, challenge system, and 50x50 board option
This commit is contained in:
452
gameManager.js
Normal file
452
gameManager.js
Normal file
@@ -0,0 +1,452 @@
|
||||
const Filter = require('bad-words');
|
||||
const filter = new Filter();
|
||||
|
||||
class GameManager {
|
||||
constructor(io, db) {
|
||||
this.io = io;
|
||||
this.db = db;
|
||||
this.players = new Map(); // socketId -> playerData
|
||||
this.challenges = new Map(); // challengeId -> challengeData
|
||||
this.activeGames = new Map(); // gameId -> gameData
|
||||
this.playerSockets = new Map(); // playerId -> socketId
|
||||
}
|
||||
|
||||
// Validate and register player
|
||||
async registerPlayer(socket, username) {
|
||||
try {
|
||||
// Validate username
|
||||
if (!username || typeof username !== 'string') {
|
||||
return { success: false, error: 'Invalid username' };
|
||||
}
|
||||
|
||||
// Clean and validate length
|
||||
username = username.trim();
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
return { success: false, error: 'Username must be 3-20 characters' };
|
||||
}
|
||||
|
||||
// Check for profanity
|
||||
if (filter.isProfane(username)) {
|
||||
return { success: false, error: 'Please use a family-friendly username' };
|
||||
}
|
||||
|
||||
// Check alphanumeric plus basic chars
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
return { success: false, error: 'Username can only contain letters, numbers, underscores, and hyphens' };
|
||||
}
|
||||
|
||||
// Create or get player from database
|
||||
const playerId = await this.db.createPlayer(username);
|
||||
const player = await this.db.getPlayerById(playerId);
|
||||
|
||||
// Add session to database
|
||||
await this.db.addSession(socket.id, playerId, username);
|
||||
|
||||
// Store in memory
|
||||
this.players.set(socket.id, {
|
||||
id: playerId,
|
||||
username: username,
|
||||
socketId: socket.id,
|
||||
currentGameId: null
|
||||
});
|
||||
|
||||
this.playerSockets.set(playerId, socket.id);
|
||||
|
||||
// Broadcast updated player list
|
||||
await this.broadcastActivePlayers();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
player: {
|
||||
id: playerId,
|
||||
username: username,
|
||||
stats: {
|
||||
wins: player.total_wins,
|
||||
losses: player.total_losses,
|
||||
draws: player.total_draws
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error registering player:', error);
|
||||
return { success: false, error: 'Registration failed' };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle player disconnect
|
||||
async handleDisconnect(socket) {
|
||||
const player = this.players.get(socket.id);
|
||||
if (!player) return;
|
||||
|
||||
// Check if player is in an active game
|
||||
if (player.currentGameId) {
|
||||
const game = this.activeGames.get(player.currentGameId);
|
||||
if (game && game.state === 'active') {
|
||||
// Notify opponent
|
||||
const opponentId = game.player1Id === player.id ? game.player2Id : game.player1Id;
|
||||
const opponentSocketId = this.playerSockets.get(opponentId);
|
||||
|
||||
if (opponentSocketId) {
|
||||
this.io.to(opponentSocketId).emit('opponent_disconnected', {
|
||||
message: `${player.username} disconnected`,
|
||||
waitTime: 30
|
||||
});
|
||||
}
|
||||
|
||||
// Set timeout for game abandonment
|
||||
setTimeout(async () => {
|
||||
const stillDisconnected = !this.players.has(socket.id);
|
||||
if (stillDisconnected && game.state === 'active') {
|
||||
// Award win to opponent
|
||||
await this.db.abandonGame(player.currentGameId, opponentId);
|
||||
|
||||
if (opponentSocketId) {
|
||||
const opponentPlayer = await this.db.getPlayerById(opponentId);
|
||||
this.io.to(opponentSocketId).emit('game_ended', {
|
||||
reason: 'opponent_abandoned',
|
||||
winner: opponentId,
|
||||
stats: {
|
||||
wins: opponentPlayer.total_wins,
|
||||
losses: opponentPlayer.total_losses,
|
||||
draws: opponentPlayer.total_draws
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.activeGames.delete(player.currentGameId);
|
||||
}
|
||||
}, 30000); // 30 second grace period
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from active lists
|
||||
await this.db.removeSession(socket.id);
|
||||
this.players.delete(socket.id);
|
||||
this.playerSockets.delete(player.id);
|
||||
|
||||
// Broadcast updated player list
|
||||
await this.broadcastActivePlayers();
|
||||
}
|
||||
|
||||
// Broadcast active players to all connected clients
|
||||
async broadcastActivePlayers() {
|
||||
try {
|
||||
const activePlayers = await this.db.getActivePlayers();
|
||||
this.io.emit('active_players_update', activePlayers);
|
||||
} catch (error) {
|
||||
console.error('Error broadcasting active players:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Send challenge
|
||||
async sendChallenge(socket, targetUsername, boardSize) {
|
||||
const challenger = this.players.get(socket.id);
|
||||
if (!challenger) {
|
||||
return { success: false, error: 'Not registered' };
|
||||
}
|
||||
|
||||
// Find target player
|
||||
const target = Array.from(this.players.values()).find(p => p.username === targetUsername);
|
||||
if (!target) {
|
||||
return { success: false, error: 'Player not found' };
|
||||
}
|
||||
|
||||
if (target.currentGameId) {
|
||||
return { success: false, error: 'Player is already in a game' };
|
||||
}
|
||||
|
||||
// Create challenge
|
||||
const challengeId = `${challenger.id}-${target.id}-${Date.now()}`;
|
||||
this.challenges.set(challengeId, {
|
||||
id: challengeId,
|
||||
challengerId: challenger.id,
|
||||
challengerUsername: challenger.username,
|
||||
targetId: target.id,
|
||||
targetUsername: target.username,
|
||||
boardSize: boardSize,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Send challenge to target
|
||||
this.io.to(target.socketId).emit('challenge_received', {
|
||||
challengeId: challengeId,
|
||||
from: challenger.username,
|
||||
boardSize: boardSize
|
||||
});
|
||||
|
||||
return { success: true, message: 'Challenge sent' };
|
||||
}
|
||||
|
||||
// Accept challenge
|
||||
async acceptChallenge(socket, challengeId) {
|
||||
const player = this.players.get(socket.id);
|
||||
const challenge = this.challenges.get(challengeId);
|
||||
|
||||
if (!player || !challenge) {
|
||||
return { success: false, error: 'Invalid challenge' };
|
||||
}
|
||||
|
||||
if (challenge.targetId !== player.id) {
|
||||
return { success: false, error: 'Not your challenge' };
|
||||
}
|
||||
|
||||
// Remove challenge from pending
|
||||
this.challenges.delete(challengeId);
|
||||
|
||||
// Create game in database
|
||||
const gameId = await this.db.createGame(
|
||||
challenge.challengerId,
|
||||
challenge.targetId,
|
||||
challenge.challengerUsername,
|
||||
challenge.targetUsername,
|
||||
challenge.boardSize
|
||||
);
|
||||
|
||||
// Randomly assign X and O
|
||||
const player1IsX = Math.random() < 0.5;
|
||||
|
||||
// Create game in memory
|
||||
const gameData = {
|
||||
id: gameId,
|
||||
player1Id: challenge.challengerId,
|
||||
player2Id: challenge.targetId,
|
||||
player1Username: challenge.challengerUsername,
|
||||
player2Username: challenge.targetUsername,
|
||||
player1Symbol: player1IsX ? 'X' : 'O',
|
||||
player2Symbol: player1IsX ? 'O' : 'X',
|
||||
boardSize: challenge.boardSize,
|
||||
currentTurn: challenge.challengerId, // Challenger goes first
|
||||
state: 'active',
|
||||
board: Array(challenge.boardSize).fill(null).map(() => Array(challenge.boardSize).fill(null)),
|
||||
moveCount: 0
|
||||
};
|
||||
|
||||
this.activeGames.set(gameId, gameData);
|
||||
|
||||
// Update players' current game
|
||||
const challenger = this.players.get(this.playerSockets.get(challenge.challengerId));
|
||||
const accepter = this.players.get(socket.id);
|
||||
if (challenger) challenger.currentGameId = gameId;
|
||||
if (accepter) accepter.currentGameId = gameId;
|
||||
|
||||
// Notify both players
|
||||
const challengerSocket = this.playerSockets.get(challenge.challengerId);
|
||||
if (challengerSocket) {
|
||||
this.io.to(challengerSocket).emit('game_started', {
|
||||
gameId: gameId,
|
||||
opponent: challenge.targetUsername,
|
||||
yourSymbol: gameData.player1Symbol,
|
||||
boardSize: challenge.boardSize,
|
||||
yourTurn: true
|
||||
});
|
||||
}
|
||||
|
||||
this.io.to(socket.id).emit('game_started', {
|
||||
gameId: gameId,
|
||||
opponent: challenge.challengerUsername,
|
||||
yourSymbol: gameData.player2Symbol,
|
||||
boardSize: challenge.boardSize,
|
||||
yourTurn: false
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Decline challenge
|
||||
declineChallenge(socket, challengeId) {
|
||||
const challenge = this.challenges.get(challengeId);
|
||||
if (!challenge) return;
|
||||
|
||||
this.challenges.delete(challengeId);
|
||||
|
||||
// Notify challenger
|
||||
const challengerSocket = this.playerSockets.get(challenge.challengerId);
|
||||
if (challengerSocket) {
|
||||
this.io.to(challengerSocket).emit('challenge_declined', {
|
||||
by: challenge.targetUsername
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle game move
|
||||
async handleMove(socket, moveData) {
|
||||
const player = this.players.get(socket.id);
|
||||
if (!player) return { success: false, error: 'Not registered' };
|
||||
|
||||
const game = this.activeGames.get(moveData.gameId);
|
||||
if (!game) return { success: false, error: 'Game not found' };
|
||||
|
||||
if (game.state !== 'active') return { success: false, error: 'Game not active' };
|
||||
|
||||
// Validate it's player's turn
|
||||
if (game.currentTurn !== player.id) {
|
||||
return { success: false, error: 'Not your turn' };
|
||||
}
|
||||
|
||||
// Validate move
|
||||
const { row, col } = moveData;
|
||||
if (row < 0 || row >= game.boardSize || col < 0 || col >= game.boardSize) {
|
||||
return { success: false, error: 'Invalid position' };
|
||||
}
|
||||
|
||||
if (game.board[row][col] !== null) {
|
||||
return { success: false, error: 'Cell occupied' };
|
||||
}
|
||||
|
||||
// Determine player's symbol
|
||||
const playerSymbol = game.player1Id === player.id ? game.player1Symbol : game.player2Symbol;
|
||||
|
||||
// Make move
|
||||
game.board[row][col] = playerSymbol;
|
||||
game.moveCount++;
|
||||
|
||||
// Record move in database
|
||||
await this.db.recordMove(game.id, player.id, row, col, game.moveCount);
|
||||
|
||||
// Broadcast move to opponent
|
||||
const opponentId = game.player1Id === player.id ? game.player2Id : game.player1Id;
|
||||
const opponentSocket = this.playerSockets.get(opponentId);
|
||||
|
||||
if (opponentSocket) {
|
||||
this.io.to(opponentSocket).emit('opponent_move', {
|
||||
row: row,
|
||||
col: col,
|
||||
symbol: playerSymbol
|
||||
});
|
||||
}
|
||||
|
||||
// Check for win
|
||||
const winner = this.checkWin(game, row, col, playerSymbol);
|
||||
if (winner) {
|
||||
game.state = 'completed';
|
||||
await this.db.completeGame(game.id, player.id);
|
||||
|
||||
const winnerPlayer = await this.db.getPlayerById(player.id);
|
||||
const loserPlayer = await this.db.getPlayerById(opponentId);
|
||||
|
||||
// Notify both players
|
||||
this.io.to(socket.id).emit('game_ended', {
|
||||
reason: 'win',
|
||||
winner: player.id,
|
||||
stats: {
|
||||
wins: winnerPlayer.total_wins,
|
||||
losses: winnerPlayer.total_losses,
|
||||
draws: winnerPlayer.total_draws
|
||||
}
|
||||
});
|
||||
|
||||
if (opponentSocket) {
|
||||
this.io.to(opponentSocket).emit('game_ended', {
|
||||
reason: 'loss',
|
||||
winner: player.id,
|
||||
stats: {
|
||||
wins: loserPlayer.total_wins,
|
||||
losses: loserPlayer.total_losses,
|
||||
draws: loserPlayer.total_draws
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this.activeGames.delete(game.id);
|
||||
if (this.players.has(socket.id)) this.players.get(socket.id).currentGameId = null;
|
||||
if (opponentSocket && this.players.has(opponentSocket)) {
|
||||
this.players.get(opponentSocket).currentGameId = null;
|
||||
}
|
||||
|
||||
return { success: true, gameOver: true, winner: true };
|
||||
}
|
||||
|
||||
// Check for draw
|
||||
if (game.moveCount === game.boardSize * game.boardSize) {
|
||||
game.state = 'completed';
|
||||
await this.db.completeGame(game.id, null);
|
||||
|
||||
const player1Data = await this.db.getPlayerById(game.player1Id);
|
||||
const player2Data = await this.db.getPlayerById(game.player2Id);
|
||||
|
||||
this.io.to(socket.id).emit('game_ended', {
|
||||
reason: 'draw',
|
||||
stats: {
|
||||
wins: player.id === game.player1Id ? player1Data.total_wins : player2Data.total_wins,
|
||||
losses: player.id === game.player1Id ? player1Data.total_losses : player2Data.total_losses,
|
||||
draws: player.id === game.player1Id ? player1Data.total_draws : player2Data.total_draws
|
||||
}
|
||||
});
|
||||
|
||||
if (opponentSocket) {
|
||||
this.io.to(opponentSocket).emit('game_ended', {
|
||||
reason: 'draw',
|
||||
stats: {
|
||||
wins: opponentId === game.player1Id ? player1Data.total_wins : player2Data.total_wins,
|
||||
losses: opponentId === game.player1Id ? player1Data.total_losses : player2Data.total_losses,
|
||||
draws: opponentId === game.player1Id ? player1Data.total_draws : player2Data.total_draws
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.activeGames.delete(game.id);
|
||||
if (this.players.has(socket.id)) this.players.get(socket.id).currentGameId = null;
|
||||
if (opponentSocket && this.players.has(opponentSocket)) {
|
||||
this.players.get(opponentSocket).currentGameId = null;
|
||||
}
|
||||
|
||||
return { success: true, gameOver: true, draw: true };
|
||||
}
|
||||
|
||||
// Switch turn
|
||||
game.currentTurn = opponentId;
|
||||
|
||||
return { success: true, gameOver: false };
|
||||
}
|
||||
|
||||
// Check win condition (same as frontend logic)
|
||||
checkWin(game, row, col, symbol) {
|
||||
const directions = [
|
||||
[0, 1], // Horizontal
|
||||
[1, 0], // Vertical
|
||||
[1, 1], // Diagonal \
|
||||
[1, -1] // Diagonal /
|
||||
];
|
||||
|
||||
for (const [dx, dy] of directions) {
|
||||
let count = 1;
|
||||
count += this.countDirection(game, row, col, dx, dy, symbol);
|
||||
count += this.countDirection(game, row, col, -dx, -dy, symbol);
|
||||
|
||||
if (count >= 5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
countDirection(game, row, col, dx, dy, symbol) {
|
||||
let count = 0;
|
||||
let r = row + dx;
|
||||
let c = col + dy;
|
||||
|
||||
while (
|
||||
r >= 0 && r < game.boardSize &&
|
||||
c >= 0 && c < game.boardSize &&
|
||||
game.board[r][c] === symbol
|
||||
) {
|
||||
count++;
|
||||
r += dx;
|
||||
c += dy;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// Heartbeat to keep session alive
|
||||
async heartbeat(socket) {
|
||||
const player = this.players.get(socket.id);
|
||||
if (player) {
|
||||
await this.db.updateHeartbeat(socket.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GameManager;
|
||||
Reference in New Issue
Block a user