Minesweeper in Javascript and Canvas
canvasgamedevtutorialjavascriptarchivedMinesweeper theory
Minesweeper! That wonderfully simple yet entertaining puzzle that provides a welcome distraction from real work! In this article we'll look at the theory, and then how to put the game together in Javascript, Canvas and HTML.
The premise is simple; clear a grid of hidden tiles to isolate the mines, but touch a mine and it's all over! As you clear, numbers on revealed tiles tell you how many adjacent tiles have mines on them, or if the tile has no dangerous neighbours it reveals all neighbouring tiles.
When developing this game, there are two key areas. The first is how the grid itself is built:
[grid] = [array of tiles, width x height] [placed mines] = 0 do until [placed mines] == [desired number of mines]: [pos] = select random tile if [pos] has mine then restart 'do' loop else: [grid][pos] has mine [placed mines] + 1 (loop) foreach [pos] in [grid]: set [grid][pos] danger = (number of adjacent tiles that have mines) (loop)
User interaction
Once our grid has been built, we handle user input. This consists of the user clicking on a tile, as well as a function that handles revealing the tile to the player, and works as follows:
function reveal tile ( [tile] ): set [tile] revealed if [tile][danger] == 0: foreach [n] in [neighbours]: reveal tile( [n] ) (loop) end function if [clicked tile] has mine: game over else: reveal tile( [clicked tile] ) if [all tiles without mines] are revealed: game won
These are the key concepts behind the game minesweeper, so if you follow these carry on to see how the game can be put together!
The HTML Document
We need a simple HTML document to begin with, which I'll call minesweeper.htm. This is a simple document, that requires a references to an external javascript where we'll put our game code (I'll call this minesweeper-game.js), and a Canvas element in the body, which we'll give a width and height of 300px and 400px respectively, and the ID game:
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="minesweeper-game.js"></script>
</head>
<body>
<canvas id="game" width="300" height="400"></canvas>
</body>
</html>
Global variables and objects
Now on to the main code of the game! To begin with, we'll need some global variables to keep track of various things throughout the game.
We'll start by creating a variable, ctx, that we'll use as a reference to our Canvas elements 2D drawing context later on. We'll also create gameTime, to keep track of the elapsed time for the current game in milliseconds, and lastFrameTime, the time the game update function last ran in milliseconds, as well as three variables (currentSecond, frameCount and framesLastSecond) for displaying the frame rate.
var ctx = null;
var gameTime = 0, lastFrameTime = 0;
var currentSecond = 0, frameCount = 0, framesLastSecond = 0;
We'll also need offsetX and offsetY when calculating the the position of the game grid on the Canvas, and the grid array which will store our game tiles when we start a play a level:
var offsetX = 0, offsetY = 0;
var grid = [];
Global objects
We also need some globally available objects for keeping track on events, states, and settings throughout the game. The first of these is mouseState, which stores the position of the cursor on the Canvas (x, y), and if a click event has occured and needs to be processed. The value of this will either be null if there is no unprocessed click event, or an array in the form [x, y, button], where x, y is the coordinates of the event, and button is the mouse button that was clicked there (1 for left button, otherwise 2).
var mouseState = {
x : 0,
y : 0,
click : null
};
We'll also store some information about the gameState. We need to know the current difficulty, the screen to display, whether or not the Player has achieved a newBest time for this difficulty once the grid is cleared, and the timeTaken, in milliseconds, for the player to clear the grid.
We'll also specify the tileW and tileH (width and height) at which we'll draw tiles on the grid, in pixels.
var gameState = {
difficulty : 'easy',
screen : 'menu',
newBest : false,
timeTaken : 0,
tileW : 20,
tileH : 20
};
We'll also have a map of available difficulty settings. For each setting we need to specify the name of the difficulty, the width and height of the grid, in tiles, the number of mines to place randomly on the grid, and bestTime (which we'll give the value 0) to keep track of the players best time for this difficulty, as well as menuBox, an array with the values [0, 0] that we'll use later:
var difficulties = {
easy : {
name : "Easy",
width : 10,
height : 10,
mines : 10,
bestTime : 0,
menuBox : [0,0]
},
medium : {
name : "Medium",
width : 12,
height : 12,
mines : 20,
bestTime : 0,
menuBox : [0,0]
},
hard : {
name : "Hard",
width : 15,
height : 15,
mines : 50,
bestTime : 0,
menuBox : [0,0]
}
};
Grid Tile object
Each tile on our grid will be represented by a Tile object, that will store the x and y position of the tile, a boolean indicating in the tile hasMine, the danger posed by the number of adjacent tiles that have mines also, and the currentState of the tile, which is hidden by default.
function Tile(x, y)
{
this.x = x;
this.y = y;
this.hasMine = false;
this.danger = 0;
this.currentState = 'hidden';
}
The Tile object needs a method, calcDanger, which we use to calculate the danger value for a given tile by checking all of the neighbouring tiles, starting at [(x - 1), (y - 1)], ending at [(x + 1), (y + 1)]. In the example grid, where T marks the current Tile, X marks the tiles which are examined:
- 1 2 3 4 5 6 7 8 9 1 2 X X X 3 X T X 4 X X X 5 6
Here, the tile we're checking is x = 2, y = 3, so we check tiles x = 1, y = 2 through x = 3, y = 4. Our code to begin checking these neighbours is as follows:
Tile.prototype.calcDanger = function()
{
var cDiff = difficulties[gameState.difficulty];
for(var py = this.y - 1; py <= this.y + 1; py++)
{
for(var px = this.x - 1; px <= this.x + 1; px++)
{
For readability sake, we've also created a reference to the current difficulty information, cDiff.
We can now check and see if the current neighbour we're looking at is either out of bounds (px or py are less than 0 or greater than or equal to the current difficulties (cDiff) width or height, or if it's the Tile we're calculating danger for. In either of the cases, we continue on to the next tile without doing anything.
if(px==this.x && py==this.y) { continue; }
if(px < 0 || py < 0 ||
px >= cDiff.width ||
py >= cDiff.height)
{
continue;
}
If it's a valid tile but not the tile we're calculating danger for itself, we check if the tile has a mine. If so, we increase the danger level.
if(grid[((py*cDiff.width)+px)].hasMine)
{
this.danger++;
}
}
}
};
After this check we can also close the nested loops and the method itself.
The Tile class also has a flag method. This allows the player to flag tile they believe have a mine so they do not accidentally click on them, and to keep track of their progress in isolating mines. This method, when called, simply sets the tiles currentState to flagged if it was hidden, or hidden if it was flagged (to allow flags to be removed).
Tile.prototype.flag = function()
{
if(this.currentState=='hidden') { this.currentState = 'flagged'; }
else if(this.currentState=='flagged') { this.currentState = 'hidden'; }
};
The click handler for tiles firstly checks if the tiles currentState is hidden - if it is not, we don't want to handle click events for this tile so we return our of the method immediately:
Tile.prototype.click = function()
{
if(this.currentState!='hidden') { return; }
If, however, the tile hasMine value is true, the player has lost the game, and we call the gameOver method to handle this.
if(this.hasMine) { gameOver(); }
If there is no mine but the danger level is not 0, we make the tile visible:
else if(this.danger>0) { this.currentState = 'visible'; }
Otherwise, when the danger is 0, we'll both show the tile and reveal its neighbours:
else
{
this.currentState = 'visible';
this.revealNeighbours();
}
As we may have completed the game by revealing this tile, we'll call the checkState function to see if all safe tiles are cleared, and close the method.
checkState();
};
Revealing neighbours
If a tiles danger is 0, when it is revealed we also reveal all neighbours that are currently hidden, as we know they cannot possibly be mines. We do so with the revealNeighbours methods, which begins in the same way as the calcDanger method, by creating a shorthand reference to the current difficulty (cDiff), and then iterating over all neighbouring tiles:
Tile.prototype.revealNeighbours = function()
{
var cDiff = difficulties[gameState.difficulty];
for(var py = this.y - 1; py <= this.y + 1; py++)
{
for(var px = this.x - 1; px <= this.x + 1; px++)
{
As with the calcDanger method we also skip any tiles that fall outside of grid bounds, and do not need to process the tile for which this method is called.
if(px==this.x && py==this.y) { continue; }
if(px < 0 || py < 0 ||
px >= cDiff.width ||
py >= cDiff.height)
{
continue;
}
To make the code simpler to read, we now calculate the index in the grid array of the current neighbouring tile we're looking at:
var idx = ((py * cDiff.width) + px);
...and check this neighbours currentState is hidden. If so, we set the currentState to visible, and if it also has a danger level of 0 we also reveal its neighbours:
if(grid[idx].currentState=='hidden')
{
grid[idx].currentState = 'visible';
if(grid[idx].danger==0)
{
grid[idx].revealNeighbours();
}
}
}
}
};
We also closed the nested loops and ended this method, as we're done with the Tile object.
Game state and Starting a new level
Our checkState method, which is called every time a hidden tile is revealed, begins by checking every tile in the grid array. If a tile is found which does not have a mine (hasMine equals false) and does not have the currentState visible, we leave the function immediately:
function checkState()
{
for(var i in grid)
{
if(grid[i].hasMine==false && grid[i].currentState!='visible')
{
return;
}
}
If the check is passed, the game has been won. We set the gameState.timeTaken to the current gameTime, and create a reference to the current difficulty level called cDiff.
gameState.timeTaken = gameTime;
var cDiff = difficulties[gameState.difficulty];
If there isn't currently a bestTime for the difficulty level, or the time the player has taken is better than the bestTime, update the bestTime accordingly and set the gameState.newBest flag to true.
if(cDiff.bestTime==0 ||
gameTime < cDiff.bestTime)
{
gameState.newBest = true;
cDiff.bestTime = gameTime;
}
The current screen is changed to the won screen, and the method is complete:
gameState.screen = 'won';
}
Game Over
If the gameOver method is called in our game, we're just going to change to the lost screen:
function gameOver()
{
gameState.screen = 'lost';
}
Starting a new level
When a new level is started with the startLevel method, it is passed the difficulty level which will be played (diff), and begins by resetting the gameState newBest and timeTaken properties, changing the screen to playing, and the difficulty to the provided diff. We also set the gameTime and lastFrameTime to 0 and empty the grid array.
function startLevel(diff) { gameState.newBest = false; gameState.timeTaken = 0; gameState.difficulty
= diff; gameState.screen = 'playing'; gameTime = 0; lastFrameTime = 0; grid.length = 0;
A temporary reference to the information for the current difficulty level called cDiff is also created:
var cDiff = difficulties[diff];
The offsetX, offsetY values for drawing the game grid are also calculated by subtracting the total width or height of the grid (gameState.tileW x cDiff.width and gameState.tileH x cDiff.height) from the Canvas elements width or height, and dividing the result by 2:
offsetX = Math.floor((document.getElementById('game').width -
(cDiff.width * gameState.tileW)) / 2);
offsetY = Math.floor((document.getElementById('game').height -
(cDiff.height * gameState.tileH)) / 2);
After, we loop through each column and each row and create a new Tile object for the current px, py position and add it to the grid array:
for(var py = 0; py < cDiff.height; py++)
{
for(var px = 0; px < cDiff.width; px++)
{
var idx = ((py * cDiff.width) + px);
grid.push(new Tile(px, py));
}
}
A counter for the number of minesPlaced is created, and we then begin a loop that will continue until we've placed the number of mines the current difficulty requires:
var minesPlaced = 0;
while(minesPlaced < cDiff.mines)
{
A random grid index is selected. If the position already has a mine, we jump back to the start of the loop and try again. Otherwise, we place a mine at the target index and increase the minesPlaced counter. Afterwards we close this loop.
var idx = Math.floor(Math.random() * grid.length);
if(grid[idx].hasMine) { continue; }
grid[idx].hasMine = true;
minesPlaced++;
}
Finally, for each grid entry we call the calcDanger method, and the function can be closed.
for(var i in grid) { grid[i].calcDanger(); }
}
Updating game logic
Our updateGame method will serve to process click events. How it does so depends on the current screen.
function updateGame()
{
If the current screen is the menu screen and the mouseState.click event is not null, we loop through the difficulties to see if the event fell on the menuBox area of an entry. If it did, we start a new game at that difficulty and clear the click event.
if(gameState.screen=='menu')
{
if(mouseState.click!=null)
{
for(var i in difficulties)
{
if(mouseState.y >= difficulties[i].menuBox[0] &&
mouseState.y <= difficulties[i].menuBox[1])
{
startLevel(i);
break;
}
}
mouseState.click = null;
}
}
If the current screen is won or lost however, we just return to the menu screen and clear the click event.
else if(gameState.screen=='won' || gameState.screen=='lost')
{
if(mouseState.click!=null)
{
gameState.screen = 'menu';
mouseState.click = null;
}
}
Otherwise, we are on the playing screen and a game is in progress. If a click event has occured, we begin by creating a temporary reference to the current difficulty (cDiff):
else
{
if(mouseState.click!=null)
{
var cDiff = difficulties[gameState.difficulty];
Next, we test to see if the event fell on the grid. It must be greater than or equal to the offsetX, offsetY values, and less than these values plus the width or height of the current difficulty grid multiplied by the gameState tileW or tileH.
if(mouseState.click[0]>=offsetX &&
mouseState.click[1]>=offsetY &&
mouseState.click[0]<(offsetX + (cDiff.width * gameState.tileW)) &&
mouseState.click[1]<(offsetY + (cDiff.height * gameState.tileH)))
{
If the click fell on the grid, we convert the pixel position of the click to the tile x, y position on the grid:
var tile = [
Math.floor((mouseState.click[0]-offsetX)/gameState.tileW),
Math.floor((mouseState.click[1]-offsetY)/gameState.tileH)
];
If the click button was the left mouse button, we call the click method for the target tile. Otherwise, we call the flag method:
if(mouseState.click[2]==1)
{
grid[((tile[1] * cDiff.width) + tile[0])].click();
}
else
{
grid[((tile[1] * cDiff.width) + tile[0])].flag();
}
If the click event did not fall on the grid, we see if the y value is greater than 380, in which case we'll leave the current game and go back to the menu screen.
}
else if(mouseState.click[1]>=380)
{
gameState.screen = 'menu';
}
The click event is then cleared, and the if statements and current method is closed.
mouseState.click = null;
}
}
}
Window onload
When the window loads we begin by creating a reference to the Canvas elements 2D drawing context with the ctx global:
window.onload = function()
{
ctx = document.getElementById('game').getContext('2d');
Event listeners are added for the click, mousemove, and contextmenu events, which update the mouseState accordingly. The contextmenu event, tied to the right mouse button on Windows or Linux, is the method we'll use to allow the player to flag tiles.
document.getElementById('game').addEventListener('click', function(e) {
var pos = realPos(e.pageX, e.pageY);
mouseState.click = [pos[0], pos[1], 1];
});
document.getElementById('game').addEventListener('mousemove',
function(e) {
var pos = realPos(e.pageX, e.pageY);
mouseState.x = pos[0];
mouseState.y = pos[1];
});
document.getElementById('game').addEventListener('contextmenu',
function(e) {
e.preventDefault();
var pos = realPos(e.pageX, e.pageY);
mouseState.click = [pos[0], pos[1], 2];
return false;
});
We then tell the window to call the drawGame method when it's ready to begin animating our Canvas, and we're finished with the onload method.
requestAnimationFrame(drawGame);
};
Drawing the menu
The menu screen is drawn with the drawMenu function, which begins by setting the font we wish to use to draw the difficulties names, and creates a variable y with the vertical offset at which we wish to begin drawing the difficulties:
function drawMenu()
{
ctx.textAlign = 'center';
ctx.font = "bold 20pt sans-serif";
ctx.fillStyle = "#000000";
var y = 100;
The difficulties are looped over, and we check to see if the vertical position of the cursor falls on the current difficulty. If it does, we change the colour for this text before rendering its name, and change it back afterwards. Otherwise, it draws with the current fillStyle. We also use the position information to calculate the menuBox start and end values for the difficulty before increasing the y offset ready for the next entry:
for(var d in difficulties)
{
var mouseOver = (mouseState.y>=(y-20) && mouseState.y<=(y+10));
if(mouseOver) { ctx.fillStyle = "#000099"; }
difficulties[d].menuBox = [y-20, y+10];
ctx.fillText(difficulties[d].name, 150, y);
y+= 80;
if(mouseOver) { ctx.fillStyle = "#000000"; }
}
Once the difficulty titles are drawn, we reset the y offset to slightly after the first name will have been drawn, and loop again, this time to show the bestTime values for each difficulty. If there is not a bestTime value yet, we tell the player so.
for(var d in difficulties)
{
if(difficulties[d].bestTime==0)
{
ctx.fillText("No best time", 150, y);
}
Otherwise, we calculate the bestTime minutes and seconds and show the player:
else
{
var t = difficulties[d].bestTime;
var bestTime = "";
if((t/1000)>=60)
{
bestTime = Math.floor((t/1000)/60) + ":";
t = t % (60000);
}
bestTime+= Math.floor(t/1000) +
"." + (t%1000);
ctx.fillText("Best time " + bestTime, 150, y);
}
We increase the y offset each loop by the same amount as in the first loop, and we're done with drawing the menu.
y+= 80;
}
}
Drawing the game playing, won, or lost
To draw the playing screen, as well as the won or lost screens, we use the drawPlaying method, which begins by creating some temporary variables to improve readability, such as halfW and halfH (half tile width and height), and a reference to the current difficulty information (cDiff):
function drawPlaying()
{
var halfW = gameState.tileW / 2;
var halfH = gameState.tileH / 2;
var cDiff = difficulties[gameState.difficulty];
The font is then set and used to draw the current game difficulty level name and the link to return to the menu screen.
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillStyle = "#000000";
ctx.font = "12px sans-serif";
ctx.fillText(cDiff.name, 150, 20);
ctx.fillText("Return to menu", 150, 390);
If the game has not been lost, we'll show how many mines are on the grid:
if(gameState.screen!='lost')
{
ctx.textAlign = "left";
ctx.fillText("Mines: " + cDiff.mines, 10, 40);
We then choose either the currently elapsed gameTime, or the gameState.timeTaken, depending on whether the game is still ongoing or has been won, and format the time to show minutes and seconds.
var whichT = (gameState.screen=='won' ?
gameState.timeTaken : gameTime);
var t = '';
if((gameTime / 1000) > 60)
{
t = Math.floor((whichT / 1000) / 60) + ':';
}
var s = Math.floor((whichT / 1000) % 60);
t+= (s > 9 ? s : '0' + s);
ctx.textAlign = "right";
ctx.fillText("Time: " + t, 290, 40);
}
If the game is no longer playing (it has been lost or won), we show text stating so slightly above the game grid.
if(gameState.screen=='lost' || gameState.screen=='won')
{
ctx.textAlign = "center";
ctx.font = "bold 20px sans-serif";
ctx.fillText(
(gameState.screen=='lost' ?
"Game Over" : "Cleared!"), 150, offsetY - 15);
}
Next, we draw a boundary rect around the outside of the game grid area, and set the font we'll be using to draw each Tile.
ctx.strokeStyle = "#999999";
ctx.strokeRect(offsetX, offsetY,
(cDiff.width * gameState.tileW),
(cDiff.height * gameState.tileH));
ctx.font = "bold 10px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
We then loop over all the Tile objects in the grid array, and calculate the pixel position of the current Tile (px, py):
for(var i in grid)
{
var px = offsetX + (grid[i].x * gameState.tileW)
;
var py = offsetY + (grid[i].y * gameState.tileH);
If the game has been lost and this tile contained a mine, we draw a red square for the Tile and place and x on it.
if(gameState.screen=='lost' && grid[i].hasMine)
{
ctx.fillStyle = "#ff0000";
ctx.fillRect(px, py,
gameState.tileW, gameState.tileH);
ctx.fillStyle = "#000000";
ctx.fillText("x", px + halfW, py + halfH);
}
If the Tile does not have a mine but is visible, we draw a light grey square, and show the danger level if it is greater than 0.
else if(grid[i].currentState=='visible')
{
ctx.fillStyle = "#dddddd";
if(grid[i].danger)
{
ctx.fillStyle = "#000000";
ctx.fillText(grid[i].danger, px + halfW, py + halfH);
}
}
When the Tile is not revealed, we'll just draw a darker grey square and stroke it to give it a border.
else
{
ctx.fillStyle = "#cccccc";
ctx.fillRect(px, py,
gameState.tileW, gameState.tileH);
ctx.strokeRect(px, py,
gameState.tileW, gameState.tileH);
if(grid[i].currentState=='flagged')
{
ctx.fillStyle = "#0000cc";
ctx.fillText("P", px + halfW, py + halfH);
}
}
}
}
That was all the logic we needed for drawing our playing, won and lost screens, so we can close the method and our open if blocks.
Main Game loop
Our main drawGame method checks that the drawing context for the Canvas exists, and then updates the gameTime and calculates the timeElapsed since the method last executed.
function drawGame()
{
if(ctx==null) { return; }
// Frame & update related timing
var currentFrameTime = Date.now();
if(lastFrameTime==0) { lastFrameTime = currentFrameTime; }
var timeElapsed = currentFrameTime - lastFrameTime;
gameTime+= timeElapsed;
With the gameTime update, we process our game logic with the updateGame method:
updateGame();
We also calculate and update the variables we're using to calculate the frame rate of the game.
var sec = Math.floor(Date.now()/1000);
if(sec!=currentSecond)
{
currentSecond = sec;
framesLastSecond = frameCount;
frameCount = 1;
}
else { frameCount++; }
The whole Canvas is then cleared to a light grey-blue, and the drawing method is called depending on the current gameState.screen value.
ctx.fillStyle = "#ddddee";
ctx.fillRect(0, 0, 300, 400);
if(gameState.screen=='menu') { drawMenu(); }
else { drawPlaying(); }
Setting the font we wish to use, we then show the frame rate in the top-left corner of the Canvas:
ctx.textAlign = "left";
ctx.font = "10pt sans-serif";
ctx.fillStyle = "#000000";
ctx.fillText("Frames: " + framesLastSecond, 5, 15);
The lastFrameTime variable is then updated to the time of the current frame (in milliseconds), and we tell the browser to call this method again when it's ready for us to do another animation update before the function is closed.
lastFrameTime = currentFrameTime;
requestAnimationFrame(drawGame);
}
Mouse position helper
A helper function, realPos, is also used to convert the x, y mouse coordinates in the event listeners from their position on the document to their true position on the Canvas elements, by removing the x, y offset of the Canvas relative to the top-left of the document from the values the events provide:
function realPos(x, y)
{
var p = document.getElementById('game');
do {
x-= p.offsetLeft;
y-= p.offsetTop;
p = p.offsetParent;
} while(p!=null);
return [x, y];
}
Javascript source code
var ctx = null;
var gameTime = 0, lastFrameTime = 0;
var currentSecond = 0, frameCount = 0, framesLastSecond = 0;
var offsetX = 0, offsetY = 0;
var grid = [];
var mouseState = {
x : 0,
y : 0,
click : null
};
var gameState = {
difficulty : 'easy',
screen : 'menu',
newBest : false,
timeTaken : 0,
tileW : 20,
tileH : 20
};
var difficulties = {
easy : {
name : "Easy",
width : 10,
height : 10,
mines : 10,
bestTime : 0,
menuBox : [0,0]
},
medium : {
name : "Medium",
width : 12,
height : 12,
mines : 20,
bestTime : 0,
menuBox : [0,0]
},
hard : {
name : "Hard",
width : 15,
height : 15,
mines : 50,
bestTime : 0,
menuBox : [0,0]
}
};
function Tile(x, y)
{
this.x = x;
this.y = y;
this.hasMine = false;
this.danger = 0;
this.currentState = 'hidden';
}
Tile.prototype.calcDanger = function()
{
var cDiff = difficulties[gameState.difficulty];
for(var py = this.y - 1; py <= this.y + 1; py++)
{
for(var px = this.x - 1; px <= this.x + 1; px++)
{
if(px==this.x && py==this.y) { continue; }
if(px < 0 || py < 0 ||
px >= cDiff.width ||
py >= cDiff.height)
{
continue;
}
if(grid[((py*cDiff.width)+px)].hasMine)
{
this.danger++;
}
}
}
};
Tile.prototype.flag = function()
{
if(this.currentState=='hidden') { this.currentState = 'flagged'; }
else if(this.currentState=='flagged') { this.currentState = 'hidden'; }
};
Tile.prototype.click = function()
{
if(this.currentState!='hidden') { return; }
if(this.hasMine) { gameOver(); }
else if(this.danger>0) { this.currentState = 'visible'; }
else
{
this.currentState = 'visible';
this.revealNeighbours();
}
checkState();
};
Tile.prototype.revealNeighbours = function()
{
var cDiff = difficulties[gameState.difficulty];
for(var py = this.y - 1; py <= this.y + 1; py++)
{
for(var px = this.x - 1; px <= this.x + 1; px++)
{
if(px==this.x && py==this.y) { continue; }
if(px < 0 || py < 0 ||
px >= cDiff.width ||
py >= cDiff.height)
{
continue;
}
var idx = ((py * cDiff.width) + px);
if(grid[idx].currentState=='hidden')
{
grid[idx].currentState = 'visible';
if(grid[idx].danger==0)
{
grid[idx].revealNeighbours();
}
}
}
}
};
function checkState()
{
for(var i in grid)
{
if(grid[i].hasMine==false && grid[i].currentState!='visible')
{
return;
}
}
gameState.timeTaken = gameTime;
var cDiff = difficulties[gameState.difficulty];
if(cDiff.bestTime==0 ||
gameTime < cDiff.bestTime)
{
gameState.newBest = true;
cDiff.bestTime = gameTime;
}
gameState.screen = 'won';
}
function gameOver()
{
gameState.screen = 'lost';
}
function startLevel(diff)
{
gameState.newBest = false;
gameState.timeTaken = 0;
gameState.difficulty = diff;
gameState.screen = 'playing';
gameTime = 0;
lastFrameTime = 0;
grid.length = 0;
var cDiff = difficulties[diff];
offsetX = Math.floor((document.getElementById('game').width -
(cDiff.width * gameState.tileW)) / 2);
offsetY = Math.floor((document.getElementById('game').height -
(cDiff.height * gameState.tileH)) / 2);
for(var py = 0; py < cDiff.height; py++)
{
for(var px = 0; px < cDiff.width; px++)
{
var idx = ((py * cDiff.width) + px);
grid.push(new Tile(px, py));
}
}
var minesPlaced = 0;
while(minesPlaced < cDiff.mines)
{
var idx = Math.floor(Math.random() * grid.length);
if(grid[idx].hasMine) { continue; }
grid[idx].hasMine = true;
minesPlaced++;
}
for(var i in grid) { grid[i].calcDanger(); }
}
function updateGame()
{
if(gameState.screen=='menu')
{
if(mouseState.click!=null)
{
for(var i in difficulties)
{
if(mouseState.y >= difficulties[i].menuBox[0] &&
mouseState.y <= difficulties[i].menuBox[1])
{
startLevel(i);
break;
}
}
mouseState.click = null;
}
}
else if(gameState.screen=='won' || gameState.screen=='lost')
{
if(mouseState.click!=null)
{
gameState.screen = 'menu';
mouseState.click = null;
}
}
else
{
if(mouseState.click!=null)
{
var cDiff = difficulties[gameState.difficulty];
if(mouseState.click[0]>=offsetX &&
mouseState.click[1]>=offsetY &&
mouseState.click[0]<(offsetX + (cDiff.width * gameState.tileW)) &&
mouseState.click[1]<(offsetY + (cDiff.height * gameState.tileH)))
{
var tile = [
Math.floor((
mouseState.click[0]-offsetX)/gameState.tileW),
Math.floor((mouseState.click[1]-offsetY)/gameState.tileH)
];
if(mouseState.click[2]==1)
{
grid[((tile[1] * cDiff.width) + tile[0])].click();
}
else
{
grid[((tile[1] * cDiff.width) + tile[0])].flag();
}
}
else if(mouseState.click[1]>=380)
{
gameState.screen = 'menu';
}
mouseState.click = null;
}
}
}
window.onload = function()
{
ctx = document.getElementById('game').getContext('2d');
// Event listeners
document.getElementById('game').addEventListener('click', function(e) {
var pos = realPos(e.pageX, e.pageY);
mouseState.click = [pos[0], pos[1], 1];
});
document.getElementById('game').addEventListener('mousemove',
function(e) {
var pos = realPos(e.pageX, e.pageY);
mouseState.x = pos[0];
mouseState.y = pos[1];
});
document.getElementById('game').addEventListener('contextmenu',
function(e) {
e.preventDefault();
var pos = realPos(e.pageX, e.pageY);
mouseState.click = [pos[0], pos[1], 2];
return false;
});
requestAnimationFrame(drawGame);
};
function drawMenu()
{
ctx.textAlign = 'center';
ctx.font = "bold 20pt sans-serif";
ctx.fillStyle = "#000000";
var y = 100;
for(var d in difficulties)
{
var mouseOver = (mouseState.y>=(y-20) && mouseState.y<=(y+10));
if(mouseOver) { ctx.fillStyle = "#000099"; }
difficulties[d].menuBox = [y-20, y+10];
ctx.fillText(difficulties[d].name, 150, y);
y+= 80;
if(mouseOver) { ctx.fillStyle = "#000000"; }
}
var y = 120;
ctx.font = "italic 12pt sans-serif";
for(var d in difficulties)
{
if(difficulties[d].bestTime==0)
{
ctx.fillText("No best time", 150, y);
}
else
{
var t = difficulties[d].bestTime;
var bestTime = "";
if((t/1000)>=60)
{
bestTime = Math.floor((t/1000)/60) + ":";
t = t % (60000);
}
bestTime+= Math.floor(t/1000) +
"." + (t%1000);
ctx.fillText("Best time " + bestTime, 150, y);
}
y+= 80;
}
}
function drawPlaying()
{
var halfW = gameState.tileW / 2;
var halfH = gameState.tileH / 2;
var cDiff = difficulties[gameState.difficulty];
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillStyle = "#000000";
ctx.font = "12px sans-serif";
ctx.fillText(cDiff.name, 150, 20);
ctx.fillText("Return to menu", 150, 390);
if(gameState.screen!='lost')
{
ctx.textAlign = "left";
ctx.fillText("Mines: " + cDiff.mines, 10, 40);
var whichT = (gameState.screen=='won' ?
gameState.timeTaken : gameTime);
var t = '';
if((gameTime / 1000) > 60)
{
t = Math.floor((whichT / 1000) / 60) + ':';
}
var s = Math.floor((whichT / 1000) % 60);
t+= (s > 9 ? s : '0' + s);
ctx.textAlign = "right";
ctx.fillText("Time: " + t, 290, 40);
}
if(gameState.screen=='lost' || gameState.screen=='won')
{
ctx.textAlign = "center";
ctx.font = "bold 20px sans-serif";
ctx.fillText(
(gameState.screen=='lost' ?
"Game Over" : "Cleared!"), 150, offsetY - 15);
}
ctx.strokeStyle = "#999999";
ctx.strokeRect(offsetX, offsetY,
(cDiff.width * gameState.tileW),
(cDiff.height * gameState.tileH));
ctx.font = "bold 10px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for(var i in grid)
{
var px = offsetX + (grid[i].x * gameState.tileW);
var py = offsetY + (grid[i].y * gameState.tileH);
if(gameState.screen=='lost' && grid[i].hasMine)
{
ctx.fillStyle = "#ff0000";
ctx.fillRect(px, py,
gameState.tileW, gameState.tileH);
ctx.fillStyle = "#000000";
ctx.fillText("x", px + halfW, py + halfH);
}
else if(grid[i].currentState=='visible')
{
ctx.fillStyle = "#dddddd";
if(grid[i].danger)
{
ctx.fillStyle = "#000000";
ctx.fillText(grid[i].danger, px + halfW, py + halfH);
}
}
else
{
ctx.fillStyle = "#cccccc";
ctx.fillRect(px, py,
gameState.tileW, gameState.tileH);
ctx.strokeRect(px, py,
gameState.tileW, gameState.tileH);
if(grid[i].currentState=='flagged')
{
ctx.fillStyle = "#0000cc";
ctx.fillText("P", px + halfW, py + halfH);
}
}
}
}
function drawGame()
{
if(ctx==null) { return; }
// Frame & update related timing
var currentFrameTime = Date.now();
if(lastFrameTime==0) { lastFrameTime = currentFrameTime; }
var timeElapsed = currentFrameTime - lastFrameTime;
gameTime+= timeElapsed;
// Update game
updateGame();
// Frame counting
var sec = Math.floor(Date.now()/1000);
if(sec!=currentSecond)
{
currentSecond = sec;
framesLastSecond = frameCount;
frameCount = 1;
}
else { frameCount++; }
// Clear canvas
ctx.fillStyle = "#ddddee";
ctx.fillRect(0, 0, 300, 400);
if(gameState.screen=='menu') { drawMenu(); }
else { drawPlaying(); }
// Draw the frame count
ctx.textAlign = "left";
ctx.font = "10pt sans-serif";
ctx.fillStyle = "#000000";
ctx.fillText("Frames: " + framesLastSecond, 5, 15);
// Update the lastFrameTime
lastFrameTime = currentFrameTime;
// Wait for the next frame...
requestAnimationFrame(drawGame);
}
function realPos(x, y)
{
var p = document.getElementById('game');
do {
x-= p.offsetLeft;
y-= p.offsetTop;
p = p.offsetParent;
} while(p!=null);
return [x, y];
}