286 lines
9.3 KiB
TypeScript
286 lines
9.3 KiB
TypeScript
import {chunkifyArray, shuffleArrayInPlace} from "./Common";
|
|
|
|
|
|
/**
|
|
* A playing field for a game of Minesweeper.
|
|
*/
|
|
export class Field {
|
|
readonly width: number;
|
|
readonly height: number;
|
|
readonly mineCount: number;
|
|
readonly squareList: Square[];
|
|
readonly squares: any;
|
|
|
|
coveredRemaining: number;
|
|
started: boolean;
|
|
startTime: number | undefined;
|
|
endTime: number | undefined;
|
|
won: boolean;
|
|
lost: boolean;
|
|
|
|
|
|
/**
|
|
* 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 seed the seed to generate the field with
|
|
*/
|
|
constructor(width: number, height: number, mineCount: number, seed: number | undefined = undefined) {
|
|
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;
|
|
|
|
const mines = Array(width * height).fill(true, 0, mineCount).fill(false, mineCount);
|
|
shuffleArrayInPlace(mines, seed);
|
|
|
|
this.squareList =
|
|
mines.map((hasMine, i) => new Square(this, i % this.width, Math.floor(i / this.width), hasMine));
|
|
this.squares = chunkifyArray(this.squareList, this.width);
|
|
|
|
this.coveredRemaining = this.width * this.height - this.mineCount;
|
|
this.started = false;
|
|
this.startTime = undefined;
|
|
this.endTime = undefined;
|
|
this.won = false;
|
|
this.lost = false;
|
|
}
|
|
|
|
/**
|
|
* Returns a deep copy of this field.
|
|
*
|
|
* @return a deep copy of this field
|
|
*/
|
|
copy(): Field {
|
|
const copy = new Field(this.width, this.height, this.mineCount, undefined);
|
|
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.coveredRemaining = this.coveredRemaining;
|
|
copy.started = this.started;
|
|
copy.startTime = this.startTime;
|
|
copy.endTime = this.endTime;
|
|
copy.won = this.won;
|
|
copy.lost = this.lost;
|
|
return copy;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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
|
|
* @return 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;
|
|
}
|
|
|
|
/**
|
|
* Returns the time in milliseconds that clearing the field takes or has taken.
|
|
*
|
|
* If the game has not started, returns 0.
|
|
* If the game has not finished, returns the time since it has started.
|
|
* Otherwise, returns the time it took from start to finish.
|
|
*
|
|
* @returns the time in milliseconds that clearing the field takes or has taken
|
|
*/
|
|
getTime(): number {
|
|
return this.startTime !== undefined
|
|
? (this.endTime !== undefined
|
|
? this.endTime - this.startTime
|
|
: Date.now() - this.startTime)
|
|
: 0;
|
|
}
|
|
|
|
/**
|
|
* Returns the number of flags that has been placed.
|
|
*
|
|
* @return the number of flags that has been placed
|
|
*/
|
|
getFlagCount(): number {
|
|
return this.squareList.filter(it => it.hasFlag).length;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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.started = true;
|
|
this.startTime = Date.now();
|
|
|
|
if (square.hasMine) {
|
|
square.hasMine = false;
|
|
this.squareList.find(it => !it.hasMine && it !== square)!.hasMine = true;
|
|
}
|
|
|
|
square.getNeighbors()
|
|
.filter(it => it.hasMine)
|
|
.forEach(it => {
|
|
const candidate = this.squareList
|
|
.find(it => !it.hasMine && it !== square && square.getNeighbors().indexOf(it) < 0);
|
|
if (candidate === undefined) return;
|
|
|
|
it.hasMine = false;
|
|
candidate.hasMine = true;
|
|
});
|
|
}
|
|
|
|
if (!square.hasMine) {
|
|
this.coveredRemaining--;
|
|
if (this.coveredRemaining === 0) {
|
|
this.endTime = Date.now();
|
|
this.squareList.filter(it => it.isCovered && !it.hasFlag).forEach(it => it.flag());
|
|
this.won = true;
|
|
}
|
|
} else {
|
|
this.endTime = Date.now();
|
|
this.lost = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @return 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.
|
|
*/
|
|
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.
|
|
*/
|
|
flag(): void {
|
|
if (!this.isCovered || this.field.won || this.field.lost) return;
|
|
|
|
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;
|
|
|
|
next.isCovered = false;
|
|
next.hasFlag = false;
|
|
this.field.onUncover(next); // Also moves mine on first click
|
|
|
|
if (!next.hasMine && next.getNeighborCount(it => it.hasMine || it.hasFlag) === 0)
|
|
uncoverQueue.push(...next.getNeighbors().filter(it => it.isCovered));
|
|
}
|
|
}
|
|
}
|