Rewrite to TypeScript
This commit is contained in:
parent
671fb5ff2a
commit
ac7c964ed0
21
Gruntfile.js
21
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",
|
||||
]);
|
||||
|
|
Binary file not shown.
12
package.json
12
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"
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
1007
src/main/js/index.js
1007
src/main/js/index.js
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2019",
|
||||
"strict": true,
|
||||
"rootDir": "./src/main/js/",
|
||||
"outDir": "./dist/js/"
|
||||
},
|
||||
"include": [
|
||||
"src/main/js/**/*.ts"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue