minesweeper-backend/index.js
Jose134 e1c0ae1953
All checks were successful
gitea/minesweeper-backend/pipeline/head This commit looks good
Fix turn when player disconnect
2025-01-22 18:02:14 +01:00

459 lines
14 KiB
JavaScript

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