minesweeper/src/main/js/Field.ts

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();
}
}