245 lines
7.9 KiB
TypeScript
245 lines
7.9 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) {
|
|
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;
|
|
}
|
|
|
|
|
|
/**
|
|
* Handles the event when a square is clicked, which includes moving the mine if the player hits a mine on the first
|
|
* click.
|
|
*
|
|
* @param square the square that was clicked on
|
|
*/
|
|
onUncover(square: Square): void {
|
|
if (!this.started) {
|
|
this.started = true;
|
|
this.startTime = Date.now();
|
|
|
|
const squareAndNeighs = [square].concat(square.getNeighbors());
|
|
squareAndNeighs
|
|
.filter(it => it.hasMine)
|
|
.forEach(it => {
|
|
it.hasMine = false;
|
|
this.squareList.filter(it => !it.hasMine && squareAndNeighs.indexOf(it) < 0)[0].hasMine = true;
|
|
});
|
|
}
|
|
|
|
if (!square.hasMine) {
|
|
this.coveredRemaining = this.squareList.filter(it => !it.hasMine && it.isCovered).length;
|
|
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.isCovered || this.hasFlag || this.field.won || this.field.lost) return;
|
|
|
|
this.isCovered = false;
|
|
this.hasFlag = false;
|
|
this.field.onUncover(this); // Also moves mine on first click
|
|
|
|
if (!this.hasMine && this.getNeighborCount(it => it.hasMine) === 0)
|
|
this.chord();
|
|
}
|
|
}
|