All checks were successful
gitea/minesweeper-backend/pipeline/head This commit looks good
459 lines
14 KiB
JavaScript
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;
|
|
} |