360 lines
12 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
}
|