Compare commits

..

1 Commits

Author SHA1 Message Date
Florine W. Dekker 4d6b615513
Try out vectorious for matrix operations
See also #60.
2022-12-20 19:23:54 +01:00
20 changed files with 443 additions and 806 deletions

View File

@ -6,19 +6,13 @@ An implementation of Minesweeper.
* [npm](https://www.npmjs.com/)
### Setting up
```shell
```shell script
# Install dependencies (only needed once)
$> npm ci
```
### Testing
```shell
# Run linter
$> npm run lint
```
### Building
```shell
```shell script
# Build the tool in `dist/` for development
$> npm run dev
# Same as above, but automatically rerun it whenever files are changed

View File

@ -1,46 +0,0 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
// noinspection JSUnusedGlobalSymbols
export default tseslint.config(
// Configure parser for type checking
{
languageOptions: {
parserOptions: {
project: ["tsconfig.json"],
},
},
},
// Basic
eslint.configs.recommended,
// Strict TS checks
...tseslint.configs.strictTypeChecked,
{
"rules": {
"@typescript-eslint/no-confusing-void-expression": ["error", {ignoreArrowShorthand: true}],
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unnecessary-condition": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unused-vars": ["error", {argsIgnorePattern: "_"}],
"@typescript-eslint/restrict-template-expressions": ["error", {allowBoolean: true, allowNumber: true}],
"no-unused-vars": "off",
},
},
// Stylistic TS checks
...tseslint.configs.stylisticTypeChecked,
{
rules: {
"@typescript-eslint/no-inferrable-types": "off",
},
},
);

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "minesweeper",
"version": "0.86.13",
"version": "0.86.0",
"description": "Just Minesweeper!",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",
@ -13,31 +13,26 @@
"clean": "grunt clean",
"dev": "grunt dev",
"dev:server": "grunt dev:server",
"deploy": "grunt deploy",
"lint": "npx eslint src/main/js/"
"deploy": "grunt deploy"
},
"dependencies": {
"alea": "^1.0.1",
"canvas-confetti": "^1.9.3"
"canvas-confetti": "^1.6.0",
"vectorious": "^6.1.4"
},
"devDependencies": {
"@eslint/js": "^9.2.0",
"@types/canvas-confetti": "^1.6.4",
"@typescript-eslint/parser": "^7.8.0",
"eslint": "^8.57.0",
"grunt": "^1.6.1",
"grunt": "^1.5.3",
"grunt-cli": "^1.4.3",
"grunt-contrib-clean": "^2.0.1",
"grunt-contrib-copy": "^1.0.0",
"grunt-contrib-watch": "^1.1.0",
"grunt-focus": "^1.0.0",
"grunt-text-replace": "^0.4.0",
"grunt-webpack": "^6.0.0",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"typescript-eslint": "^7.8.0",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
"grunt-webpack": "^5.0.0",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1"
}
}

View File

@ -164,7 +164,7 @@
<input role="switch" type="checkbox" id="preferences-show-all-neighbors-are-mines-hints" />
<label for="preferences-show-all-neighbors-are-mines-hints">
*Highlight squares in blue if all neighbors have mines.
*Highlight squares in green if all neighbors have mines.
</label>
</form>
<footer>

View File

@ -1,6 +1,3 @@
import {noop, req} from "./Common";
/**
* An action that can (possibly) be done and undone.
*/
@ -22,17 +19,10 @@ export class Action {
this.undo = undo;
this.isUndoable = isUndoable;
}
/**
* An empty action that does nothing and cannot be undone, providing a barrier from previous entries in the
* history.
*/
static barrier = new Action(noop, () => false, () => false);
}
/**
* A sequence of actions that should be treated as a single, atomic action.
* A sequence of actions that can be done and undone.
*/
export class ActionSequence extends Action {
private readonly actions: Action[] = [];
@ -107,6 +97,13 @@ export class ActionHistory {
return this.sequences[this.sequenceIndex];
}
/**
* `true` if and only if there is a sequence of actions that has not been committed yet.
*/
get hasUncommittedSequence(): boolean {
return this.depth > 0;
}
/**
* Starts a new sequence of actions, or continues the current uncommitted sequence if there is one.
@ -114,7 +111,7 @@ export class ActionHistory {
startSequence(): void {
if (this.depth === 0) {
this.sequenceIndex++;
this.sequences.length = this.sequenceIndex; // Truncates newer action sequences
this.sequences.length = this.sequenceIndex;
this.sequences.push(new ActionSequence());
}
this.depth++;
@ -126,29 +123,25 @@ export class ActionHistory {
* @param action the action to add
*/
addAction(action: Action): void {
req(this.depth > 0, () => "Cannot add action if there is no uncommitted sequence.");
if (!this.hasUncommittedSequence)
throw new Error("Cannot add action if there is no uncommitted sequence.");
this.currentSequence!.addAction(action);
}
/**
* Commits the current sequence of actions, provided the number of calls to this method equals the number of calls
* to `#startSequence`.
*
* If the sequence of actions to commit is empty, the sequence is instead deleted.
*
* @returns `true` if and only if the sequence was actually committed
* Commits the last sequence of actions or removes it if no actions were added, but only if the number of calls to
* this function equals the number of calls to `#startSequence`.
*/
commitSequence(): boolean {
req(this.depth > 0, () => "Cannot commit sequence if there is no uncommitted sequence.");
commitSequence(): void {
if (!this.hasUncommittedSequence)
throw new Error("Cannot commit sequence if there is no uncommitted sequence.");
this.depth--;
if (this.depth === 0 && this.currentSequence!.isEmpty()) {
this.sequences.pop();
this.sequenceIndex--;
}
return this.depth === 0;
}
@ -158,7 +151,7 @@ export class ActionHistory {
* @returns `true` if and only if there is an action sequence that can be undone
*/
canUndo(): boolean {
return this.currentSequence?.isUndoable() ?? false;
return this.sequenceIndex >= 1;
}
/**
@ -169,7 +162,8 @@ export class ActionHistory {
* @returns the actual amount of sequences that have been undone
*/
undo(amount: number = this.sequenceIndex + 1): number {
req(this.depth === 0, () => "Cannot undo sequences while there is an uncommitted sequence.");
if (this.hasUncommittedSequence)
throw new Error("Cannot undo sequences while there is an uncommitted sequence.");
amount = Math.min(amount, this.sequenceIndex + 1);
let i;
@ -190,7 +184,7 @@ export class ActionHistory {
* @returns `true` if and only if there is an action sequence that can be redone
*/
canRedo(): boolean {
return this.currentSequence !== this.sequences.at(-1);
return this.sequenceIndex < this.sequences.length - 1;
}
/**
@ -200,7 +194,8 @@ export class ActionHistory {
* @returns the actual amount of sequences that have been redone
*/
redo(amount: number = this.sequences.length - (this.sequenceIndex + 1)): number {
req(this.depth === 0, () => "Cannot redo sequences while there is an uncommitted sequence.");
if (this.hasUncommittedSequence)
throw new Error("Cannot redo sequences while there is an uncommitted sequence.");
amount = Math.min(amount, this.sequences.length - (this.sequenceIndex + 1));
for (let i = 0; i < amount; i++) {

View File

@ -1,58 +1,5 @@
/**
* A no-op, which does absolutely nothing.
*/
export function noop(): void {
// Do nothing
}
/**
* Throws an error.
*
* @param message the error message
*/
export function error(message: string): never {
throw new Error(message);
}
/**
* Requires that `condition` is `true`, and throws an error with `message` otherwise.
*
* @param condition the condition to require
* @param message a function that returns the error message if `condition` is `false`
*/
export function req(condition: boolean, message: () => string): asserts condition {
if (!condition) error(message());
}
/**
* Requires that `value` is neither `null` nor `undefined`, and throws an error with `message` otherwise.
*
* @param value the value that is asserted to not be nullish
* @param message a function that returns the error message if `value` is nullish
*/
export function reqNotNullish<T>(
value: T,
message: () => string = () => "Value should be non-nullable, was was nullable."
): asserts value is NonNullable<T> {
req(value !== null && value !== undefined, message);
}
/**
* Creates an array of `size` consecutive integers starting at `startAt`.
*
* If `startAt` is not given, this creates an array of consecutive integers from 0 (inclusive) until `size` (exclusive).
*
* Taken from https://stackoverflow.com/a/10050831 (CC BY-SA 4.0).
*
* @param length the number of consecutive integers to put in the array
* @param beginAt the first integer to return
* @returns the array of consecutive integers
*/
export function range(length: number, beginAt: number = 0): number[] {
return [...Array(length).keys()].map(i => i + beginAt);
}
// @ts-ignore
import alea from "alea";
/**
@ -64,7 +11,7 @@ export function range(length: number, beginAt: number = 0): number[] {
* @param chunkSize the size of each chunk
* @returns an array of the extracted chunks
*/
export function chunkifyArray<T>(array: T[], chunkSize: number): T[][] {
export function chunkifyArray(array: any[], chunkSize: number): any[] {
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize)
chunks.push(array.slice(i, i + chunkSize));
@ -77,22 +24,53 @@ export function chunkifyArray<T>(array: T[], chunkSize: number): T[][] {
* @param seconds the number of seconds; the time to be formatted
* @param minutes whether to include minutes
* @param hours whether to include hours; requires that `minutes` is true
* @returns the formatted time
* @return the formatted time
*/
export function formatTime(seconds: number, minutes: boolean, hours: boolean): string {
req(minutes || !hours, () => "Cannot format time with hours but without minutes.");
if (!minutes && !hours) return seconds.toString();
if (!minutes && hours) throw new Error("Cannot format time with hours but without minutes.");
if (!minutes && !hours) return "" + seconds;
const secondsString = (seconds % 60).toString().padStart(2, '0');
const secondsString = ("" + (seconds % 60)).padStart(2, '0');
const minutesString = hours
? Math.floor((seconds % 3600) / 60).toString().padStart(2, '0')
: Math.floor(seconds / 60).toString();
? ("" + Math.floor((seconds % 3600) / 60)).padStart(2, '0')
: ("" + Math.floor(seconds / 60));
if (!hours) return `${minutesString}:${secondsString}`;
const hoursString = Math.floor(seconds / 3600);
return `${hoursString}:${minutesString}:${secondsString}`;
}
/**
* Creates an array of `size` consecutive integers starting at `startAt`.
*
* Taken from https://stackoverflow.com/a/10050831 (CC BY-SA 4.0).
*
* @param length the number of consecutive integers to put in the array
* @param beginAt the first integer to return
* @returns the array of consecutive integers
*/
export function range(length: number, beginAt: number = 0): number[] {
return [...Array(length).keys()].map(i => i + beginAt);
}
/**
* Shuffles the given array in-place.
*
* @param array the array to shuffle
* @param seed the seed for the random number generator
* @returns the array that was given to this function to shuffle
*/
export function shuffleArrayInPlace(array: any[], seed: number): any[] {
const rng = alea("" + seed);
for (let i = array.length - 1; i > 0; i--) {
const j = rng.uint32() % (i + 1);
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
/**
* Hashes the given string.
*
@ -124,7 +102,7 @@ export function waitForForkAwesome(onSuccess: () => void, onFailure: () => void,
const ctx = canvas.getContext("2d")!;
const fontSize = 36;
const testCharacter = "\uF047";
const targetPixelCount = 500; // 316 is failure, 528 is success, so use 500 for margin
const targetPixelCount = 528; // Found by doing lots of trials
const ccw = canvas.width = fontSize * 1.5;
const cch = canvas.height = fontSize * 1.5;

View File

@ -38,7 +38,8 @@ export const difficulties: Difficulty[] = [
new Difficulty("Beginner", "9x9, 10 mines", 9, 9, 10, true),
new Difficulty("Intermediate", "16x16, 40 mines", 16, 16, 40, true),
new Difficulty("Expert", "30x16, 99 mines", 30, 16, 99, true),
new Difficulty("Custom", null, 0, 0, 0, false)
new Difficulty("Insane", "30x16, 170 mines", 30, 16, 170, true),
new Difficulty("Custom", null, 0, 0, 0, false),
];
/**
@ -59,9 +60,9 @@ export const customDifficulty = difficulties[difficulties.length - 1];
* @param mineCount the number of mines to match a difficulty with
*/
export const findDifficulty = function(width: number, height: number, mineCount: number): Difficulty {
for (const difficulty of difficulties) {
if (difficulty === customDifficulty)
continue;
for (let i = 0; i < difficulties.length; i++) {
const difficulty = difficulties[i];
if (difficulty === customDifficulty) continue;
if (width === difficulty.width && height === difficulty.height && mineCount === difficulty.mineCount)
return difficulty;

View File

@ -1,6 +1,8 @@
// @ts-ignore
import confetti from "canvas-confetti";
import {error, formatTime, range, reqNotNullish} from "./Common";
import {Coords, Field, Square} from "./Field";
import {formatTime, range} from "./Common";
import {Field, Square} from "./Field";
import {Preferences} from "./Preferences";
@ -9,9 +11,8 @@ import {Preferences} from "./Preferences";
*/
export class Display {
private readonly errorColor: string = "rgba(255, 0, 0, 0.3)";
private readonly hintColor: string = "rgba(128, 0, 128, 0.3)";
private readonly chordableColor: string = "rgba(0, 0, 255, 0.5)";
private readonly allNeighborsAreMinesColor: string = "rgba(0, 255, 0, 0.5)";
private readonly hintColor: string = "rgba(0, 0, 255, 0.3)";
private readonly safeColor: string = "rgba(0, 255, 0, 0.5)";
private readonly scale: number = 30;
@ -100,7 +101,7 @@ export class Display {
this.digitSymbols = range(10).map(digit => {
const canvas = createCanvas(this.scale, this.scale);
ctx = canvas.getContext("2d")!;
if (digit !== 0) fillText(digit.toString(), "Courier New", digitColors[digit]);
if (digit !== 0) fillText("" + digit, "Courier New", digitColors[digit]);
return canvas;
});
@ -176,11 +177,9 @@ export class Display {
* @param pos the client-relative pixel coordinates to find the square at
* @returns the square grid coordinates corresponding to the given client coordinates
*/
posToSquare(pos: Coords): Coords {
reqNotNullish(this.field);
posToSquare(pos: { x: number, y: number }): { x: number, y: number } {
const rect = this.canvas.getBoundingClientRect();
const scaling = (rect.width - 2 * this.canvas.clientLeft) / (this.field.width * this.scale);
const scaling = (rect.width - 2 * this.canvas.clientLeft) / (this.field!.width * this.scale);
return {
x: Math.floor((pos.x - rect.left - this.canvas.clientLeft) / (this.scale * scaling)),
@ -269,25 +268,20 @@ export class Display {
ctx.save();
ctx.fillStyle = "#555";
const isUncovering = this.mouseHoldUncover || this.mouseHoldChord;
const isChording = this.mouseHoldUncover && !this.mouseSquare?.isCovered || this.mouseHoldChord;
this.field.squareList
.filter(it => it.isCovered)
.filter(it => {
// Return true for each square that should get a cover
// True if square should be covered
if (this.field!.isOver || this.mouseSquare == null)
return true;
if (isUncovering && this.mouseSquare === it)
if (this.mouseHoldUncover && this.mouseSquare === it)
return it.hasFlag || it.hasMark;
if (isChording && this.mouseSquare.neighbors.includes(it))
if (this.mouseHoldChord && (this.mouseSquare === it || this.mouseSquare.neighbors.indexOf(it) >= 0))
return it.hasFlag || it.hasMark;
return true;
})
.forEach(it => ctx.drawImage(this.coverSymbol!, it.coords.x * this.scale, it.coords.y * this.scale));
.forEach(square => ctx.drawImage(this.coverSymbol!, square.x * this.scale, square.y * this.scale));
ctx.restore();
}
@ -305,24 +299,21 @@ export class Display {
if (this.hintSquare != null) {
ctx.save();
ctx.scale(this.scale, this.scale);
ctx.fillStyle = this.hintColor;
ctx.fillRect(this.hintSquare.coords.x, this.hintSquare.coords.y, 1, 1);
ctx.fillRect(this.hintSquare.x * this.scale, this.hintSquare.y * this.scale, this.scale, this.scale);
ctx.restore();
showsHint = true;
}
if (!showsHint && this.preferences.showTooManyFlagsHints) {
const mistakes = this.field.squareList
.filter(it => !it.isCovered)
.filter(it => it.getNeighborCount(it => it.hasMine) < it.getNeighborCount(it => it.hasFlag));
madeMistakes = mistakes.length > 0;
ctx.save();
ctx.scale(this.scale, this.scale);
ctx.fillStyle = this.errorColor;
mistakes.forEach(square => ctx.fillRect(square.coords.x, square.coords.y, 1, 1));
madeMistakes = madeMistakes || this.field.squareList
.filter(it => !it.isCovered)
.filter(it => it.getNeighborCount(it => it.hasMine) < it.getNeighborCount(it => it.hasFlag))
.map(square => ctx.fillRect(square.x * this.scale, square.y * this.scale, this.scale, this.scale))
.length > 0;
ctx.restore();
}
@ -331,22 +322,20 @@ export class Display {
(this.preferences.showChordableHints || this.preferences.showAllNeighborsAreMinesHints)
) {
ctx.save();
ctx.scale(this.scale, this.scale);
ctx.fillStyle = this.safeColor;
this.field.squareList
.filter(it => !it.isCovered)
.forEach(it => {
.filter(it => {
const mines = it.getNeighborCount(it => it.hasMine);
const flags = it.getNeighborCount(it => it.hasFlag);
const covered = it.getNeighborCount(it => it.isCovered);
if (this.preferences.showChordableHints && mines === covered && mines !== flags) {
ctx.fillStyle = this.chordableColor;
ctx.fillRect(it.coords.x, it.coords.y, 1, 1);
} else if (this.preferences.showAllNeighborsAreMinesHints && mines === flags && covered !== flags) {
ctx.fillStyle = this.allNeighborsAreMinesColor;
ctx.fillRect(it.coords.x, it.coords.y, 1, 1);
}
});
return (
(this.preferences.showChordableHints && mines === flags && covered !== flags) ||
(this.preferences.showAllNeighborsAreMinesHints && mines === covered && mines !== flags)
);
})
.forEach(square => ctx.fillRect(square.x * this.scale, square.y * this.scale, this.scale, this.scale));
ctx.restore();
}
}
@ -374,13 +363,13 @@ export class Display {
else if (!square.isCovered)
icon = this.digitSymbols![square.getNeighborCount(it => it.hasMine)];
if (icon !== undefined) ctx.drawImage(icon, square.coords.x * this.scale, square.coords.y * this.scale);
if (icon !== undefined) ctx.drawImage(icon, square.x * this.scale, square.y * this.scale);
});
ctx.restore();
}
/**
* Draws the status bar with remaining mines, number of deaths, and the time.
* Draws the status bar with remaining mines and the time.
*
* @param ctx the drawing context
* @private
@ -390,7 +379,7 @@ export class Display {
ctx.save();
ctx.fillStyle = "#000";
ctx.font = Math.floor(0.55 * this.scale).toString() + "px Courier New";
ctx.font = Math.floor(0.55 * this.scale) + "px Courier New";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
@ -421,12 +410,12 @@ export class Display {
deathsSymbol = this.deathsSymbolA;
}
ctx.drawImage(
deathsSymbol ?? error("Failed to initialize deaths symbol."),
deathsSymbol!,
Math.floor(this.canvas.width / 2 - this.scale),
Math.floor(this.canvas.height - this.scale)
);
ctx.fillText(
this.field.deathCount.toString(),
"" + this.field.deathCount,
Math.floor(this.canvas.width / 2),
Math.floor(this.canvas.height - 0.5 * this.scale),
this.scale
@ -458,12 +447,13 @@ export class Display {
const rect = this.canvas.getBoundingClientRect();
if (this.field.hasWon && this.winTime == null) {
void confetti({
confetti({
origin: {
x: (rect.left + rect.width / 2) / document.documentElement.clientWidth,
y: (rect.top + rect.height / 2) / document.documentElement.clientHeight
},
spread: 360,
initialVelocity: 10
});
this.winTime = Date.now();
} else if (!this.field.hasWon) {

View File

@ -1,10 +1,11 @@
const {MemoryStorage} = (window as any).fwdekker.storage; // eslint-disable-line
const {MemoryStorage} = (window as any).fwdekker.storage;
// @ts-ignore
import alea from "alea";
import {Action, ActionHistory} from "./Action";
import {chunkifyArray, req} from "./Common";
import {chunkifyArray, shuffleArrayInPlace} from "./Common";
import {findDifficulty} from "./Difficulty";
import {HighScores} from "./HighScores";
import {Random} from "./Random";
import {Solver} from "./Solver";
import {Statistics} from "./Statistics";
import {Timer} from "./Timer";
@ -17,8 +18,8 @@ export class Field {
private readonly statistics: Statistics;
private readonly highScores: HighScores;
private readonly history = new ActionHistory();
private random: Random;
private timer: Timer = new Timer();
private readonly rng: any;
private timer = new Timer();
readonly width: number;
readonly height: number;
@ -27,10 +28,6 @@ export class Field {
readonly squares: Square[][] = [];
readonly isSolvable: boolean;
get maxMines(): number {
return Field.maxMines(this.width, this.height);
}
private _coveredNonMineCount: number;
get coveredNonMineCount(): number {
return this._coveredNonMineCount;
@ -84,22 +81,23 @@ export class Field {
*/
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.random = new Random(seed);
this.rng = alea("" + seed);
this.isSolvable = solvable;
req(mineCount <= this.maxMines, () => `Mine count must be at most ${this.maxMines}, but was ${mineCount}.`);
this.squareList = Array(this.size).fill(0)
.map((_, i) => new Square(this, {x: i % this.width, y: Math.floor(i / this.width)}, true));
.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.shuffle(this.rng.uint32());
}
/**
@ -113,10 +111,9 @@ export class Field {
const copy = new Field(
this.width, this.height,
this.mineCount,
false,
0,
false, 0,
new Statistics(new MemoryStorage()),
new HighScores(new MemoryStorage()),
new HighScores(new MemoryStorage())
);
copy.squareList.length = 0;
@ -124,7 +121,6 @@ export class Field {
copy.squares.length = 0;
copy.squares.push(...chunkifyArray(copy.squareList, copy.width));
copy.random = this.random.copy();
copy.timer = this.timer.copy();
copy._coveredNonMineCount = this.coveredNonMineCount;
copy._flagCount = this.flagCount;
@ -136,88 +132,6 @@ export class Field {
}
/**
* Shuffles all mines in the field by changing the `#hasMine` property of its squares.
*
* @private
*/
private shuffle(): void {
req(!this.hasStarted, () => "Cannot shuffle mines after field has started");
const newMineMask = Array<boolean>(this.size).fill(true, 0, this.mineCount).fill(false, this.mineCount);
this.random.shuffleInPlace(newMineMask);
newMineMask.forEach((hasMine, i) => this.squareList[i].hasMine = hasMine);
}
/**
* Moves mines from the given coords and its neighbors to other coordinates in the field, if possible.
*
* @param coords the coordinate to move mines away from
* @private
*/
private removeMinesAround(coords: Coords): void {
const swapAction = (source: Square, target: Square) => new Action(
() => {
[source.hasMine, target.hasMine] = [target.hasMine, source.hasMine];
},
() => {
[source.hasMine, target.hasMine] = [target.hasMine, source.hasMine];
return true;
},
() => true
);
this.runUndoably(() => {
const square = this.getSquare(coords);
const from = [square].concat(square.neighbors).filter(it => it.hasMine);
const to = this.squareList.filter(it => !it.hasMine && !from.includes(it)).slice(0, from.length);
from.slice(0, to.length).forEach(it => this.runAction(swapAction(it, this.random.pop(to)!)));
});
}
/**
* Generates new contents for this field, starting at `start`.
*
* @param start the initial square from which the field should be generated
* @private
*/
private generate(start: Coords): void {
req(!this.hasStarted, () => "Cannot generate new field after field has started.");
this.statistics.gamesStarted++;
if (this.isSolvable) {
let attempts = 1;
const time = Timer.time(() => {
while (!Solver.canSolve(this, start)) {
this.shuffle();
attempts++;
}
});
console.log(`Found solvable field in ${time}ms in ${attempts} attempt(s).`);
}
this.removeMinesAround(start);
this._hasStarted = true;
this.timer.start();
this.runAction(Action.barrier);
}
/**
* Returns the square at the given coordinates, and throws an error if no such square exists.
*
* @param coords the coordinates of the square to look up
* @returns the square at the given coordinates
*/
getSquare(coords: Coords): Square {
req(coords.x >= 0 && coords.x < this.width, () => `Expected 0 <= x < ${this.width}, actual ${coords.x}.`);
req(coords.y >= 0 && coords.y < this.height, () => `Expected 0 <= y < ${this.height}, actual ${coords.y}.`);
return this.squares[coords.y][coords.x];
}
/**
* Returns the square at the given coordinates, `orElse` if there is no square there, or `undefined` if there is no
* square there and `orElse` is not given.
@ -227,7 +141,7 @@ export class Field {
* @returns the square at the given coordinates, `orElse` if there is no square there, or `undefined` if there is no
* square there and `orElse` is not given
*/
getSquareOrElse<T = void>(coords: Coords, orElse?: T): Square | T {
getSquareOrElse<T = void>(coords: { x: number, y: number }, orElse?: T): Square | T {
return this.squares[coords.y]?.[coords.x] ?? orElse;
}
@ -237,7 +151,7 @@ export class Field {
* @param coords the coordinates to check
* @returns `true` if and only if this field contains a square at the given coordinates
*/
hasSquareAt(coords: Coords): boolean {
hasSquareAt(coords: { x: number, y: number }): boolean {
return coords.x >= 0 && coords.x < this.width && coords.y >= 0 && coords.y < this.height;
}
@ -250,63 +164,116 @@ export class Field {
/**
* Chords the square at the given position, i.e. if the square is uncovered and the number of neighboring flags
* equals the number in the square, then all unflagged neighbors are uncovered.
* 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: Coords): void {
const square = this.getSquare(coords);
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.isOver) 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 => {
if (it.hasMark) this.toggleMark(it.coords);
this.uncover(it.coords);
});
.forEach(it => this.uncover(it.coords));
});
this.invokeEventListeners();
if (!this.isAutoSolving && this.hasLost) this.statistics.squaresChordedLeadingToLoss++;
}
/**
* Anti-chords the square at the given position, i.e. if the square is uncovered and the number of covered
* neighbours equals the number of neighbouring mines, then all covered neighbours are flagged.
*
* @param coords the coordinates of the square to anti-chord
*/
antiChord(coords: Coords): void {
const square = this.getSquare(coords);
if (square.isCovered || this.isOver) return;
if (square.getNeighborCount(it => it.isCovered) !== square.getNeighborCount(it => it.hasMine)) return;
this.runUndoably(() => {
square.neighbors
.filter(it => it.isCovered && !it.hasFlag)
.forEach(it => {
if (it.hasMark) this.toggleMark(it.coords);
this.toggleFlag(it.coords)
});
});
this.invokeEventListeners();
}
/**
* Uncovers this square, revealing the contents beneath.
*
* @param coords the coordinates of the square to uncover
*/
uncover(coords: Coords): void {
const square = this.getSquare(coords);
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.isOver) return;
this.runUndoably(() => {
if (!this.hasStarted) this.generate(coords);
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) {
@ -314,7 +281,7 @@ export class Field {
if (!next.isCovered || next.hasFlag || next.hasMark) continue;
let remainingFlags: Square[] | undefined;
this.runAction(new Action(
this.addAction(new Action(
() => {
next.isCovered = false;
if (!this.isAutoSolving) this.statistics.squaresUncovered++;
@ -379,11 +346,12 @@ export class Field {
*
* @param coords the coordinates of the square to toggle the flag at
*/
toggleFlag(coords: Coords): void {
const square = this.getSquare(coords);
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.isOver) return;
this.runAction(new Action(
this.addAction(new Action(
() => {
square.hasFlag = !square.hasFlag;
if (!this.isAutoSolving && square.hasFlag) this.statistics.squaresFlagged++;
@ -404,11 +372,12 @@ export class Field {
*
* @param coords the coordinates of the square to toggle the question mark at
*/
toggleMark(coords: Coords): void {
const square = this.getSquare(coords);
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.isOver) return;
this.runAction(new Action(
this.addAction(new Action(
() => {
square.hasMark = !square.hasMark;
if (!this.isAutoSolving && square.hasMark) this.statistics.squaresMarked++;
@ -425,42 +394,34 @@ export class Field {
/**
* Runs the given callback such that all calls to `#runAction` inside the callback are stored as a single
* `ActionSequence`.
* Runs the given callback such that all calls to `#addAction` can be undone with a single invocation of `#undo`.
*
* This function is re-entrant. That is, calling this function inside the callback will create only a single
* sequence of actions.
* 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 `#runAction` should be undoable with a single invocation of
* @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();
if (this.history.commitSequence())
this.invokeEventListeners();
this.history.commitSequence();
}
/**
* Runs `#runUndoably` asynchronously.
*/
async runUndoablyAsync(callback: () => Promise<void>): Promise<void> {
this.history.startSequence();
await callback();
if (this.history.commitSequence())
this.invokeEventListeners();
}
/**
* Runs and stores the given action such that it can be undone.
* 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 runAction(action: Action): void {
this.runUndoably(() => this.history.addAction(action));
private addAction(action: Action): void {
if (this.history.hasUncommittedSequence)
this.history.addAction(action);
else
this.runUndoably(() => this.history.addAction(action));
action.run();
}
@ -558,7 +519,8 @@ export class Field {
export class Square {
private readonly field: Field;
private _neighbors: Square[] | undefined = undefined;
readonly coords: Coords;
readonly x: number;
readonly y: number;
isCovered: boolean;
hasMine: boolean;
hasFlag: boolean;
@ -569,12 +531,14 @@ export class Square {
* Constructs a new square.
*
* @param field the field in which this square is located
* @param coords the coordinates of this square in the field
* @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, coords: Coords, hasMine: boolean) {
constructor(field: Field, x: number, y: number, hasMine: boolean) {
this.field = field;
this.coords = coords;
this.x = x;
this.y = y;
this.isCovered = true;
this.hasMine = hasMine;
@ -589,7 +553,7 @@ export class Square {
* @returns a deep copy of this square
*/
copy(field: Field): Square {
const copy = new Square(field, this.coords, this.hasMine);
const copy = new Square(field, this.x, this.y, this.hasMine);
copy.isCovered = this.isCovered;
copy.hasFlag = this.hasFlag;
copy.hasMark = this.hasMark;
@ -597,24 +561,31 @@ export class Square {
}
/**
* 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.coords.x - 1, y: this.coords.y - 1}, null),
this.field.getSquareOrElse({x: this.coords.x, y: this.coords.y - 1}, null),
this.field.getSquareOrElse({x: this.coords.x + 1, y: this.coords.y - 1}, null),
this.field.getSquareOrElse({x: this.coords.x - 1, y: this.coords.y}, null),
this.field.getSquareOrElse({x: this.coords.x + 1, y: this.coords.y}, null),
this.field.getSquareOrElse({x: this.coords.x - 1, y: this.coords.y + 1}, null),
this.field.getSquareOrElse({x: this.coords.x, y: this.coords.y + 1}, null),
this.field.getSquareOrElse({x: this.coords.x + 1, y: this.coords.y + 1}, null),
this.field.getSquareOrElse({x: this.x - 1, y: this.y - 1}, null),
this.field.getSquareOrElse({x: this.x, y: this.y - 1}, null),
this.field.getSquareOrElse({x: this.x + 1, y: this.y - 1}, null),
this.field.getSquareOrElse({x: this.x - 1, y: this.y}, null),
this.field.getSquareOrElse({x: this.x + 1, y: this.y}, null),
this.field.getSquareOrElse({x: this.x - 1, y: this.y + 1}, null),
this.field.getSquareOrElse({x: this.x, y: this.y + 1}, null),
this.field.getSquareOrElse({x: this.x + 1, y: this.y + 1}, null),
].filter((it): it is Square => it !== null);
}
return this._neighbors;
return this._neighbors!;
}
/**
@ -623,16 +594,7 @@ export class Square {
* @param property the property to check on each neighbor
* @returns the number of neighbors that satisfy the given property
*/
getNeighborCount(property: (_: Square) => boolean): number {
getNeighborCount(property: (neighbor: Square) => boolean): number {
return this.neighbors.filter(property).length;
}
}
/**
* A pair of coordinates, typically those of a `Square`.
*/
export interface Coords {
readonly x: number;
readonly y: number;
}

View File

@ -1,13 +1,14 @@
const {$, stringToHtml} = (window as any).fwdekker; // eslint-disable-line
const {$, stringToHtml} = (window as any).fwdekker;
// @ts-ignore
import alea from "alea";
import {stringToHash} from "./Common";
import {customDifficulty, defaultDifficulty, difficulties} from "./Difficulty";
import {Display} from "./Display";
import {Field, Square} from "./Field";
import {Field} from "./Field";
import {HighScores} from "./HighScores";
import {ModalDialog} from "./ModalDialog";
import {Preferences} from "./Preferences";
import {Random} from "./Random";
import {Solver} from "./Solver";
import {Statistics} from "./Statistics";
@ -33,16 +34,14 @@ export class Game {
private readonly statisticsDiv: HTMLDivElement;
private readonly highScoresDiv: HTMLDivElement;
private readonly random: Random;
private readonly rng: any;
private seed: string;
private field: Field | null;
private display: Display;
private leftDown: boolean;
private middleDown: boolean;
private rightDown: boolean;
private bothHeld: boolean;
private holdStart: Square | null;
private holdsAfterChord: boolean;
/**
@ -58,13 +57,11 @@ export class Game {
this.display.startDrawLoop();
this.canvas.classList.remove("hidden");
this.random = new Random();
this.seed = this.random.uniform().toString();
this.rng = alea("" + Date.now());
this.seed = "" + this.rng.uint32();
this.leftDown = false;
this.middleDown = false;
this.rightDown = false;
this.bothHeld = false;
this.holdStart = null;
this.holdsAfterChord = false;
this.initNewField();
@ -94,9 +91,9 @@ export class Game {
this.customDifficultyOverlay = new ModalDialog({
dialog: $("#custom-difficulty-dialog"),
onOpen: () => {
this.widthInput.value = (this.field?.width ?? defaultDifficulty.width).toString();
this.heightInput.value = (this.field?.height ?? defaultDifficulty.height).toString();
this.minesInput.value = (this.field?.mineCount ?? defaultDifficulty.mineCount).toString();
this.widthInput.value = "" + (this.field?.width ?? defaultDifficulty.width);
this.heightInput.value = "" + (this.field?.height ?? defaultDifficulty.height);
this.minesInput.value = "" + (this.field?.mineCount ?? defaultDifficulty.mineCount);
this.solvableInput.checked = this.field?.isSolvable ?? defaultDifficulty.solvable;
this.setMineLimit();
},
@ -136,6 +133,7 @@ export class Game {
"click",
(event: MouseEvent) => {
event.preventDefault();
this.field?.undo(); // Undoes all
}
);
@ -167,8 +165,8 @@ export class Game {
"click",
(event: MouseEvent) => {
event.preventDefault();
this.field?.undo(1);
this.display.hintSquare = null;
return this.field?.undo(1);
}
);
@ -178,8 +176,8 @@ export class Game {
"click",
(event: MouseEvent) => {
event.preventDefault();
this.field?.redo(1);
this.display.hintSquare = null;
return this.field?.redo(1);
}
);
@ -206,9 +204,8 @@ export class Game {
if (this.field != null) {
this.statistics.solverUsages++;
void Solver.solveAnimated(this.field);
Solver.solve(this.field);
}
this.display.hintSquare = null;
}
);
@ -275,27 +272,24 @@ export class Game {
// Canvas
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
this.canvas.addEventListener(
"mousemove",
event => {
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
this.display.mouseSquare = this.field?.getSquareOrElse(coords, null) ?? null;
const squarePos = this.display.posToSquare({x: event.clientX, y: event.clientY});
this.display.mouseSquare = this.field?.getSquareOrElse(squarePos, null) ?? null;
}
);
this.canvas.addEventListener(
"mouseleave",
_ => {
this.leftDown = false;
this.middleDown = false;
this.rightDown = false;
this.bothHeld = false;
this.holdStart = null;
this.display.mouseSquare = null;
this.leftDown = false;
this.rightDown = false;
this.holdsAfterChord = false;
this.display.mouseHoldChord = false;
}
);
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
this.canvas.addEventListener(
"mousedown",
event => {
@ -304,46 +298,36 @@ export class Game {
this.field.runUndoably(() => {
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (!this.field?.hasSquareAt(coords)) return;
const square = this.field.getSquare(coords);
if (this.field == null || !this.field.hasSquareAt(coords)) return;
this.holdStart = square;
switch (event.button) {
case 0:
if (this.middleDown) break;
this.leftDown = true;
if (this.rightDown) this.bothHeld = true;
break;
case 1:
if (!this.leftDown && !this.rightDown) this.middleDown = true;
break;
case 2:
if (this.middleDown) break;
if (this.leftDown) {
this.bothHeld = true;
} else if (!square.isCovered) {
this.rightDown = true;
} else {
if (square.hasFlag) {
this.field.toggleFlag(coords);
if (preferences.marksEnabled)
if (!this.leftDown) {
const square = this.field.getSquareOrElse(coords);
if (square != null) {
if (square.hasFlag) {
this.field.toggleFlag(coords);
if (preferences.marksEnabled)
this.field.toggleMark(coords);
} else if (square.hasMark) {
this.field.toggleMark(coords);
} else if (square.hasMark) {
this.field.toggleMark(coords);
} else {
this.field.toggleFlag(coords);
} else {
this.field.toggleFlag(coords);
}
}
}
this.rightDown = true;
break;
}
});
this.display.mouseHoldUncover = this.leftDown && !this.bothHeld;
this.display.mouseHoldChord = this.bothHeld || this.middleDown;
this.display.hintSquare = null;
this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord;
this.display.mouseHoldChord = this.leftDown && this.rightDown;
}
);
this.canvas.addEventListener(
@ -354,47 +338,34 @@ export class Game {
this.field.runUndoably(() => {
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (!this.field?.hasSquareAt(coords)) return;
const square = this.field.getSquare(coords);
if (this.field == null || !this.field.hasSquareAt(coords)) return;
switch (event.button) {
case 0:
if (!this.leftDown) break;
if (!this.bothHeld) {
if (square.isCovered && this.holdStart?.isCovered === square.isCovered)
this.field.uncover(coords);
else
this.field.chord(coords);
} else if (!this.rightDown) {
if (this.leftDown && this.rightDown)
this.field.chord(coords);
}
else if (!this.holdsAfterChord && this.leftDown)
this.field.uncover(coords);
this.leftDown = false;
this.bothHeld = this.rightDown;
this.holdsAfterChord = this.rightDown;
break;
case 1:
if (!this.middleDown) break;
this.field.chord(coords);
this.middleDown = false;
break;
case 2:
if (!this.rightDown) break;
if (this.bothHeld && !this.leftDown)
if (this.leftDown && this.rightDown)
this.field.chord(coords);
this.rightDown = false;
this.bothHeld = this.leftDown;
this.holdsAfterChord = this.leftDown;
break;
}
});
if (!this.leftDown && !this.middleDown && !this.rightDown) this.holdStart = null;
this.display.mouseHoldUncover = this.leftDown && !this.bothHeld;
this.display.mouseHoldChord = this.bothHeld || this.middleDown;
this.display.hintSquare = null;
this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord;
this.display.mouseHoldChord = this.leftDown && this.rightDown;
}
);
}
@ -406,7 +377,7 @@ export class Game {
* @private
*/
private setMineLimit(): void {
this.minesInput.max = Field.maxMines(+this.widthInput.value, +this.heightInput.value).toString();
this.minesInput.max = "" + Field.maxMines(+this.widthInput.value, +this.heightInput.value);
}
/**
@ -422,10 +393,10 @@ export class Game {
else element.removeAttribute("href");
};
toggleHref(this.undo, this.field?.canUndo() ?? false);
toggleHref(this.redo, this.field?.canRedo() ?? false);
toggleHref(this.hint, (this.field?.hasStarted ?? false) && !this.field?.isOver);
toggleHref(this.solve, (this.field?.hasStarted ?? false) && !this.field?.isOver);
toggleHref(this.undo, !!this.field?.canUndo());
toggleHref(this.redo, !!this.field?.canRedo());
toggleHref(this.hint, !!this.field?.hasStarted && !this.field?.isOver);
toggleHref(this.solve, !!this.field?.hasStarted && !this.field?.isOver);
}
/**
@ -454,7 +425,7 @@ export class Game {
solvable: boolean = defaultDifficulty.solvable,
seed?: string
) {
this.seed = seed ?? this.random.uniform().toString();
this.seed = seed ?? "" + this.rng.uint32();
this.field = new Field(
width, height, mineCount, solvable,
isNaN(+this.seed) ? stringToHash(this.seed) : +this.seed,

View File

@ -1,4 +1,4 @@
const {LocalStorage} = (window as any).fwdekker.storage; // eslint-disable-line
const {LocalStorage} = (window as any).fwdekker.storage;
import {formatTime} from "./Common";
import {difficulties, Difficulty} from "./Difficulty";
@ -7,10 +7,7 @@ import {difficulties, Difficulty} from "./Difficulty";
/**
* A score obtained by clearing a field.
*/
export interface Score {
time: number;
deaths: number;
}
export type Score = {time: number, deaths: number};
/**
@ -72,7 +69,8 @@ export class HighScores {
*/
generateHtmlReport(): string {
let report = "";
for (const difficulty of difficulties) {
for (let i = 0; i < difficulties.length; i++) {
const difficulty = difficulties[i];
report += `<h3>${difficulty.name}</h3>`;
const highScores = this.getScores(difficulty);

View File

@ -1,4 +1,4 @@
const {doAfterLoad} = (window as any).fwdekker; // eslint-disable-line
const {doAfterLoad} = (window as any).fwdekker;
import {waitForForkAwesome} from "./Common";
import {BasicIconFont, ForkAwesomeFont} from "./Display";
@ -14,10 +14,10 @@ doAfterLoad(() => {
new Game(preferences);
},
() => {
alert("Icon font could not be loaded. Using fallback font. Is a browser extension blocking fonts?");
alert("External font could not be loaded. Using fallback font. Is a browser extension blocking fonts?");
preferences.font = new BasicIconFont();
new Game(preferences);
},
2500
3000
);
});

View File

@ -1,4 +1,4 @@
const {$} = (window as any).fwdekker; // eslint-disable-line
const {$} = (window as any).fwdekker;
/**
@ -65,6 +65,7 @@ export class ModalDialog {
"click",
event => {
event.preventDefault();
this.open();
}
);

View File

@ -1,4 +1,4 @@
const {LocalStorage} = (window as any).fwdekker.storage; // eslint-disable-line
const {LocalStorage} = (window as any).fwdekker.storage;
import {BasicIconFont, IconFont} from "./Display";

View File

@ -1,73 +0,0 @@
// @ts-expect-error: Alea has no types
import alea from "alea";
/**
* Generates random numbers.
*/
export class Random {
private readonly rng: any; // eslint-disable-line @typescript-eslint/no-explicit-any
/**
* Constructs a new random number generator using the given seed.
*
* @param seed the seed to initialize the generator with
*/
constructor(seed: number = Date.now()) {
this.rng = alea(seed.toString());
}
/**
* Returns a deep copy of this generator.
*/
copy(): Random {
const copy = new Random();
copy.rng.importState(this.rng.exportState());
return copy;
}
/**
* Returns a random element from `array`, or `undefined` if the array is empty.
*
* @param array the array to return a random element of
*/
get<T>(array: T[]): T | undefined {
return array[this.uniform(0, array.length)];
}
/**
* Removes and returns a random element from `array`, or returns `undefined` if the array is empty.
*
* @param array the array to remove and return a random element of
*/
pop<T>(array: T[]): T | undefined {
const idx = this.uniform(0, array.length);
const item = array[idx];
if (idx >= 0) array.splice(idx, 1);
return item;
}
/**
* Shuffles the elements of `array` in-place.
*
* @param array the array to shuffle in-place
*/
shuffleInPlace<T>(array: T[]) {
for (let i = array.length - 1; i > 0; i--) {
const j = this.uniform(0, i + 1);
[array[i], array[j]] = [array[j], array[i]];
}
}
/**
* Generates a random integer between `start` (inclusive) and `end` (exclusive).
*
* @param start the lowest possible integer to output
* @param end one higher than the highest possible integer to output
*/
uniform(start: number = 0, end: number = 4294967296) {
return this.rng.uint32() % (end - start) + start;
}
}

View File

@ -1,5 +1,8 @@
import {range, req} from "./Common";
import {Coords, Field, Square} from "./Field";
// @ts-ignore
import {array} from "vectorious";
import {range} from "./Common";
import {Field, Square} from "./Field";
/**
@ -17,64 +20,29 @@ export class Solver {
if (field.isOver) return;
if (field.hasStarted && !this.step(field.copy())) return;
field.isAutoSolving = true;
this.solveStart(field);
field.runUndoably(() => {
this.clearUserInputs(field);
while (this.step(field)) {
// Intentionally left empty
}
});
field.isAutoSolving = false;
}
/**
* Runs `#solve`, animating steps in between.
*/
static async solveAnimated(field: Field): Promise<void> {
if (field.isOver) return;
if (field.hasStarted && !this.step(field.copy())) return;
field.isAutoSolving = true;
this.solveStart(field);
await field.runUndoablyAsync(async () => {
this.clearUserInputs(field);
while (this.step(field)) {
// Timeout in between steps to create animation
await new Promise(it => setTimeout(it, 10));
}
});
field.isAutoSolving = false;
}
/**
* If the field has not started yet, clicks somewhere and starts solving.
*/
private static solveStart(field: Field): void {
if (!field.hasStarted) {
field.isAutoSolving = true;
field.runUndoably(() => {
const target = {x: Math.floor(field.width / 2), y: Math.floor(field.height / 2)};
const targetSquare = field.getSquare(target);
const targetSquare = field.getSquareOrElse(target)!;
if (targetSquare.hasFlag) field.toggleFlag(target);
if (targetSquare.hasMark) field.toggleMark(target);
field.uncover(target);
});
field.isAutoSolving = false;
}
}
/**
* Removes all the user's inputs from the given field.
*
* @param field the field to remove the user's inputs from
* @private
*/
private static clearUserInputs(field: Field): void {
field.squareList.filter(it => it.hasFlag).forEach(it => field.toggleFlag(it.coords));
field.squareList.filter(it => it.hasMark).forEach(it => field.toggleMark(it.coords));
}
field.isAutoSolving = true;
field.runUndoably(() => {
field.squareList.filter(it => it.hasFlag).forEach(it => field.toggleFlag(it.coords));
field.squareList.filter(it => it.hasMark).forEach(it => field.toggleMark(it.coords));
while (this.step(field)) {
// Repeat until `step` returns false
}
});
field.isAutoSolving = false;
}
/**
* Returns `true` if and only if this solver can solve the given field.
@ -87,84 +55,13 @@ export class Solver {
* @param field the field to check for solvability
* @param initialSquare the initial coordinates to click at
*/
static canSolve(field: Field, initialSquare: Coords | undefined = undefined): boolean {
req(field.hasStarted || initialSquare !== undefined, () => "Cannot determine solvability of unstarted field.");
static canSolve(field: Field, initialSquare: { x: number, y: number } | undefined = undefined): boolean {
const copy = field.copy();
if (initialSquare !== undefined) copy.runUndoably(() => copy.uncover(initialSquare));
if (this.isUnsolvableByHeuristic(copy))
return false;
this.solve(copy);
return copy.hasWon;
}
/**
* Uses heuristics to determine whether the given field is solvable.
*
* @param field the field to check for solvability
* @private
*/
private static isUnsolvableByHeuristic(field: Field): boolean {
for (let i = 0; i < field.width; i++) {
for (let j = 0; j < field.height; j++) {
// H
if (i < field.width - 1) {
// Bar
const barLeft = field.squares[j][i];
const barRight = field.squares[j][i + 1];
if (barLeft.hasMine === barRight.hasMine)
continue;
// Left leg
if (i > 0) {
if (j > 0 && !field.squares[j - 1][i - 1].hasMine) continue;
if (!field.squares[j][i - 1].hasMine) continue;
if (j < field.height - 1 && !field.squares[j + 1][i - 1].hasMine) continue;
}
// Right leg
if (i < field.width - 2) {
if (j > 0 && !field.squares[j - 1][i + 2].hasMine) continue;
if (!field.squares[j][i + 2].hasMine) continue;
if (j < field.height - 1 && !field.squares[j + 1][i + 2].hasMine) continue;
}
return true;
}
// Rotated H
if (j < field.height - 1) {
// Bar
const barTop = field.squares[j][i];
const barBot = field.squares[j + 1][i];
if (barTop.hasMine === barBot.hasMine)
continue;
// Top leg
if (j > 0) {
if (i > 0 && !field.squares[j - 1][i - 1].hasMine) continue;
if (!field.squares[j - 1][i].hasMine) continue;
if (i < field.width - 1 && !field.squares[j - 1][i + 1].hasMine) continue;
}
// Bottom leg
if (j < field.height - 2) {
if (i > 0 && !field.squares[j + 2][i - 1].hasMine) continue;
if (!field.squares[j + 2][i].hasMine) continue;
if (i < field.width - 1 && !field.squares[j + 2][i + 1].hasMine) continue;
}
return true;
}
}
}
return false;
}
/**
* Returns a suggestion for a next move based on the current state of the field.
*
@ -173,9 +70,9 @@ export class Solver {
*/
static getHint(field: Field): Square | null {
if (!field.hasStarted || field.isOver) return null;
const frontier = Solver.getUncoveredFrontier(field);
const knowns = Solver.getKnowns(field);
const candidate = frontier.find(square =>
const candidate = knowns.find(square =>
// Can chord
square.getNeighborCount(it => it.hasFlag) === square.getNeighborCount(it => it.hasMine) ||
// Can flag
@ -184,14 +81,15 @@ export class Solver {
);
if (candidate !== undefined) return candidate;
for (const square of frontier) {
for (let i = 0; i < knowns.length; i++) {
const square = knowns[i];
const solution = this.matrixSolve(field, square.neighbors.filter(it => !it.isCovered).concat(square), true);
const candidate = solution.find(it => it !== undefined);
if (candidate !== undefined)
return candidate[1];
}
const solution = this.matrixSolve(field, frontier, false);
const solution = this.matrixSolve(field, knowns, false);
const candidate2 = solution.find(it => it !== undefined);
if (candidate2 !== undefined)
return candidate2[1];
@ -208,12 +106,12 @@ export class Solver {
* @private
*/
private static step(field: Field): boolean {
let flagCount = field.flagCount;
let coveredCount = field.coveredNonMineCount;
if (field.isOver)
return false;
const flagCount = field.flagCount;
const coveredCount = field.coveredNonMineCount;
this.stepSingleSquares(field);
if (field.hasWon || field.flagCount !== flagCount || field.coveredNonMineCount !== coveredCount)
return true;
@ -223,7 +121,7 @@ export class Solver {
return true;
this.stepAllSquares(field);
// noinspection RedundantIfStatementJS // Makes it easier to add more steps
// noinspection RedundantIfStatementJS // Makes it easier to add more steps
if (field.hasWon || field.flagCount !== flagCount || field.coveredNonMineCount !== coveredCount)
return true;
@ -240,10 +138,11 @@ export class Solver {
* @private
*/
private static stepSingleSquares(field: Field): void {
Solver.getUncoveredFrontier(field)
Solver.getKnowns(field)
.forEach(square => {
field.chord(square.coords);
field.antiChord(square.coords);
field.chord(square);
if (square.getNeighborCount(it => it.isCovered) === square.getNeighborCount(it => it.hasMine))
square.neighbors.filter(it => !it.hasFlag).forEach(it => field.toggleFlag(it));
});
}
@ -260,9 +159,12 @@ export class Solver {
* @private
*/
private static stepNeighboringSquares(field: Field): void {
Solver.getUncoveredFrontier(field)
.map(known => this.matrixSolve(field, known.neighbors.filter(it => !it.isCovered).concat(known), true))
.forEach(solution => Solver.applySolution(field, solution));
Solver.getKnowns(field).forEach(known => {
Solver.applySolution(
field,
this.matrixSolve(field, known.neighbors.filter(it => !it.isCovered).concat(known), true)
);
});
}
/**
@ -277,21 +179,11 @@ export class Solver {
private static stepAllSquares(field: Field): void {
if (!field.hasStarted || field.hasWon || field.hasLost) return;
Solver.applySolution(field, this.matrixSolve(field, Solver.getUncoveredFrontier(field), false));
}
/**
* Returns all uncovered squares that have at least one covered unflagged neighbor.
*
* @param field the field to find the known squares in
* @returns all uncovered squares that have at least one covered unflagged neighbor
* @private
*/
private static getUncoveredFrontier(field: Field): Square[] {
return field.squareList
.filter(it => !it.isCovered)
.filter(it => it.getNeighborCount(it => it.isCovered && !it.hasFlag) > 0);
const knowns = Solver.getKnowns(field);
Solver.applySolution(
field,
this.matrixSolve(field, knowns, false)
);
}
/**
@ -308,31 +200,45 @@ export class Solver {
if (knowns.length === 0) return [];
let unknowns: Square[];
if (adjacentSquaresOnly) {
unknowns =
Array.from(new Set(knowns.flatMap(it => it.neighbors.filter(it => it.isCovered && !it.hasFlag))))
.filter(it => !knowns.includes(it));
} else {
if (adjacentSquaresOnly)
unknowns = Array
.from(new Set(knowns.reduce((acc, it) => acc.concat(it.neighbors), <Square[]>[])))
.filter(it => it.isCovered && !it.hasFlag && knowns.indexOf(it) < 0);
else
unknowns = field.squareList
.filter(it => it.isCovered && !it.hasFlag)
.filter(it => !knowns.includes(it));
}
.filter(it => it.isCovered && !it.hasFlag && knowns.indexOf(it) < 0);
if (unknowns.length === 0) return [];
const matrix = knowns.map(square => {
const row = Array<number>(unknowns.length + 1).fill(0);
const matrix: number[][] = [];
knowns.forEach(square => {
const row = Array(unknowns.length).fill(0);
square.neighbors
.filter(it => it.isCovered && !it.hasFlag)
.forEach(it => row[unknowns.indexOf(it)] = 1);
row[row.length - 1] = square.getNeighborCount(it => it.hasMine) - square.getNeighborCount(it => it.hasFlag);
return row;
row.push(square.getNeighborCount(it => it.hasMine) - square.getNeighborCount(it => it.hasFlag));
matrix.push(row);
});
if (!adjacentSquaresOnly)
matrix.push(Array(unknowns.length).fill(1).concat(field.mineCount - field.flagCount));
return (new Matrix(matrix))
.solveBinary()
.map((it, idx) => it === undefined ? undefined : [it, unknowns[idx]]);
.map((it, i) => it === undefined ? undefined : [it, unknowns[i]]);
}
/**
* Returns all uncovered squares that have at least one covered unflagged neighbor.
*
* @param field the field to find the known squares in
* @returns all uncovered squares that have at least one covered unflagged neighbor
* @private
*/
private static getKnowns(field: Field): Square[] {
return field.squareList
.filter(it => !it.isCovered)
.filter(it => it.getNeighborCount(it => it.isCovered && !it.hasFlag) > 0);
}
/**
@ -358,7 +264,7 @@ export class Solver {
* A matrix of numbers.
*/
export class Matrix {
private readonly cells: number[][];
private readonly matrix: array;
private readonly rowCount: number;
private readonly colCount: number;
@ -369,35 +275,39 @@ export class Matrix {
* @param cells an array of rows of numbers
*/
constructor(cells: number[][]) {
req(cells.length > 0, () => "Matrix must have at least 1 row.");
req(cells[0].length > 0, () => "Matrix must have at least 1 column.");
if (cells.length === 0) throw new Error("Matrix must have at least 1 row.");
if (cells[0].length === 0) throw new Error("Matrix must have at least 1 column.");
this.cells = cells;
this.rowCount = this.cells.length;
this.colCount = this.cells[0].length;
this.matrix = array(cells);
this.rowCount = this.matrix.shape[0];
this.colCount = this.matrix.shape[1];
}
/**
* Transforms this matrix into its row-reduced echelon form using Gauss-Jordan elimination.
*/
rref(): void {
private rref(): void {
const shape = this.matrix.shape;
let pivot = 0;
for (let row = 0; row < this.rowCount; row++) {
for (let row = 0; row < this.matrix.shape[1]; row++) {
// Find pivot
while (pivot < this.colCount && this.cells.slice(row).every(it => it[pivot] === 0)) pivot++;
while (pivot < this.colCount && this.rowWhereColSatisfies(row, pivot, it => it !== 0) == null) pivot++;
if (pivot >= this.colCount) return;
// Set pivot to non-zero
if (this.cells[row][pivot] === 0)
this.swap(row, this.cells.slice(row + 1).findIndex(it => it[pivot] !== 0) + row + 1);
// Set pivot to 1
this.multiply(row, 1 / this.cells[row][pivot]);
// Swap with any lower row with non-zero in pivot column
if (this.matrix.get(row, pivot) === 0) {
const row2 = this.rowWhereColSatisfies(row + 1, pivot, it => it !== 0)!;
this.matrix.swap(row, row2);
}
// Scale row so pivot equals 1
this.matrix.slice(row, row + 1).scale(1 / this.matrix.get(row, pivot));
// Set all other cells in this column to 0
for (let row2 = 0; row2 < this.rowCount; row2++) {
if (row2 === row) continue;
this.add(row2, row, -this.cells[row2][pivot]);
this.matrix.row_add(row2, row, -this.matrix.get(row2, pivot));
}
}
}
@ -410,17 +320,17 @@ export class Matrix {
*
* @returns the value of each variable, and `undefined` for each variable that could not be determined uniquely
*/
solve(): (number | undefined)[] {
private solve(): (number | undefined)[] {
this.rref();
return range(this.colCount - 1)
.map(col => {
const rowPivotIndex = this.cells.findIndex(it => it[col] === 1);
if (rowPivotIndex < 0) return undefined;
.map(column => {
const rowPivotIndex = this.rowWhereColSatisfies(0, column, it => it === 1);
if (rowPivotIndex == null) return undefined;
const row = this.cells[rowPivotIndex];
if (row.slice(0, col).every(it => it === 0) && row.slice(col + 1, -1).every(it => it === 0))
return row.at(-1);
const row = this.matrix.slice(rowPivotIndex, rowPivotIndex + 1);
if (row.map((it: number) => it === 0).sum() >= this.colCount - 1)
return row.get(row.length - 1);
return undefined;
});
@ -434,7 +344,7 @@ export class Matrix {
solveBinary(): (number | undefined)[] {
const resultsA = this.solve();
const resultsB = this.solveBinarySub();
return resultsA.map((it, idx) => it ?? resultsB[idx]);
return resultsA.map((it, i) => it ?? resultsB[i]);
}
/**
@ -445,68 +355,38 @@ export class Matrix {
* @private
*/
private solveBinarySub(): (number | undefined)[] {
const results = Array<number | undefined>(this.colCount - 1).fill(undefined);
this.cells.forEach(row => {
const results = Array(this.colCount - 1).fill(undefined);
for (let row = 0; row < this.rowCount; row++) {
// ax = b
const a = row.slice(0, -1);
const b = row.at(-1);
const a = this.matrix.slice(0, this.colCount - 1);
const b = this.matrix.get(row, this.colCount - 1);
const negSum = a.filter(it => it < 0).reduce((sum, cell) => sum + cell, 0);
const posSum = a.filter(it => it > 0).reduce((sum, cell) => sum + cell, 0);
const sign = a.copy().sign();
const negSum = -sign.copy().map((it: number) => it === -1).product(a).sum();
const posSum = sign.copy().map((it: number) => it === 1).product(a).sum();
if (b === negSum) {
a.forEach((it, i) => {
a.forEach((it: number, i: number) => {
if (it < 0) results[i] = 1;
if (it > 0) results[i] = 0;
});
} else if (b === posSum) {
a.forEach((it, i) => {
a.forEach((it: number, i: number) => {
if (it < 0) results[i] = 0;
if (it > 0) results[i] = 1;
});
}
});
}
return results;
}
/**
* Swaps the rows at the given indices.
*
* @param rowA the index of the row to swap
* @param rowB the index of the other row to swap
*/
swap(rowA: number, rowB: number) {
for (let i = 0; i < this.colCount; i++) {
const temp = this.cells[rowA][i];
this.cells[rowA][i] = this.cells[rowB][i];
this.cells[rowB][i] = temp;
}
}
private rowWhereColSatisfies(rowStart: number = 0, column: number, criterion: (cell: number) => boolean): number | null {
for (let row = rowStart; row < this.rowCount; row++)
if (criterion(this.matrix.get(row, column)))
return row;
/**
* Multiplies all numbers in the `row`th number by `factor`.
*
* @param row the index of the row to multiply
* @param factor the factory to multiply each number with
*/
multiply(row: number, factor: number) {
for (let i = 0; i < this.colCount; i++)
this.cells[row][i] *= factor;
}
/**
* Adds `factor` multiples of the `rowB`th row to the `rowA`th row.
*
* Effectively, sets `A = A + B * factor`.
*
* @param rowA the index of the row to add to
* @param rowB the index of the row to add a multiple of
* @param factor the factor to multiply each added number with
*/
add(rowA: number, rowB: number, factor: number) {
for (let i = 0; i < this.colCount; i++)
this.cells[rowA][i] += this.cells[rowB][i] * factor;
return null;
}
}

View File

@ -1,4 +1,4 @@
const {LocalStorage} = (window as any).fwdekker.storage; // eslint-disable-line
const {LocalStorage} = (window as any).fwdekker.storage;
import {formatTime} from "./Common";

View File

@ -64,14 +64,6 @@ export class Timer {
this.endTimes.push(Date.now());
}
/**
* Resets the timer.
*/
clear(): void {
this.startTimes.length = 0;
this.endTimes.length = 0;
}
/**
* Runs the given callback and adds its execution time to this timer.
*

View File

@ -1,10 +1,9 @@
{
"compilerOptions": {
"target": "es2022",
"target": "es2019",
"strict": true,
"rootDir": "./src/main/js/",
"outDir": "./dist/js/",
"allowSyntheticDefaultImports": true
"outDir": "./dist/js/"
},
"include": [
"src/main/js/**/*.ts"