import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template"; const logArea = document.getElementById("logArea"); const log = (message) => { logArea.value += `${message}\n`; logArea.scrollTop = logArea.scrollHeight; } /** * Controls the interaction with a game of Minesweeper. */ class Game { /** * Constructs and starts a new game of Minesweeper. */ constructor() { this.canvas = document.getElementById("canvas"); this.settingsForm = document.getElementById("settingsForm"); this.widthInput = document.getElementById("settingsWidth"); this.heightInput = document.getElementById("settingsHeight"); this.minesInput = document.getElementById("settingsMines"); this.seedInput = document.getElementById("settingsSeed"); this.reset(); this.display = new Display(this.canvas, this.field); this.display.startDrawLoop(); this.settingsForm.addEventListener( "submit", event => { event.preventDefault(); this.reset(); this.display.field = this.field; } ); this.canvas.addEventListener( "mousemove", event => this.display.mouseCell = this.display.posToCell({x: event.clientX, y: event.clientY}) ); this.canvas.addEventListener( "contextmenu", event => event.preventDefault() ); this.canvas.addEventListener( "mouseup", event => { event.preventDefault(); if (!this.isAlive) return; const cell = this.display.posToCell({x: event.clientX, y: event.clientY}); switch (event.button) { case 0: if (!cell.hasFlag) { if (!this.hasClicked) { cell.firstUncover(); log("First uncover complete."); } else cell.uncover(); this.hasClicked = true; if (cell.hasMine) { this.isAlive = false; log("You died!"); } } break; case 1: cell.chord(); break; case 2: cell.flag(); break; } if (this.field.isCleared()) log("Level complete!"); } ) } /** * Resets the game, re-generating the field according to the current settings. */ reset() { this.field = new Field( +this.widthInput.value, +this.heightInput.value, +this.minesInput.value, +this.seedInput.value ); this.isAlive = true; this.hasClicked = false; log("Let's go!"); } } /** * Displays a Minesweeper field. */ class Display { /** * Constructs a new display. * * @param canvas {HTMLCanvasElement} the canvas to draw the field in * @param field {Field} the field to draw */ constructor(canvas, field) { // TODO Remove this \/ this.frameNumber = 0; this.counter = document.getElementById("counter"); window.setInterval(() => { this.counter.innerText = "" + (this.frameNumber * 4); this.frameNumber = 0; }, 250); // TODO Remove this /\ this.canvas = canvas; this.field = field; this.mouseCell = undefined; } /** * Calculates the scale, which is defined as the width and height of each (square) cell in pixels. * * @return the scale of the display */ calcScale() { const rect = this.canvas.getBoundingClientRect(); return Math.min(rect.width / this.field.width, rect.height / this.field.height); } /** * Returns the cell at the given coordinates, or `undefined` if there is no cell there. * * @param pos {{x: number, y: number}} the client-relative pixel coordinates to find the cell at * @return {Cell} the cell at the given coordinates */ posToCell(pos) { const rect = this.canvas.getBoundingClientRect(); const scale = this.calcScale(); return this.field.getCellOrElse( Math.floor((pos.x - rect.left) / scale), Math.floor((pos.y - rect.top) / scale) ); } /** * Invokes `#draw` in every animation frame of this window. */ startDrawLoop() { const cb = () => { this.draw(); this.frameNumber++; // TODO Remove this window.requestAnimationFrame(cb); }; window.requestAnimationFrame(cb); } /** * Draws the field. */ draw() { const ctx = this.canvas.getContext("2d", {alpha: false}); const rect = this.canvas.getBoundingClientRect(); const scale = this.calcScale(); // Clear ctx.save(); ctx.fillStyle = "#FFF"; ctx.fillRect(0, 0, rect.width, rect.height); ctx.restore(); // Cover cells ctx.save(); ctx.fillStyle = "#555"; for (let x = 0; x < this.field.width; x++) { for (let y = 0; y < this.field.height; y++) { const cell = this.field.getCell(x, y); if (cell.isCovered) { ctx.fillRect(x * scale, y * scale, scale, scale); } } } ctx.restore(); // Fill cells ctx.save(); ctx.fillStyle = "#000"; ctx.font = "30px serif"; ctx.textBaseline = "middle"; ctx.textAlign = "center"; for (let x = 0; x < this.field.width; x++) { for (let y = 0; y < this.field.height; y++) { const cell = this.field.getCell(x, y); const neighborMineCount = cell.getNeighborMineCount(); let contents; if (cell.isCovered) { if (cell.hasFlag) contents = "⚑"; else contents = ""; } else { if (cell.hasMine) contents = "💣"; else if (neighborMineCount === 0) contents = ""; else contents = "" + neighborMineCount; } ctx.fillText(contents, (x + 0.5) * scale, (y + 0.5) * scale, scale); } } ctx.restore(); // Create grid ctx.save(); ctx.strokeStyle = "#000"; ctx.beginPath(); for (let x = 0; x <= this.field.width; x++) { ctx.moveTo(x * scale, 0); ctx.lineTo(x * scale, this.field.height * scale); } for (let y = 0; y <= this.field.height; y++) { ctx.moveTo(0, y * scale); ctx.lineTo(this.field.width * scale, y * scale); } ctx.stroke(); ctx.restore(); // Highlight mouse cell if (this.mouseCell !== undefined) { ctx.save(); ctx.strokeStyle = "#F00"; ctx.strokeRect(this.mouseCell.x * scale, this.mouseCell.y * scale, scale, scale); ctx.restore(); } // Done } } /** * A playing field for a game of Minesweeper. */ class Field { /** * Constructs a new playing field for a game of Minesweeper. * * @param width {number} the number of cells per row in the field * @param height {number} the number of rows in the field * @param mineCount {number} the initial number of mines to place in the field * @param seed {number|undefined} the seed to generate the field with */ constructor(width, height, mineCount, seed = undefined) { this.width = width; this.height = height; const mines = Array(width * height).fill(true, 0, mineCount).fill(false, mineCount); shuffleArrayInPlace(mines, seed); this.cells = chunkifyArray( mines.map((hasMine, i) => new Cell(this, Math.floor(i / this.width), i % this.width, hasMine)), this.width ); } /** * Returns the cell at the given coordinates, or throws an error if there is no cell there. * * @param x {number} the horizontal coordinate of the cell to look up * @param y {number} the vertical coordinate of the cell to look up * @return {Cell} the cell at the given coordinates */ getCell(x, y) { if (x < 0 || x >= this.width) throw new Error(`x must be in range [0, ${this.width}), but was ${x}.`); if (y < 0 || y >= this.height) throw new Error(`y must be in range [0, ${this.height}), but was ${y}.`); return this.cells[x][y]; } /** * Returns the cell at the given coordinates, or `orElse` if there is no cell there. * * @param x {number} the horizontal coordinate of the cell to look up * @param y {number} the vertical coordinate of the cell to look up * @param orElse {*} the value to return if there is no cell at the given coordinates * @return {Cell|*} the cell at the given coordinates, or `orElse` if there is no cell there */ getCellOrElse(x, y, orElse = undefined) { const row = this.cells[x]; return row === undefined ? orElse : row[y]; } /** * Returns `true` if and only if all mineless cells have been uncovered. * * @return `true` if and only if all mineless cells have been uncovered */ isCleared() { for (let x = 0; x < this.width; x++) { for (let y = 0; y < this.height; y++) { const cell = this.getCell(x, y); if (cell.isCovered && !cell.hasMine) return false; } } return true; } } /** * A cell in a Minesweeper `Field`. */ class Cell { /** * Constructs a new cell. * * @param field {Field} the field in which this cell is located * @param x {number} the horizontal coordinate of this cell in the field * @param y {number} the vertical coordinate of this cell in the field * @param hasMine {boolean} `true` if and only if this cell contains a mine */ constructor(field, x, y, hasMine) { this.field = field; this.x = x; this.y = y; this.isCovered = true; this.hasMine = hasMine; this.hasFlag = false; } /** * Returns the `Cell`s that are adjacent to this cell. * * @return {Cell[]} the `Cell`s that are adjacent to this cell */ getNeighbors() { return [ this.field.getCellOrElse(this.x - 1, this.y - 1), this.field.getCellOrElse(this.x, this.y - 1), this.field.getCellOrElse(this.x + 1, this.y - 1), this.field.getCellOrElse(this.x - 1, this.y), this.field.getCellOrElse(this.x + 1, this.y), this.field.getCellOrElse(this.x - 1, this.y + 1), this.field.getCellOrElse(this.x, this.y + 1), this.field.getCellOrElse(this.x + 1, this.y + 1), ].filter(it => it !== undefined); } /** * Returns the number of neighbors that have a flag. * * @returns {number} the number of neighbors that have a flag */ getNeighborFlagCount() { return this.getNeighbors().filter(it => it.hasFlag).length; } /** * Returns the number of neighbors that have a mine. * * @returns {number} the number of neighbors that have a mine */ getNeighborMineCount() { return this.getNeighbors().filter(it => it.hasMine).length; } /** * Chords this cell, i.e. if this cell is covered and the number of neighboring flags equals the number in this * cell, then all unflagged neighbors are uncovered. */ chord() { if (this.isCovered) return; if (this.getNeighborMineCount() !== this.getNeighborFlagCount()) return; this.getNeighbors() .filter(it => it.isCovered && !it.hasFlag) .forEach(it => it.uncover()); } /** * Uncovers this cell as in `#uncover`, but adjacent 0-mine cells are also uncovered and if this cell contains a * mine the mine is moved to the first cell without a mine, starting from the top-left moving in a horizontal * scanning fashion. */ firstUncover() { if (this.hasMine) { this.hasMine = false; for (let y = 0; y < this.field.height; y++) { for (let x = 0; x < this.field.width; x++) { if (x === this.x && y === this.y) continue; const cell = this.field.getCell(x, y); if (!cell.hasMine) { cell.hasMine = true; break; } } } } this.getNeighbors() .filter(it => it.getNeighborMineCount() === 0 && !it.hasMine && !it.hasFlag) .forEach(it => it.uncover()); } /** * Adds or removes a flag at this cell. */ flag() { if (!this.isCovered) return; this.hasFlag = !this.hasFlag; } /** * Uncovers this cell, revealing the contents beneath. */ uncover() { if (!this.isCovered) return; this.isCovered = false; this.hasFlag = false; if (!this.hasMine && this.getNeighborMineCount() === 0) this.chord(); } } /** * Shuffles the given array in-place. * * @param array {*[]} the array to shuffle * @param seed {number|undefined} the seed for the random number generator * @returns {*[]} the array that was given to this function to shuffle */ function shuffleArrayInPlace(array, seed = undefined) { const engine = Random.engines.mt19937(); engine.autoSeed(); if (seed !== undefined) engine.seed(seed); return new Random(engine).shuffle(array); } /** * Slices `array` into chunks of `chunkSize` elements each. * * If `array` does not contain a multiple of `chunkSize` elements, the last chunk will contain fewer elements. * * @param array {*[]} the array to chunkify * @param chunkSize {number} the size of each chunk * @returns {*[]} an array of the extracted chunks */ function chunkifyArray(array, chunkSize) { const chunks = []; for (let i = 0; i < array.length; i += chunkSize) chunks.push(array.slice(i, i + chunkSize)); return chunks; } doAfterLoad(() => { // Initialize template $("#nav").appendChild(nav("/Tools/Minesweeper/")); $("#header").appendChild(header({ title: "Minesweeper", description: "Just Minesweeper!" })); $("#footer").appendChild(footer({ author: "Felix W. Dekker", authorURL: "https://fwdekker.com/", license: "MIT License", licenseURL: "https://git.fwdekker.com/FWDekker/minesweeper/src/branch/master/LICENSE", vcs: "git", vcsURL: "https://git.fwdekker.com/FWDekker/minesweeper/", version: "v%%VERSION_NUMBER%%" })); $("main").style.display = null; // Initialize game const urlParams = new URLSearchParams(window.location.search); document.getElementById("settingsSeed").value = urlParams.get("seed") === null ? "" + Math.floor(Math.random() * 1000000000000) : urlParams.get("seed"); new Game(); });