diff --git a/package.json b/package.json index 7956252..c864835 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minesweeper", - "version": "0.83.0", + "version": "0.83.1", "description": "Just Minesweeper!", "author": "Florine W. Dekker", "browser": "dist/bundle.js", diff --git a/src/main/index.html b/src/main/index.html index 6c6f237..7b732d1 100644 --- a/src/main/index.html +++ b/src/main/index.html @@ -71,10 +71,10 @@ diff --git a/src/main/js/Action.ts b/src/main/js/Action.ts index a54212e..5301f4b 100644 --- a/src/main/js/Action.ts +++ b/src/main/js/Action.ts @@ -146,21 +146,12 @@ export class ActionHistory { /** - * Redoes up to the last `amount` of sequences that were undone. + * Returns `true` if and only if there is an action sequence that can be undone. * - * @param amount the maximum number of sequences to redo - * @returns the actual amount of sequences that have been redone + * @returns `true` if and only if there is an action sequence that can be undone */ - redo(amount: number = this.sequences.length - (this.sequenceIndex + 1)): number { - if (this.hasUncommittedSequence) - throw new Error("Cannot redo sequences while there is an uncommitted sequence."); - - amount = Math.min(amount, this.sequences.length - (this.sequenceIndex + 1)); - for (let i = 0; i < amount; i++) { - this.sequenceIndex++; - this.currentSequence!.run(); - } - return amount; + canUndo(): boolean { + return this.sequenceIndex >= 1; } /** @@ -186,4 +177,31 @@ export class ActionHistory { } return i; } + + /** + * Returns `true` if and only if there is an action sequence that can be redone. + * + * @returns `true` if and only if there is an action sequence that can be redone + */ + canRedo(): boolean { + return this.sequenceIndex < this.sequences.length - 1; + } + + /** + * Redoes up to the last `amount` of sequences that were undone. + * + * @param amount the maximum number of sequences to redo + * @returns the actual amount of sequences that have been redone + */ + redo(amount: number = this.sequences.length - (this.sequenceIndex + 1)): number { + if (this.hasUncommittedSequence) + throw new Error("Cannot redo sequences while there is an uncommitted sequence."); + + amount = Math.min(amount, this.sequences.length - (this.sequenceIndex + 1)); + for (let i = 0; i < amount; i++) { + this.sequenceIndex++; + this.currentSequence!.run(); + } + return amount; + } } diff --git a/src/main/js/Display.ts b/src/main/js/Display.ts index 0b730c6..a949548 100644 --- a/src/main/js/Display.ts +++ b/src/main/js/Display.ts @@ -279,7 +279,7 @@ export class Display { .filter(it => it.isCovered) .filter(it => { // True if square should be covered - if (this.field!.hasLost || this.field!.hasWon || this.mouseSquare == null) + if (this.field!.isOver || this.mouseSquare == null) return true; if (this.mouseHoldUncover && this.mouseSquare === it) return it.hasFlag || it.hasMark; diff --git a/src/main/js/Field.ts b/src/main/js/Field.ts index 8c1b0ab..8ce303b 100644 --- a/src/main/js/Field.ts +++ b/src/main/js/Field.ts @@ -54,6 +54,10 @@ export class Field { return this._hasLost; } + get isOver(): boolean { + return this.hasWon || this.hasLost; + } + private _deathCount: number = 0; get deathCount(): number { return this._deathCount; @@ -61,6 +65,8 @@ export class Field { isAutoSolving: boolean = false; + private _changeListeners: (() => void)[] = []; + /** * Constructs a new playing field for a game of Minesweeper. @@ -221,7 +227,7 @@ export class Field { const square = this.squares[coords.y][coords.x]; if (square === undefined) throw new Error(`Cannot chord undefined square at (${coords}).`); - if (square.isCovered || this.hasWon || this.hasLost) return; + if (square.isCovered || this.isOver) return; if (square.getNeighborCount(it => it.hasMark) > 0) return; if (square.getNeighborCount(it => it.hasFlag) !== square.getNeighborCount(it => it.hasMine)) return; @@ -231,6 +237,7 @@ export class Field { .filter(it => it.isCovered && !it.hasFlag) .forEach(it => this.uncover(it.coords)); }); + this.invokeEventListeners(); if (!this.isAutoSolving && this.hasLost) this.statistics.squaresChordedLeadingToLoss++; } @@ -243,7 +250,7 @@ export class Field { uncover(coords: { x: number, y: number }): void { const square = this.squares[coords.y][coords.x]; if (square === undefined) throw new Error(`Cannot uncover undefined square at (${coords}).`); - if (this.hasWon || this.hasLost) return; + if (this.isOver) return; this.runUndoably(() => { if (!this.hasStarted) { @@ -331,6 +338,7 @@ export class Field { uncoverQueue.push(...next.neighbors.filter(it => it.isCovered)); } }); + this.invokeEventListeners(); } /** @@ -341,7 +349,7 @@ export class Field { toggleFlag(coords: { x: number, y: number }): void { const square = this.squares[coords.y][coords.x]; if (square === undefined) throw new Error(`Cannot toggle flag of undefined square at (${coords}).`); - if (!square.isCovered || square.hasMark || this.hasWon || this.hasLost) return; + if (!square.isCovered || square.hasMark || this.isOver) return; this.addAction(new Action( () => { @@ -356,6 +364,7 @@ export class Field { }, () => true )); + this.invokeEventListeners(); } /** @@ -366,7 +375,7 @@ export class Field { toggleMark(coords: { x: number, y: number }): void { const square = this.squares[coords.y][coords.x]; if (square === undefined) throw new Error(`Cannot toggle flag of undefined square at (${coords}).`); - if (!square.isCovered || square.hasFlag || this.hasWon || this.hasLost) return; + if (!square.isCovered || square.hasFlag || this.isOver) return; this.addAction(new Action( () => { @@ -380,6 +389,7 @@ export class Field { }, () => true )); + this.invokeEventListeners(); } @@ -416,18 +426,12 @@ export class Field { } /** - * Redoes the last `amount` actions in the field's history, if possible. + * Returns `true` if and only if there is an action that can be undone. * - * Redoing an action removes all subsequent actions from its history. - * - * @param amount the maximum amount of actions to redo, or all future actions if `undefined` + * @returns `true` if and only if there is an action that can be undone */ - redo(amount: number | undefined = undefined): void { - const wasLost = this.hasLost; - - const redone = this.history.redo(amount); - if (!this.isAutoSolving && redone > 0) this.statistics.actionsRedone++; - if (!this.isAutoSolving && !wasLost && this.hasLost) this.statistics.lossesRedone++; + canUndo(): boolean { + return this.history.canUndo(); } /** @@ -441,6 +445,32 @@ export class Field { const undone = this.history.undo(amount); if (!this.isAutoSolving && undone > 0) this.statistics.actionsUndone++; if (!this.isAutoSolving && wasLost && !this.hasLost) this.statistics.lossesUndone++; + this.invokeEventListeners(); + } + + /** + * Returns `true` if and only if there is an action that can be redone. + * + * @returns `true` if and only if there is an action that can be redone + */ + canRedo(): boolean { + return this.history.canRedo(); + } + + /** + * Redoes the last `amount` actions in the field's history, if possible. + * + * Redoing an action removes all subsequent actions from its history. + * + * @param amount the maximum amount of actions to redo, or all future actions if `undefined` + */ + redo(amount: number | undefined = undefined): void { + const wasLost = this.hasLost; + + const redone = this.history.redo(amount); + if (!this.isAutoSolving && redone > 0) this.statistics.actionsRedone++; + if (!this.isAutoSolving && !wasLost && this.hasLost) this.statistics.lossesRedone++; + this.invokeEventListeners(); } @@ -452,6 +482,25 @@ export class Field { } + /** + * Set up `changeListener` to be invoked each time the state of this field changes. + * + * @param changeListener + */ + addEventListener(changeListener: () => void): void { + this._changeListeners.push(changeListener); + } + + /** + * Invokes each registered event listener. + * + * @private + */ + private invokeEventListeners(): void { + this._changeListeners.forEach(it => it()); + } + + /** * Returns the maximum number of mines that can be placed in a `width` x `height` field. * diff --git a/src/main/js/Game.ts b/src/main/js/Game.ts index 4e872a3..c99523c 100644 --- a/src/main/js/Game.ts +++ b/src/main/js/Game.ts @@ -27,6 +27,10 @@ export class Game { private readonly heightInput: HTMLInputElement; private readonly minesInput: HTMLInputElement; private readonly solvableInput: HTMLInputElement; + private readonly undo: HTMLAnchorElement; + private readonly redo: HTMLAnchorElement; + private readonly hint: HTMLAnchorElement; + private readonly solve: HTMLAnchorElement; private readonly statisticsDiv: HTMLDivElement; private readonly highScoresDiv: HTMLDivElement; @@ -145,13 +149,16 @@ export class Game { }); // Undo - $("#undo").addEventListener("click", () => this.field?.undo(1)); + this.undo = $("#undo"); + this.undo.addEventListener("click", () => this.field?.undo(1)); // Redo - $("#redo").addEventListener("click", () => this.field?.redo(1)); + this.redo = $("#redo"); + this.redo.addEventListener("click", () => this.field?.redo(1)); // Hint - $("#hint").addEventListener( + this.hint = $("#hint"); + this.hint.addEventListener( "click", () => { if (this.field != null) { @@ -162,7 +169,8 @@ export class Game { ); // Solve - $("#solve").addEventListener( + this.solve = $("#solve"); + this.solve.addEventListener( "click", () => { if (this.field != null) { @@ -227,6 +235,7 @@ export class Game { } }); + // Canvas this.canvas.addEventListener( "mousemove", @@ -336,6 +345,25 @@ export class Game { this.minesInput.max = "" + Field.maxMines(+this.widthInput.value, +this.heightInput.value); } + /** + * Updates states of controls relating to the game. + * + * @private + */ + private updateControls(): void { + const toggleHref = (element: HTMLAnchorElement | undefined, forceOn: boolean): void => { + if (element === undefined) return; + + if (forceOn) element.href = "#"; + else element.removeAttribute("href"); + }; + + toggleHref(this.undo, !!this.field?.canUndo()); + toggleHref(this.redo, !!this.field?.canRedo()); + toggleHref(this.hint, !this.field?.isOver); + toggleHref(this.solve, !this.field?.isOver); + } + /** * Updates the statistics report in the statistics overview. * @@ -370,6 +398,7 @@ export class Game { this.highScores ); this.display.setField(this.field); + this.field.addEventListener(() => this.updateControls()); // Start timer for statistics let lastTime: number | null = null; diff --git a/src/main/js/ModalDialog.ts b/src/main/js/ModalDialog.ts index fdce313..29b4612 100644 --- a/src/main/js/ModalDialog.ts +++ b/src/main/js/ModalDialog.ts @@ -51,7 +51,6 @@ export class ModalDialog { event => { if (!(event.target instanceof Node)) return; - console.log("close"); if (event.target !== openButton && !this.dialog.contains(event.target) || this.dialog === event.target) this.close(); } @@ -101,7 +100,6 @@ export class ModalDialog { * Opens the dialog. */ open(): void { - console.log("opening"); blurActiveElement(); setTimeout(() => $("[autofocus]", this.dialog)?.focus(), 100); diff --git a/src/main/js/Solver.ts b/src/main/js/Solver.ts index b002d87..142f7cc 100644 --- a/src/main/js/Solver.ts +++ b/src/main/js/Solver.ts @@ -106,7 +106,7 @@ export class Solver { let flagCount = field.flagCount; let coveredCount = field.coveredNonMineCount; - if (field.hasWon || field.hasLost) + if (field.isOver) return false; this.stepSingleSquares(field);