minesweeper/src/main/js/index.js

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);
});