diff --git a/Gruntfile.js b/Gruntfile.js
index 8532404..872bbef 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -16,7 +16,7 @@ module.exports = grunt => {
},
focus: {
dev: {
- include: ["css", "html", "js", "link"],
+ include: ["css", "html", "link", "ts"],
},
},
replace: {
@@ -50,22 +50,23 @@ module.exports = grunt => {
files: ["src/main/**/*.html"],
tasks: ["copy:html"],
},
- js: {
- files: ["src/main/**/*.js"],
- tasks: ["webpack:dev", "replace:dev"],
- },
link: {
files: ["node_modules/@fwdekker/*/dist/**"],
tasks: ["webpack:dev", "replace:dev"],
},
+ js: {
+ files: ["src/main/**/*.ts"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
},
webpack: {
options: {
- entry: "./src/main/js/index.js",
+ entry: "./src/main/js/Main.ts",
module: {
rules: [
{
- test: /\.js$/,
+ test: /\.ts$/,
+ use: "ts-loader",
exclude: /node_modules/,
},
{
@@ -85,7 +86,7 @@ module.exports = grunt => {
],
},
resolve: {
- extensions: [".js", ".css"],
+ extensions: [".ts", ".css"],
},
output: {
filename: "bundle.js",
@@ -115,7 +116,7 @@ module.exports = grunt => {
// Copy files
"copy:css",
"copy:html",
- // Compile JS
+ // Compile TS
"webpack:dev",
"replace:dev",
]);
@@ -126,7 +127,7 @@ module.exports = grunt => {
// Copy files
"copy:css",
"copy:html",
- // Compile JS
+ // Compile TS
"webpack:deploy",
"replace:deploy",
]);
diff --git a/package-lock.json b/package-lock.json
index 557de65..e150762 100644
Binary files a/package-lock.json and b/package-lock.json differ
diff --git a/package.json b/package.json
index c9e0bb1..c48519c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "minesweeper",
- "version": "0.0.38",
+ "version": "0.0.39",
"description": "Just Minesweeper!",
"author": "Felix W. Dekker",
"browser": "dist/bundle.js",
@@ -16,10 +16,9 @@
"deploy": "grunt deploy"
},
"dependencies": {
- "@fwdekker/template": "^0.0.18",
- "fork-awesome": "^1.1.7",
- "random": "^2.2.0",
- "seedrandom": "^3.0.5"
+ "@fwdekker/template": "^0.0.19",
+ "fast-random": "^2.0.4",
+ "fork-awesome": "^1.1.7"
},
"devDependencies": {
"css-loader": "^4.1.0",
@@ -33,6 +32,9 @@
"grunt-text-replace": "^0.4.0",
"grunt-webpack": "^4.0.2",
"style-loader": "^1.2.1",
+ "ts-loader": "^8.0.1",
+ "ts-node": "^8.10.2",
+ "typescript": "^3.9.7",
"webpack": "^4.44.0",
"webpack-cli": "^3.3.12"
}
diff --git a/src/main/index.html b/src/main/index.html
index 80dc70c..6e8520b 100644
--- a/src/main/index.html
+++ b/src/main/index.html
@@ -29,7 +29,7 @@
-
+
diff --git a/src/main/js/Common.ts b/src/main/js/Common.ts
new file mode 100644
index 0000000..fc0a246
--- /dev/null
+++ b/src/main/js/Common.ts
@@ -0,0 +1,104 @@
+// @ts-ignore
+import * as random from "fast-random";
+
+
+/**
+ * Shuffles the given array in-place.
+ *
+ * @param array the array to shuffle
+ * @param seed the seed for the random number generator
+ * @returns the array that was given to this function to shuffle
+ */
+export function shuffleArrayInPlace(array: any[], seed: number | undefined = undefined): any[] {
+ const rng = random(seed);
+
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = rng.nextInt() % (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 the size of each chunk
+ * @returns an array of the extracted chunks
+ */
+export function chunkifyArray(array: any[], chunkSize: number): any[] {
+ 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 the number of consecutive integers to put in the array
+ * @param beginAt the first integer to return
+ * @returns the array of consecutive integers
+ */
+export function range(length: number, beginAt: number = 0): number[] {
+ 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 the function to invoke once the font has loaded
+ * @param timeout the maximum time in milliseconds to wait for the font to load
+ */
+export function waitForForkAwesome(callback: () => void, timeout: number | undefined = undefined): void {
+ 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 = timeout === undefined ? undefined : startTime + timeout;
+ requestAnimationFrame(fontOnload);
+
+ /**
+ * Repeatedly invokes itself until the font has loaded or the timeout has been reached.
+ *
+ * @param time the time in milliseconds at which this function is invoked
+ */
+ function fontOnload(time: number): void {
+ const currentCount = getPixelCount();
+ if (failTime !== undefined && 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 the number of pixels that have been drawn
+ */
+ function getPixelCount(): number {
+ 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;
+ }
+}
diff --git a/src/main/js/Display.ts b/src/main/js/Display.ts
new file mode 100644
index 0000000..97f3870
--- /dev/null
+++ b/src/main/js/Display.ts
@@ -0,0 +1,273 @@
+import {range} from "./Common";
+import {Field, Square} from "./Field";
+
+
+/**
+ * Displays a Minesweeper field.
+ */
+export class Display {
+ private readonly canvas: HTMLCanvasElement;
+
+ field: Field;
+ private scale: number;
+ mouseSquare: Square | null;
+ mouseHoldChord: boolean;
+
+ private coverSymbol: HTMLCanvasElement | undefined;
+ private flagSymbol: HTMLCanvasElement | undefined;
+ private bombSymbol: HTMLCanvasElement | undefined;
+ private deadSymbol: HTMLCanvasElement | undefined;
+ private wrongSymbol: HTMLCanvasElement | undefined;
+ private digitSymbols: HTMLCanvasElement[] | undefined;
+ private winSymbol: HTMLCanvasElement | undefined;
+ private loseSymbol: HTMLCanvasElement | undefined;
+
+
+ /**
+ * Constructs a new display.
+ *
+ * @param canvas the canvas to draw the field in
+ * @param field the field to draw
+ */
+ constructor(canvas: HTMLCanvasElement, field: 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(): void {
+ let ctx: CanvasRenderingContext2D;
+ const createCanvas = (width: number, height: number) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+ };
+ const fillText = (text: string, font: string, color: string = "#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 the context to fill with the cover symbol
+ */
+ drawCoverSymbol(ctx: CanvasRenderingContext2D): void {
+ 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 the client-relative pixel coordinates to find the square at
+ * @return the square at the given coordinates
+ */
+ posToSquare(pos: { x: number, y: number }): Square | null {
+ 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 the size of a square in pixels
+ */
+ setScale(scale: number): void {
+ 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(): void {
+ const cb = () => {
+ this.draw();
+ window.requestAnimationFrame(cb);
+ };
+ window.requestAnimationFrame(cb);
+ }
+
+ /**
+ * Draws the field.
+ */
+ draw(): void {
+ 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
+ }
+}
diff --git a/src/main/js/Field.ts b/src/main/js/Field.ts
new file mode 100644
index 0000000..aeff17d
--- /dev/null
+++ b/src/main/js/Field.ts
@@ -0,0 +1,244 @@
+import {chunkifyArray, shuffleArrayInPlace} from "./Common";
+
+
+/**
+ * A playing field for a game of Minesweeper.
+ */
+export class Field {
+ readonly width: number;
+ readonly height: number;
+ readonly mineCount: number;
+ readonly squareList: Square[];
+ readonly squares: any;
+
+ coveredRemaining: number;
+ started: boolean;
+ startTime: number | undefined;
+ endTime: number | undefined;
+ won: boolean;
+ lost: boolean;
+
+
+ /**
+ * Constructs a new playing field for a game of Minesweeper.
+ *
+ * @param width the number of squares per row in the field
+ * @param height the number of rows in the field
+ * @param mineCount the initial number of mines to place in the field
+ * @param seed the seed to generate the field with
+ */
+ constructor(width: number, height: number, mineCount: number, seed: number | undefined = 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.coveredRemaining = this.width * this.height - this.mineCount;
+ this.started = false;
+ this.startTime = undefined;
+ this.endTime = undefined;
+ this.won = false;
+ this.lost = false;
+ }
+
+ /**
+ * Returns a deep copy of this field.
+ *
+ * @return a deep copy of this field
+ */
+ copy(): Field {
+ const copy = new Field(this.width, this.height, this.mineCount, undefined);
+ copy.squareList.length = 0;
+ copy.squareList.push(...this.squareList.map(it => it.copy(copy)));
+ copy.squares.length = 0;
+ copy.squares.push(...chunkifyArray(copy.squareList, copy.width));
+ copy.coveredRemaining = this.coveredRemaining;
+ copy.started = this.started;
+ copy.startTime = this.startTime;
+ copy.endTime = this.endTime;
+ copy.won = this.won;
+ copy.lost = this.lost;
+ return copy;
+ }
+
+
+ /**
+ * Returns the square at the given coordinates, or `orElse` if there is no square there.
+ *
+ * @param x the horizontal coordinate of the square to look up
+ * @param y 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 the square at the given coordinates, or `orElse` if there is no square there
+ */
+ getSquareOrElse(x: number, y: number, orElse: any = undefined): Square | any {
+ return this.squares[y]?.[x] ?? orElse;
+ }
+
+ /**
+ * 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 the time in milliseconds that clearing the field takes or has taken
+ */
+ getTime(): number {
+ return this.startTime !== undefined
+ ? (this.endTime !== undefined
+ ? this.endTime - this.startTime
+ : Date.now() - this.startTime)
+ : 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 the square that was clicked on
+ */
+ onUncover(square: Square): void {
+ 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`.
+ */
+export class Square {
+ private readonly field: Field;
+ readonly x: number;
+ readonly y: number;
+ isCovered: boolean;
+ hasMine: boolean;
+ hasFlag: boolean;
+
+
+ /**
+ * Constructs a new square.
+ *
+ * @param field the field in which this square is located
+ * @param x the horizontal coordinate of this square in the field
+ * @param y the vertical coordinate of this square in the field
+ * @param hasMine `true` if and only if this square contains a mine
+ */
+ constructor(field: Field, x: number, y: number, hasMine: boolean) {
+ 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.
+ *
+ * @param field the field in which this square is present
+ * @returns a deep copy of this square
+ */
+ copy(field: Field): Square {
+ const copy = new Square(field, 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 the `Square`s that are adjacent to this square
+ */
+ getNeighbors(): Square[] {
+ 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 the property to check on each neighbor
+ * @returns the number of neighbors that satisfy the given property
+ */
+ getNeighborCount(property: (neighbor: Square) => boolean): number {
+ 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(): void {
+ 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(): void {
+ if (!this.isCovered || this.field.won || this.field.lost) return;
+
+ this.hasFlag = !this.hasFlag;
+ }
+
+ /**
+ * Uncovers this square, revealing the contents beneath.
+ */
+ uncover(): void {
+ 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();
+ }
+}
diff --git a/src/main/js/Game.ts b/src/main/js/Game.ts
new file mode 100644
index 0000000..499d816
--- /dev/null
+++ b/src/main/js/Game.ts
@@ -0,0 +1,172 @@
+// @ts-ignore
+import {$} from "@fwdekker/template";
+import {Display} from "./Display";
+import {Field} from "./Field";
+import {Solver} from "./Solver";
+
+
+/**
+ * Controls the interaction with a game of Minesweeper.
+ */
+export class Game {
+ private readonly canvas: HTMLCanvasElement;
+ private readonly solveForm: HTMLFormElement;
+ private readonly controlForm: HTMLFormElement;
+ private readonly displayScale: HTMLInputElement;
+ private readonly settingsForm: HTMLFormElement;
+ private readonly widthInput: HTMLInputElement;
+ private readonly heightInput: HTMLInputElement;
+ private readonly minesInput: HTMLInputElement;
+ private readonly seedInput: HTMLInputElement;
+ private field: Field;
+ private display: Display;
+ private leftDown: boolean;
+ private rightDown: boolean;
+ private holdsAfterChord: boolean;
+
+
+ /**
+ * 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.field = this.createNewField();
+ 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.field = this.createNewField();
+ 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;
+ }
+ );
+ }
+
+
+ /**
+ * Creates a new field according to the current settings.
+ *
+ * @return the newly created field
+ */
+ createNewField(): Field {
+ return new Field(
+ +this.widthInput.value,
+ +this.heightInput.value,
+ +this.minesInput.value,
+ +this.seedInput.value
+ );
+ }
+}
diff --git a/src/main/js/Main.ts b/src/main/js/Main.ts
new file mode 100644
index 0000000..c3bba6d
--- /dev/null
+++ b/src/main/js/Main.ts
@@ -0,0 +1,37 @@
+// @ts-ignore
+import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template";
+import "fork-awesome/css/fork-awesome.css";
+import {waitForForkAwesome} from "./Common";
+import {Game} from "./Game";
+
+
+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;
+
+
+ // Load settings
+ const urlParams = new URLSearchParams(window.location.search);
+ $("#settingsSeed").value =
+ urlParams.get("seed") === null
+ ? "" + Math.floor(Math.random() * 1000000000000)
+ : urlParams.get("seed");
+
+
+ // Start game
+ waitForForkAwesome(() => new Game(), 3000);
+});
diff --git a/src/main/js/Solver.ts b/src/main/js/Solver.ts
new file mode 100644
index 0000000..af2336f
--- /dev/null
+++ b/src/main/js/Solver.ts
@@ -0,0 +1,271 @@
+import {range} from "./Common";
+import {Field, Square} from "./Field";
+
+
+/**
+ * A solver for a game of Minesweeper.
+ */
+export class Solver {
+ /**
+ * Solves the given field as far as the algorithm is able to.
+ *
+ * @param field the field to solve
+ */
+ solve(field: 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 the field to solve
+ */
+ bigStep(field: 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: number[][] = [];
+ // 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.
+ */
+export class Matrix {
+ private readonly cells: number[][];
+ private readonly rowCount: number;
+ private readonly colCount: number;
+
+
+ /**
+ * Constructs a new matrix from the given numbers.
+ *
+ * @param cells an array of rows of numbers
+ */
+ constructor(cells: number[][]) {
+ 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 the index of the row to return
+ * @returns the `row`th row of numbers
+ */
+ getRow(row: number): number[] {
+ 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 the index of the column to return
+ * @returns the `col`th column of numbers
+ */
+ getCol(col: number): number[] {
+ 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 the index of the row to find the number in
+ * @param col the index of the column to find the number in
+ * @returns the `col`th number in the `row`th row
+ */
+ getCell(row: number, col: number): number {
+ 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(): void {
+ 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 the value of each variable, and `undefined` for each variable that could not be determined uniquely
+ */
+ solve(): (number | undefined)[] {
+ 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 the value of each variable, and `undefined` for each variable that could not be determined uniquely
+ */
+ solveBinary(): (number | undefined)[] {
+ 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];
+ });
+ }
+
+ /**
+ * Helper function for `#solveBinary` that tries to solve for variables in the range [0, 1] in the current matrix
+ * without applying transformations.
+ *
+ * @returns the value of each variable, and `undefined` for each variable that could not be determined uniquely
+ */
+ solveBinarySub(): (number | undefined)[] {
+ 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 the index of the row to swap
+ * @param rowB the index of the other row to swap
+ */
+ swap(rowA: number, rowB: number) {
+ [this.cells[rowA], this.cells[rowB]] = [this.cells[rowB], this.cells[rowA]];
+ }
+
+ /**
+ * Multiplies all numbers in the `row`th number by `factor`.
+ *
+ * @param row the index of the row to multiply
+ * @param factor the factory to multiply each number with
+ */
+ multiply(row: number, factor: number) {
+ 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 the index of the row to add to
+ * @param rowB the index of the row to add a multiple of
+ * @param factor the factor to multiply each added number with
+ */
+ add(rowA: number, rowB: number, factor: number) {
+ this.cells[rowA] =
+ this.cells[rowA].map((it, i) => this.cells[rowA][i] + this.cells[rowB][i] * factor);
+ }
+}
diff --git a/src/main/js/index.js b/src/main/js/index.js
deleted file mode 100644
index e6704b7..0000000
--- a/src/main/js/index.js
+++ /dev/null
@@ -1,1007 +0,0 @@
-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);
-});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..e05d682
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "target": "es2019",
+ "strict": true,
+ "rootDir": "./src/main/js/",
+ "outDir": "./dist/js/"
+ },
+ "include": [
+ "src/main/js/**/*.ts"
+ ]
+}