From ac7c964ed0c800ddd9bfc5f8da165715d32a81e1 Mon Sep 17 00:00:00 2001 From: "Felix W. Dekker" Date: Fri, 31 Jul 2020 23:12:16 +0200 Subject: [PATCH] Rewrite to TypeScript --- Gruntfile.js | 21 +- package-lock.json | Bin 223361 -> 226597 bytes package.json | 12 +- src/main/index.html | 2 +- src/main/js/Common.ts | 104 +++++ src/main/js/Display.ts | 273 +++++++++++ src/main/js/Field.ts | 244 ++++++++++ src/main/js/Game.ts | 172 +++++++ src/main/js/Main.ts | 37 ++ src/main/js/Solver.ts | 271 +++++++++++ src/main/js/index.js | 1007 ---------------------------------------- tsconfig.json | 11 + 12 files changed, 1131 insertions(+), 1023 deletions(-) create mode 100644 src/main/js/Common.ts create mode 100644 src/main/js/Display.ts create mode 100644 src/main/js/Field.ts create mode 100644 src/main/js/Game.ts create mode 100644 src/main/js/Main.ts create mode 100644 src/main/js/Solver.ts delete mode 100644 src/main/js/index.js create mode 100644 tsconfig.json 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 557de6509e5d031b88d3f5ea21a76ce1f9651dab..e15076244a42a4e4e8ce44308e778cfa3492a312 100644 GIT binary patch delta 1726 zcmYLJU5p!771kYZl+9+BW$A9S*$}eXDrr})*Y^C_o{&Xrd;RaRJ!{YS=b_*k&y0U( z#$%6XY*eyEq99eEBo}g(NI*g20U@DQR<%-FXz~aWf=EcHJhX{mUr^O#MPG_|U@TC$ zy3+Z(I{NN8-#M>6T>8a7mTnRl5xPaaf!N<-^sUcdfNXdT?(ME|@kE_qiQw+wc+b`{sx@Hy`EfMH zalV}1O2NmK)jNN~Hm83CpAyjc_$z=!D_?W1LOrs*XeeHfw{9pNa>oz-FS|J^RHWJc ze!^F=@nF>|6o<5&wp*iiwM!CsHEPNGj9haE{RiPbXVfG)m^CtNjmRdmc`I5Z>7MeSVq-Pz=i3X+1CftYY$HCpv#ZJ7W(mb0KL>` z$+81yCOjluIrcbcx;;Bn43m-EI6HAClMFZM=3~0zqvO0=8U<>MM2ym{rqZw{ifGkB zycUZUdf{xemY}n}zRYVmhRNu|VX9i<69IF-8ia5EGcdjWJ4cI{h5vqH!v@kqI13O*k{UVt#-he;S$}T zTJ1p;j|8=1VZ^EV^w67VDq6VIEhMUP-5=yz;(n$e_AM@IB=vkJY8&3MkgZyRS|r81 z-5bHjzh}^m*T5O{!xQWR;v=qe$gW`P=;Q0I=MXZ%IrM(W^+ouXKWv~c9D}pyvnyZ~ z9qs`VesJ>)+!mMNO?wr70wdrFN0?>DQ z0o_=`E~4LrTuU=fF5hW`^%)UY(B@~DA5A|2=bXeV$Lp~0`bBOWjO)pwz;KK@YfeW06kp8V(9EWY}wIIZUc%F z2)Y!hmx?kU7)Ok{&V^{5k~2~#hWETVOWoJRJa3q6yy*9iBt~gW%z~)q8G>X2QOhoe z+DUKMj@2n6qU}qZWm3sx3H4ucU42#-r%)?X`z{OM(|d2sIn7tm;VQNU?{6%+Le8$J zKltj59;()m{!5OT=5Zouy38jT|z4BI{PKhz|?ik7;atL+Y{gow;qHp&Kv{LXz9b**Pa<3V#j#^Pq1w+k`-D9omONLwgzo; zNZQ`Upd}Pbk-)gDTP3Pr?nUvuY(^C#q3~?9s4xR^6cdNlLUT}03>&_%WSU8))2`u7 zyqpLz#mT{JY|;Gh9R5{5ci8{yWw86s*Imyy(cv;!1Y_h+flFxb2Vf3T88dE+1 delta 1238 zcmZvaPiP}$7{ z0ztBDlcFMJYn_!$ixT>T+1HTNrjIBKOpmclo?wWu!KV8Rr`CmZs}m1Ls`lfX^YGhc zr~BZ0$8UjyN1@!p^A%A>+g;UYDE8yfwTX}y582ok*F6>COc8yk?_7_LzrDP-vLwqr zt4Il3^)_D>eMG|7-wc>(qsaJ(wf&>Z_N?~Ol-3qespGls_Pm9|{LjF()E4WaDz*hf zYo9I`$FkpxVerl&SUFh9e%XOP90Io!_l6+$30Pg&Ihix^a$2Dy$-XVTUJ6>0+7b%B zK$SC9xn~C4xaE&ETvdiavaEKsJ(bG6sKK z0xwKhsN8@L?g5Vt)YZ$Qy>i1iKG$hAvayE%oD&KNA}MM1g0o!nrFBH~b6(tMG;jCNGUmSI*HkMq^Ce+^36^*0_bjH(&wgyHQ@DogHZSoEDs+l;MVladGwCCho5_#H@yFY<#Fu)!ipY)Y6~}!(q_F& zMT1&H^4Hb_G#}uY8o}c%S)ww#88t)cW;$K3Cf3C*a=j3ZSZtAuWugSjcKKF`=Cr~N z#ig^MEYZ)!!xBa&lPNZSj2=8vz~Zp^Hu&51+`nm{aMj_lKL%0PS;CRQvjz>=^jByp zvSlfirp)CAmN<~8w5pU5d`G60M32s!nxaeDa5kxLm2^Yt_Lw?d(znQfk`g#%(=4mO zWR?^89$%v+D_bu5R9`09)yJlE8!W=_9L}ro_9HMeeE$}>urpk~;B-5O?_6@ey9-@i G=hQ!`Tcw`> 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" + ] +}