Toggle control buttons depending on game state

Works towards #88.
This commit is contained in:
Florine W. Dekker 2022-11-26 12:55:28 +01:00
parent b4f2586aa8
commit 9843b24770
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
8 changed files with 134 additions and 40 deletions

View File

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

View File

@ -71,10 +71,10 @@
</canvas>
</div>
<footer class="controls">
<a role="button" href="#" id="undo"><i class="fa fa-undo"></i>&emsp;Undo</a>
<a role="button" href="#" id="redo"><i class="fa fa-repeat"></i>&emsp;Redo</a>
<a role="button" href="#" id="hint"><i class="fa fa-lightbulb-o"></i>&emsp;Hint</a>
<a role="button" href="#" id="solve"><i class="fa fa-key"></i>&emsp;Solve</a>
<a role="button" id="undo"><i class="fa fa-undo"></i>&emsp;Undo</a>
<a role="button" id="redo"><i class="fa fa-repeat"></i>&emsp;Redo</a>
<a role="button" id="hint"><i class="fa fa-lightbulb-o"></i>&emsp;Hint</a>
<a role="button" id="solve"><i class="fa fa-key"></i>&emsp;Solve</a>
</footer>
</article>
</section>

View File

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

View File

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

View File

@ -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.
*

View File

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

View File

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

View File

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