550 lines
19 KiB
TypeScript
550 lines
19 KiB
TypeScript
// @ts-ignore
|
|
import alea from "alea";
|
|
import {Action, ActionHistory} from "./Action";
|
|
import {chunkifyArray, shuffleArrayInPlace} from "./Common";
|
|
import {findDifficulty} from "./Difficulty";
|
|
import {HighScores} from "./HighScores";
|
|
import {Solver} from "./Solver";
|
|
import {Statistics} from "./Statistics";
|
|
import {Timer} from "./Timer";
|
|
// @ts-ignore
|
|
const {MemoryStorage} = window.fwdekker.storage;
|
|
|
|
|
|
/**
|
|
* A playing field for a game of Minesweeper.
|
|
*/
|
|
export class Field {
|
|
private readonly statistics: Statistics;
|
|
private readonly highScores: HighScores;
|
|
private readonly history = new ActionHistory();
|
|
private readonly rng: any;
|
|
private timer = new Timer();
|
|
|
|
readonly width: number;
|
|
readonly height: number;
|
|
readonly mineCount: number;
|
|
readonly squareList: Square[] = [];
|
|
readonly squares: Square[][] = [];
|
|
readonly isSolvable: boolean;
|
|
|
|
private _coveredNonMineCount: number;
|
|
get coveredNonMineCount(): number {
|
|
return this._coveredNonMineCount;
|
|
}
|
|
|
|
private _flagCount: number = 0;
|
|
get flagCount(): number {
|
|
return this._flagCount;
|
|
}
|
|
|
|
private _hasStarted: boolean = false;
|
|
get hasStarted(): boolean {
|
|
return this._hasStarted;
|
|
}
|
|
|
|
private _hasWonBefore: boolean = false;
|
|
private _hasWon: boolean = false;
|
|
get hasWon(): boolean {
|
|
return this._hasWon;
|
|
}
|
|
|
|
private _hasLost: boolean = false;
|
|
get hasLost(): boolean {
|
|
return this._hasLost;
|
|
}
|
|
|
|
private _deathCount: number = 0;
|
|
get deathCount(): number {
|
|
return this._deathCount;
|
|
}
|
|
|
|
isAutoSolving: boolean = false;
|
|
|
|
|
|
/**
|
|
* 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
|
|
* @param statistics the statistics tracker, or `null` if statistics should not be tracked
|
|
* @param highScores the tracker to store new scores in
|
|
*/
|
|
constructor(width: number, height: number, mineCount: number, solvable: boolean, seed: number,
|
|
statistics: Statistics, highScores: HighScores) {
|
|
if (mineCount > Field.maxMines(width, height))
|
|
throw new Error(`Mine count must be at most ${Field.maxMines(width, height)}, but was ${mineCount}.`);
|
|
|
|
this.statistics = statistics;
|
|
this.highScores = highScores;
|
|
|
|
this.width = width;
|
|
this.height = height;
|
|
this.mineCount = mineCount;
|
|
this.rng = alea("" + seed);
|
|
this.isSolvable = 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._coveredNonMineCount = this.size - this.mineCount;
|
|
this.shuffle(this.rng.uint32());
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
new Statistics(new MemoryStorage()),
|
|
new HighScores(new MemoryStorage())
|
|
);
|
|
|
|
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._coveredNonMineCount = this.coveredNonMineCount;
|
|
copy._flagCount = this.flagCount;
|
|
copy._hasStarted = this.hasStarted;
|
|
copy._hasWon = this.hasWon;
|
|
copy._hasLost = this.hasLost;
|
|
copy._deathCount = this.deathCount;
|
|
return copy;
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the square at the given coordinates, or `orElse` if there is no square there.
|
|
*
|
|
* @param coords the coordinates 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(coords: { x: number, y: number }, orElse: any = null): Square | any {
|
|
return this.squares[coords.y]?.[coords.x] ?? orElse;
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if and only if this field contains a square at the given coordinates.
|
|
*
|
|
* @param coords the coordinates to check
|
|
* @returns `true` if and only if this field contains a square at the given coordinates
|
|
*/
|
|
hasSquareAt(coords: { x: number, y: number }): boolean {
|
|
return coords.x >= 0 && coords.x < this.width && coords.y >= 0 && coords.y < this.height;
|
|
}
|
|
|
|
/**
|
|
* The number of squares in this field.
|
|
*/
|
|
get size(): number {
|
|
return this.width * this.height;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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 {
|
|
const swapAction = (source: Square, target: Square) => new Action(
|
|
() => {
|
|
source.hasMine = false;
|
|
target.hasMine = true;
|
|
},
|
|
() => {
|
|
source.hasMine = true;
|
|
target.hasMine = false;
|
|
return true;
|
|
},
|
|
() => true
|
|
);
|
|
|
|
this.runUndoably(() => {
|
|
if (square.hasMine) {
|
|
const target = this.squareList.find(it => !it.hasMine && it !== square)!;
|
|
this.addAction(swapAction(square, target));
|
|
}
|
|
|
|
square.neighbors
|
|
.filter(it => it.hasMine)
|
|
.forEach(it => {
|
|
const target = this.squareList
|
|
.find(it => !it.hasMine && it !== square && square.neighbors.indexOf(it) < 0);
|
|
|
|
if (target !== undefined)
|
|
this.addAction(swapAction(it, target));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
if (this.hasStarted)
|
|
throw new Error("Cannot shuffle mines after field has started");
|
|
|
|
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);
|
|
}
|
|
|
|
|
|
/**
|
|
* Chords the square at the given position, i.e. if the square is covered and the number of neighboring flags equals
|
|
* the number in the square, then all unflagged neighbors are uncovered.
|
|
*
|
|
* @param coords the coordinates of the square to chord
|
|
*/
|
|
chord(coords: { x: number, y: number }): void {
|
|
const square = this.squares[coords.y][coords.x];
|
|
|
|
if (square === undefined) throw new Error(`Cannot chord undefined square at (${coords}).`);
|
|
if (square.isCovered || this.hasWon || this.hasLost) return;
|
|
if (square.getNeighborCount(it => it.hasMark) > 0) return;
|
|
if (square.getNeighborCount(it => it.hasFlag) !== square.getNeighborCount(it => it.hasMine)) return;
|
|
|
|
if (!this.isAutoSolving) this.statistics.squaresChorded++;
|
|
this.runUndoably(() => {
|
|
square.neighbors
|
|
.filter(it => it.isCovered && !it.hasFlag)
|
|
.forEach(it => this.uncover(it.coords));
|
|
});
|
|
|
|
if (!this.isAutoSolving && this.hasLost) this.statistics.squaresChordedLeadingToLoss++;
|
|
}
|
|
|
|
/**
|
|
* Uncovers this square, revealing the contents beneath.
|
|
*
|
|
* @param coords the coordinates of the square to uncover
|
|
*/
|
|
uncover(coords: { x: number, y: number }): void {
|
|
const square = this.squares[coords.y][coords.x];
|
|
if (square === undefined) throw new Error(`Cannot uncover undefined square at (${coords}).`);
|
|
if (this.hasWon || this.hasLost) return;
|
|
|
|
this.runUndoably(() => {
|
|
if (!this.hasStarted) {
|
|
this.statistics.gamesStarted++;
|
|
|
|
if (this.isSolvable) {
|
|
let i = 1;
|
|
const time = Timer.time(() => {
|
|
while (!Solver.canSolve(this, coords)) {
|
|
this.shuffle(this.rng.uint32());
|
|
i++;
|
|
}
|
|
});
|
|
console.log(`Found solvable field in ${time}ms in ${i} attempts.`);
|
|
}
|
|
this.clearMines(square);
|
|
|
|
this._hasStarted = true;
|
|
this.timer.start();
|
|
// @formatter:off
|
|
this.addAction(new Action(() => {}, () => false, () => false));
|
|
// @formatter:on
|
|
}
|
|
|
|
const uncoverQueue: Square[] = [square];
|
|
while (uncoverQueue.length > 0) {
|
|
const next = uncoverQueue.pop()!;
|
|
if (!next.isCovered || next.hasFlag || next.hasMark) continue;
|
|
|
|
let remainingFlags: Square[] | undefined;
|
|
this.addAction(new Action(
|
|
() => {
|
|
next.isCovered = false;
|
|
if (!this.isAutoSolving) this.statistics.squaresUncovered++;
|
|
|
|
if (next.hasMine) {
|
|
this.timer.stop();
|
|
this._hasLost = true;
|
|
this._deathCount++;
|
|
if (!this.isAutoSolving) this.statistics.minesUncovered++;
|
|
} else {
|
|
this._coveredNonMineCount--;
|
|
if (this.coveredNonMineCount === 0) {
|
|
this.timer.stop();
|
|
remainingFlags = this.squareList.filter(it => it.isCovered && !it.hasFlag);
|
|
remainingFlags.forEach(it => it.hasFlag = true);
|
|
this._flagCount = this.mineCount;
|
|
this._hasWon = true;
|
|
|
|
if (!this._hasWonBefore) {
|
|
this._hasWonBefore = true;
|
|
if (!this.isAutoSolving) {
|
|
this.statistics.gamesWon++;
|
|
if (this.deathCount === 0) this.statistics.gamesWonWithoutLosing++;
|
|
|
|
const difficulty = findDifficulty(this.width, this.height, this.mineCount);
|
|
const score = {time: this.elapsedTime, deaths: this.deathCount};
|
|
this.highScores.addScore(difficulty, score);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
() => {
|
|
next.isCovered = true;
|
|
if (next.hasMine) {
|
|
this._hasLost = false;
|
|
this.timer.start();
|
|
} else {
|
|
this._coveredNonMineCount++;
|
|
this._hasWon = false;
|
|
if (remainingFlags !== undefined) {
|
|
remainingFlags.forEach(it => it.hasFlag = false);
|
|
this._flagCount = this.mineCount - remainingFlags.length;
|
|
}
|
|
this.timer.start();
|
|
}
|
|
return true;
|
|
},
|
|
() => true
|
|
));
|
|
|
|
if (next.hasMine) break;
|
|
if (next.getNeighborCount(it => it.hasMine) === 0)
|
|
uncoverQueue.push(...next.neighbors.filter(it => it.isCovered));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Toggles the flag at the given square.
|
|
*
|
|
* @param coords the coordinates of the square to toggle the flag at
|
|
*/
|
|
toggleFlag(coords: { x: number, y: number }): void {
|
|
const square = this.squares[coords.y][coords.x];
|
|
if (square === undefined) throw new Error(`Cannot toggle flag of undefined square at (${coords}).`);
|
|
if (!square.isCovered || square.hasMark || this.hasWon || this.hasLost) return;
|
|
|
|
this.addAction(new Action(
|
|
() => {
|
|
square.hasFlag = !square.hasFlag;
|
|
if (!this.isAutoSolving && square.hasFlag) this.statistics.squaresFlagged++;
|
|
this._flagCount += (square.hasFlag ? 1 : -1);
|
|
},
|
|
() => {
|
|
square.hasFlag = !square.hasFlag;
|
|
this._flagCount += (square.hasFlag ? 1 : -1);
|
|
return true;
|
|
},
|
|
() => true
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Toggles the question mark at the given square.
|
|
*
|
|
* @param coords the coordinates of the square to toggle the question mark at
|
|
*/
|
|
toggleMark(coords: { x: number, y: number }): void {
|
|
const square = this.squares[coords.y][coords.x];
|
|
if (square === undefined) throw new Error(`Cannot toggle flag of undefined square at (${coords}).`);
|
|
if (!square.isCovered || square.hasFlag || this.hasWon || this.hasLost) return;
|
|
|
|
this.addAction(new Action(
|
|
() => {
|
|
square.hasMark = !square.hasMark;
|
|
if (!this.isAutoSolving && square.hasMark) this.statistics.squaresMarked++;
|
|
},
|
|
() => {
|
|
square.hasMark = !square.hasMark;
|
|
if (!this.isAutoSolving && square.hasMark) this.statistics.squaresMarked++;
|
|
return true;
|
|
},
|
|
() => true
|
|
));
|
|
}
|
|
|
|
|
|
/**
|
|
* Runs the given callback such that all calls to `#addAction` can be undone with a single invocation of `#undo`.
|
|
*
|
|
* Calling this function again inside the callback adds all actions inside the inner callback does not create a new
|
|
* undoable unit of actions.
|
|
*
|
|
* @param callback a function such that all its calls to `#addAction` should be undoable with a single invocation of
|
|
* `#undo`
|
|
*/
|
|
runUndoably(callback: () => void): void {
|
|
this.history.startSequence();
|
|
callback();
|
|
this.history.commitSequence();
|
|
}
|
|
|
|
/**
|
|
* Stores the given action such that it can be undone.
|
|
*
|
|
* If this method is not called in `#runUndoably`, the given action will be added to its own undoable unit.
|
|
*
|
|
* @param action the action that can be undone
|
|
* @private
|
|
*/
|
|
private addAction(action: Action): void {
|
|
if (this.history.hasUncommittedSequence)
|
|
this.history.addAction(action);
|
|
else
|
|
this.runUndoably(() => this.history.addAction(action));
|
|
|
|
action.run();
|
|
}
|
|
|
|
/**
|
|
* Redoes the last `amount` actions in the field's history, if possible.
|
|
*
|
|
* Redoing an action removes all subsequent actions from its history.
|
|
*
|
|
* @param amount the maximum amount of actions to redo, or all future actions if `undefined`
|
|
*/
|
|
redo(amount: number | undefined = undefined): void {
|
|
const wasLost = this.hasLost;
|
|
|
|
const redone = this.history.redo(amount);
|
|
if (!this.isAutoSolving && redone > 0) this.statistics.actionsRedone++;
|
|
if (!this.isAutoSolving && !wasLost && this.hasLost) this.statistics.lossesRedone++;
|
|
}
|
|
|
|
/**
|
|
* Undoes the last `amount` actions in the field's history, if any.
|
|
*
|
|
* @param amount the maximum amount of actions to undo, or all past actions if `undefined`
|
|
*/
|
|
undo(amount: number | undefined = undefined): void {
|
|
const wasLost = this.hasLost;
|
|
|
|
const undone = this.history.undo(amount);
|
|
if (!this.isAutoSolving && undone > 0) this.statistics.actionsUndone++;
|
|
if (!this.isAutoSolving && wasLost && !this.hasLost) this.statistics.lossesUndone++;
|
|
}
|
|
|
|
|
|
/**
|
|
* The time the player has played on this field so far.
|
|
*/
|
|
get elapsedTime(): number {
|
|
return this.timer.elapsedTime;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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;
|
|
private _neighbors: Square[] | undefined = undefined;
|
|
readonly x: number;
|
|
readonly y: number;
|
|
isCovered: boolean;
|
|
hasMine: boolean;
|
|
hasFlag: boolean;
|
|
hasMark: 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;
|
|
this.hasMark = 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;
|
|
copy.hasMark = this.hasMark;
|
|
return copy;
|
|
}
|
|
|
|
|
|
/**
|
|
* The coordinates of this square in the field.
|
|
*/
|
|
get coords(): { x: number, y: number } {
|
|
return {x: this.x, y: this.y};
|
|
}
|
|
|
|
/**
|
|
* The `Square`s that are adjacent to this square.
|
|
*/
|
|
get neighbors(): Square[] {
|
|
if (this._neighbors === undefined) {
|
|
this._neighbors = [
|
|
this.field.getSquareOrElse({x: this.x - 1, y: this.y - 1}),
|
|
this.field.getSquareOrElse({x: this.x, y: this.y - 1}),
|
|
this.field.getSquareOrElse({x: this.x + 1, y: this.y - 1}),
|
|
this.field.getSquareOrElse({x: this.x - 1, y: this.y}),
|
|
this.field.getSquareOrElse({x: this.x + 1, y: this.y}),
|
|
this.field.getSquareOrElse({x: this.x - 1, y: this.y + 1}),
|
|
this.field.getSquareOrElse({x: this.x, y: this.y + 1}),
|
|
this.field.getSquareOrElse({x: this.x + 1, y: this.y + 1}),
|
|
].filter(it => it !== null);
|
|
}
|
|
|
|
return this._neighbors!;
|
|
}
|
|
|
|
/**
|
|
* 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.neighbors.filter(property).length;
|
|
}
|
|
}
|