diff --git a/package.json b/package.json index 1c2520a..ca9240b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minesweeper", - "version": "0.72.2", + "version": "0.73.0", "description": "Just Minesweeper!", "author": "Felix W. Dekker", "browser": "dist/bundle.js", diff --git a/src/main/js/Display.ts b/src/main/js/Display.ts index 1164c5b..edec06a 100644 --- a/src/main/js/Display.ts +++ b/src/main/js/Display.ts @@ -156,19 +156,20 @@ export class Display { } /** - * Returns the square at the given coordinates, or `null` if there is no square there. + * Returns the square grid coordinates for the given client coordinates. + * + * Note that the returned coordinates need not actually be valid in the current field. * * @param pos the client-relative pixel coordinates to find the square at - * @returns the square at the given coordinates + * @returns the square grid coordinates corresponding to the given client coordinates */ - posToSquare(pos: { x: number, y: number }): Square | null { + posToSquare(pos: { x: number, y: number }): {x: number, y: number} { const rect = this.canvas.getBoundingClientRect(); const offset = this.getFieldOffset(); - return this.field?.getSquareOrElse( - Math.floor((pos.x - rect.left - this.canvas.clientLeft - offset.x) / this.scale), - Math.floor((pos.y - rect.top - this.canvas.clientTop - offset.y) / this.scale), - null - ); + return { + x: Math.floor((pos.x - rect.left - this.canvas.clientLeft - offset.x) / this.scale), + y: Math.floor((pos.y - rect.top - this.canvas.clientTop - offset.y) / this.scale) + }; } diff --git a/src/main/js/Field.ts b/src/main/js/Field.ts index ca17a2d..fcec2df 100644 --- a/src/main/js/Field.ts +++ b/src/main/js/Field.ts @@ -101,7 +101,7 @@ export class Field { */ onUncover(square: Square): void { if (square.isCovered) - throw new Error(`Cannot invoke 'onUncover' on covered square at (${square.x}, ${square.y}).`); + throw new Error(`Cannot invoke 'onUncover' on covered square at (${square.coords}).`); if (!this.started) { this.history.addAction(new UnAction()); @@ -114,7 +114,7 @@ export class Field { this.coveredRemaining--; if (this.coveredRemaining === 0) { this.timer.stop(); - this.squareList.filter(it => it.isCovered && !it.hasFlag).forEach(it => it.flag()); + this.squareList.filter(it => it.isCovered && !it.hasFlag).forEach(it => this.flag(it.coords)); this.won = true; } } else { @@ -139,13 +139,13 @@ export class Field { copy = this.copy(); copy.history.startSequence(); - const copySquare = copy.getSquareOrElse(clickedSquare.x, clickedSquare.y)!; + const copySquare = copy.getSquareOrElse(clickedSquare.coords)!; copySquare.isCovered = true; - copySquare.uncover(); + copy.uncover(copySquare.coords); copy.history.commitSequence(); new Solver().solve(copy); - } while(!copy.won); + } while (!copy.won); } this.clearMines(clickedSquare); } @@ -181,13 +181,22 @@ export class Field { /** * 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 coords the coordinates of the square to look up * @param orElse the value to return if there is no square at the given coordinates * @returns 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; + getSquareOrElse(coords: { x: number, y: number }, orElse: any = undefined): Square | any { + return this.squares[coords.y]?.[coords.x] ?? orElse; + } + + /** + * Returns `true` if and only if this field contains a square at the given coordinates. + * + * @param coords the coordinates to check + * @returns `true` if and only if this field contains a square at the given coordinates + */ + hasSquareAt(coords: { x: number, y: number }): boolean { + return coords.x >= 0 && coords.x < this.width && coords.y >= 0 && coords.y < this.height; } /** @@ -205,6 +214,63 @@ export class Field { } + /** + * Chords the square at the given position, i.e. if the square is covered and the number of neighboring flags equals + * the number in the square, then all unflagged neighbors are uncovered. + * + * @param coords the coordinates of the square to chord + */ + chord(coords: { x: number, y: number }): void { + const square = this.squares[coords.y][coords.x]; + + if (square === undefined) throw new Error(`Cannot chord undefined square at (${coords}).`); + if (square.isCovered || this.won || this.lost) return; + if (square.getNeighborCount(it => it.hasFlag) !== square.getNeighborCount(it => it.hasMine)) return; + + square.neighbors + .filter(it => it.isCovered && !it.hasFlag) + .forEach(it => this.uncover(it.coords)); + } + + /** + * Toggles the flag at the given square. + * + * @param coords the coordinates of the square to flag + */ + flag(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 || this.won || this.lost) return; + + this.addAction(new FlagAction(square)); + square.hasFlag = !square.hasFlag; + } + + /** + * Uncovers this square, revealing the contents beneath. + * + * @param coords the coordinates of the square to uncover + */ + 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.won || this.lost) return; + + const uncoverQueue: Square[] = [square]; + while (uncoverQueue.length > 0) { + const next = uncoverQueue.pop()!; + if (!next.isCovered || next.hasFlag) continue; + + this.addAction(new UncoverAction(next)); + next.isCovered = false; + this.onUncover(next); // Also moves mine on first click and swaps mines through field + + if (!next.hasMine && next.getNeighborCount(it => it.hasMine || it.hasFlag) === 0) + uncoverQueue.push(...next.neighbors.filter(it => it.isCovered)); + } + } + + /** * Runs the given callback such that all calls to `#addAction` can be undone with a single invocation of `#undo`. * @@ -310,20 +376,27 @@ export class Square { } + /** + * The coordinates of this square in the field. + */ + get coords(): { x: number, y: number } { + return {x: this.x, y: this.y}; + } + /** * The `Square`s that are adjacent to this square. */ get neighbors(): Square[] { if (this._neighbors === undefined) { this._neighbors = [ - 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), + this.field.getSquareOrElse({x: this.x - 1, y: this.y - 1}), + this.field.getSquareOrElse({x: this.x, y: this.y - 1}), + this.field.getSquareOrElse({x: this.x + 1, y: this.y - 1}), + this.field.getSquareOrElse({x: this.x - 1, y: this.y}), + this.field.getSquareOrElse({x: this.x + 1, y: this.y}), + this.field.getSquareOrElse({x: this.x - 1, y: this.y + 1}), + this.field.getSquareOrElse({x: this.x, y: this.y + 1}), + this.field.getSquareOrElse({x: this.x + 1, y: this.y + 1}), ].filter(it => it !== undefined); } @@ -339,52 +412,4 @@ export class Square { getNeighborCount(property: (neighbor: Square) => boolean): number { return this.neighbors.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. - * - * The `#startSequence` function on this square's field must be called before invoking this function. - */ - 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.neighbors - .filter(it => it.isCovered && !it.hasFlag) - .forEach(it => it.uncover()); - } - - /** - * Adds or removes a flag at this square. - * - * The `#startSequence` function on this square's field must be called before invoking this function. - */ - flag(): void { - if (!this.isCovered || this.field.won || this.field.lost) return; - - this.field.addAction(new FlagAction(this)); - this.hasFlag = !this.hasFlag; - } - - /** - * Uncovers this square, revealing the contents beneath. - */ - uncover(): void { - if (this.field.won || this.field.lost) return; - - const uncoverQueue: Square[] = [this]; - while (uncoverQueue.length > 0) { - const next = uncoverQueue.pop()!; - if (!next.isCovered || next.hasFlag) continue; - - this.field.addAction(new UncoverAction(next)); - next.isCovered = false; - this.field.onUncover(next); // Also moves mine on first click and swaps mines through field - - if (!next.hasMine && next.getNeighborCount(it => it.hasMine || it.hasFlag) === 0) - uncoverQueue.push(...next.neighbors.filter(it => it.isCovered)); - } - } } diff --git a/src/main/js/Game.ts b/src/main/js/Game.ts index 8edd2e6..e17c655 100644 --- a/src/main/js/Game.ts +++ b/src/main/js/Game.ts @@ -206,7 +206,12 @@ export class Game { // Canvas this.canvas.addEventListener( "mousemove", - event => this.display.mouseSquare = this.display.posToSquare({x: event.clientX, y: event.clientY}) + event => { + this.display.mouseSquare = this.field?.getSquareOrElse( + this.display.posToSquare({x: event.clientX, y: event.clientY}), + null + ); + } ); this.canvas.addEventListener( "mouseleave", @@ -226,13 +231,16 @@ export class Game { if (this.field === null) return; this.field.runUndoably(() => { - const square = this.display.posToSquare({x: event.clientX, y: event.clientY}); + const coords = this.display.posToSquare({x: event.clientX, y: event.clientY}); + if (this.field === null || !this.field.hasSquareAt(coords)) return; + switch (event.button) { case 0: this.leftDown = true; break; case 2: - if (!this.leftDown && square !== null) square.flag(); + if (!this.leftDown) + this.field.flag(coords); this.rightDown = true; break; @@ -249,23 +257,25 @@ export class Game { if (this.field === null) return; this.field.runUndoably(() => { - const square = this.display.posToSquare({x: event.clientX, y: event.clientY}); + const coords = this.display.posToSquare({x: event.clientX, y: event.clientY}); + if (this.field === null || !this.field.hasSquareAt(coords)) return; + switch (event.button) { case 0: - if (square !== null && this.leftDown && this.rightDown) - square.chord(); - else if (square !== null && !this.holdsAfterChord && this.leftDown) - square.uncover(); + if (this.leftDown && this.rightDown) + this.field.chord(coords); + else if (!this.holdsAfterChord && this.leftDown) + this.field.uncover(coords); this.leftDown = false; this.holdsAfterChord = this.rightDown; break; case 1: - if (square !== null) square.chord(); + this.field.chord(coords); break; case 2: - if (square !== null && this.leftDown && this.rightDown) - square.chord(); + if (this.leftDown && this.rightDown) + this.field.chord(coords); this.rightDown = false; this.holdsAfterChord = this.leftDown; diff --git a/src/main/js/Solver.ts b/src/main/js/Solver.ts index d8e8b0f..1f669c2 100644 --- a/src/main/js/Solver.ts +++ b/src/main/js/Solver.ts @@ -16,13 +16,13 @@ export class Solver { solve(field: Field) { if (!field.started) { field.runUndoably(() => { - field.squareList.filter(it => it.hasFlag).forEach(it => it.flag()); - field.getSquareOrElse(Math.floor(field.width / 2), Math.floor(field.height / 2)).uncover(); + field.squareList.filter(it => it.hasFlag).forEach(it => field.flag(it.coords)); + field.uncover({x: Math.floor(field.width / 2), y: Math.floor(field.height / 2)}); }); } field.runUndoably(() => { - field.squareList.filter(it => it.hasFlag).forEach(it => it.flag()); + field.squareList.filter(it => it.hasFlag).forEach(it => field.flag(it.coords)); let flagCount = -1; let coveredCount = -1; @@ -72,8 +72,8 @@ export class Solver { system.forEach((it, i) => { const square = neighs[i]; - if (it === 0) square.uncover(); - else if (it === 1) square.flag(); + if (it === 0) field.uncover(square.coords); + else if (it === 1) field.flag(square.coords); }); } }