minesweeper/src/main/js/Field.ts

360 lines
12 KiB
TypeScript

// @ts-ignore
import alea from "alea";
import {ActionHistory, FlagAction, SwapMineAction, UnAction, UncoverAction} from "./Action";
import {chunkifyArray, shuffleArrayInPlace} from "./Common";
import {Solver} from "./Solver";
import {Timer} from "./Timer";
/**
* A playing field for a game of Minesweeper.
*/
export class Field {
// Do not call `undo` directly on the history, thanks
readonly history = new ActionHistory();
readonly width: number;
readonly height: number;
readonly mineCount: number;
readonly squareList: Square[] = [];
readonly squares: Square[][] = [];
readonly solvable: boolean;
private readonly rng: any;
timer = new Timer();
coveredRemaining: number;
started: boolean = false;
won: boolean = false;
lost: boolean = false;
deaths: number = 0;
/**
* 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 solvable whether the field must be solvable
* @param seed the seed to generate the field with
*/
constructor(width: number, height: number, mineCount: number, solvable: boolean, seed: number) {
if (mineCount > Field.maxMines(width, height))
throw new Error(`Mine count must be at most ${Field.maxMines(width, height)}, but was ${mineCount}.`);
this.width = width;
this.height = height;
this.mineCount = mineCount;
this.rng = alea("" + seed);
this.solvable = solvable;
this.squareList = Array(this.size).fill(0)
.map((_, i) => new Square(this, i % this.width, Math.floor(i / this.width), true));
this.squares = chunkifyArray(this.squareList, this.width);
this.coveredRemaining = this.size - this.mineCount;
this.shuffle(this.rng.uint32());
}
/**
* Shuffles the mines in the field by changing the `#hasMine` property of its squares.
*
* @param seed the seed to determine the shuffling by
* @private
*/
private shuffle(seed: number): void {
const mines = Array(this.size).fill(true, 0, this.mineCount).fill(false, this.mineCount);
shuffleArrayInPlace(mines, seed);
mines.forEach((hasMine, i) => this.squareList[i].hasMine = hasMine);
}
/**
* Returns a deep copy of this field.
*
* Note that the copy cannot be guaranteed to be solvable.
*
* @returns a deep copy of this field
*/
copy(): Field {
const copy = new Field(this.width, this.height, this.mineCount, false, 0);
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.timer = this.timer.copy();
copy.coveredRemaining = this.coveredRemaining;
copy.started = this.started;
copy.won = this.won;
copy.lost = this.lost;
copy.deaths = this.deaths;
return copy;
}
/**
* Handles the event when a square is clicked, which includes moving the mine if the player hits a mine on the first
* click.
*
* This function should not be invoked when the square was already covered and is uncovered again.
*
* @param square the square that was clicked on
*/
onUncover(square: Square): void {
if (square.isCovered)
throw new Error(`Cannot invoke 'onUncover' on covered square at (${square.x}, ${square.y}).`);
if (!this.started) {
this.history.addAction(new UnAction());
this.initField(square);
this.started = true;
this.timer.start();
}
if (!square.hasMine) {
this.coveredRemaining--;
if (this.coveredRemaining === 0) {
this.timer.stop();
this.squareList.filter(it => it.isCovered && !it.hasFlag).forEach(it => it.flag());
this.won = true;
}
} else {
this.timer.stop();
this.lost = true;
this.deaths++;
}
}
/**
* Initializes the field by clearing mines around the clicked square and
*
* @param clickedSquare the first square that is clicked in the field
* @private
*/
private initField(clickedSquare: Square): void {
if (this.solvable) {
let copy: Field;
do {
this.shuffle(this.rng.uint32());
copy = this.copy();
copy.history.startSequence();
const copySquare = copy.getSquareOrElse(clickedSquare.x, clickedSquare.y)!;
copySquare.isCovered = true;
copySquare.uncover();
copy.history.commitSequence();
new Solver().solve(copy);
} while(!copy.won);
}
this.clearMines(clickedSquare);
}
/**
* Moves mines from the given square and its neighbors to other squares in the field, if possible.
*
* @param square the square from to move mines away from
* @private
*/
private clearMines(square: Square): void {
if (square.hasMine) {
const target = this.squareList.find(it => !it.hasMine && it !== square)!;
this.history.addAction(new SwapMineAction(square, target));
square.hasMine = false;
target.hasMine = true;
}
square.getNeighbors()
.filter(it => it.hasMine)
.forEach(it => {
const target = this.squareList
.find(it => !it.hasMine && it !== square && square.getNeighbors().indexOf(it) < 0);
if (target === undefined) return;
this.history.addAction(new SwapMineAction(it, target));
it.hasMine = false;
target.hasMine = true;
});
}
/**
* 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
* @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;
}
/**
* The number of flags the player has placed in this field.
*/
get flagCount(): number {
return this.squareList.filter(it => it.hasFlag).length;
}
/**
* The number of squares in this field.
*/
get size(): number {
return this.width * this.height;
}
/**
* Undoes the last `amount` actions in the field's history, if any.
*
* @param amount the amount of actions to undo, or all actions if `undefined`
*/
undo(amount: number | undefined = undefined): void {
this.history.undo(amount);
this.coveredRemaining = this.squareList.filter(it => !it.hasMine && it.isCovered).length;
if (this.coveredRemaining === this.size - this.mineCount) {
this.timer.stop();
} else if (this.coveredRemaining > 0) {
this.lost = false;
this.won = false;
this.timer.start();
}
}
/**
* Returns the maximum number of mines that can be placed in a `width` x `height` field.
*
* @param width the width of the field
* @param height the height of the field
*/
static maxMines(width: number, height: number): number {
return width * height - 1;
}
}
/**
* 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.
*
* @returns 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.
*
* 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.getNeighbors()
.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.history.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.history.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.getNeighbors().filter(it => it.isCovered));
}
}
}