/** * Constructor for a Minesweeper game object. * * @param {String} containerId HTML ID for an empty element to contain this * minesweeper game's display. */ var Minesweeper = function(containerId) { this.mainContainer = $(containerId); this.MINE = '\uD83D\uDCA3'; // bomb emoji this.FLAG = '\uD83D\uDEA9'; // triangle flag emoji this.MAX_TIME = 999; this.SETTINGS = { BEGINNER: { rows: 8, cols: 8, mines: 10 }, INTERMEDIATE: { rows: 16, cols: 16, mines: 40 }, EXPERT: { rows: 16, cols: 30, mines: 99 } }; }; /** * Attaches Minesweeper display to screen and sets up click listeners. */ Minesweeper.prototype.init = function(settings) { this.debug = false; this.settings = settings; this.won = false; this.lost = false; this.firstClickOccurred = false; this.cellsRevealed = 0; this.cellsFlagged = 0; this.cellsToReveal = (settings.rows * settings.cols) - settings.mines; this.elapsedTime = 0; clearInterval(this.timeInterval); this.misclickCount = 0; this.initField(settings.rows, settings.cols); this.initDisplay(); }; /** * Only generates a 2-D array with the given rows and cols as dimensions; does * not actually add mines to field. Mines are added after the first click. * * Each cell is an object with two properties: val and flagged. Each val is * either MINE or a number representing how many MINEs are adjacent to that * cell. flagged starts out as false and is toggled when the user marks a cell * as a flag. */ Minesweeper.prototype.initField = function(rows, cols) { // Initialize 2-D array this.field = []; for (var i = 0; i < rows; i++) { var row = new Array(cols); for (var j = 0; j < cols; j++) { row[j] = { val: 0, flagged: false }; } this.field.push(row); } }; /** * Updates this.field to contain the number of mines requested. * * Should be called after the user has made their first click so that mines can * be placed while avoiding that location. */ Minesweeper.prototype.setMines = function(mines, firstClickRow, firstClickCol) { var field = this.field; var rows = field.length; var cols = field[0].length; // Must count mines actually planted so that mines are not placed in // previously selected locations. var minesPlanted = 0; while (minesPlanted != mines) { var row = Math.floor(Math.random() * rows); var col = Math.floor(Math.random() * cols); // Cannot use cell if the user just clicked it or if it was already a mine if ((row == firstClickRow && col == firstClickCol) || (field[row][col] && field[row][col].val == this.MINE)) continue; field[row][col] = { val: this.MINE, flagged: false }; minesPlanted++; } // Fill field with mine counts for (var i = 0; i < rows; i++) { for (var j = 0; j < cols; j++) { field[i][j].val = this.countAdjacentMines(i, j); } } // console.log(field); }; /** * For a given coordinate, returns the number of mines it is next to. */ Minesweeper.prototype.countAdjacentMines = function(row, col) { var field = this.field; if (field[row][col] && field[row][col].val == this.MINE) return this.MINE; var count = 0; var neighbors = this.getNeighbors(row, col); for (var i = 0; i < neighbors.length; i++) { var r = neighbors[i].row; var c = neighbors[i].col; if (field[r][c] && field[r][c].val == this.MINE) count++; } return count; }; /** * Every cell calls this click handler the first time it's clicked. If it was * the first cell to be clicked (flagged cells do not count), this finally adds * mines to the screen and excludes this click. Otherwise, a first click was * already made, so we can just change this cell's click handler for the * duration of the game. */ Minesweeper.prototype.firstClickHandler = function(row, col) { if (this.field[row][col].flagged) return; if (this.firstClickOccurred) { var that = this; var cell = this.getCell(row, col); cell.unbind('click'); cell.click(function(event) { that.mainClickHandler(row, col); }); } else { this.firstClickOccurred = true; this.setMines(this.settings.mines, row, col); this.initDisplay(); this.startTimer(); } this.mainClickHandler(row, col); }; /** * Adds HTML table for this object's already initialized field. Control panel is * added after mines are added in order to set width correctly. */ Minesweeper.prototype.initDisplay = function() { // for resetting the game if (this.display) this.display.empty(); this.display = $('#game-container'); this.gameTable = $(document.createElement('table')); this.gameTable.addClass('no-highlight inset'); this.gameTable.attr('cellspacing', 0); var that = this; this.field.forEach(function(row, r) { var tr = document.createElement('tr'); row.forEach(function(cell, c) { var td = $(document.createElement('td')); td.html(that.createElemForValue(that.field[r][c].val)); // left click td.click(function(event) { that.firstClickHandler(r, c); }); // right click td.get(0).oncontextmenu = function(event) { event.preventDefault(); that.toggleFlag(r, c); }; // styling td.addClass('cell outset'); if (that.debug) td.addClass('debug'); if (that.field[r][c].flagged) { td.addClass('flagged'); td.html(that.FLAG); } tr.appendChild(td.get(0)); }); that.gameTable.append(tr); }); this.display.prepend(this.gameTable); this.initControlPanel(); this.initHelper(); }; /** * Control panel is the top portion that has the timer, mine count, and reset * button. This adds those components to the top of the container. Must be * called after the game cells are added so that the total width can be used. */ Minesweeper.prototype.initControlPanel = function() { var that = this; this.controlPanel = $(document.createElement('div')); this.controlPanel.resetButton = $(document.createElement('div')); this.controlPanel.flagCount = $(document.createElement('div')); this.controlPanel.timer = $(document.createElement('div')); var controlPanel = this.controlPanel; var resetButton = this.controlPanel.resetButton; resetButton.html('🙂'); var flagCount = this.controlPanel.flagCount; var timer = this.controlPanel.timer; controlPanel.append(resetButton); this.display.prepend(controlPanel); // overall panel styling controlPanel.addClass('control-panel inset'); // debug link styling var debugLink = $(document.createElement('a')); debugLink.html('debug'); debugLink.click(this.toggleDebug); debugLink.addClass('debug-link'); this.display.append(debugLink); // reset button styling and clicks resetButton.addClass('reset-button outset'); resetButton.css('margin-left', (controlPanel.innerWidth() - resetButton.width()) / 2); resetButton.click(function(event) { that.init(that.settings); }); // counter for mines left controlPanel.prepend(flagCount); flagCount.addClass('counter'); flagCount.html(this.zeroFill(this.settings.mines, 2)); flagCount.css('float', 'left'); // counter for time elapsed controlPanel.prepend(timer); timer.addClass('counter'); timer.html(this.zeroFill(0)); timer.css('float', 'right'); timer.css('text-align', 'right'); }; Minesweeper.prototype.initHelper = function() { }; /** * Returns the HTML element for this cell. */ Minesweeper.prototype.getCell = function(row, col) { return $(this.gameTable[0].rows[row].cells[col]); }; /** * Click (left) usually means reveal the cell selected. For revealed cells, * behavior depends on its neighbors. If the number of unrevealed neighbors is * equal to this cell's value, flag all of them. If the user has already flagged * exactly as many mines as this cell's value, expand the rest (potentially * resulting in a loss). */ Minesweeper.prototype.mainClickHandler = function(row, col) { var field = this.field; // do nothing if already lost or if a flagged cell is clicked if (this.gameEnded() || field[row][col].flagged) return; if (!this.getCell(row, col).hasClass('revealed')) { this.revealCell(row, col); } else if (this.flagAllNeighborsRequested(row, col)) { var neighbors = this.getNeighbors(row, col); for (var i = 0; i < neighbors.length; i++) { this.toggleFlag(neighbors[i].row, neighbors[i].col, true); } } else if (this.expandRequested(row, col)) { var neighbors = this.getNeighbors(row, col); for (var i = 0; i < neighbors.length; i++) { this.revealCell(neighbors[i].row, neighbors[i].col); if (this.gameEnded()) return; } } else { this.misclickCount++; this.misclickDisplay.html(this.misclickCount); } }; /** * Reveals cell at (row, col), then recursively expands its neighbors if it * doesn't have any neighboring mines (i.e., its field value is 0). * * It is possible to lose if the user manually tries to expand all neighbors on * a cell by incorrectly flagging neighboring cells (see mainClickHandler); * otherwise, it shouldn't be possible for this to result in a loss. */ Minesweeper.prototype.revealCell = function(row, col) { // base case: don't reveal flagged or already revealed cells if (this.field[row][col].flagged || this.getCell(row, col).hasClass('revealed')) return; this.revealSingleCell(row, col); // base case: stop expanding if cell is non-zero or its reveal lead to a loss if (this.gameEnded() || this.field[row][col].val != 0) return; // recursive step: reveal neighbors var neighbors = this.getNeighbors(row, col); for (var i = 0; i < neighbors.length; i++) { this.revealCell(neighbors[i].row, neighbors[i].col); if (this.gameEnded()) return; } }; /** * Updates styling for a cell so that it shows its number on the screen. * Checks whether this reveal resulted in a win or loss. */ Minesweeper.prototype.revealSingleCell = function(row, col) { var displayCell = this.getCell(row, col); var cell = this.field[row][col]; if (displayCell.hasClass('revealed') || cell.flagged) return; displayCell.html(this.createElemForValue(cell.val)); displayCell.addClass('revealed cell-' + (cell.val == this.MINE ? 'X' : cell.val)); displayCell.removeClass('outset'); if (this.debug) displayCell.removeClass('debug'); this.cellsRevealed++; if (cell.val == this.MINE) { this.displayLoss(); } else if (this.cellsRevealed == this.cellsToReveal) { this.displayWin(); } }; /** * Returns a list of coordinates for cells adjacent to this row and column. */ Minesweeper.prototype.getNeighbors = function(row, col) { var field = this.field; var neighbors = []; for (var r = row - 1; r <= row + 1; r++) { for (var c = col - 1; c <= col + 1; c++) { if (0 <= r && r < field.length && 0 <= c && c < field[0].length && !(r == row && c == col)) { neighbors.push({row: r, col: c}); } } } return neighbors; }; /** * Non-traditional feature: If user clicks on a revealed cell and it has exactly * as many unrevealed cells as its mine count, this returns true so that all of * its cells can be flagged immediately. */ Minesweeper.prototype.flagAllNeighborsRequested = function(row, col) { if (!this.getCell(row, col).hasClass('revealed')) return false; var unrevealedCount = 0; var neighbors = this.getNeighbors(row, col); for (var i = 0; i < neighbors.length; i++) { var neighbor = this.getCell(neighbors[i].row, neighbors[i].col); if (!neighbor.hasClass('revealed')) { unrevealedCount++; } } return unrevealedCount == this.field[row][col].val; }; /** * A valid expansion request is one where the cell clicked has already been * revealed and has exactly as many flagged neighbors as its own value. */ Minesweeper.prototype.expandRequested = function(row, col) { if (!this.getCell(row, col).hasClass('revealed')) return false; var flagCount = 0; var neighbors = this.getNeighbors(row, col); for (var i = 0; i < neighbors.length; i++) { var r = neighbors[i].row; var c = neighbors[i].col; var neighbor = this.getCell(r, c); if (!neighbor.hasClass('revealed') && this.field[r][c].flagged) { flagCount++; } } return flagCount == this.field[row][col].val; }; /** * Switches styling to display a flagged or unflagged cell. * @param {Boolean} forceFlag allows an already-flagged cell to stay flagged. */ Minesweeper.prototype.toggleFlag = function(row, col, forceFlag) { var displayCell = this.getCell(row, col); if (this.gameEnded() || displayCell.hasClass('revealed')) return; var cell = this.field[row][col]; if (!this.field[row][col].flagged) { displayCell.addClass('flagged'); displayCell.html(this.createElemForValue(this.FLAG)); this.cellsFlagged++; cell.flagged = true; } else if (!forceFlag) { displayCell.removeClass('flagged'); displayCell.html(this.createElemForValue(cell.val)); this.cellsFlagged--; cell.flagged = false; } this.controlPanel.flagCount.html(this.settings.mines - this.cellsFlagged); }; /** * Starts timer so that display clock ticks every second. */ Minesweeper.prototype.startTimer = function() { var that = this; this.timeInterval = setInterval(function() { that.controlPanel.timer.html(++that.elapsedTime); if (that.elapsedTime == that.MAX_TIME) clearInterval(that.timeInterval); }, 1000); }; /** * Stops timer and shows win message. */ Minesweeper.prototype.displayWin = function() { this.won = true; this.controlPanel.resetButton.html('😎'); clearInterval(this.timeInterval); }; /** * Reveals all unflagged mines, shows loss message, and sets this.lost to true. */ Minesweeper.prototype.displayLoss = function() { if (this.lost) return; this.lost = true; this.controlPanel.resetButton.html('😵'); // dizzy face emoji clearInterval(this.timeInterval); for (var r = 0; r < this.field.length; r++) { for (var c = 0; c < this.field[0].length; c++) { var cell = this.field[r][c]; if (cell.val == this.MINE && !cell.flagged) { this.revealSingleCell(r, c); } } } }; /** * For debugging purposes, allow the user to continue playing as if they haven't * lost, even if mines have been revealed. */ Minesweeper.prototype.gameEnded = function() { return !this.debug && (this.won || this.lost); }; /** * Returns the given value as a string padded with zeroes up to length. */ Minesweeper.prototype.zeroFill = function(value, length) { if (length === undefined) length = 3; return value; }; /** * When called, reveals (or hides) values of all cells. */ Minesweeper.prototype.toggleDebug = function() { this.debug = !this.debug; $('.cell:not(.revealed)').toggleClass('debug'); }; /** * Creates the div that must wrap cell values for min-width/height reasons. */ Minesweeper.prototype.createElemForValue = function(val) { var div = $(document.createElement('div')); if (val == this.MINE) { div.addClass('mine'); } div.html(val); return div.get(0); }