Compare commits

...

18 Commits

Author SHA1 Message Date
Florine W. Dekker 5c53f07df1
Remove inefficient array usage
The solver is now 1.5 times as fast.
2024-05-10 18:03:37 +02:00
Florine W. Dekker dd61229642
Fix silly (goose) mistake 2024-05-10 17:02:53 +02:00
Florine W. Dekker 3f82aed17b
Clean up code using advanced linter feedback 2024-05-10 17:00:53 +02:00
Florine W. Dekker fcd0207aa3
Move first-click mines to random places
Fixes #106.
2024-05-10 13:23:34 +02:00
Florine W. Dekker 144f378fc1
Clear up distinction between Square and Coords 2024-05-10 13:06:15 +02:00
Florine W. Dekker 5f9b86ebed
Use single interface for random numbers 2024-05-10 12:46:50 +02:00
Florine W. Dekker 3f68f04d35
Add linter 2024-05-10 12:25:28 +02:00
Florine W. Dekker 4537ad14e8
Apply minor QoL code changes 2024-05-10 11:26:10 +02:00
Florine W. Dekker 38032255b0
Fix right-click accidentally triggering chording 2024-05-02 00:02:50 +02:00
Florine W. Dekker 0c47e03a83
Overhaul click mappings
Fixes #105.
2024-05-01 23:57:25 +02:00
Florine W. Dekker 302d2e8847
Animate field when showing solution
Fixes #102.
2024-05-01 22:42:28 +02:00
Florine W. Dekker 654148d09c
Hide hint after undo/redo/solve
Fixes #103.
2024-05-01 22:13:23 +02:00
Florine W. Dekker 09fcff20bf
Fixes #104 2024-05-01 22:07:05 +02:00
Florine W. Dekker 6365f39b12
Somewhat clean up heuristics code 2024-05-01 20:02:02 +02:00
Florine W. Dekker f9fafbece2
Add ugly heuristic removal code 2024-05-01 18:08:29 +02:00
Florine W. Dekker 09629dc783
Improve some code quality here and there 2024-05-01 15:57:37 +02:00
Florine W. Dekker 3eae6ae635
Fix ForkAwesome load detection
Fixes #98.
2024-04-30 18:24:06 +02:00
Florine W. Dekker fc959d9619
Update highlighting colors
And fix a bug where the two preferences would get mixed up.
2023-12-01 12:58:44 +01:00
20 changed files with 763 additions and 458 deletions

View File

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

46
eslint.config.mjs Normal file
View File

@ -0,0 +1,46 @@
// @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.85.3",
"version": "0.86.13",
"description": "Just Minesweeper!",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",
@ -13,25 +13,31 @@
"clean": "grunt clean",
"dev": "grunt dev",
"dev:server": "grunt dev:server",
"deploy": "grunt deploy"
"deploy": "grunt deploy",
"lint": "npx eslint src/main/js/"
},
"dependencies": {
"alea": "^1.0.1",
"canvas-confetti": "^1.6.0"
"canvas-confetti": "^1.9.3"
},
"devDependencies": {
"grunt": "^1.5.3",
"@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-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": "^5.0.0",
"ts-loader": "^9.4.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.3",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.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"
}
}

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 green if all neighbors have mines.
*Highlight squares in blue if all neighbors have mines.
</label>
</form>
<footer>

View File

@ -1,3 +1,6 @@
import {noop, req} from "./Common";
/**
* An action that can (possibly) be done and undone.
*/
@ -19,10 +22,17 @@ 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 can be done and undone.
* A sequence of actions that should be treated as a single, atomic action.
*/
export class ActionSequence extends Action {
private readonly actions: Action[] = [];
@ -97,13 +107,6 @@ 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.
@ -111,7 +114,7 @@ export class ActionHistory {
startSequence(): void {
if (this.depth === 0) {
this.sequenceIndex++;
this.sequences.length = this.sequenceIndex;
this.sequences.length = this.sequenceIndex; // Truncates newer action sequences
this.sequences.push(new ActionSequence());
}
this.depth++;
@ -123,25 +126,29 @@ export class ActionHistory {
* @param action the action to add
*/
addAction(action: Action): void {
if (!this.hasUncommittedSequence)
throw new Error("Cannot add action if there is no uncommitted sequence.");
req(this.depth > 0, () => "Cannot add action if there is no uncommitted sequence.");
this.currentSequence!.addAction(action);
}
/**
* 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`.
* 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
*/
commitSequence(): void {
if (!this.hasUncommittedSequence)
throw new Error("Cannot commit sequence if there is no uncommitted sequence.");
commitSequence(): boolean {
req(this.depth > 0, () => "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;
}
@ -151,7 +158,7 @@ export class ActionHistory {
* @returns `true` if and only if there is an action sequence that can be undone
*/
canUndo(): boolean {
return this.sequenceIndex >= 1;
return this.currentSequence?.isUndoable() ?? false;
}
/**
@ -162,8 +169,7 @@ export class ActionHistory {
* @returns the actual amount of sequences that have been undone
*/
undo(amount: number = this.sequenceIndex + 1): number {
if (this.hasUncommittedSequence)
throw new Error("Cannot undo sequences while there is an uncommitted sequence.");
req(this.depth === 0, () => "Cannot undo sequences while there is an uncommitted sequence.");
amount = Math.min(amount, this.sequenceIndex + 1);
let i;
@ -184,7 +190,7 @@ export class ActionHistory {
* @returns `true` if and only if there is an action sequence that can be redone
*/
canRedo(): boolean {
return this.sequenceIndex < this.sequences.length - 1;
return this.currentSequence !== this.sequences.at(-1);
}
/**
@ -194,8 +200,7 @@ export class ActionHistory {
* @returns the actual amount of sequences that have been redone
*/
redo(amount: number = this.sequences.length - (this.sequenceIndex + 1)): number {
if (this.hasUncommittedSequence)
throw new Error("Cannot redo sequences while there is an uncommitted sequence.");
req(this.depth === 0, () => "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,5 +1,58 @@
// @ts-ignore
import alea from "alea";
/**
* 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);
}
/**
@ -11,7 +64,7 @@ import alea from "alea";
* @param chunkSize the size of each chunk
* @returns an array of the extracted chunks
*/
export function chunkifyArray(array: any[], chunkSize: number): any[] {
export function chunkifyArray<T>(array: T[], chunkSize: number): T[][] {
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize)
chunks.push(array.slice(i, i + chunkSize));
@ -24,53 +77,22 @@ export function chunkifyArray(array: any[], chunkSize: number): any[] {
* @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
* @return the formatted time
* @returns the formatted time
*/
export function formatTime(seconds: number, minutes: boolean, hours: boolean): string {
if (!minutes && hours) throw new Error("Cannot format time with hours but without minutes.");
if (!minutes && !hours) return "" + seconds;
req(minutes || !hours, () => "Cannot format time with hours but without minutes.");
if (!minutes && !hours) return seconds.toString();
const secondsString = ("" + (seconds % 60)).padStart(2, '0');
const secondsString = (seconds % 60).toString().padStart(2, '0');
const minutesString = hours
? ("" + Math.floor((seconds % 3600) / 60)).padStart(2, '0')
: ("" + Math.floor(seconds / 60));
? Math.floor((seconds % 3600) / 60).toString().padStart(2, '0')
: Math.floor(seconds / 60).toString();
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.
*
@ -102,7 +124,7 @@ export function waitForForkAwesome(onSuccess: () => void, onFailure: () => void,
const ctx = canvas.getContext("2d")!;
const fontSize = 36;
const testCharacter = "\uF047";
const targetPixelCount = 528; // Found by doing lots of trials
const targetPixelCount = 500; // 316 is failure, 528 is success, so use 500 for margin
const ccw = canvas.width = fontSize * 1.5;
const cch = canvas.height = fontSize * 1.5;

View File

@ -59,9 +59,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 (let i = 0; i < difficulties.length; i++) {
const difficulty = difficulties[i];
if (difficulty === customDifficulty) continue;
for (const difficulty of difficulties) {
if (difficulty === customDifficulty)
continue;
if (width === difficulty.width && height === difficulty.height && mineCount === difficulty.mineCount)
return difficulty;

View File

@ -1,7 +1,6 @@
// @ts-ignore
import confetti from "canvas-confetti";
import {formatTime, range} from "./Common";
import {Field, Square} from "./Field";
import {error, formatTime, range, reqNotNullish} from "./Common";
import {Coords, Field, Square} from "./Field";
import {Preferences} from "./Preferences";
@ -10,8 +9,9 @@ import {Preferences} from "./Preferences";
*/
export class Display {
private readonly errorColor: string = "rgba(255, 0, 0, 0.3)";
private readonly hintColor: string = "rgba(0, 0, 255, 0.3)";
private readonly safeColor: string = "rgba(0, 255, 0, 0.5)";
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 scale: number = 30;
@ -100,7 +100,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, "Courier New", digitColors[digit]);
if (digit !== 0) fillText(digit.toString(), "Courier New", digitColors[digit]);
return canvas;
});
@ -176,9 +176,11 @@ 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: { x: number, y: number }): { x: number, y: number } {
posToSquare(pos: Coords): Coords {
reqNotNullish(this.field);
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)),
@ -267,20 +269,25 @@ 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 => {
// True if square should be covered
// Return true for each square that should get a cover
if (this.field!.isOver || this.mouseSquare == null)
return true;
if (this.mouseHoldUncover && this.mouseSquare === it)
if (isUncovering && this.mouseSquare === it)
return it.hasFlag || it.hasMark;
if (this.mouseHoldChord && (this.mouseSquare === it || this.mouseSquare.neighbors.indexOf(it) >= 0))
if (isChording && this.mouseSquare.neighbors.includes(it))
return it.hasFlag || it.hasMark;
return true;
})
.forEach(square => ctx.drawImage(this.coverSymbol!, square.x * this.scale, square.y * this.scale));
.forEach(it => ctx.drawImage(this.coverSymbol!, it.coords.x * this.scale, it.coords.y * this.scale));
ctx.restore();
}
@ -298,21 +305,24 @@ export class Display {
if (this.hintSquare != null) {
ctx.save();
ctx.scale(this.scale, this.scale);
ctx.fillStyle = this.hintColor;
ctx.fillRect(this.hintSquare.x * this.scale, this.hintSquare.y * this.scale, this.scale, this.scale);
ctx.fillRect(this.hintSquare.coords.x, this.hintSquare.coords.y, 1, 1);
ctx.restore();
showsHint = true;
}
if (!showsHint && this.preferences.showTooManyFlagsHints) {
ctx.save();
ctx.fillStyle = this.errorColor;
madeMistakes = madeMistakes || this.field.squareList
const mistakes = 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;
.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));
ctx.restore();
}
@ -321,20 +331,22 @@ export class Display {
(this.preferences.showChordableHints || this.preferences.showAllNeighborsAreMinesHints)
) {
ctx.save();
ctx.fillStyle = this.safeColor;
ctx.scale(this.scale, this.scale);
this.field.squareList
.filter(it => !it.isCovered)
.filter(it => {
.forEach(it => {
const mines = it.getNeighborCount(it => it.hasMine);
const flags = it.getNeighborCount(it => it.hasFlag);
const covered = it.getNeighborCount(it => it.isCovered);
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));
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);
}
});
ctx.restore();
}
}
@ -362,13 +374,13 @@ export class Display {
else if (!square.isCovered)
icon = this.digitSymbols![square.getNeighborCount(it => it.hasMine)];
if (icon !== undefined) ctx.drawImage(icon, square.x * this.scale, square.y * this.scale);
if (icon !== undefined) ctx.drawImage(icon, square.coords.x * this.scale, square.coords.y * this.scale);
});
ctx.restore();
}
/**
* Draws the status bar with remaining mines and the time.
* Draws the status bar with remaining mines, number of deaths, and the time.
*
* @param ctx the drawing context
* @private
@ -378,7 +390,7 @@ export class Display {
ctx.save();
ctx.fillStyle = "#000";
ctx.font = Math.floor(0.55 * this.scale) + "px Courier New";
ctx.font = Math.floor(0.55 * this.scale).toString() + "px Courier New";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
@ -409,12 +421,12 @@ export class Display {
deathsSymbol = this.deathsSymbolA;
}
ctx.drawImage(
deathsSymbol!,
deathsSymbol ?? error("Failed to initialize deaths symbol."),
Math.floor(this.canvas.width / 2 - this.scale),
Math.floor(this.canvas.height - this.scale)
);
ctx.fillText(
"" + this.field.deathCount,
this.field.deathCount.toString(),
Math.floor(this.canvas.width / 2),
Math.floor(this.canvas.height - 0.5 * this.scale),
this.scale
@ -446,13 +458,12 @@ export class Display {
const rect = this.canvas.getBoundingClientRect();
if (this.field.hasWon && this.winTime == null) {
confetti({
void 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,11 +1,10 @@
const {MemoryStorage} = (window as any).fwdekker.storage;
const {MemoryStorage} = (window as any).fwdekker.storage; // eslint-disable-line
// @ts-ignore
import alea from "alea";
import {Action, ActionHistory} from "./Action";
import {chunkifyArray, shuffleArrayInPlace} from "./Common";
import {chunkifyArray, req} 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";
@ -18,8 +17,8 @@ export class Field {
private readonly statistics: Statistics;
private readonly highScores: HighScores;
private readonly history = new ActionHistory();
private readonly rng: any;
private timer = new Timer();
private random: Random;
private timer: Timer = new Timer();
readonly width: number;
readonly height: number;
@ -28,6 +27,10 @@ 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;
@ -81,23 +84,22 @@ 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.rng = alea("" + seed);
this.random = new Random(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, i % this.width, Math.floor(i / this.width), true));
.map((_, i) => new Square(this, {x: i % this.width, y: Math.floor(i / this.width)}, true));
this.squares = chunkifyArray(this.squareList, this.width);
this._coveredNonMineCount = this.size - this.mineCount;
this.shuffle(this.rng.uint32());
this.shuffle();
}
/**
@ -111,9 +113,10 @@ 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;
@ -121,6 +124,7 @@ 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;
@ -132,6 +136,88 @@ 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.
@ -141,7 +227,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: { x: number, y: number }, orElse?: T): Square | T {
getSquareOrElse<T = void>(coords: Coords, orElse?: T): Square | T {
return this.squares[coords.y]?.[coords.x] ?? orElse;
}
@ -151,7 +237,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: { x: number, y: number }): boolean {
hasSquareAt(coords: Coords): boolean {
return coords.x >= 0 && coords.x < this.width && coords.y >= 0 && coords.y < this.height;
}
@ -164,116 +250,63 @@ export class Field {
/**
* 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.
* 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.
*
* @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}).`);
chord(coords: Coords): void {
const square = this.getSquare(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 => this.uncover(it.coords));
.forEach(it => {
if (it.hasMark) this.toggleMark(it.coords);
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: { 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}).`);
uncover(coords: Coords): void {
const square = this.getSquare(coords);
if (this.isOver) 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
}
if (!this.hasStarted) this.generate(coords);
const uncoverQueue: Square[] = [square];
while (uncoverQueue.length > 0) {
@ -281,7 +314,7 @@ export class Field {
if (!next.isCovered || next.hasFlag || next.hasMark) continue;
let remainingFlags: Square[] | undefined;
this.addAction(new Action(
this.runAction(new Action(
() => {
next.isCovered = false;
if (!this.isAutoSolving) this.statistics.squaresUncovered++;
@ -346,12 +379,11 @@ export class Field {
*
* @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}).`);
toggleFlag(coords: Coords): void {
const square = this.getSquare(coords);
if (!square.isCovered || square.hasMark || this.isOver) return;
this.addAction(new Action(
this.runAction(new Action(
() => {
square.hasFlag = !square.hasFlag;
if (!this.isAutoSolving && square.hasFlag) this.statistics.squaresFlagged++;
@ -372,12 +404,11 @@ export class Field {
*
* @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}).`);
toggleMark(coords: Coords): void {
const square = this.getSquare(coords);
if (!square.isCovered || square.hasFlag || this.isOver) return;
this.addAction(new Action(
this.runAction(new Action(
() => {
square.hasMark = !square.hasMark;
if (!this.isAutoSolving && square.hasMark) this.statistics.squaresMarked++;
@ -394,34 +425,42 @@ export class Field {
/**
* Runs the given callback such that all calls to `#addAction` can be undone with a single invocation of `#undo`.
* Runs the given callback such that all calls to `#runAction` inside the callback are stored as a single
* `ActionSequence`.
*
* Calling this function again inside the callback adds all actions inside the inner callback does not create a new
* undoable unit of actions.
* This function is re-entrant. That is, calling this function inside the callback will create only a single
* sequence of actions.
*
* @param callback a function such that all its calls to `#addAction` should be undoable with a single invocation of
* @param callback a function such that all its calls to `#runAction` should be undoable with a single invocation of
* `#undo`
*/
runUndoably(callback: () => void): void {
this.history.startSequence();
callback();
this.history.commitSequence();
if (this.history.commitSequence())
this.invokeEventListeners();
}
/**
* Stores the given action such that it can be undone.
* 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.
*
* 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));
private runAction(action: Action): void {
this.runUndoably(() => this.history.addAction(action));
action.run();
}
@ -519,8 +558,7 @@ export class Field {
export class Square {
private readonly field: Field;
private _neighbors: Square[] | undefined = undefined;
readonly x: number;
readonly y: number;
readonly coords: Coords;
isCovered: boolean;
hasMine: boolean;
hasFlag: boolean;
@ -531,14 +569,12 @@ export class Square {
* 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 coords the coordinates 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) {
constructor(field: Field, coords: Coords, hasMine: boolean) {
this.field = field;
this.x = x;
this.y = y;
this.coords = coords;
this.isCovered = true;
this.hasMine = hasMine;
@ -553,7 +589,7 @@ export class Square {
* @returns a deep copy of this square
*/
copy(field: Field): Square {
const copy = new Square(field, this.x, this.y, this.hasMine);
const copy = new Square(field, this.coords, this.hasMine);
copy.isCovered = this.isCovered;
copy.hasFlag = this.hasFlag;
copy.hasMark = this.hasMark;
@ -561,31 +597,24 @@ 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.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),
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),
].filter((it): it is Square => it !== null);
}
return this._neighbors!;
return this._neighbors;
}
/**
@ -594,7 +623,16 @@ export class Square {
* @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 {
getNeighborCount(property: (_: 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,14 +1,13 @@
const {$, stringToHtml} = (window as any).fwdekker;
const {$, stringToHtml} = (window as any).fwdekker; // eslint-disable-line
// @ts-ignore
import alea from "alea";
import {stringToHash} from "./Common";
import {customDifficulty, defaultDifficulty, difficulties} from "./Difficulty";
import {Display} from "./Display";
import {Field} from "./Field";
import {Field, Square} 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";
@ -34,14 +33,16 @@ export class Game {
private readonly statisticsDiv: HTMLDivElement;
private readonly highScoresDiv: HTMLDivElement;
private readonly rng: any;
private readonly random: Random;
private seed: string;
private field: Field | null;
private display: Display;
private leftDown: boolean;
private middleDown: boolean;
private rightDown: boolean;
private holdsAfterChord: boolean;
private bothHeld: boolean;
private holdStart: Square | null;
/**
@ -57,11 +58,13 @@ export class Game {
this.display.startDrawLoop();
this.canvas.classList.remove("hidden");
this.rng = alea("" + Date.now());
this.seed = "" + this.rng.uint32();
this.random = new Random();
this.seed = this.random.uniform().toString();
this.leftDown = false;
this.middleDown = false;
this.rightDown = false;
this.holdsAfterChord = false;
this.bothHeld = false;
this.holdStart = null;
this.initNewField();
@ -91,9 +94,9 @@ export class Game {
this.customDifficultyOverlay = new ModalDialog({
dialog: $("#custom-difficulty-dialog"),
onOpen: () => {
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.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.solvableInput.checked = this.field?.isSolvable ?? defaultDifficulty.solvable;
this.setMineLimit();
},
@ -133,7 +136,6 @@ export class Game {
"click",
(event: MouseEvent) => {
event.preventDefault();
this.field?.undo(); // Undoes all
}
);
@ -165,8 +167,8 @@ export class Game {
"click",
(event: MouseEvent) => {
event.preventDefault();
return this.field?.undo(1);
this.field?.undo(1);
this.display.hintSquare = null;
}
);
@ -176,8 +178,8 @@ export class Game {
"click",
(event: MouseEvent) => {
event.preventDefault();
return this.field?.redo(1);
this.field?.redo(1);
this.display.hintSquare = null;
}
);
@ -204,8 +206,9 @@ export class Game {
if (this.field != null) {
this.statistics.solverUsages++;
Solver.solve(this.field);
void Solver.solveAnimated(this.field);
}
this.display.hintSquare = null;
}
);
@ -272,24 +275,27 @@ export class Game {
// Canvas
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
this.canvas.addEventListener(
"mousemove",
event => {
const squarePos = this.display.posToSquare({x: event.clientX, y: event.clientY});
this.display.mouseSquare = this.field?.getSquareOrElse(squarePos, null) ?? null;
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
this.display.mouseSquare = this.field?.getSquareOrElse(coords, null) ?? null;
}
);
this.canvas.addEventListener(
"mouseleave",
_ => {
this.display.mouseSquare = null;
this.leftDown = false;
this.middleDown = false;
this.rightDown = false;
this.holdsAfterChord = false;
this.bothHeld = false;
this.holdStart = null;
this.display.mouseSquare = null;
this.display.mouseHoldChord = false;
}
);
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
this.canvas.addEventListener(
"mousedown",
event => {
@ -298,36 +304,46 @@ export class Game {
this.field.runUndoably(() => {
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (this.field == null || !this.field.hasSquareAt(coords)) return;
if (!this.field?.hasSquareAt(coords)) return;
const square = this.field.getSquare(coords);
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.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) {
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)
this.field.toggleMark(coords);
} else {
this.field.toggleFlag(coords);
}
} else if (square.hasMark) {
this.field.toggleMark(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(
@ -338,34 +354,47 @@ export class Game {
this.field.runUndoably(() => {
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (this.field == null || !this.field.hasSquareAt(coords)) return;
if (!this.field?.hasSquareAt(coords)) return;
const square = this.field.getSquare(coords);
switch (event.button) {
case 0:
if (this.leftDown && this.rightDown)
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) {
this.field.chord(coords);
else if (!this.holdsAfterChord && this.leftDown)
this.field.uncover(coords);
}
this.leftDown = false;
this.holdsAfterChord = this.rightDown;
this.bothHeld = this.rightDown;
break;
case 1:
if (!this.middleDown) break;
this.field.chord(coords);
this.middleDown = false;
break;
case 2:
if (this.leftDown && this.rightDown)
if (!this.rightDown) break;
if (this.bothHeld && !this.leftDown)
this.field.chord(coords);
this.rightDown = false;
this.holdsAfterChord = this.leftDown;
this.bothHeld = 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;
}
);
}
@ -377,7 +406,7 @@ export class Game {
* @private
*/
private setMineLimit(): void {
this.minesInput.max = "" + Field.maxMines(+this.widthInput.value, +this.heightInput.value);
this.minesInput.max = Field.maxMines(+this.widthInput.value, +this.heightInput.value).toString();
}
/**
@ -393,10 +422,10 @@ export class Game {
else element.removeAttribute("href");
};
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);
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);
}
/**
@ -425,7 +454,7 @@ export class Game {
solvable: boolean = defaultDifficulty.solvable,
seed?: string
) {
this.seed = seed ?? "" + this.rng.uint32();
this.seed = seed ?? this.random.uniform().toString();
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;
const {LocalStorage} = (window as any).fwdekker.storage; // eslint-disable-line
import {formatTime} from "./Common";
import {difficulties, Difficulty} from "./Difficulty";
@ -7,7 +7,10 @@ import {difficulties, Difficulty} from "./Difficulty";
/**
* A score obtained by clearing a field.
*/
export type Score = {time: number, deaths: number};
export interface Score {
time: number;
deaths: number;
}
/**
@ -69,8 +72,7 @@ export class HighScores {
*/
generateHtmlReport(): string {
let report = "";
for (let i = 0; i < difficulties.length; i++) {
const difficulty = difficulties[i];
for (const difficulty of difficulties) {
report += `<h3>${difficulty.name}</h3>`;
const highScores = this.getScores(difficulty);

View File

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

View File

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

View File

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

73
src/main/js/Random.ts Normal file
View File

@ -0,0 +1,73 @@
// @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,5 @@
import {range} from "./Common";
import {Field, Square} from "./Field";
import {range, req} from "./Common";
import {Coords, Field, Square} from "./Field";
/**
@ -17,30 +17,65 @@ export class Solver {
if (field.isOver) return;
if (field.hasStarted && !this.step(field.copy())) return;
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.getSquareOrElse(target)!;
if (targetSquare.hasFlag) field.toggleFlag(target);
if (targetSquare.hasMark) field.toggleMark(target);
field.uncover(target);
});
field.isAutoSolving = false;
}
field.isAutoSolving = true;
this.solveStart(field);
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));
this.clearUserInputs(field);
while (this.step(field)) {
// Repeat until `step` returns false
// 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.runUndoably(() => {
const target = {x: Math.floor(field.width / 2), y: Math.floor(field.height / 2)};
const targetSquare = field.getSquare(target);
if (targetSquare.hasFlag) field.toggleFlag(target);
if (targetSquare.hasMark) field.toggleMark(target);
field.uncover(target);
});
}
}
/**
* 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));
}
/**
* Returns `true` if and only if this solver can solve the given field.
*
@ -52,13 +87,84 @@ export class Solver {
* @param field the field to check for solvability
* @param initialSquare the initial coordinates to click at
*/
static canSolve(field: Field, initialSquare: { x: number, y: number } | undefined = undefined): boolean {
static canSolve(field: Field, initialSquare: Coords | undefined = undefined): boolean {
req(field.hasStarted || initialSquare !== undefined, () => "Cannot determine solvability of unstarted field.");
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.
*
@ -67,9 +173,9 @@ export class Solver {
*/
static getHint(field: Field): Square | null {
if (!field.hasStarted || field.isOver) return null;
const knowns = Solver.getKnowns(field);
const frontier = Solver.getUncoveredFrontier(field);
const candidate = knowns.find(square =>
const candidate = frontier.find(square =>
// Can chord
square.getNeighborCount(it => it.hasFlag) === square.getNeighborCount(it => it.hasMine) ||
// Can flag
@ -78,15 +184,14 @@ export class Solver {
);
if (candidate !== undefined) return candidate;
for (let i = 0; i < knowns.length; i++) {
const square = knowns[i];
for (const square of frontier) {
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, knowns, false);
const solution = this.matrixSolve(field, frontier, false);
const candidate2 = solution.find(it => it !== undefined);
if (candidate2 !== undefined)
return candidate2[1];
@ -103,12 +208,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;
@ -118,7 +223,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;
@ -135,11 +240,10 @@ export class Solver {
* @private
*/
private static stepSingleSquares(field: Field): void {
Solver.getKnowns(field)
Solver.getUncoveredFrontier(field)
.forEach(square => {
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));
field.chord(square.coords);
field.antiChord(square.coords);
});
}
@ -156,12 +260,9 @@ export class Solver {
* @private
*/
private static stepNeighboringSquares(field: Field): void {
Solver.getKnowns(field).forEach(known => {
Solver.applySolution(
field,
this.matrixSolve(field, known.neighbors.filter(it => !it.isCovered).concat(known), true)
);
});
Solver.getUncoveredFrontier(field)
.map(known => this.matrixSolve(field, known.neighbors.filter(it => !it.isCovered).concat(known), true))
.forEach(solution => Solver.applySolution(field, solution));
}
/**
@ -176,11 +277,21 @@ export class Solver {
private static stepAllSquares(field: Field): void {
if (!field.hasStarted || field.hasWon || field.hasLost) return;
const knowns = Solver.getKnowns(field);
Solver.applySolution(
field,
this.matrixSolve(field, knowns, false)
);
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);
}
/**
@ -197,45 +308,31 @@ export class Solver {
if (knowns.length === 0) return [];
let unknowns: Square[];
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
if (adjacentSquaresOnly) {
unknowns =
Array.from(new Set(knowns.flatMap(it => it.neighbors.filter(it => it.isCovered && !it.hasFlag))))
.filter(it => !knowns.includes(it));
} else {
unknowns = field.squareList
.filter(it => it.isCovered && !it.hasFlag && knowns.indexOf(it) < 0);
.filter(it => it.isCovered && !it.hasFlag)
.filter(it => !knowns.includes(it));
}
if (unknowns.length === 0) return [];
const matrix: number[][] = [];
knowns.forEach(square => {
const row = Array(unknowns.length).fill(0);
const matrix = knowns.map(square => {
const row = Array<number>(unknowns.length + 1).fill(0);
square.neighbors
.filter(it => it.isCovered && !it.hasFlag)
.forEach(it => row[unknowns.indexOf(it)] = 1);
row.push(square.getNeighborCount(it => it.hasMine) - square.getNeighborCount(it => it.hasFlag));
matrix.push(row);
row[row.length - 1] = square.getNeighborCount(it => it.hasMine) - square.getNeighborCount(it => it.hasFlag);
return row;
});
if (!adjacentSquaresOnly)
matrix.push(Array(unknowns.length).fill(1).concat(field.mineCount - field.flagCount));
return (new Matrix(matrix))
.solveBinary()
.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);
.map((it, idx) => it === undefined ? undefined : [it, unknowns[idx]]);
}
/**
@ -272,8 +369,8 @@ export class Matrix {
* @param cells an array of rows of numbers
*/
constructor(cells: number[][]) {
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.");
req(cells.length > 0, () => "Matrix must have at least 1 row.");
req(cells[0].length > 0, () => "Matrix must have at least 1 column.");
this.cells = cells;
this.rowCount = this.cells.length;
@ -281,49 +378,6 @@ export class Matrix {
}
/**
* Returns the `row`th row of numbers.
*
* @param row the index of the row to return
* @returns the `row`th row of numbers
*/
getRow(row: number): number[] {
if (row < 0 || row >= this.rowCount)
throw new Error(`Row must be in range [0, ${this.rowCount}) but was ${row}.`);
return this.cells[row];
}
/**
* Returns the `col`th column of numbers.
*
* @param col the index of the column to return
* @returns the `col`th column of numbers
*/
getCol(col: number): number[] {
if (col < 0 || col >= this.colCount)
throw new Error(`Col must be in range [0, ${this.colCount}) but was ${col}.`);
return this.cells.map(row => row[col]);
}
/**
* Returns the `col`th number in the `row`th row.
*
* @param row the index of the row to find the number in
* @param col the index of the column to find the number in
* @returns the `col`th number in the `row`th row
*/
getCell(row: number, col: number): number {
if (row < 0 || row >= this.rowCount)
throw new Error(`Row must be in range [0, ${this.rowCount}) but was ${row}.`);
if (col < 0 || col >= this.colCount)
throw new Error(`Row must be in range [0, ${this.colCount}) but was ${col}.`);
return this.cells[row][col];
}
/**
* Transforms this matrix into its row-reduced echelon form using Gauss-Jordan elimination.
*/
@ -331,19 +385,19 @@ export class Matrix {
let pivot = 0;
for (let row = 0; row < this.rowCount; row++) {
// Find pivot
while (pivot < this.colCount && this.getCol(pivot).slice(row).every(it => it === 0)) pivot++;
while (pivot < this.colCount && this.cells.slice(row).every(it => it[pivot] === 0)) pivot++;
if (pivot >= this.colCount) return;
// Set pivot to non-zero
if (this.getCell(row, pivot) === 0)
this.swap(row, this.getCol(pivot).slice(row + 1).findIndex(it => it !== 0) + row + 1);
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.getCell(row, pivot));
this.multiply(row, 1 / this.cells[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.getCell(row2, pivot));
this.add(row2, row, -this.cells[row2][pivot]);
}
}
}
@ -360,13 +414,13 @@ export class Matrix {
this.rref();
return range(this.colCount - 1)
.map(it => {
const rowPivotIndex = this.getCol(it).findIndex(it => it === 1);
.map(col => {
const rowPivotIndex = this.cells.findIndex(it => it[col] === 1);
if (rowPivotIndex < 0) return undefined;
const row = this.getRow(rowPivotIndex);
if (row.slice(0, it).every(it => it === 0) && row.slice(it + 1, -1).every(it => it === 0))
return row.slice(-1)[0];
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);
return undefined;
});
@ -380,7 +434,7 @@ export class Matrix {
solveBinary(): (number | undefined)[] {
const resultsA = this.solve();
const resultsB = this.solveBinarySub();
return resultsA.map((it, i) => it ?? resultsB[i]);
return resultsA.map((it, idx) => it ?? resultsB[idx]);
}
/**
@ -391,11 +445,11 @@ export class Matrix {
* @private
*/
private solveBinarySub(): (number | undefined)[] {
const results = Array(this.colCount - 1).fill(undefined);
const results = Array<number | undefined>(this.colCount - 1).fill(undefined);
this.cells.forEach(row => {
// ax = b
const a = row.slice(0, -1);
const b = row.slice(-1)[0];
const b = row.at(-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);
@ -423,7 +477,11 @@ export class Matrix {
* @param rowB the index of the other row to swap
*/
swap(rowA: number, rowB: number) {
[this.cells[rowA], this.cells[rowB]] = [this.cells[rowB], this.cells[rowA]];
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;
}
}
/**
@ -433,7 +491,8 @@ export class Matrix {
* @param factor the factory to multiply each number with
*/
multiply(row: number, factor: number) {
this.cells[row] = this.cells[row].map(it => it * factor);
for (let i = 0; i < this.colCount; i++)
this.cells[row][i] *= factor;
}
/**
@ -446,8 +505,8 @@ export class Matrix {
* @param factor the factor to multiply each added number with
*/
add(rowA: number, rowB: number, factor: number) {
this.cells[rowA] =
this.cells[rowA].map((it, i) => this.cells[rowA][i] + this.cells[rowB][i] * factor);
for (let i = 0; i < this.colCount; i++)
this.cells[rowA][i] += this.cells[rowB][i] * factor;
}
}

View File

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

View File

@ -64,6 +64,14 @@ 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,9 +1,10 @@
{
"compilerOptions": {
"target": "es2019",
"target": "es2022",
"strict": true,
"rootDir": "./src/main/js/",
"outDir": "./dist/js/"
"outDir": "./dist/js/",
"allowSyntheticDefaultImports": true
},
"include": [
"src/main/js/**/*.ts"