I decided to write my own version of a Mine Sweeper game as an excuse for a Javascript/HTML/CSS project. Just to make the question self-contained: Mine Sweeper is a game with a rectangular board consisting of hidden cells. Mines are randomly placed on the board and the goal is to reveal the whole board, except for the mines! Each revealed cell will either have a mine, a number or be empty. The number in the cell tells us how many mines exist in the immediate vicinity of this cell (an empty cell is equivalent to a cell with a 0 in it).
My implementation currently supports:
- Selection of size and difficulty (number of mines).
- A timer of the game which stops when the game is over (win or lose), and resets when it starts.
- Auto-reveal when clicking an empty cell.
- The use of flags with a right-click (or long press on mobile).
- A mine counter showing the current amount of unflagged mines (this relies on flags so it might not be correct at a given moment).
I am pretty happy with the current state and I am posting here after a few iterations and changes, but since this is basically my first big JS/HTML/CSS project I wanted to get it reviewed especially on:
- The general use of scopes - Sometimes I found the need to use global variables although I'm used to them being considered a bad habit. Because of my lack of JS expertise, there might be a better way using them (and scoping in general).
- I implemented the timer essentially through combining this and this Stack Overflow questions. Not sure if it's the best use for this case.
- I used classes to signal some states or to pass information just because it seemed a good use of classes. I'm not sure if that's actually the best way to go (for example, see below the use of the
visible
class to signal that a cell has been clicked). - Stating the obvious probably, but of course anything else that you see and I didn't mention. Specifically I only tagged JS as I feel is the main aspect I want reviewed, but of course if the use of HTML/CSS has some obvious wrongs I would like to know as well.
If anyone is interested, the game is also on GitHub.
const grid = document.getElementById("grid");
var gameOver = false;
var placeMines = function(difficulty) {
const cells = document.getElementsByClassName("cell");
const minesI = [];
// Get unique random indexes to place the mines
mines_count = Math.ceil(cells.length * difficulty);
const mines_text = document.getElementById("mines");
mines_text.setAttribute("data-mines", mines_count);
mines_text.innerHTML = "Mines left: " + mines_count;
hidden = N * N;
while (minesI.length < mines_count) {
var I = Math.floor(Math.random() * cells.length);
if (minesI.indexOf(I) === -1) minesI.push(I);
}
// Set the cells with mines according to the indexes in the minesI array
for (var i = 0; i < minesI.length; i++) {
cells[minesI[i]].classList.add("mine");
cells[minesI[i]].innerHTML = "X";
}
};
var getImmediateNeighbors = function(cell) {
const i = [...cells].indexOf(cell);
let topRow = true;
let bottomRow = false;
const neighbors = [];
if (i > N - 1) {
topRow = false;
neighbors.push(cells[i - N]);
}
if (i < N * (N - 1)) {
neighbors.push(cells[i + N]);
} else bottomRow = true;
if (i % N != 0) {
neighbors.push(cells[i - 1]);
if (!topRow) neighbors.push(cells[i - N - 1]);
if (!bottomRow) neighbors.push(cells[i + N - 1]);
}
if (i % N != N - 1) {
neighbors.push(cells[i + 1]);
if (!topRow) neighbors.push(cells[i - N + 1]);
if (!bottomRow) neighbors.push(cells[i + N + 1]);
}
return neighbors;
};
var placeNums = function() {
for (var i = 0; i < cells.length; i++) {
if (cells[i].classList.contains('mine')) continue;
let neighbors = getImmediateNeighbors(cells[i]);
let count = 0;
for (var j = 0; j < neighbors.length; j++) {
count += neighbors[j].classList.contains('mine');
}
cells[i].innerHTML = count > 0 ? count : '';
}
};
var resetBoard = function() {
document.getElementById('text').innerHTML = "";
document.getElementById("seconds").innerHTML = "00";
document.getElementById("minutes").innerHTML = "00";
const difficulty = document.getElementById("diff").value;
N = Number(document.getElementById("size").value);
grid.style.setProperty('grid-template-columns', 'repeat(' + N + ', 30px)');
grid.style.setProperty('grid-template-rows', 'repeat(' + N + ', 30px)');
grid.innerHTML = '';
for (var i = 0; i < N * N; i++) {
const cell = document.createElement("button");
cell.className = 'cell';
grid.appendChild(cell);
}
cells = grid.getElementsByClassName("cell");
for (var i = 0; i < cells.length; i++) {
cells[i].className = 'cell';
cells[i].innerHTML = '';
cells[i].addEventListener('click', clickCell, false);
cells[i].oncontextmenu = clickFlag;
}
gameOver = false;
placeMines(difficulty);
placeNums();
totalSeconds = 0;
clearInterval(timer);
timer = setInterval(setTime, 1000);
};
var clickFlag = function() {
if (gameOver) return;
if (this.classList.contains('visible')) return;
mines_text = document.getElementById("mines");
const mines_count = Number(mines_text.getAttribute("data-mines"));
if (this.classList.contains('flag')) {
this.classList.remove('flag');
mines_text.setAttribute("data-mines", mines_count + 1);
mines_text.innerHTML = "Mines left: " + (mines_count + 1);
} else {
this.classList.add('flag');
mines_text.setAttribute("data-mines", mines_count - 1);
mines_text.innerHTML = "Mines left: " + (mines_count - 1);
}
return false;
};
var clickCell = function() {
if (gameOver) return;
if (this.classList.contains('visible')) return;
if (this.classList.contains('flag')) return;
this.classList.add('visible');
hidden = hidden - 1;
if (this.classList.contains('mine')) {
document.getElementById('text').innerHTML = "Game Over";
endGame();
return;
}
if (hidden === mines_count) {
document.getElementById('text').innerHTML = "You Won!";
endGame();
return;
}
if (this.childNodes.length === 0) {
let neighbors = getImmediateNeighbors(this);
for (var i = 0; i < neighbors.length; i++) {
neighbors[i].click();
}
}
};
function setTime() {
++totalSeconds;
document.getElementById("seconds").innerHTML = pad(totalSeconds % 60);
document.getElementById("minutes").innerHTML = pad(parseInt(totalSeconds / 60));
}
function pad(val) {
var valString = val + "";
if (valString.length < 2) {
return "0" + valString;
} else {
return valString;
}
}
function endGame() {
gameOver = true;
clearInterval(timer);
}
document.getElementById('reset').addEventListener('click', resetBoard);
resetBoard();
span {
font-family: Arial, Helvetica, sans-serif;
}
#grid {
display: grid;
grid-template-columns: repeat(3, 30px);
grid-template-rows: repeat(3, 30px);
}
.cell {
background: LightBlue;
font-size: 0;
border: 1px solid Blue;
border-radius: 2px;
padding: 10px;
}
.visible {
font-size: 16px;
background: LightGrey;
outline: none;
-webkit-box-shadow: inset 0px 0px 5px #c1c1c1;
-moz-box-shadow: inset 0px 0px 5px #c1c1c1;
box-shadow: inset 0px 0px 5px #c1c1c1;
}
.flag {
background: yellow;
}
.visible.mine {
background: Red;
}
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="mysweeper.css">
<script src="mysweeper.js" defer></script>
</head>
<body>
<h1>My Mine Sweeper</h1>
<label for="diff">Difficulty:</label>
<select name="difficulty" id="diff">
<option value=0.1>Easy</option>
<option value=0.3>Medium</option>
<option value=0.6>Hard</option>
</select>
<label for="size">Size:</label>
<select name="size" id="size">
<option value=4>Small</option>
<option value=6>Medium</option>
<option value=10>Big</option>
</select>
<button id="reset">Reset</button>
<div id="mines" data-mines=""></div>
<div id="timer"><label id="minutes">00</label>:<label id="seconds">00</label></div>
<div id="grid">
</div>
<span id="text"></span>
</body>
</html>