const express = require('express'); const { createServer } = require('http'); const { Server } = require('socket.io'); const cors = require('cors'); const { randomUUID } = require('crypto'); // -------------------------------------- // SERVER // -------------------------------------- const app = express(); app.use(cors({ origin: "*", methods: ["GET", "POST"], allowedHeaders: ["Content-Type"], credentials: true })); const server = createServer(app); const io = new Server(server, { cors: { origin: "*", methods: ["GET", "POST"], allowedHeaders: ["Content-Type"], credentials: true } }); io.on('connection', (socket) => { socket.onAny((event, ...args) => console.log(event, args)); socket.on('disconnect', () => handleDisconnect(socket, playerRoomMap)); socket.on('createRoom', (data) => handleCreateRoom(socket, data)); socket.on('joinRoom', (data) => handleJoinRoom(socket, data)); socket.on('tileClick', (data) => handleTileClick(socket, data)); socket.on('tileSelect', (data) => handleTileSelect(socket, data)); socket.on('tileFlag', (data) => handleTileFlag(socket, data)); }); const PORT = 5174; server.listen(PORT, () => { console.log('server started at port', PORT); }); // -------------------------------------- // ROOM HANDLING // -------------------------------------- let rooms = new Map(); const playerRoomMap = new Map(); const handleDisconnect = (socket, playerRoomMap) => { const playerRoom = playerRoomMap.get(socket.id); if (playerRoom) { if (playerRoom.currentPlayer === socket.id) { playerRoom.currentPlayer = getNextPlayer(playerRoom.players, socket.id); } playerRoom.players = playerRoom.players.filter(user => user.socketId !== socket.id); if (checkLoseCondition(playerRoom.players)) { playerRoom.gameState = "lost"; } io.to(playerRoom.id).emit('roomUserLeft', buildRoomView(playerRoom)); } playerRoomMap.delete(socket.id); cleanupRooms(); socket.leaveAll(); }; const handleCreateRoom = (socket, data) => { const { username, rows, cols, bombs, autoflag, gameMode } = data; const room = buildRoom(socket.id, username, parseInt(rows), parseInt(cols), parseInt(bombs), autoflag, gameMode); rooms.set(room.id, room); playerRoomMap.set(socket.id, room); console.log('created room', room.id); socket.join(room.id); socket.emit('youJoinedRoom', buildRoomView(room)); }; const handleJoinRoom = (socket, data) => { const room = rooms.get(data.room); if (!room) { console.log(`room ${data.room} does not exist`); socket.emit('error', `room ${data.room} does not exist`); return; } room.players.push(buildPlayer(socket.id, data.username)); playerRoomMap.set(socket.id, room); socket.join(data.room); socket.broadcast.to(room.id).emit('roomUserJoined', buildRoomView(room)); socket.emit('youJoinedRoom', buildRoomView(room)); }; // -------------------------------------- // GAME LOGIC // -------------------------------------- const handleTileClick = (socket, data) => { const room = playerRoomMap.get(socket.id); if (!room || room.gameState !== "playing") { console.log("Could not find room for player", socket.id); socket.emit('error', "Could not find room for player"); return; } // Find the player and make sure they are alive and have not already selected a tile const player = room.players.find(player => player.socketId === socket.id); if (!player || player.tombstone || (room.gameMode === "rounds" && player.confirmedSelection)) { return; } // Find the tile that was clicked and select it if it is not already selected by another player const tile = room.board.tiles.find(tile => tile.id === data.tileId); if (!isSelectableTile(tile, room.players, player.socketId)) { return; } if (room.gameMode === "rounds") { const player = room.players.find(player => player.socketId === socket.id) if (player) { player.selection = tile.id; } else { return; } } else { if (room.gameMode === "turns") { // Check that the current player is the one who has the turn if (room.currentPlayer !== socket.id) { return; } } else if (room.gameMode !== "realtime") { // Unrecognized gameMode return; } if (tile.bomb === true) { console.log('bomb clicked'); room.players.find(user => user.socketId === socket.id).tombstone = tile.id; } revealTile(room.board, data.tileId); if (room.autoflag) { flagBombs(room.board); } if (checkLoseCondition(room.players)) { room.gameState = "lost"; } else if (checkWinCondition(room.players, room.board)) { room.gameState = "won"; } room.currentPlayer = getNextPlayer(room.players, room.currentPlayer); } io.to(room.id).emit('updateRoom', buildRoomView(room)); }; const handleTileSelect = (socket, data) => { const room = playerRoomMap.get(socket.id); if (!room || room.gameState !== "playing") { console.log("Could not find room for player", socket.id); socket.emit('error', "Could not find room for player"); return; } if (room.gameMode !== "rounds") { console.log('game mode is not rounds'); return; } if (room.players.find(user => user.socketId === socket.id).tombstone) { console.log('player is dead'); return; } const player = room.players.find(player => player.socketId === socket.id); if (!player || player.tombstone || !player.selection || player.confirmedSelection) { return; } // Find the tile that was clicked and select it if it is not already selected by another player const tile = room.board.tiles.find(tile => tile.id === data.tileId); if (!isSelectableTile(tile, room.players, player.socketId)) { console.log('tile is not selectable'); return; } player.confirmedSelection = true; const playersSelected = room.players.filter(player => !player.tombstone && player.selection && player.confirmedSelection === true); const shouldPlayRound = playersSelected.length === room.players.filter(player => !player.tombstone).length || room.players.filter(player => player.tombstone).length === room.board.tiles.filter(tile => !tile.revealed).length; if (shouldPlayRound) { room.players.filter(player => !player.tombstone).forEach(player => { if (room.board.tiles.find(tile => tile.id === player.selection).bomb === true) { player.tombstone = player.selection; } revealTile(room.board, player.selection); }); if (room.autoflag) { flagBombs(room.board); } room.players.forEach(player => { player.selection = null; player.confirmedSelection = false; }); if (checkLoseCondition(room.players)) { room.gameState = "lost"; } else if (checkWinCondition(room.players, room.board)) { room.gameState = "won"; } } io.to(room.id).emit('updateRoom', buildRoomView(room)); }; const handleTileFlag = (socket, data) => { const room = playerRoomMap.get(socket.id); if (!room) { console.log("Could not find room for player", socket.id); socket.emit('error', "Could not find room for player"); return; } if (room.players.find(user => user.socketId === socket.id).tombstone) { console.log('player is dead'); return; } if (room.gameMode === "turns" && room.currentPlayer !== socket.id) { return; } // Find the tile that was clicked and select it if it is not already selected by another player const tile = room.board.tiles.find(tile => tile.id === data.tileId); if (!tile || tile.revealed || room.players.some(player => player.selection === data.tileId)) { return; } tile.flagged = !tile.flagged; if (checkWinCondition(room.players, room.board)) { room.gameState = "won"; } io.to(room.id).emit('updateRoom', buildRoomView(room)); }; // -------------------------------------- // HELPER FUNCTIONS // -------------------------------------- const cleanupRooms = () => { for (let room of rooms.values()) { if (room.players.length === 0) { rooms.delete(room.id); } } }; const buildRoom = (userId, username, rows, cols, bombs, autoflag, gameMode) => { const roomId = randomUUID().slice(0, 5); // TODO: avoid collisions const board = buildBoard(rows, cols, bombs); const user = buildPlayer(userId, username); return { id: roomId, autoflag: autoflag, players: [user], gameMode: gameMode, gameState: "playing", currentPlayer: user.socketId, board: board }; }; const buildBoard = (rows, cols, bombs) => { // Generate empty board const board = { rows: rows, cols: cols, bombs: bombs, tiles: Array(rows * cols).fill().map((_, idx) => { return { id: idx, value: null, bomb: false, revealed: false, flagged: false, playerSelect: null } }) }; // Place bombs for (let i = 0; i < bombs; i++) { const bombIdx = Math.floor(Math.random() * board.tiles.length); board.tiles[bombIdx].bomb = true; } // Calculate values board.tiles.forEach((tile, idx) => { if (tile.bomb === true) { return; } const bombCount = getSurroundingTiles(board, idx) .filter(tile => tile && tile.bomb) .length; tile.value = bombCount; }); return board; }; const buildPlayer = (socketId, name) => { const getRandomColorHex = () => { const colorValue = Math.floor(0x888888 + (Math.random() * 0xffffff - 0x888888)); return '#' + colorValue.toString(16).padStart(6, '0'); }; return { socketId, name, color: getRandomColorHex() }; }; const buildRoomView = (room) => { const buildBoardView = (board, gameState) => { return { rows: board.rows, cols: board.cols, bombs: board.bombs, tiles: board.tiles.map(tile => tile.revealed || gameState === "lost" || gameState === "won" ? { id: tile.id, value: tile.value, bomb: tile.bomb } : { id: tile.id, flagged: tile.flagged } ) }; } return { id: room.id, players: room.players, gameMode: room.gameMode, gameState: room.gameState, currentPlayer: room.currentPlayer, board: buildBoardView(room.board, room.gameState) }; }; const revealTile = (board, idx) => { const tile = board.tiles.find(tile => tile.id === idx); if (!tile || tile.revealed || tile.flagged) { return; } tile.revealed = true; if (tile.value === 0) { const neighbors = getSurroundingTiles(board, idx); neighbors.forEach(neighbor => revealTile(board, neighbor.id)); } }; const flagBombs = (board) => { board.tiles.forEach(tile => { if (!tile.bomb && !tile.flagged) { const neighbors = getSurroundingTiles(board, tile.id); if (tile.revealed && neighbors.filter(neighbor => !neighbor.revealed || neighbor.bomb).length == tile.value) { neighbors.forEach(neighbor => { if (!neighbor.revealed && !neighbor.flagged) { neighbor.flagged = true; } }); } } }); }; const getSurroundingTiles = (board, idx) => { const { rows, cols } = board; const neighbors = []; const isTopBorder = idx < cols; const isBottomBorder = idx >= (rows - 1) * cols; const isLeftBorder = idx % cols === 0; const isRightBorder = (idx + 1) % cols === 0; // Top left neighbors.push(!isTopBorder && !isLeftBorder ? board.tiles[idx - cols - 1] : undefined); // Top neighbors.push(!isTopBorder ? board.tiles[idx - cols] : undefined); // Top right neighbors.push(!isTopBorder && !isRightBorder ? board.tiles[idx - cols + 1] : undefined); // Left neighbors.push(!isLeftBorder ? board.tiles[idx - 1] : undefined); // Right neighbors.push(!isRightBorder ? board.tiles[idx + 1] : undefined); // Bottom left neighbors.push(!isBottomBorder && !isLeftBorder ? board.tiles[idx + cols - 1] : undefined); // Bottom neighbors.push(!isBottomBorder ? board.tiles[idx + cols] : undefined); // Bottom right neighbors.push(!isBottomBorder && !isRightBorder ? board.tiles[idx + cols + 1] : undefined); return neighbors.filter(tile => tile != undefined); }; const checkWinCondition = (players, board) => { return board.tiles.every(tile => tile.revealed || (tile.bomb && tile.flagged) || players.some(player => player.tombstone === tile.id)) && players.filter(player => !player.tombstone).length > 0; }; const checkLoseCondition = (players) => { return players.every(player => player.tombstone); } const isSelectableTile = (tile, players, currentPlayerId) => { return tile && !tile.revealed && !tile.flagged && players.filter(player => player.socketId != currentPlayerId).every(player => player.selection !== tile.id) && players.every(player => player.tombstone !== tile.id); } const getNextPlayer = (players, currentPlayerId) => { if (players.every(player => player.tombstone)) { return null; } let currentPlayerIdx = players.findIndex(player => player.socketId === currentPlayerId); let nextPlayerIdx; do { nextPlayerIdx = (currentPlayerIdx + 1) % players.length; currentPlayerIdx = nextPlayerIdx; } while (players[nextPlayerIdx].tombstone); return players[nextPlayerIdx].socketId; }