Rewrite to TypeScript

This commit is contained in:
Florine W. Dekker 2020-07-31 23:12:16 +02:00
parent 671fb5ff2a
commit ac7c964ed0
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
12 changed files with 1131 additions and 1023 deletions

View File

@ -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",
]);

BIN
package-lock.json generated

Binary file not shown.

View File

@ -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"
}

View File

@ -29,7 +29,7 @@
<div class="row">
<div class="column column-60">
<!-- Field -->
<canvas id="canvas" width="1" height="1" style="font-family:ForkAwesome;"></canvas>
<canvas id="canvas" width="1" height="1"></canvas>
</div>
<div class="column column-40">

104
src/main/js/Common.ts Normal file
View File

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

273
src/main/js/Display.ts Normal file
View File

@ -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
}
}

244
src/main/js/Field.ts Normal file
View File

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

172
src/main/js/Game.ts Normal file
View File

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

37
src/main/js/Main.ts Normal file
View File

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

271
src/main/js/Solver.ts Normal file
View File

@ -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()), <Square[]> [])
))
.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);
}
}

File diff suppressed because it is too large Load Diff

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2019",
"strict": true,
"rootDir": "./src/main/js/",
"outDir": "./dist/js/"
},
"include": [
"src/main/js/**/*.ts"
]
}