minesweeper/src/main/js/Field.ts

449 lines
15 KiB
TypeScript
Raw Normal View History

// @ts-ignore
2020-08-03 17:25:03 +02:00
import alea from "alea";
2020-08-05 22:37:32 +02:00
import {Action, ActionHistory} from "./Action";
import {chunkifyArray, shuffleArrayInPlace} from "./Common";
import {Solver} from "./Solver";
import {Timer} from "./Timer";
2020-07-31 23:12:16 +02:00
/**
* A playing field for a game of Minesweeper.
*/
export class Field {
private readonly history = new ActionHistory();
2020-08-04 21:00:12 +02:00
private readonly rng: any;
private timer = new Timer();
2020-08-02 16:22:18 +02:00
2020-07-31 23:12:16 +02:00
readonly width: number;
readonly height: number;
readonly mineCount: number;
readonly squareList: Square[] = [];
readonly squares: Square[][] = [];
readonly isSolvable: boolean;
2020-07-31 23:12:16 +02:00
coveredNonMineCount: number;
flagCount: number = 0;
hasStarted: boolean = false;
hasWon: boolean = false;
hasLost: boolean = false;
deathCount: number = 0;
2020-07-31 23:12:16 +02:00
/**
* 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
2020-07-31 23:12:16 +02:00
* @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}.`);
2020-07-31 23:12:16 +02:00
this.width = width;
this.height = height;
this.mineCount = mineCount;
2020-08-03 17:25:03 +02:00
this.rng = alea("" + seed);
this.isSolvable = solvable;
2020-07-31 23:12:16 +02:00
this.squareList = Array(this.size).fill(0)
.map((_, i) => new Square(this, i % this.width, Math.floor(i / this.width), true));
2020-07-31 23:12:16 +02:00
this.squares = chunkifyArray(this.squareList, this.width);
this.coveredNonMineCount = this.size - this.mineCount;
2020-08-03 17:25:03 +02:00
this.shuffle(this.rng.uint32());
}
2020-07-31 23:12:16 +02:00
/**
* Returns a deep copy of this field.
*
* Note that the copy cannot be guaranteed to be solvable.
*
2020-08-02 13:56:28 +02:00
* @returns a deep copy of this field
2020-07-31 23:12:16 +02:00
*/
copy(): Field {
const copy = new Field(this.width, this.height, this.mineCount, false, 0);
2020-07-31 23:12:16 +02:00
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;
2020-07-31 23:12:16 +02:00
return copy;
}
2020-08-04 21:00:12 +02:00
/**
* 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 = undefined): 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 {
2020-08-05 22:37:32 +02:00
const swapAction = (source: Square, target: Square) => new Action(
() => {
source.hasMine = false;
target.hasMine = true;
2020-08-05 22:37:32 +02:00
},
() => {
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));
});
});
}
2020-08-04 20:26:05 +02:00
/**
2020-08-04 21:00:12 +02:00
* Shuffles the mines in the field by changing the `#hasMine` property of its squares.
2020-08-04 20:26:05 +02:00
*
2020-08-04 21:00:12 +02:00
* @param seed the seed to determine the shuffling by
* @private
2020-08-03 17:25:03 +02:00
*/
2020-08-04 21:00:12 +02:00
private shuffle(seed: number): void {
if (this.hasStarted)
2020-08-05 22:37:32 +02:00
throw new Error("Cannot shuffle mines after field has started");
2020-08-04 21:00:12 +02:00
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);
2020-08-02 16:22:18 +02:00
}
2020-08-04 20:26:05 +02:00
/**
* 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;
2020-08-04 20:26:05 +02:00
if (square.getNeighborCount(it => it.hasFlag) !== square.getNeighborCount(it => it.hasMine)) return;
2020-08-05 22:37:32 +02:00
this.runUndoably(() => {
square.neighbors
.filter(it => it.isCovered && !it.hasFlag)
.forEach(it => this.uncover(it.coords));
});
2020-08-04 20:26:05 +02:00
}
/**
* Toggles the flag at the given square.
*
* @param coords the coordinates of the square to flag
*/
flag(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 || this.hasWon || this.hasLost) return;
2020-08-04 20:26:05 +02:00
2020-08-05 22:37:32 +02:00
this.addAction(new Action(
() => {
square.hasFlag = !square.hasFlag;
this.flagCount += (square.hasFlag ? 1 : -1);
},
() => {
square.hasFlag = !square.hasFlag;
this.flagCount += (square.hasFlag ? 1 : -1);
return true;
},
() => true
));
2020-08-04 20:26:05 +02:00
}
/**
* 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;
2020-08-04 20:26:05 +02:00
2020-08-05 22:37:32 +02:00
this.runUndoably(() => {
if (!this.hasStarted) {
2020-08-07 12:24:56 +02:00
if (this.isSolvable) {
let i = 1;
const time = Timer.time(() => {
const solver = new Solver();
while (!solver.canSolve(this, coords)) {
this.shuffle(this.rng.uint32());
i++;
}
});
console.log(`Found solvable field in ${time}ms in ${i} attempts.`)
}
2020-08-05 22:37:32 +02:00
this.clearMines(square);
this.hasStarted = true;
2020-08-05 22:37:32 +02:00
this.timer.start();
// @formatter:off
this.addAction(new Action(() => {}, () => false, () => false));
// @formatter:on
}
2020-08-04 20:26:05 +02:00
2020-08-05 22:37:32 +02:00
const uncoverQueue: Square[] = [square];
while (uncoverQueue.length > 0) {
const next = uncoverQueue.pop()!;
if (!next.isCovered || next.hasFlag) continue;
2020-08-07 00:19:17 +02:00
let remainingFlags: Square[] | undefined;
2020-08-05 22:37:32 +02:00
this.addAction(new Action(
() => {
next.isCovered = false;
if (next.hasMine) {
this.timer.stop();
this.hasLost = true;
this.deathCount++;
2020-08-05 22:37:32 +02:00
} else {
this.coveredNonMineCount--;
if (this.coveredNonMineCount === 0) {
this.timer.stop();
2020-08-07 00:19:17 +02:00
remainingFlags = this.squareList.filter(it => it.isCovered && !it.hasFlag);
remainingFlags.forEach(it => it.hasFlag = true);
2020-08-07 11:38:09 +02:00
this.flagCount = this.mineCount;
this.hasWon = true;
2020-08-05 22:37:32 +02:00
}
}
},
() => {
next.isCovered = true;
if (next.hasMine) {
this.hasLost = false;
2020-08-05 22:37:32 +02:00
this.timer.start();
} else {
this.coveredNonMineCount++;
this.hasWon = false;
2020-08-07 11:38:09 +02:00
if (remainingFlags !== undefined) {
2020-08-07 00:19:17 +02:00
remainingFlags.forEach(it => it.hasFlag = false);
2020-08-07 11:38:09 +02:00
this.flagCount = this.mineCount - remainingFlags.length;
}
2020-08-05 22:37:32 +02:00
this.timer.start();
}
return true;
},
() => true
));
if (next.hasMine) break;
2020-08-05 22:37:32 +02:00
if (next.getNeighborCount(it => it.hasMine || it.hasFlag) === 0)
uncoverQueue.push(...next.neighbors.filter(it => it.isCovered));
2020-08-04 21:00:12 +02:00
}
2020-08-05 22:37:32 +02:00
});
2020-08-04 20:26:05 +02:00
}
/**
* 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
2020-08-04 21:00:12 +02:00
* @private
*/
2020-08-04 21:00:12 +02:00
private addAction(action: Action): void {
if (this.history.hasUncommittedSequence)
this.history.addAction(action);
else
this.runUndoably(() => this.history.addAction(action));
2020-08-05 22:37:32 +02:00
action.run();
}
2020-08-07 00:19:17 +02:00
/**
* 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 {
this.history.redo(amount);
}
2020-08-02 16:22:18 +02:00
/**
* Undoes the last `amount` actions in the field's history, if any.
*
2020-08-07 00:19:17 +02:00
* @param amount the maximum amount of actions to undo, or all past actions if `undefined`
2020-08-02 16:22:18 +02:00
*/
undo(amount: number | undefined = undefined): void {
this.history.undo(amount);
}
2020-08-02 16:22:18 +02:00
2020-08-04 21:00:12 +02:00
/**
* The time the player has played on this field so far.
*/
2020-08-07 12:24:56 +02:00
get elapsedTime(): number {
return this.timer.elapsedTime;
2020-08-04 21:00:12 +02:00
}
2020-08-02 16:22:18 +02:00
/**
* 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;
}
2020-07-31 23:12:16 +02:00
}
/**
* A square in a Minesweeper `Field`.
*/
export class Square {
private readonly field: Field;
2020-08-03 18:57:12 +02:00
private _neighbors: Square[] | undefined = undefined;
2020-07-31 23:12:16 +02:00
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;
}
2020-08-04 20:26:05 +02:00
/**
* The coordinates of this square in the field.
*/
get coords(): { x: number, y: number } {
return {x: this.x, y: this.y};
}
2020-07-31 23:12:16 +02:00
/**
2020-08-03 18:57:12 +02:00
* The `Square`s that are adjacent to this square.
2020-07-31 23:12:16 +02:00
*/
2020-08-03 18:57:12 +02:00
get neighbors(): Square[] {
if (this._neighbors === undefined) {
this._neighbors = [
2020-08-04 20:26:05 +02:00
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}),
2020-08-03 18:57:12 +02:00
].filter(it => it !== undefined);
}
return this._neighbors!;
2020-07-31 23:12:16 +02:00
}
/**
* 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 {
2020-08-03 18:57:12 +02:00
return this.neighbors.filter(property).length;
2020-07-31 23:12:16 +02:00
}
}