diff --git a/package.json b/package.json index c6e4ea5..6729f52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minesweeper", - "version": "0.79.3", + "version": "0.80.0", "description": "Just Minesweeper!", "author": "Felix W. Dekker", "browser": "dist/bundle.js", diff --git a/src/main/index.html b/src/main/index.html index 5eee63f..f311b26 100644 --- a/src/main/index.html +++ b/src/main/index.html @@ -68,6 +68,11 @@ + +
+ +
+
diff --git a/src/main/js/Display.ts b/src/main/js/Display.ts index d571e51..ceba566 100644 --- a/src/main/js/Display.ts +++ b/src/main/js/Display.ts @@ -15,6 +15,7 @@ export class Display { private field: Field | null = null; private winTime: number | null = null; private loseTime: number | null = null; + hintSquare: Square | null = null; mouseSquare: Square | null = null; mouseHoldUncover: boolean = false; mouseHoldChord: boolean = false; @@ -154,6 +155,7 @@ export class Display { * @param field the field to draw, or `null` if no field should be drawn */ setField(field: Field | null): void { + this.hintSquare = null; this.field = field; if (this.field === null) return; @@ -294,6 +296,13 @@ export class Display { .filter(it => it.getNeighborCount(it => it.hasMine) < it.getNeighborCount(it => it.hasFlag)) .forEach(square => ctx.fillRect(square.x * this.scale, square.y * this.scale, this.scale, this.scale)); ctx.restore(); + + if (this.hintSquare !== null) { + ctx.save(); + ctx.fillStyle = "rgba(0, 255, 0, 0.3)"; + ctx.fillRect(this.hintSquare.x * this.scale, this.hintSquare.y * this.scale, this.scale, this.scale); + ctx.restore(); + } } /** diff --git a/src/main/js/Game.ts b/src/main/js/Game.ts index 171f086..bdfb56f 100644 --- a/src/main/js/Game.ts +++ b/src/main/js/Game.ts @@ -27,6 +27,7 @@ export class Game { private readonly seedInput: HTMLFormElement; private readonly undoForm: HTMLFormElement; private readonly redoForm: HTMLFormElement; + private readonly hintForm: HTMLFormElement; private readonly solveForm: HTMLFormElement; private readonly customDifficultyOverlay: Overlay; private readonly widthInput: HTMLInputElement; @@ -195,6 +196,20 @@ export class Game { } ); + // Hint + this.hintForm = $("#hintForm"); + this.hintForm.addEventListener( + "submit", + event => { + event.preventDefault(); + + if (this.field !== null) { + this.statistics.hintsRequested++; + this.display.hintSquare = (new Solver()).getHint(this.field); + } + } + ); + // Solve this.solveForm = $("#solveForm"); this.solveForm.addEventListener( @@ -202,8 +217,10 @@ export class Game { event => { event.preventDefault(); - if (this.field !== null) - new Solver().solve(this.field); + if (this.field !== null) { + this.statistics.solverUsages++; + (new Solver()).solve(this.field); + } } ); @@ -281,6 +298,7 @@ export class Game { } }); + this.display.hintSquare = null; this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord; this.display.mouseHoldChord = this.leftDown && this.rightDown; } @@ -318,6 +336,7 @@ export class Game { } }); + this.display.hintSquare = null; this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord; this.display.mouseHoldChord = this.leftDown && this.rightDown; } diff --git a/src/main/js/Solver.ts b/src/main/js/Solver.ts index 8469e8e..78e4e54 100644 --- a/src/main/js/Solver.ts +++ b/src/main/js/Solver.ts @@ -87,6 +87,36 @@ export class Solver { return copy.hasWon; } + /** + * Returns a suggestion for a next move based on the current state of the field. + * + * @param field the field to suggest a move for + * @returns a suggestion for a next move based on the current state of the field + */ + getHint(field: Field): Square | null { + if (!field.hasStarted || field.hasWon || field.hasLost) return null; + + let candidate: Square | undefined; + const knowns = this.getKnowns(field); + + candidate = knowns.find(square => + // Can chord + square.getNeighborCount(it => it.hasFlag) === square.getNeighborCount(it => it.hasMine) || + // Can flag + square.getNeighborCount(it => it.isCovered && !it.hasFlag) === + (square.getNeighborCount(it => it.hasMine) - square.getNeighborCount(it => it.hasFlag)) + ); + if (candidate !== undefined) return candidate; + + candidate = knowns.find(square => + this.matrixSolve(field, square.neighbors.filter(it => !it.isCovered).concat(square), true) + .some(it => it !== undefined) + ); + if (candidate !== undefined) return candidate; + + return null; + } + /** * Solves the field as much as by considering just one square at a time and looking for trivial solutions. @@ -98,8 +128,7 @@ export class Solver { * @private */ private stepSingleSquares(field: Field): void { - field.squareList - .filter(it => !it.isCovered) + this.getKnowns(field) .forEach(square => { field.chord(square); if (square.getNeighborCount(it => it.isCovered) === square.getNeighborCount(it => it.hasMine)) @@ -120,19 +149,12 @@ export class Solver { * @private */ private stepNeighboringSquares(field: Field): void { - const knowns = field.squareList - .filter(it => !it.isCovered) - .filter(it => it.getNeighborCount(it => it.isCovered && !it.hasFlag) > 0); + const knowns = this.getKnowns(field); knowns.forEach(known => { - const system = this.matrixSolve(field, known.neighbors.filter(it => !it.isCovered).concat(known), true); - - system.forEach(target => { - if (target === undefined) return; - - const [solution, square] = target; - if (solution === 0) field.uncover(square.coords); - else if (solution === 1) field.flag(square.coords); - }); + this.applySolution( + field, + this.matrixSolve(field, known.neighbors.filter(it => !it.isCovered).concat(known), true) + ); }); } @@ -148,18 +170,11 @@ export class Solver { private stepAllSquares(field: Field): void { if (!field.hasStarted || field.hasWon || field.hasLost) return; - const knowns = field.squareList - .filter(it => !it.isCovered) - .filter(it => it.getNeighborCount(it => it.isCovered && !it.hasFlag) > 0); - const system = this.matrixSolve(field, knowns, false); - - system.forEach(target => { - if (target === undefined) return; - - const [solution, square] = target; - if (solution === 0) field.uncover(square.coords); - else if (solution === 1) field.flag(square.coords); - }); + const knowns = this.getKnowns(field); + this.applySolution( + field, + this.matrixSolve(field, knowns, false) + ); } /** @@ -200,6 +215,38 @@ export class Solver { return new Matrix(matrix).solveBinary() .map((it, i) => it === undefined ? undefined : [it, unknowns[i]]); } + + + /** + * Returns all uncovered squares that have at least one covered unflagged neighbor. + * + * @param field the field to find the known squares in + * @returns all uncovered squares that have at least one covered unflagged neighbor + * @private + */ + private getKnowns(field: Field): Square[] { + return field.squareList + .filter(it => !it.isCovered) + .filter(it => it.getNeighborCount(it => it.isCovered && !it.hasFlag) > 0); + } + + /** + * Applies the given solution to the field. + * + * @param field the field to apply the solution to + * @param solution an array in which each `undefined` element is ignored, and each pair contains a square and the + * number `1` if the square contains a mine or the number `0` if the square does not contain a mine + * @private + */ + private applySolution(field: Field, solution: ([number, Square] | undefined)[]): void { + solution.forEach(target => { + if (target === undefined) return; + + const [solution, square] = target; + if (solution === 0) field.uncover(square.coords); + else if (solution === 1) field.flag(square.coords); + }); + } } diff --git a/src/main/js/Statistics.ts b/src/main/js/Statistics.ts index 7a2e4a9..22b9848 100644 --- a/src/main/js/Statistics.ts +++ b/src/main/js/Statistics.ts @@ -8,6 +8,7 @@ export interface Statistics { actionsUndone: number; actionsRedone: number; lossesUndone: number; + timeSpent: number; gamesStarted: number; gamesLost: number; @@ -19,7 +20,8 @@ export interface Statistics { squaresFlagged: number; squaresUncovered: number; - timeSpent: number; + hintsRequested: number; + solverUsages: number; /** @@ -89,6 +91,16 @@ export class LocalStatistics implements Statistics { this.write(statistics); } + get timeSpent(): number { + return +(this.read()["timeSpent"] ?? 0); + } + + set timeSpent(value: number) { + const statistics = this.read(); + statistics["timeSpent"] = "" + value; + this.write(statistics); + } + get gamesStarted(): number { return +(this.read()["gamesStarted"] ?? 0); } @@ -169,13 +181,23 @@ export class LocalStatistics implements Statistics { this.write(statistics); } - get timeSpent(): number { - return +(this.read()["timeSpent"] ?? 0); + get hintsRequested(): number { + return +(this.read()["hintsRequested"] ?? 0); } - set timeSpent(value: number) { + set hintsRequested(value: number) { const statistics = this.read(); - statistics["timeSpent"] = "" + value; + statistics["hintsRequested"] = "" + value; + this.write(statistics); + } + + get solverUsages(): number { + return +(this.read()["solverUsages"] ?? 0); + } + + set solverUsages(value: number) { + const statistics = this.read(); + statistics["solverUsages"] = "" + value; this.write(statistics); } @@ -243,6 +265,18 @@ export class LocalStatistics implements Statistics { Squares uncovered ${this.squaresUncovered} + + +

Solver usage

+ + + + + + + + +
Hints requested${this.hintsRequested}
Solver usages${this.solverUsages}
`; } } @@ -254,6 +288,7 @@ export class MemoryStatistics implements Statistics { actionsUndone: number = 0; actionsRedone: number = 0; lossesUndone: number = 0; + timeSpent: number = 0; gamesLost: number = 0; gamesStarted: number = 0; gamesWon: number = 0; @@ -262,13 +297,15 @@ export class MemoryStatistics implements Statistics { squaresChordedLeadingToLoss: number = 0; squaresFlagged: number = 0; squaresUncovered: number = 0; - timeSpent: number = 0; + hintsRequested: number = 0; + solverUsages: number = 0; clear() { this.actionsUndone = 0; this.actionsRedone = 0; this.lossesUndone = 0; + this.timeSpent = 0; this.gamesLost = 0; this.gamesStarted = 0; this.gamesWon = 0; @@ -277,6 +314,7 @@ export class MemoryStatistics implements Statistics { this.squaresChordedLeadingToLoss = 0; this.squaresFlagged = 0; this.squaresUncovered = 0; - this.timeSpent = 0; + this.hintsRequested = 0; + this.solverUsages = 0; } }