1008 lines
33 KiB
JavaScript
1008 lines
33 KiB
JavaScript
import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template";
|
|
import random from "random";
|
|
import seedrandom from "seedrandom";
|
|
import "fork-awesome/css/fork-awesome.css";
|
|
|
|
|
|
/**
|
|
* Controls the interaction with a game of Minesweeper.
|
|
*/
|
|
class Game {
|
|
/**
|
|
* Constructs and starts a new game of Minesweeper.
|
|
*/
|
|
constructor() {
|
|
this.canvas = $("#canvas");
|
|
|
|
this.solveForm = $("#solveForm");
|
|
this.controlForm = $("#controlForm");
|
|
this.displayScale = $("#displayScale");
|
|
|
|
this.settingsForm = $("#settingsForm");
|
|
this.widthInput = $("#settingsWidth");
|
|
this.heightInput = $("#settingsHeight");
|
|
this.minesInput = $("#settingsMines");
|
|
this.seedInput = $("#settingsSeed");
|
|
|
|
this.reset();
|
|
this.display = new Display(this.canvas, this.field);
|
|
this.display.setScale(+this.displayScale.value);
|
|
this.display.startDrawLoop();
|
|
|
|
this.leftDown = false;
|
|
this.rightDown = false;
|
|
this.holdsAfterChord = false;
|
|
|
|
|
|
this.solveForm.addEventListener(
|
|
"submit",
|
|
event => {
|
|
event.preventDefault();
|
|
new Solver().solve(this.field);
|
|
}
|
|
);
|
|
this.controlForm.addEventListener(
|
|
"submit",
|
|
event => event.preventDefault()
|
|
);
|
|
this.displayScale.addEventListener(
|
|
"change",
|
|
event => {
|
|
event.preventDefault();
|
|
this.display.setScale(+this.displayScale.value);
|
|
}
|
|
);
|
|
|
|
this.settingsForm.addEventListener(
|
|
"submit",
|
|
event => {
|
|
event.preventDefault();
|
|
if (+this.widthInput.value * +this.heightInput.value < +this.minesInput.value + 9) {
|
|
window.alert("Field must contain at least 9 empty squares.")
|
|
return;
|
|
}
|
|
|
|
this.reset();
|
|
this.display.field = this.field;
|
|
this.display.setScale(+this.displayScale.value);
|
|
}
|
|
);
|
|
this.canvas.addEventListener(
|
|
"mousemove",
|
|
event => this.display.mouseSquare = this.display.posToSquare({x: event.clientX, y: event.clientY})
|
|
);
|
|
this.canvas.addEventListener(
|
|
"mouseleave",
|
|
_ => {
|
|
this.display.mouseSquare = null;
|
|
this.leftDown = false;
|
|
this.rightDown = false;
|
|
this.holdsAfterChord = false;
|
|
this.display.mouseHoldChord = false;
|
|
}
|
|
);
|
|
this.canvas.addEventListener(
|
|
"contextmenu",
|
|
event => event.preventDefault()
|
|
);
|
|
this.canvas.addEventListener(
|
|
"mousedown",
|
|
event => {
|
|
event.preventDefault()
|
|
|
|
const square = this.display.posToSquare({x: event.clientX, y: event.clientY});
|
|
switch (event.button) {
|
|
case 0:
|
|
this.leftDown = true;
|
|
break;
|
|
case 2:
|
|
if (!this.leftDown && square !== null) square.flag();
|
|
|
|
this.rightDown = true;
|
|
break;
|
|
}
|
|
|
|
this.display.mouseHoldChord = this.leftDown && this.rightDown;
|
|
}
|
|
);
|
|
this.canvas.addEventListener(
|
|
"mouseup",
|
|
event => {
|
|
event.preventDefault();
|
|
|
|
const square = this.display.posToSquare({x: event.clientX, y: event.clientY});
|
|
switch (event.button) {
|
|
case 0:
|
|
if (square !== null && this.leftDown && this.rightDown)
|
|
square.chord();
|
|
else if (square !== null && !this.holdsAfterChord && this.leftDown)
|
|
square.uncover();
|
|
|
|
this.leftDown = false;
|
|
this.holdsAfterChord = this.rightDown;
|
|
break;
|
|
case 1:
|
|
if (square !== null) square.chord();
|
|
break;
|
|
case 2:
|
|
if (square !== null && this.leftDown && this.rightDown)
|
|
square.chord();
|
|
|
|
this.rightDown = false;
|
|
this.holdsAfterChord = this.leftDown;
|
|
break;
|
|
}
|
|
|
|
this.display.mouseHoldChord = this.leftDown && this.rightDown;
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* 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
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A solver for a game of Minesweeper.
|
|
*/
|
|
class Solver {
|
|
/**
|
|
* Solves the given field as far as the algorithm is able to.
|
|
*
|
|
* @param field {Field} the field to solve
|
|
*/
|
|
solve(field) {
|
|
if (!field.started)
|
|
field.getSquareOrElse(Math.floor(field.width / 2), Math.floor(field.height / 2)).uncover();
|
|
|
|
let flagCount = -1;
|
|
let coveredCount = -1;
|
|
while (true) {
|
|
this.bigStep(field);
|
|
|
|
const newFlagCount = field.squareList.filter(it => it.hasFlag).length;
|
|
const newCoveredCount = field.coveredRemaining;
|
|
if (newFlagCount === flagCount && newCoveredCount === coveredCount)
|
|
break;
|
|
|
|
flagCount = newFlagCount;
|
|
coveredCount = newCoveredCount;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Solves the given field given only the information currently available, without considering the information that
|
|
* is gained from the actions performed by this function.
|
|
*
|
|
* @param field {Field} the field to solve
|
|
*/
|
|
bigStep(field) {
|
|
if (!field.started || field.won || field.lost) return;
|
|
|
|
const knowns = field.squareList
|
|
.filter(it => !it.isCovered)
|
|
.filter(it => it.getNeighborCount(it => it.isCovered && !it.hasFlag));
|
|
const neighs = Array.from(new Set(
|
|
knowns.reduce((neighs, square) => neighs.concat(square.getNeighbors()), [])
|
|
)).filter(it => it.isCovered && !it.hasFlag);
|
|
|
|
if (knowns.length === 0 || neighs.length === 0) return;
|
|
|
|
const matrix = [];
|
|
// TODO Add row for remaining mines
|
|
knowns.forEach(square => {
|
|
const row = Array(neighs.length).fill(0);
|
|
square.getNeighbors()
|
|
.filter(it => it.isCovered && !it.hasFlag)
|
|
.forEach(it => row[neighs.indexOf(it)] = 1);
|
|
|
|
row.push(square.getNeighborCount(it => it.hasMine) - square.getNeighborCount(it => it.hasFlag));
|
|
matrix.push(row);
|
|
});
|
|
|
|
const system = new Matrix(matrix).solveBinary();
|
|
|
|
system.forEach((it, i) => {
|
|
const square = neighs[i];
|
|
if (it === 0) square.uncover();
|
|
else if (it === 1) square.flag();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A matrix of numbers.
|
|
*/
|
|
class Matrix {
|
|
/**
|
|
* Constructs a new matrix from the given numbers.
|
|
*
|
|
* @param cells {number[][]} an array of rows of numbers
|
|
*/
|
|
constructor(cells) {
|
|
if (cells.length === 0) throw new Error("Matrix must have at least 1 row.");
|
|
if (cells[0].length === 0) throw new Error("Matrix must have at least 1 column.");
|
|
|
|
this.cells = cells;
|
|
this.rowCount = this.cells.length;
|
|
this.colCount = this.cells[0].length;
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the `row`th row of numbers.
|
|
*
|
|
* @param row {number} the index of the row to return
|
|
* @returns {number[]} the `row`th row of numbers
|
|
*/
|
|
getRow(row) {
|
|
if (row < 0 || row >= this.rowCount) throw new Error(`Row must be in range [0, ${this.rowCount}) but was ${row}.`);
|
|
|
|
return this.cells[row];
|
|
}
|
|
|
|
/**
|
|
* Returns the `col`th column of numbers.
|
|
*
|
|
* @param col {number} the index of the column to return
|
|
* @returns {number[]} the `col`th column of numbers
|
|
*/
|
|
getCol(col) {
|
|
if (col < 0 || col >= this.colCount) throw new Error(`Col must be in range [0, ${this.colCount}) but was ${col}.`);
|
|
|
|
return this.cells.map(row => row[col]);
|
|
}
|
|
|
|
/**
|
|
* Returns the `col`th number in the `row`th row.
|
|
*
|
|
* @param row {number} the index of the row to find the number in
|
|
* @param col {number} the index of the column to find the number in
|
|
* @returns {number} the `col`th number in the `row`th row
|
|
*/
|
|
getCell(row, col) {
|
|
if (row < 0 || row >= this.rowCount) throw new Error(`Row must be in range [0, ${this.rowCount}) but was ${row}.`);
|
|
if (col < 0 || col >= this.colCount) throw new Error(`Row must be in range [0, ${this.colCount}) but was ${col}.`);
|
|
|
|
return this.cells[row][col];
|
|
}
|
|
|
|
|
|
/**
|
|
* Transforms this matrix into its row-reduced echelon form using Gauss-Jordan elimination.
|
|
*/
|
|
rref() {
|
|
let pivot = 0;
|
|
for (let row = 0; row < this.rowCount; row++) {
|
|
// Find pivot
|
|
while (pivot < this.colCount && this.getCol(pivot).slice(row).every(it => it === 0)) pivot++;
|
|
if (pivot >= this.colCount) return;
|
|
|
|
// Set pivot to non-zero
|
|
if (this.getCell(row, pivot) === 0)
|
|
this.swap(row, this.getCol(pivot).slice(row + 1).findIndex(it => it !== 0) + row + 1);
|
|
// Set pivot to 1
|
|
this.multiply(row, 1 / this.getCell(row, pivot));
|
|
|
|
// Set all other cells in this column to 0
|
|
for (let row2 = 0; row2 < this.rowCount; row2++) {
|
|
if (row2 === row) continue;
|
|
this.add(row2, row, -this.getCell(row2, pivot));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Interprets this matrix as an augmented matrix and returns for each variable the value or `undefined` if its value
|
|
* could not be determined.
|
|
*
|
|
* This function invokes `#rref`, so this matrix will change as a result.
|
|
*
|
|
* @returns {(number|undefined)[]} the value of each variable, and `undefined` for each variable that could not be
|
|
* determined uniquely
|
|
*/
|
|
solve() {
|
|
this.rref();
|
|
|
|
return range(this.colCount - 1).map(it => {
|
|
const row = this.getRow(this.getCol(it).findIndex(it => it === 1));
|
|
if (row.slice(0, it).every(it => it === 0) && row.slice(it + 1, -1).every(it => it === 0))
|
|
return row.slice(-1)[0];
|
|
|
|
return undefined;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Same as `#solve`, except that it assumes that every variable is an integer in the range [0, 1].
|
|
*
|
|
* @returns {(number|undefined)[]} the value of each variable, and `undefined` for each variable that could not be
|
|
* determined uniquely
|
|
*/
|
|
solveBinary() {
|
|
const resultsA = this.solveBinarySub(); // This check effectively auto-chords and auto-flags
|
|
const resultsB = this.solve();
|
|
const resultsC = this.solveBinarySub();
|
|
|
|
return range(this.colCount - 1, 0).map((_, i) => {
|
|
if (resultsA[i] !== undefined) return resultsA[i];
|
|
else if (resultsB[i] !== undefined) return resultsB[i];
|
|
else return resultsC[i];
|
|
});
|
|
}
|
|
|
|
solveBinarySub() {
|
|
const results = Array(this.colCount - 1).fill(undefined);
|
|
this.cells.forEach(row => {
|
|
// ax = b
|
|
const a = row.slice(0, -1);
|
|
const b = row.slice(-1)[0];
|
|
|
|
const negSum = a.filter(it => it < 0).reduce((sum, cell) => sum + cell, 0);
|
|
const posSum = a.filter(it => it > 0).reduce((sum, cell) => sum + cell, 0);
|
|
|
|
if (b === negSum) {
|
|
a.forEach((it, i) => {
|
|
if (it < 0) results[i] = 1;
|
|
if (it > 0) results[i] = 0;
|
|
});
|
|
} else if (b === posSum) {
|
|
a.forEach((it, i) => {
|
|
if (it < 0) results[i] = 0;
|
|
if (it > 0) results[i] = 1;
|
|
});
|
|
}
|
|
});
|
|
return results;
|
|
}
|
|
|
|
|
|
/**
|
|
* Swaps the rows at the given indices.
|
|
*
|
|
* @param rowA {number} the index of the row to swap
|
|
* @param rowB {number} the index of the other row to swap
|
|
*/
|
|
swap(rowA, rowB) {
|
|
[this.cells[rowA], this.cells[rowB]] = [this.cells[rowB], this.cells[rowA]];
|
|
}
|
|
|
|
/**
|
|
* Multiplies all numbers in the `row`th number by `factor`.
|
|
*
|
|
* @param row {number} the index of the row to multiply
|
|
* @param factor {number} the factory to multiply each number with
|
|
*/
|
|
multiply(row, factor) {
|
|
this.cells[row] = this.cells[row].map(it => it * factor);
|
|
}
|
|
|
|
/**
|
|
* Adds `factor` multiples of the `rowB`th row to the `rowA`th row.
|
|
*
|
|
* Effectively, sets `A = A + B * factor`.
|
|
*
|
|
* @param rowA {number} the index of the row to add to
|
|
* @param rowB {number} the index of the row to add a multiple of
|
|
* @param factor {number} the factor to multiply each added number with
|
|
*/
|
|
add(rowA, rowB, factor) {
|
|
this.cells[rowA] = this.cells[rowA].map((it, i) => this.cells[rowA][i] + this.cells[rowB][i] * factor);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
this.canvas = canvas;
|
|
this.field = field;
|
|
this.scale = 10;
|
|
|
|
this.mouseSquare = null;
|
|
this.mouseHoldChord = false;
|
|
this.initSymbols();
|
|
}
|
|
|
|
/**
|
|
* Initializes commonly-used symbols into pre-rendered canvases for easy copy-pasting during the draw loop.
|
|
*/
|
|
initSymbols() {
|
|
let ctx;
|
|
const createCanvas = (width, height) => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
return canvas;
|
|
};
|
|
const fillText = (text, font, color = "#000") => {
|
|
ctx.save();
|
|
ctx.fillStyle = color;
|
|
ctx.font = `bold ${Math.floor(this.scale * 0.55)}px ${font}`;
|
|
ctx.textBaseline = "middle";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(text, Math.floor(this.scale / 2), Math.floor(this.scale / 2));
|
|
ctx.restore();
|
|
};
|
|
|
|
this.coverSymbol = createCanvas(this.scale, this.scale);
|
|
this.drawCoverSymbol(this.coverSymbol.getContext("2d"));
|
|
|
|
this.flagSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.flagSymbol.getContext("2d");
|
|
fillText("\uf024", "ForkAwesome", "#f00");
|
|
|
|
this.bombSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.bombSymbol.getContext("2d");
|
|
fillText("\uf1e2", "ForkAwesome");
|
|
|
|
this.deadSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.deadSymbol.getContext("2d");
|
|
fillText("\uf1e2", "ForkAwesome", "#f00");
|
|
|
|
this.wrongSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.wrongSymbol.getContext("2d");
|
|
fillText("\uf1e2", "ForkAwesome");
|
|
fillText("\uf00d", "ForkAwesome", "#f00");
|
|
|
|
const digitColors =
|
|
["", "#0000ff", "#007b00", "#ff0000", "#00007b", "#7b0000", "#007b7b", "#000000", "#7b7b7b"];
|
|
this.digitSymbols = range(10).map(digit => {
|
|
const canvas = createCanvas(this.scale, this.scale);
|
|
ctx = canvas.getContext("2d");
|
|
if (digit !== 0) fillText("" + digit, "Courier New", digitColors[digit]);
|
|
return canvas;
|
|
});
|
|
|
|
this.winSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.winSymbol.getContext("2d");
|
|
fillText("\uf25b", "ForkAwesome");
|
|
|
|
this.loseSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.loseSymbol.getContext("2d");
|
|
fillText("\uf0f9", "ForkAwesome");
|
|
}
|
|
|
|
/**
|
|
* Helper method for `#initSymbols` to fill the given canvas with the cover symbol.
|
|
*
|
|
* @param ctx {CanvasRenderingContext2D} the context to fill with the cover symbol
|
|
*/
|
|
drawCoverSymbol(ctx) {
|
|
const coverBorderThickness = Math.floor(this.scale / 10);
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.fillStyle = "#7b7b7b";
|
|
ctx.moveTo(this.scale, 0);
|
|
ctx.lineTo(this.scale, this.scale);
|
|
ctx.lineTo(0, this.scale);
|
|
ctx.lineTo(coverBorderThickness, this.scale - coverBorderThickness);
|
|
ctx.lineTo(this.scale - coverBorderThickness, this.scale - coverBorderThickness);
|
|
ctx.lineTo(this.scale - coverBorderThickness, coverBorderThickness);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.fillStyle = "#fff";
|
|
ctx.moveTo(0, this.scale);
|
|
ctx.lineTo(0, 0);
|
|
ctx.lineTo(this.scale, 0);
|
|
ctx.lineTo(this.scale - coverBorderThickness, coverBorderThickness);
|
|
ctx.lineTo(coverBorderThickness, coverBorderThickness);
|
|
ctx.lineTo(coverBorderThickness, this.scale - coverBorderThickness);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the square at the given coordinates, or `null` if there is no square there.
|
|
*
|
|
* @param pos {{x: number, y: number}} the client-relative pixel coordinates to find the square at
|
|
* @return {Square} the square at the given coordinates
|
|
*/
|
|
posToSquare(pos) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
return this.field.getSquareOrElse(
|
|
Math.floor((pos.x - rect.left) / this.scale),
|
|
Math.floor((pos.y - rect.top) / this.scale),
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Rescales the display appropriately.
|
|
*
|
|
* @param scale {number} the size of a square in pixels
|
|
*/
|
|
setScale(scale) {
|
|
this.scale = scale;
|
|
this.canvas.width = this.field.width * this.scale;
|
|
this.canvas.height = this.field.height * this.scale + this.scale;
|
|
this.initSymbols();
|
|
}
|
|
|
|
|
|
/**
|
|
* Invokes `#draw` in every animation frame of this window.
|
|
*/
|
|
startDrawLoop() {
|
|
const cb = () => {
|
|
this.draw();
|
|
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.scale;
|
|
|
|
// Clear
|
|
ctx.save();
|
|
ctx.fillStyle = "#bdbdbd";
|
|
ctx.fillRect(0, 0, rect.width, rect.height);
|
|
ctx.restore();
|
|
|
|
// Create grid
|
|
ctx.save();
|
|
ctx.strokeStyle = "#7b7b7b";
|
|
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();
|
|
|
|
// Cover squares
|
|
ctx.save();
|
|
ctx.fillStyle = "#555";
|
|
this.field.squareList
|
|
.filter(it => it.isCovered)
|
|
.filter(it => {
|
|
// True if square should be covered
|
|
if (!this.mouseHoldChord || this.mouseSquare === null) return true;
|
|
if (this.mouseSquare === it || this.mouseSquare.getNeighbors().indexOf(it) >= 0) return it.hasFlag;
|
|
|
|
return true;
|
|
})
|
|
.forEach(square => ctx.drawImage(this.coverSymbol, square.x * scale, square.y * scale));
|
|
ctx.restore();
|
|
|
|
// Fill squares
|
|
ctx.save();
|
|
ctx.fillStyle = "#000";
|
|
ctx.font = scale + "px serif";
|
|
ctx.textBaseline = "middle";
|
|
ctx.textAlign = "center";
|
|
this.field.squareList.forEach(square => {
|
|
let icon;
|
|
if (square.hasFlag) {
|
|
if (this.field.lost && !square.hasMine)
|
|
icon = this.wrongSymbol;
|
|
else
|
|
icon = this.flagSymbol;
|
|
} else if (square.hasMine) {
|
|
if (square.isCovered && this.field.lost)
|
|
icon = this.bombSymbol;
|
|
else if (!square.isCovered)
|
|
icon = this.deadSymbol;
|
|
} else if (!square.isCovered) {
|
|
icon = this.digitSymbols[square.getNeighborCount(it => it.hasMine)];
|
|
}
|
|
|
|
if (icon !== undefined) ctx.drawImage(icon, square.x * scale, square.y * scale);
|
|
});
|
|
ctx.restore();
|
|
|
|
// Draw bottom info
|
|
ctx.save();
|
|
ctx.fillStyle = "#000";
|
|
ctx.font = scale + "px serif";
|
|
ctx.textBaseline = "top";
|
|
ctx.textAlign = "left";
|
|
ctx.fillText(
|
|
`${this.field.squareList.filter(it => it.hasFlag).length}/${this.field.mineCount}`,
|
|
0, this.field.height * scale
|
|
);
|
|
|
|
if (this.field.won || this.field.lost) {
|
|
ctx.drawImage(
|
|
this.field.won ? this.winSymbol : this.loseSymbol,
|
|
Math.floor((this.field.width - 0.5) * scale / 2), this.field.height * scale
|
|
);
|
|
}
|
|
|
|
ctx.textAlign = "right";
|
|
ctx.fillText(
|
|
"" + Math.floor(this.field.getTime() / 1000),
|
|
this.field.width * scale, this.field.height * 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 squares 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;
|
|
this.mineCount = mineCount;
|
|
|
|
const mines = Array(width * height).fill(true, 0, mineCount).fill(false, mineCount);
|
|
shuffleArrayInPlace(mines, seed);
|
|
|
|
this.squareList =
|
|
mines.map((hasMine, i) => new Square(this, i % this.width, Math.floor(i / this.width), hasMine));
|
|
this.squares = chunkifyArray(this.squareList, this.width);
|
|
|
|
this.started = false;
|
|
this.startTime = undefined;
|
|
this.endTime = undefined;
|
|
this.won = false;
|
|
this.lost = false;
|
|
this.coveredRemaining = this.width * this.height - this.mineCount;
|
|
}
|
|
|
|
/**
|
|
* Returns a deep copy of this field.
|
|
*
|
|
* @return {Field} a deep copy of this field
|
|
*/
|
|
copy() {
|
|
const copy = new Field(this.width, this.height, this.mineCount, undefined);
|
|
copy.squareList = this.squareList.map(it => it.copy());
|
|
copy.squareList.forEach(it => it.field = copy);
|
|
copy.squares = chunkifyArray(copy.squareList, copy.width);
|
|
copy.started = this.started;
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the square at the given coordinates, or `orElse` if there is no square there.
|
|
*
|
|
* @param x {number} the horizontal coordinate of the square to look up
|
|
* @param y {number} the vertical coordinate of the square to look up
|
|
* @param orElse {*} the value to return if there is no square at the given coordinates
|
|
* @return {Square|*} the square at the given coordinates, or `orElse` if there is no square there
|
|
*/
|
|
getSquareOrElse(x, y, orElse = undefined) {
|
|
return this.squares[y] === undefined
|
|
? orElse
|
|
: this.squares[y][x] === undefined
|
|
? orElse
|
|
: this.squares[y][x];
|
|
}
|
|
|
|
/**
|
|
* Returns the time in milliseconds that clearing the field takes or has taken.
|
|
*
|
|
* If the game has not started, returns 0.
|
|
* If the game has not finished, returns the time since it has started.
|
|
* Otherwise, returns the time it took from start to finish.
|
|
*
|
|
* @returns {number} the time in milliseconds that clearing the field takes or has taken
|
|
*/
|
|
getTime() {
|
|
if (this.endTime !== undefined)
|
|
return this.endTime - this.startTime;
|
|
else if (this.startTime !== undefined)
|
|
return Date.now() - this.startTime;
|
|
else
|
|
return 0;
|
|
}
|
|
|
|
|
|
/**
|
|
* Handles the event when a square is clicked, which includes moving the mine if the player hits a mine on the first
|
|
* click.
|
|
*
|
|
* @param square {Square} the square that was clicked on
|
|
*/
|
|
onUncover(square) {
|
|
if (!this.started) {
|
|
this.started = true;
|
|
this.startTime = Date.now();
|
|
|
|
const squareAndNeighs = [square].concat(square.getNeighbors());
|
|
squareAndNeighs
|
|
.filter(it => it.hasMine)
|
|
.forEach(it => {
|
|
it.hasMine = false;
|
|
this.squareList.filter(it => !it.hasMine && squareAndNeighs.indexOf(it) < 0)[0].hasMine = true;
|
|
});
|
|
}
|
|
|
|
if (!square.hasMine) {
|
|
this.coveredRemaining = this.squareList.filter(it => !it.hasMine && it.isCovered).length;
|
|
if (this.coveredRemaining === 0) {
|
|
this.endTime = Date.now();
|
|
this.squareList.filter(it => it.isCovered && !it.hasFlag).forEach(it => it.flag());
|
|
this.won = true;
|
|
}
|
|
} else {
|
|
this.endTime = Date.now();
|
|
this.lost = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A square in a Minesweeper `Field`.
|
|
*/
|
|
class Square {
|
|
/**
|
|
* Constructs a new square.
|
|
*
|
|
* @param field {Field} the field in which this square is located
|
|
* @param x {number} the horizontal coordinate of this square in the field
|
|
* @param y {number} the vertical coordinate of this square in the field
|
|
* @param hasMine {boolean} `true` if and only if this square 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 a deep copy of this square, without a reference to any field.
|
|
*
|
|
* @returns {Square} a deep copy of this square, without a reference to any field
|
|
*/
|
|
copy() {
|
|
const copy = new Square(undefined, this.x, this.y, this.hasMine);
|
|
copy.isCovered = this.isCovered;
|
|
copy.hasFlag = this.hasFlag
|
|
return copy;
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the `Square`s that are adjacent to this square.
|
|
*
|
|
* @return {Square[]} the `Square`s that are adjacent to this square
|
|
*/
|
|
getNeighbors() {
|
|
return [
|
|
this.field.getSquareOrElse(this.x - 1, this.y - 1),
|
|
this.field.getSquareOrElse(this.x, this.y - 1),
|
|
this.field.getSquareOrElse(this.x + 1, this.y - 1),
|
|
this.field.getSquareOrElse(this.x - 1, this.y),
|
|
this.field.getSquareOrElse(this.x + 1, this.y),
|
|
this.field.getSquareOrElse(this.x - 1, this.y + 1),
|
|
this.field.getSquareOrElse(this.x, this.y + 1),
|
|
this.field.getSquareOrElse(this.x + 1, this.y + 1),
|
|
].filter(it => it !== undefined);
|
|
}
|
|
|
|
/**
|
|
* Returns the number of neighbors that satisfy the given property.
|
|
*
|
|
* @param property {function} the property to check on each neighbor
|
|
* @returns {number} the number of neighbors that satisfy the given property
|
|
*/
|
|
getNeighborCount(property) {
|
|
return this.getNeighbors().filter(property).length;
|
|
}
|
|
|
|
|
|
/**
|
|
* Chords this square, i.e. if this square is covered and the number of neighboring flags equals the number in this
|
|
* square, then all unflagged neighbors are uncovered.
|
|
*/
|
|
chord() {
|
|
if (this.isCovered || this.field.won || this.field.lost) return;
|
|
if (this.getNeighborCount(it => it.hasFlag) !== this.getNeighborCount(it => it.hasMine)) return;
|
|
|
|
this.getNeighbors()
|
|
.filter(it => it.isCovered && !it.hasFlag)
|
|
.forEach(it => it.uncover());
|
|
}
|
|
|
|
/**
|
|
* Adds or removes a flag at this square.
|
|
*/
|
|
flag() {
|
|
if (!this.isCovered || this.field.won || this.field.lost) return;
|
|
|
|
this.hasFlag = !this.hasFlag;
|
|
}
|
|
|
|
/**
|
|
* Uncovers this square, revealing the contents beneath.
|
|
*/
|
|
uncover() {
|
|
if (!this.isCovered || this.hasFlag || this.field.won || this.field.lost) return;
|
|
|
|
this.isCovered = false;
|
|
this.hasFlag = false;
|
|
this.field.onUncover(this); // Also moves mine on first click
|
|
|
|
if (!this.hasMine && this.getNeighborCount(it => it.hasMine) === 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 rng = seed === undefined
|
|
? random
|
|
: random.clone(seedrandom(seed));
|
|
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
const j = rng.int(0, i + 1);
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
|
|
return 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;
|
|
}
|
|
|
|
/**
|
|
* Creates an array of `size` consecutive integers starting at `startAt`.
|
|
*
|
|
* Taken from https://stackoverflow.com/a/10050831 (CC BY-SA 4.0).
|
|
*
|
|
* @param length {number} the number of consecutive integers to put in the array
|
|
* @param beginAt {number} the first integer to return
|
|
* @returns {number[]} the array of consecutive integers
|
|
*/
|
|
function range(length, beginAt = 0) {
|
|
return [...Array(length).keys()].map(i => i + beginAt);
|
|
}
|
|
|
|
/**
|
|
* Waits for FontAwesome to have loaded and then invokes the callback.
|
|
*
|
|
* Taken from https://stackoverflow.com/a/35572620/ (CC BY-SA 3.0).
|
|
*
|
|
* @param callback {function} the function to invoke once the font has loaded
|
|
* @param timeout {number|undefined} the maximum time in milliseconds to wait for the font to load
|
|
*/
|
|
function waitForForkAwesome(callback, timeout) {
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
const fontSize = 36;
|
|
const testCharacter = "\uF047";
|
|
const targetPixelCount = 500;
|
|
|
|
const ccw = canvas.width = fontSize * 1.5;
|
|
const cch = canvas.height = fontSize * 1.5;
|
|
ctx.font = `${fontSize}px ForkAwesome`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
|
|
const startTime = performance.now();
|
|
const failTime = startTime + timeout;
|
|
requestAnimationFrame(fontOnload);
|
|
|
|
/**
|
|
* Repeatedly invokes itself until the font has loaded or the timeout has been reached.
|
|
*
|
|
* @param time {number} the time in milliseconds at which this function is invoked
|
|
*/
|
|
function fontOnload(time) {
|
|
const currentCount = getPixelCount();
|
|
if (time > failTime) alert(`ForkAwesome failed to load after ${timeout}ms.`);
|
|
else if (currentCount < targetPixelCount) requestAnimationFrame(fontOnload);
|
|
else callback();
|
|
}
|
|
|
|
/**
|
|
* Draws a character in the canvas and returns the number of pixels that have been drawn.
|
|
*
|
|
* @returns {number} the number of pixels that have been drawn
|
|
*/
|
|
function getPixelCount() {
|
|
ctx.clearRect(0, 0, ccw, cch);
|
|
ctx.fillText(testCharacter, ccw / 2, cch / 2);
|
|
|
|
const data = ctx.getImageData(0, 0, ccw, cch).data;
|
|
let count = 0;
|
|
for (let i = 3; i < data.length; i += 4)
|
|
if (data[i] > 10) count++;
|
|
return count;
|
|
}
|
|
}
|
|
|
|
|
|
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);
|
|
$("#settingsSeed").value =
|
|
urlParams.get("seed") === null
|
|
? "" + Math.floor(Math.random() * 1000000000000)
|
|
: urlParams.get("seed");
|
|
|
|
waitForForkAwesome(() => {
|
|
new Game();
|
|
}, 3000);
|
|
});
|