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/) * [npm](https://www.npmjs.com/)
### Setting up ### Setting up
```shell ```shell script
# Install dependencies (only needed once) # Install dependencies (only needed once)
$> npm ci $> npm ci
``` ```
### Testing
```shell
# Run linter
$> npm run lint
```
### Building ### Building
```shell ```shell script
# Build the tool in `dist/` for development # Build the tool in `dist/` for development
$> npm run dev $> npm run dev
# Same as above, but automatically rerun it whenever files are changed # 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", "name": "minesweeper",
"version": "0.86.13", "version": "0.86.0",
"description": "Just Minesweeper!", "description": "Just Minesweeper!",
"author": "Florine W. Dekker", "author": "Florine W. Dekker",
"browser": "dist/bundle.js", "browser": "dist/bundle.js",
@ -13,31 +13,26 @@
"clean": "grunt clean", "clean": "grunt clean",
"dev": "grunt dev", "dev": "grunt dev",
"dev:server": "grunt dev:server", "dev:server": "grunt dev:server",
"deploy": "grunt deploy", "deploy": "grunt deploy"
"lint": "npx eslint src/main/js/"
}, },
"dependencies": { "dependencies": {
"alea": "^1.0.1", "alea": "^1.0.1",
"canvas-confetti": "^1.9.3" "canvas-confetti": "^1.6.0",
"vectorious": "^6.1.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.2.0", "grunt": "^1.5.3",
"@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-cli": "^1.4.3",
"grunt-contrib-clean": "^2.0.1", "grunt-contrib-clean": "^2.0.1",
"grunt-contrib-copy": "^1.0.0", "grunt-contrib-copy": "^1.0.0",
"grunt-contrib-watch": "^1.1.0", "grunt-contrib-watch": "^1.1.0",
"grunt-focus": "^1.0.0", "grunt-focus": "^1.0.0",
"grunt-text-replace": "^0.4.0", "grunt-text-replace": "^0.4.0",
"grunt-webpack": "^6.0.0", "grunt-webpack": "^5.0.0",
"ts-loader": "^9.5.1", "ts-loader": "^9.4.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.1",
"typescript": "^5.4.5", "typescript": "^4.9.4",
"typescript-eslint": "^7.8.0", "webpack": "^5.75.0",
"webpack": "^5.91.0", "webpack-cli": "^5.0.1"
"webpack-cli": "^5.1.4"
} }
} }

View File

@ -164,7 +164,7 @@
<input role="switch" type="checkbox" id="preferences-show-all-neighbors-are-mines-hints" /> <input role="switch" type="checkbox" id="preferences-show-all-neighbors-are-mines-hints" />
<label for="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> </label>
</form> </form>
<footer> <footer>

View File

@ -1,6 +1,3 @@
import {noop, req} from "./Common";
/** /**
* An action that can (possibly) be done and undone. * An action that can (possibly) be done and undone.
*/ */
@ -22,17 +19,10 @@ export class Action {
this.undo = undo; this.undo = undo;
this.isUndoable = isUndoable; 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 { export class ActionSequence extends Action {
private readonly actions: Action[] = []; private readonly actions: Action[] = [];
@ -107,6 +97,13 @@ export class ActionHistory {
return this.sequences[this.sequenceIndex]; 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. * 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 { startSequence(): void {
if (this.depth === 0) { if (this.depth === 0) {
this.sequenceIndex++; this.sequenceIndex++;
this.sequences.length = this.sequenceIndex; // Truncates newer action sequences this.sequences.length = this.sequenceIndex;
this.sequences.push(new ActionSequence()); this.sequences.push(new ActionSequence());
} }
this.depth++; this.depth++;
@ -126,29 +123,25 @@ export class ActionHistory {
* @param action the action to add * @param action the action to add
*/ */
addAction(action: Action): void { 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); this.currentSequence!.addAction(action);
} }
/** /**
* Commits the current sequence of actions, provided the number of calls to this method equals the number of calls * Commits the last sequence of actions or removes it if no actions were added, but only if the number of calls to
* to `#startSequence`. * this function 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(): boolean { commitSequence(): void {
req(this.depth > 0, () => "Cannot commit sequence if there is no uncommitted sequence."); if (!this.hasUncommittedSequence)
throw new Error("Cannot commit sequence if there is no uncommitted sequence.");
this.depth--; this.depth--;
if (this.depth === 0 && this.currentSequence!.isEmpty()) { if (this.depth === 0 && this.currentSequence!.isEmpty()) {
this.sequences.pop(); this.sequences.pop();
this.sequenceIndex--; 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 * @returns `true` if and only if there is an action sequence that can be undone
*/ */
canUndo(): boolean { 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 * @returns the actual amount of sequences that have been undone
*/ */
undo(amount: number = this.sequenceIndex + 1): number { 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); amount = Math.min(amount, this.sequenceIndex + 1);
let i; let i;
@ -190,7 +184,7 @@ export class ActionHistory {
* @returns `true` if and only if there is an action sequence that can be redone * @returns `true` if and only if there is an action sequence that can be redone
*/ */
canRedo(): boolean { 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 * @returns the actual amount of sequences that have been redone
*/ */
redo(amount: number = this.sequences.length - (this.sequenceIndex + 1)): number { 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)); amount = Math.min(amount, this.sequences.length - (this.sequenceIndex + 1));
for (let i = 0; i < amount; i++) { for (let i = 0; i < amount; i++) {

View File

@ -1,58 +1,5 @@
/** // @ts-ignore
* A no-op, which does absolutely nothing. import alea from "alea";
*/
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);
}
/** /**
@ -64,7 +11,7 @@ export function range(length: number, beginAt: number = 0): number[] {
* @param chunkSize the size of each chunk * @param chunkSize the size of each chunk
* @returns an array of the extracted chunks * @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 = []; const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) for (let i = 0; i < array.length; i += chunkSize)
chunks.push(array.slice(i, 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 seconds the number of seconds; the time to be formatted
* @param minutes whether to include minutes * @param minutes whether to include minutes
* @param hours whether to include hours; requires that `minutes` is true * @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 { export function formatTime(seconds: number, minutes: boolean, hours: boolean): string {
req(minutes || !hours, () => "Cannot format time with hours but without minutes."); if (!minutes && hours) throw new Error("Cannot format time with hours but without minutes.");
if (!minutes && !hours) return seconds.toString(); if (!minutes && !hours) return "" + seconds;
const secondsString = (seconds % 60).toString().padStart(2, '0'); const secondsString = ("" + (seconds % 60)).padStart(2, '0');
const minutesString = hours const minutesString = hours
? Math.floor((seconds % 3600) / 60).toString().padStart(2, '0') ? ("" + Math.floor((seconds % 3600) / 60)).padStart(2, '0')
: Math.floor(seconds / 60).toString(); : ("" + Math.floor(seconds / 60));
if (!hours) return `${minutesString}:${secondsString}`; if (!hours) return `${minutesString}:${secondsString}`;
const hoursString = Math.floor(seconds / 3600); const hoursString = Math.floor(seconds / 3600);
return `${hoursString}:${minutesString}:${secondsString}`; 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. * Hashes the given string.
* *
@ -124,7 +102,7 @@ export function waitForForkAwesome(onSuccess: () => void, onFailure: () => void,
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
const fontSize = 36; const fontSize = 36;
const testCharacter = "\uF047"; 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 ccw = canvas.width = fontSize * 1.5;
const cch = canvas.height = 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("Beginner", "9x9, 10 mines", 9, 9, 10, true),
new Difficulty("Intermediate", "16x16, 40 mines", 16, 16, 40, true), new Difficulty("Intermediate", "16x16, 40 mines", 16, 16, 40, true),
new Difficulty("Expert", "30x16, 99 mines", 30, 16, 99, 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 * @param mineCount the number of mines to match a difficulty with
*/ */
export const findDifficulty = function(width: number, height: number, mineCount: number): Difficulty { export const findDifficulty = function(width: number, height: number, mineCount: number): Difficulty {
for (const difficulty of difficulties) { for (let i = 0; i < difficulties.length; i++) {
if (difficulty === customDifficulty) const difficulty = difficulties[i];
continue; if (difficulty === customDifficulty) continue;
if (width === difficulty.width && height === difficulty.height && mineCount === difficulty.mineCount) if (width === difficulty.width && height === difficulty.height && mineCount === difficulty.mineCount)
return difficulty; return difficulty;

View File

@ -1,6 +1,8 @@
// @ts-ignore
import confetti from "canvas-confetti"; 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"; import {Preferences} from "./Preferences";
@ -9,9 +11,8 @@ import {Preferences} from "./Preferences";
*/ */
export class Display { export class Display {
private readonly errorColor: string = "rgba(255, 0, 0, 0.3)"; private readonly errorColor: string = "rgba(255, 0, 0, 0.3)";
private readonly hintColor: string = "rgba(128, 0, 128, 0.3)"; private readonly hintColor: string = "rgba(0, 0, 255, 0.3)";
private readonly chordableColor: string = "rgba(0, 0, 255, 0.5)"; private readonly safeColor: string = "rgba(0, 255, 0, 0.5)";
private readonly allNeighborsAreMinesColor: string = "rgba(0, 255, 0, 0.5)";
private readonly scale: number = 30; private readonly scale: number = 30;
@ -100,7 +101,7 @@ export class Display {
this.digitSymbols = range(10).map(digit => { this.digitSymbols = range(10).map(digit => {
const canvas = createCanvas(this.scale, this.scale); const canvas = createCanvas(this.scale, this.scale);
ctx = canvas.getContext("2d")!; 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; return canvas;
}); });
@ -176,11 +177,9 @@ export class Display {
* @param pos the client-relative pixel coordinates to find the square at * @param pos the client-relative pixel coordinates to find the square at
* @returns the square grid coordinates corresponding to the given client coordinates * @returns the square grid coordinates corresponding to the given client coordinates
*/ */
posToSquare(pos: Coords): Coords { posToSquare(pos: { x: number, y: number }): { x: number, y: number } {
reqNotNullish(this.field);
const rect = this.canvas.getBoundingClientRect(); 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 { return {
x: Math.floor((pos.x - rect.left - this.canvas.clientLeft) / (this.scale * scaling)), x: Math.floor((pos.x - rect.left - this.canvas.clientLeft) / (this.scale * scaling)),
@ -269,25 +268,20 @@ export class Display {
ctx.save(); ctx.save();
ctx.fillStyle = "#555"; ctx.fillStyle = "#555";
const isUncovering = this.mouseHoldUncover || this.mouseHoldChord;
const isChording = this.mouseHoldUncover && !this.mouseSquare?.isCovered || this.mouseHoldChord;
this.field.squareList this.field.squareList
.filter(it => it.isCovered) .filter(it => it.isCovered)
.filter(it => { .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) if (this.field!.isOver || this.mouseSquare == null)
return true; return true;
if (isUncovering && this.mouseSquare === it) if (this.mouseHoldUncover && this.mouseSquare === it)
return it.hasFlag || it.hasMark; 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 it.hasFlag || it.hasMark;
return true; 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(); ctx.restore();
} }
@ -305,24 +299,21 @@ export class Display {
if (this.hintSquare != null) { if (this.hintSquare != null) {
ctx.save(); ctx.save();
ctx.scale(this.scale, this.scale);
ctx.fillStyle = this.hintColor; 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(); ctx.restore();
showsHint = true; showsHint = true;
} }
if (!showsHint && this.preferences.showTooManyFlagsHints) { 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.save();
ctx.scale(this.scale, this.scale);
ctx.fillStyle = this.errorColor; 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(); ctx.restore();
} }
@ -331,22 +322,20 @@ export class Display {
(this.preferences.showChordableHints || this.preferences.showAllNeighborsAreMinesHints) (this.preferences.showChordableHints || this.preferences.showAllNeighborsAreMinesHints)
) { ) {
ctx.save(); ctx.save();
ctx.scale(this.scale, this.scale); ctx.fillStyle = this.safeColor;
this.field.squareList this.field.squareList
.filter(it => !it.isCovered) .filter(it => !it.isCovered)
.forEach(it => { .filter(it => {
const mines = it.getNeighborCount(it => it.hasMine); const mines = it.getNeighborCount(it => it.hasMine);
const flags = it.getNeighborCount(it => it.hasFlag); const flags = it.getNeighborCount(it => it.hasFlag);
const covered = it.getNeighborCount(it => it.isCovered); const covered = it.getNeighborCount(it => it.isCovered);
if (this.preferences.showChordableHints && mines === covered && mines !== flags) { return (
ctx.fillStyle = this.chordableColor; (this.preferences.showChordableHints && mines === flags && covered !== flags) ||
ctx.fillRect(it.coords.x, it.coords.y, 1, 1); (this.preferences.showAllNeighborsAreMinesHints && mines === covered && mines !== flags)
} else if (this.preferences.showAllNeighborsAreMinesHints && mines === flags && covered !== flags) { );
ctx.fillStyle = this.allNeighborsAreMinesColor; })
ctx.fillRect(it.coords.x, it.coords.y, 1, 1); .forEach(square => ctx.fillRect(square.x * this.scale, square.y * this.scale, this.scale, this.scale));
}
});
ctx.restore(); ctx.restore();
} }
} }
@ -374,13 +363,13 @@ export class Display {
else if (!square.isCovered) else if (!square.isCovered)
icon = this.digitSymbols![square.getNeighborCount(it => it.hasMine)]; 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(); 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 * @param ctx the drawing context
* @private * @private
@ -390,7 +379,7 @@ export class Display {
ctx.save(); ctx.save();
ctx.fillStyle = "#000"; 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.textBaseline = "middle";
ctx.textAlign = "left"; ctx.textAlign = "left";
@ -421,12 +410,12 @@ export class Display {
deathsSymbol = this.deathsSymbolA; deathsSymbol = this.deathsSymbolA;
} }
ctx.drawImage( ctx.drawImage(
deathsSymbol ?? error("Failed to initialize deaths symbol."), deathsSymbol!,
Math.floor(this.canvas.width / 2 - this.scale), Math.floor(this.canvas.width / 2 - this.scale),
Math.floor(this.canvas.height - this.scale) Math.floor(this.canvas.height - this.scale)
); );
ctx.fillText( ctx.fillText(
this.field.deathCount.toString(), "" + this.field.deathCount,
Math.floor(this.canvas.width / 2), Math.floor(this.canvas.width / 2),
Math.floor(this.canvas.height - 0.5 * this.scale), Math.floor(this.canvas.height - 0.5 * this.scale),
this.scale this.scale
@ -458,12 +447,13 @@ export class Display {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
if (this.field.hasWon && this.winTime == null) { if (this.field.hasWon && this.winTime == null) {
void confetti({ confetti({
origin: { origin: {
x: (rect.left + rect.width / 2) / document.documentElement.clientWidth, x: (rect.left + rect.width / 2) / document.documentElement.clientWidth,
y: (rect.top + rect.height / 2) / document.documentElement.clientHeight y: (rect.top + rect.height / 2) / document.documentElement.clientHeight
}, },
spread: 360, spread: 360,
initialVelocity: 10
}); });
this.winTime = Date.now(); this.winTime = Date.now();
} else if (!this.field.hasWon) { } 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 {Action, ActionHistory} from "./Action";
import {chunkifyArray, req} from "./Common"; import {chunkifyArray, shuffleArrayInPlace} from "./Common";
import {findDifficulty} from "./Difficulty"; import {findDifficulty} from "./Difficulty";
import {HighScores} from "./HighScores"; import {HighScores} from "./HighScores";
import {Random} from "./Random";
import {Solver} from "./Solver"; import {Solver} from "./Solver";
import {Statistics} from "./Statistics"; import {Statistics} from "./Statistics";
import {Timer} from "./Timer"; import {Timer} from "./Timer";
@ -17,8 +18,8 @@ export class Field {
private readonly statistics: Statistics; private readonly statistics: Statistics;
private readonly highScores: HighScores; private readonly highScores: HighScores;
private readonly history = new ActionHistory(); private readonly history = new ActionHistory();
private random: Random; private readonly rng: any;
private timer: Timer = new Timer(); private timer = new Timer();
readonly width: number; readonly width: number;
readonly height: number; readonly height: number;
@ -27,10 +28,6 @@ export class Field {
readonly squares: Square[][] = []; readonly squares: Square[][] = [];
readonly isSolvable: boolean; readonly isSolvable: boolean;
get maxMines(): number {
return Field.maxMines(this.width, this.height);
}
private _coveredNonMineCount: number; private _coveredNonMineCount: number;
get coveredNonMineCount(): number { get coveredNonMineCount(): number {
return this._coveredNonMineCount; return this._coveredNonMineCount;
@ -84,22 +81,23 @@ export class Field {
*/ */
constructor(width: number, height: number, mineCount: number, solvable: boolean, seed: number, constructor(width: number, height: number, mineCount: number, solvable: boolean, seed: number,
statistics: Statistics, highScores: HighScores) { 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.statistics = statistics;
this.highScores = highScores; this.highScores = highScores;
this.width = width; this.width = width;
this.height = height; this.height = height;
this.mineCount = mineCount; this.mineCount = mineCount;
this.random = new Random(seed); this.rng = alea("" + seed);
this.isSolvable = solvable; 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) 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.squares = chunkifyArray(this.squareList, this.width);
this._coveredNonMineCount = this.size - this.mineCount; this._coveredNonMineCount = this.size - this.mineCount;
this.shuffle(); this.shuffle(this.rng.uint32());
} }
/** /**
@ -113,10 +111,9 @@ export class Field {
const copy = new Field( const copy = new Field(
this.width, this.height, this.width, this.height,
this.mineCount, this.mineCount,
false, false, 0,
0,
new Statistics(new MemoryStorage()), new Statistics(new MemoryStorage()),
new HighScores(new MemoryStorage()), new HighScores(new MemoryStorage())
); );
copy.squareList.length = 0; copy.squareList.length = 0;
@ -124,7 +121,6 @@ export class Field {
copy.squares.length = 0; copy.squares.length = 0;
copy.squares.push(...chunkifyArray(copy.squareList, copy.width)); copy.squares.push(...chunkifyArray(copy.squareList, copy.width));
copy.random = this.random.copy();
copy.timer = this.timer.copy(); copy.timer = this.timer.copy();
copy._coveredNonMineCount = this.coveredNonMineCount; copy._coveredNonMineCount = this.coveredNonMineCount;
copy._flagCount = this.flagCount; 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 * 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. * 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 * @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 * 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; return this.squares[coords.y]?.[coords.x] ?? orElse;
} }
@ -237,7 +151,7 @@ export class Field {
* @param coords the coordinates to check * @param coords the coordinates to check
* @returns `true` if and only if this field contains a square at the given coordinates * @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; 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 * Moves mines from the given square and its neighbors to other squares in the field, if possible.
* equals the number in the square, then all unflagged neighbors are uncovered. *
* @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 * @param coords the coordinates of the square to chord
*/ */
chord(coords: Coords): void { chord(coords: { x: number, y: number }): void {
const square = this.getSquare(coords); 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.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 (square.getNeighborCount(it => it.hasFlag) !== square.getNeighborCount(it => it.hasMine)) return;
if (!this.isAutoSolving) this.statistics.squaresChorded++; if (!this.isAutoSolving) this.statistics.squaresChorded++;
this.runUndoably(() => { this.runUndoably(() => {
square.neighbors square.neighbors
.filter(it => it.isCovered && !it.hasFlag) .filter(it => it.isCovered && !it.hasFlag)
.forEach(it => { .forEach(it => this.uncover(it.coords));
if (it.hasMark) this.toggleMark(it.coords);
this.uncover(it.coords);
});
}); });
this.invokeEventListeners(); this.invokeEventListeners();
if (!this.isAutoSolving && this.hasLost) this.statistics.squaresChordedLeadingToLoss++; 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. * Uncovers this square, revealing the contents beneath.
* *
* @param coords the coordinates of the square to uncover * @param coords the coordinates of the square to uncover
*/ */
uncover(coords: Coords): void { uncover(coords: { x: number, y: number }): void {
const square = this.getSquare(coords); const square = this.squares[coords.y][coords.x];
if (square === undefined) throw new Error(`Cannot uncover undefined square at (${coords}).`);
if (this.isOver) return; if (this.isOver) return;
this.runUndoably(() => { 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]; const uncoverQueue: Square[] = [square];
while (uncoverQueue.length > 0) { while (uncoverQueue.length > 0) {
@ -314,7 +281,7 @@ export class Field {
if (!next.isCovered || next.hasFlag || next.hasMark) continue; if (!next.isCovered || next.hasFlag || next.hasMark) continue;
let remainingFlags: Square[] | undefined; let remainingFlags: Square[] | undefined;
this.runAction(new Action( this.addAction(new Action(
() => { () => {
next.isCovered = false; next.isCovered = false;
if (!this.isAutoSolving) this.statistics.squaresUncovered++; 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 * @param coords the coordinates of the square to toggle the flag at
*/ */
toggleFlag(coords: Coords): void { toggleFlag(coords: { x: number, y: number }): void {
const square = this.getSquare(coords); 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; if (!square.isCovered || square.hasMark || this.isOver) return;
this.runAction(new Action( this.addAction(new Action(
() => { () => {
square.hasFlag = !square.hasFlag; square.hasFlag = !square.hasFlag;
if (!this.isAutoSolving && square.hasFlag) this.statistics.squaresFlagged++; 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 * @param coords the coordinates of the square to toggle the question mark at
*/ */
toggleMark(coords: Coords): void { toggleMark(coords: { x: number, y: number }): void {
const square = this.getSquare(coords); 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; if (!square.isCovered || square.hasFlag || this.isOver) return;
this.runAction(new Action( this.addAction(new Action(
() => { () => {
square.hasMark = !square.hasMark; square.hasMark = !square.hasMark;
if (!this.isAutoSolving && square.hasMark) this.statistics.squaresMarked++; 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 * Runs the given callback such that all calls to `#addAction` can be undone with a single invocation of `#undo`.
* `ActionSequence`.
* *
* This function is re-entrant. That is, calling this function inside the callback will create only a single * Calling this function again inside the callback adds all actions inside the inner callback does not create a new
* sequence of actions. * 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` * `#undo`
*/ */
runUndoably(callback: () => void): void { runUndoably(callback: () => void): void {
this.history.startSequence(); this.history.startSequence();
callback(); callback();
if (this.history.commitSequence()) this.history.commitSequence();
this.invokeEventListeners();
} }
/** /**
* Runs `#runUndoably` asynchronously. * Stores the given action such that it can be undone.
*/
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. * 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 * @param action the action that can be undone
* @private * @private
*/ */
private runAction(action: Action): void { private addAction(action: Action): void {
this.runUndoably(() => this.history.addAction(action)); if (this.history.hasUncommittedSequence)
this.history.addAction(action);
else
this.runUndoably(() => this.history.addAction(action));
action.run(); action.run();
} }
@ -558,7 +519,8 @@ export class Field {
export class Square { export class Square {
private readonly field: Field; private readonly field: Field;
private _neighbors: Square[] | undefined = undefined; private _neighbors: Square[] | undefined = undefined;
readonly coords: Coords; readonly x: number;
readonly y: number;
isCovered: boolean; isCovered: boolean;
hasMine: boolean; hasMine: boolean;
hasFlag: boolean; hasFlag: boolean;
@ -569,12 +531,14 @@ export class Square {
* Constructs a new square. * Constructs a new square.
* *
* @param field the field in which this square is located * @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 * @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.field = field;
this.coords = coords; this.x = x;
this.y = y;
this.isCovered = true; this.isCovered = true;
this.hasMine = hasMine; this.hasMine = hasMine;
@ -589,7 +553,7 @@ export class Square {
* @returns a deep copy of this square * @returns a deep copy of this square
*/ */
copy(field: Field): 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.isCovered = this.isCovered;
copy.hasFlag = this.hasFlag; copy.hasFlag = this.hasFlag;
copy.hasMark = this.hasMark; 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. * The `Square`s that are adjacent to this square.
*/ */
get neighbors(): Square[] { get neighbors(): Square[] {
if (this._neighbors === undefined) { if (this._neighbors === undefined) {
this._neighbors = [ this._neighbors = [
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.coords.x, y: this.coords.y - 1}, null), this.field.getSquareOrElse({x: this.x, y: this.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.coords.x - 1, y: this.coords.y}, null), this.field.getSquareOrElse({x: this.x - 1, y: this.y}, null),
this.field.getSquareOrElse({x: this.coords.x + 1, y: this.coords.y}, null), this.field.getSquareOrElse({x: this.x + 1, y: this.y}, 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.coords.x, y: this.coords.y + 1}, null), this.field.getSquareOrElse({x: this.x, y: this.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),
].filter((it): it is Square => it !== 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 * @param property the property to check on each neighbor
* @returns the number of neighbors that satisfy the given property * @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; 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 {stringToHash} from "./Common";
import {customDifficulty, defaultDifficulty, difficulties} from "./Difficulty"; import {customDifficulty, defaultDifficulty, difficulties} from "./Difficulty";
import {Display} from "./Display"; import {Display} from "./Display";
import {Field, Square} from "./Field"; import {Field} from "./Field";
import {HighScores} from "./HighScores"; import {HighScores} from "./HighScores";
import {ModalDialog} from "./ModalDialog"; import {ModalDialog} from "./ModalDialog";
import {Preferences} from "./Preferences"; import {Preferences} from "./Preferences";
import {Random} from "./Random";
import {Solver} from "./Solver"; import {Solver} from "./Solver";
import {Statistics} from "./Statistics"; import {Statistics} from "./Statistics";
@ -33,16 +34,14 @@ export class Game {
private readonly statisticsDiv: HTMLDivElement; private readonly statisticsDiv: HTMLDivElement;
private readonly highScoresDiv: HTMLDivElement; private readonly highScoresDiv: HTMLDivElement;
private readonly random: Random; private readonly rng: any;
private seed: string; private seed: string;
private field: Field | null; private field: Field | null;
private display: Display; private display: Display;
private leftDown: boolean; private leftDown: boolean;
private middleDown: boolean;
private rightDown: boolean; private rightDown: boolean;
private bothHeld: boolean; private holdsAfterChord: boolean;
private holdStart: Square | null;
/** /**
@ -58,13 +57,11 @@ export class Game {
this.display.startDrawLoop(); this.display.startDrawLoop();
this.canvas.classList.remove("hidden"); this.canvas.classList.remove("hidden");
this.random = new Random(); this.rng = alea("" + Date.now());
this.seed = this.random.uniform().toString(); this.seed = "" + this.rng.uint32();
this.leftDown = false; this.leftDown = false;
this.middleDown = false;
this.rightDown = false; this.rightDown = false;
this.bothHeld = false; this.holdsAfterChord = false;
this.holdStart = null;
this.initNewField(); this.initNewField();
@ -94,9 +91,9 @@ export class Game {
this.customDifficultyOverlay = new ModalDialog({ this.customDifficultyOverlay = new ModalDialog({
dialog: $("#custom-difficulty-dialog"), dialog: $("#custom-difficulty-dialog"),
onOpen: () => { onOpen: () => {
this.widthInput.value = (this.field?.width ?? defaultDifficulty.width).toString(); this.widthInput.value = "" + (this.field?.width ?? defaultDifficulty.width);
this.heightInput.value = (this.field?.height ?? defaultDifficulty.height).toString(); this.heightInput.value = "" + (this.field?.height ?? defaultDifficulty.height);
this.minesInput.value = (this.field?.mineCount ?? defaultDifficulty.mineCount).toString(); this.minesInput.value = "" + (this.field?.mineCount ?? defaultDifficulty.mineCount);
this.solvableInput.checked = this.field?.isSolvable ?? defaultDifficulty.solvable; this.solvableInput.checked = this.field?.isSolvable ?? defaultDifficulty.solvable;
this.setMineLimit(); this.setMineLimit();
}, },
@ -136,6 +133,7 @@ export class Game {
"click", "click",
(event: MouseEvent) => { (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
this.field?.undo(); // Undoes all this.field?.undo(); // Undoes all
} }
); );
@ -167,8 +165,8 @@ export class Game {
"click", "click",
(event: MouseEvent) => { (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
this.field?.undo(1);
this.display.hintSquare = null; return this.field?.undo(1);
} }
); );
@ -178,8 +176,8 @@ export class Game {
"click", "click",
(event: MouseEvent) => { (event: MouseEvent) => {
event.preventDefault(); 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) { if (this.field != null) {
this.statistics.solverUsages++; this.statistics.solverUsages++;
void Solver.solveAnimated(this.field); Solver.solve(this.field);
} }
this.display.hintSquare = null;
} }
); );
@ -275,27 +272,24 @@ export class Game {
// Canvas // Canvas
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
this.canvas.addEventListener( this.canvas.addEventListener(
"mousemove", "mousemove",
event => { event => {
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY}); const squarePos = this.display.posToSquare({x: event.clientX, y: event.clientY});
this.display.mouseSquare = this.field?.getSquareOrElse(coords, null) ?? null; this.display.mouseSquare = this.field?.getSquareOrElse(squarePos, null) ?? null;
} }
); );
this.canvas.addEventListener( this.canvas.addEventListener(
"mouseleave", "mouseleave",
_ => { _ => {
this.leftDown = false;
this.middleDown = false;
this.rightDown = false;
this.bothHeld = false;
this.holdStart = null;
this.display.mouseSquare = null; this.display.mouseSquare = null;
this.leftDown = false;
this.rightDown = false;
this.holdsAfterChord = false;
this.display.mouseHoldChord = false; this.display.mouseHoldChord = false;
} }
); );
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
this.canvas.addEventListener( this.canvas.addEventListener(
"mousedown", "mousedown",
event => { event => {
@ -304,46 +298,36 @@ export class Game {
this.field.runUndoably(() => { this.field.runUndoably(() => {
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY}); const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (!this.field?.hasSquareAt(coords)) return; if (this.field == null || !this.field.hasSquareAt(coords)) return;
const square = this.field.getSquare(coords);
this.holdStart = square;
switch (event.button) { switch (event.button) {
case 0: case 0:
if (this.middleDown) break;
this.leftDown = true; this.leftDown = true;
if (this.rightDown) this.bothHeld = true;
break;
case 1:
if (!this.leftDown && !this.rightDown) this.middleDown = true;
break; break;
case 2: case 2:
if (this.middleDown) break; if (!this.leftDown) {
const square = this.field.getSquareOrElse(coords);
if (this.leftDown) { if (square != null) {
this.bothHeld = true; if (square.hasFlag) {
} else if (!square.isCovered) { this.field.toggleFlag(coords);
this.rightDown = true; if (preferences.marksEnabled)
} else { this.field.toggleMark(coords);
if (square.hasFlag) { } else if (square.hasMark) {
this.field.toggleFlag(coords);
if (preferences.marksEnabled)
this.field.toggleMark(coords); this.field.toggleMark(coords);
} else if (square.hasMark) { } else {
this.field.toggleMark(coords); this.field.toggleFlag(coords);
} else { }
this.field.toggleFlag(coords);
} }
} }
this.rightDown = true;
break; break;
} }
}); });
this.display.mouseHoldUncover = this.leftDown && !this.bothHeld;
this.display.mouseHoldChord = this.bothHeld || this.middleDown;
this.display.hintSquare = null; this.display.hintSquare = null;
this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord;
this.display.mouseHoldChord = this.leftDown && this.rightDown;
} }
); );
this.canvas.addEventListener( this.canvas.addEventListener(
@ -354,47 +338,34 @@ export class Game {
this.field.runUndoably(() => { this.field.runUndoably(() => {
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY}); const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (!this.field?.hasSquareAt(coords)) return; if (this.field == null || !this.field.hasSquareAt(coords)) return;
const square = this.field.getSquare(coords);
switch (event.button) { switch (event.button) {
case 0: case 0:
if (!this.leftDown) break; if (this.leftDown && this.rightDown)
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); this.field.chord(coords);
} else if (!this.holdsAfterChord && this.leftDown)
this.field.uncover(coords);
this.leftDown = false; this.leftDown = false;
this.bothHeld = this.rightDown; this.holdsAfterChord = this.rightDown;
break; break;
case 1: case 1:
if (!this.middleDown) break;
this.field.chord(coords); this.field.chord(coords);
this.middleDown = false;
break; break;
case 2: case 2:
if (!this.rightDown) break; if (this.leftDown && this.rightDown)
if (this.bothHeld && !this.leftDown)
this.field.chord(coords); this.field.chord(coords);
this.rightDown = false; this.rightDown = false;
this.bothHeld = this.leftDown; this.holdsAfterChord = this.leftDown;
break; 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.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
*/ */
private setMineLimit(): void { 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"); else element.removeAttribute("href");
}; };
toggleHref(this.undo, this.field?.canUndo() ?? false); toggleHref(this.undo, !!this.field?.canUndo());
toggleHref(this.redo, this.field?.canRedo() ?? false); toggleHref(this.redo, !!this.field?.canRedo());
toggleHref(this.hint, (this.field?.hasStarted ?? false) && !this.field?.isOver); toggleHref(this.hint, !!this.field?.hasStarted && !this.field?.isOver);
toggleHref(this.solve, (this.field?.hasStarted ?? false) && !this.field?.isOver); toggleHref(this.solve, !!this.field?.hasStarted && !this.field?.isOver);
} }
/** /**
@ -454,7 +425,7 @@ export class Game {
solvable: boolean = defaultDifficulty.solvable, solvable: boolean = defaultDifficulty.solvable,
seed?: string seed?: string
) { ) {
this.seed = seed ?? this.random.uniform().toString(); this.seed = seed ?? "" + this.rng.uint32();
this.field = new Field( this.field = new Field(
width, height, mineCount, solvable, width, height, mineCount, solvable,
isNaN(+this.seed) ? stringToHash(this.seed) : +this.seed, 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 {formatTime} from "./Common";
import {difficulties, Difficulty} from "./Difficulty"; import {difficulties, Difficulty} from "./Difficulty";
@ -7,10 +7,7 @@ import {difficulties, Difficulty} from "./Difficulty";
/** /**
* A score obtained by clearing a field. * A score obtained by clearing a field.
*/ */
export interface Score { export type Score = {time: number, deaths: number};
time: number;
deaths: number;
}
/** /**
@ -72,7 +69,8 @@ export class HighScores {
*/ */
generateHtmlReport(): string { generateHtmlReport(): string {
let report = ""; let report = "";
for (const difficulty of difficulties) { for (let i = 0; i < difficulties.length; i++) {
const difficulty = difficulties[i];
report += `<h3>${difficulty.name}</h3>`; report += `<h3>${difficulty.name}</h3>`;
const highScores = this.getScores(difficulty); 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 {waitForForkAwesome} from "./Common";
import {BasicIconFont, ForkAwesomeFont} from "./Display"; import {BasicIconFont, ForkAwesomeFont} from "./Display";
@ -14,10 +14,10 @@ doAfterLoad(() => {
new Game(preferences); 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(); preferences.font = new BasicIconFont();
new Game(preferences); 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", "click",
event => { event => {
event.preventDefault(); event.preventDefault();
this.open(); 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"; 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"; // @ts-ignore
import {Coords, Field, Square} from "./Field"; 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.isOver) return;
if (field.hasStarted && !this.step(field.copy())) 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) { if (!field.hasStarted) {
field.isAutoSolving = true;
field.runUndoably(() => { field.runUndoably(() => {
const target = {x: Math.floor(field.width / 2), y: Math.floor(field.height / 2)}; 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.hasFlag) field.toggleFlag(target);
if (targetSquare.hasMark) field.toggleMark(target); if (targetSquare.hasMark) field.toggleMark(target);
field.uncover(target); field.uncover(target);
}); });
field.isAutoSolving = false;
} }
}
/** field.isAutoSolving = true;
* Removes all the user's inputs from the given field. field.runUndoably(() => {
* field.squareList.filter(it => it.hasFlag).forEach(it => field.toggleFlag(it.coords));
* @param field the field to remove the user's inputs from field.squareList.filter(it => it.hasMark).forEach(it => field.toggleMark(it.coords));
* @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));
}
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. * 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 field the field to check for solvability
* @param initialSquare the initial coordinates to click at * @param initialSquare the initial coordinates to click at
*/ */
static canSolve(field: Field, initialSquare: Coords | undefined = undefined): boolean { static canSolve(field: Field, initialSquare: { x: number, y: number } | undefined = undefined): boolean {
req(field.hasStarted || initialSquare !== undefined, () => "Cannot determine solvability of unstarted field.");
const copy = field.copy(); const copy = field.copy();
if (initialSquare !== undefined) copy.runUndoably(() => copy.uncover(initialSquare)); if (initialSquare !== undefined) copy.runUndoably(() => copy.uncover(initialSquare));
if (this.isUnsolvableByHeuristic(copy))
return false;
this.solve(copy); this.solve(copy);
return copy.hasWon; 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. * 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 { static getHint(field: Field): Square | null {
if (!field.hasStarted || field.isOver) return 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 // Can chord
square.getNeighborCount(it => it.hasFlag) === square.getNeighborCount(it => it.hasMine) || square.getNeighborCount(it => it.hasFlag) === square.getNeighborCount(it => it.hasMine) ||
// Can flag // Can flag
@ -184,14 +81,15 @@ export class Solver {
); );
if (candidate !== undefined) return candidate; 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 solution = this.matrixSolve(field, square.neighbors.filter(it => !it.isCovered).concat(square), true);
const candidate = solution.find(it => it !== undefined); const candidate = solution.find(it => it !== undefined);
if (candidate !== undefined) if (candidate !== undefined)
return candidate[1]; return candidate[1];
} }
const solution = this.matrixSolve(field, frontier, false); const solution = this.matrixSolve(field, knowns, false);
const candidate2 = solution.find(it => it !== undefined); const candidate2 = solution.find(it => it !== undefined);
if (candidate2 !== undefined) if (candidate2 !== undefined)
return candidate2[1]; return candidate2[1];
@ -208,12 +106,12 @@ export class Solver {
* @private * @private
*/ */
private static step(field: Field): boolean { private static step(field: Field): boolean {
let flagCount = field.flagCount;
let coveredCount = field.coveredNonMineCount;
if (field.isOver) if (field.isOver)
return false; return false;
const flagCount = field.flagCount;
const coveredCount = field.coveredNonMineCount;
this.stepSingleSquares(field); this.stepSingleSquares(field);
if (field.hasWon || field.flagCount !== flagCount || field.coveredNonMineCount !== coveredCount) if (field.hasWon || field.flagCount !== flagCount || field.coveredNonMineCount !== coveredCount)
return true; return true;
@ -223,7 +121,7 @@ export class Solver {
return true; return true;
this.stepAllSquares(field); 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) if (field.hasWon || field.flagCount !== flagCount || field.coveredNonMineCount !== coveredCount)
return true; return true;
@ -240,10 +138,11 @@ export class Solver {
* @private * @private
*/ */
private static stepSingleSquares(field: Field): void { private static stepSingleSquares(field: Field): void {
Solver.getUncoveredFrontier(field) Solver.getKnowns(field)
.forEach(square => { .forEach(square => {
field.chord(square.coords); field.chord(square);
field.antiChord(square.coords); 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
*/ */
private static stepNeighboringSquares(field: Field): void { private static stepNeighboringSquares(field: Field): void {
Solver.getUncoveredFrontier(field) Solver.getKnowns(field).forEach(known => {
.map(known => this.matrixSolve(field, known.neighbors.filter(it => !it.isCovered).concat(known), true)) Solver.applySolution(
.forEach(solution => Solver.applySolution(field, solution)); 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 { private static stepAllSquares(field: Field): void {
if (!field.hasStarted || field.hasWon || field.hasLost) return; if (!field.hasStarted || field.hasWon || field.hasLost) return;
Solver.applySolution(field, this.matrixSolve(field, Solver.getUncoveredFrontier(field), false)); const knowns = Solver.getKnowns(field);
} Solver.applySolution(
field,
this.matrixSolve(field, knowns, 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);
} }
/** /**
@ -308,31 +200,45 @@ export class Solver {
if (knowns.length === 0) return []; if (knowns.length === 0) return [];
let unknowns: Square[]; let unknowns: Square[];
if (adjacentSquaresOnly) { if (adjacentSquaresOnly)
unknowns = unknowns = Array
Array.from(new Set(knowns.flatMap(it => it.neighbors.filter(it => it.isCovered && !it.hasFlag)))) .from(new Set(knowns.reduce((acc, it) => acc.concat(it.neighbors), <Square[]>[])))
.filter(it => !knowns.includes(it)); .filter(it => it.isCovered && !it.hasFlag && knowns.indexOf(it) < 0);
} else { else
unknowns = field.squareList unknowns = field.squareList
.filter(it => it.isCovered && !it.hasFlag) .filter(it => it.isCovered && !it.hasFlag && knowns.indexOf(it) < 0);
.filter(it => !knowns.includes(it));
}
if (unknowns.length === 0) return []; if (unknowns.length === 0) return [];
const matrix = knowns.map(square => { const matrix: number[][] = [];
const row = Array<number>(unknowns.length + 1).fill(0); knowns.forEach(square => {
const row = Array(unknowns.length).fill(0);
square.neighbors square.neighbors
.filter(it => it.isCovered && !it.hasFlag) .filter(it => it.isCovered && !it.hasFlag)
.forEach(it => row[unknowns.indexOf(it)] = 1); .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) if (!adjacentSquaresOnly)
matrix.push(Array(unknowns.length).fill(1).concat(field.mineCount - field.flagCount)); matrix.push(Array(unknowns.length).fill(1).concat(field.mineCount - field.flagCount));
return (new Matrix(matrix)) return (new Matrix(matrix))
.solveBinary() .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. * A matrix of numbers.
*/ */
export class Matrix { export class Matrix {
private readonly cells: number[][]; private readonly matrix: array;
private readonly rowCount: number; private readonly rowCount: number;
private readonly colCount: number; private readonly colCount: number;
@ -369,35 +275,39 @@ export class Matrix {
* @param cells an array of rows of numbers * @param cells an array of rows of numbers
*/ */
constructor(cells: number[][]) { constructor(cells: number[][]) {
req(cells.length > 0, () => "Matrix must have at least 1 row."); if (cells.length === 0) throw new Error("Matrix must have at least 1 row.");
req(cells[0].length > 0, () => "Matrix must have at least 1 column."); if (cells[0].length === 0) throw new Error("Matrix must have at least 1 column.");
this.cells = cells; this.matrix = array(cells);
this.rowCount = this.cells.length; this.rowCount = this.matrix.shape[0];
this.colCount = this.cells[0].length; this.colCount = this.matrix.shape[1];
} }
/** /**
* Transforms this matrix into its row-reduced echelon form using Gauss-Jordan elimination. * 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; let pivot = 0;
for (let row = 0; row < this.rowCount; row++) { for (let row = 0; row < this.matrix.shape[1]; row++) {
// Find pivot // 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; if (pivot >= this.colCount) return;
// Set pivot to non-zero // Swap with any lower row with non-zero in pivot column
if (this.cells[row][pivot] === 0) if (this.matrix.get(row, pivot) === 0) {
this.swap(row, this.cells.slice(row + 1).findIndex(it => it[pivot] !== 0) + row + 1); const row2 = this.rowWhereColSatisfies(row + 1, pivot, it => it !== 0)!;
// Set pivot to 1 this.matrix.swap(row, row2);
this.multiply(row, 1 / this.cells[row][pivot]); }
// 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 // Set all other cells in this column to 0
for (let row2 = 0; row2 < this.rowCount; row2++) { for (let row2 = 0; row2 < this.rowCount; row2++) {
if (row2 === row) continue; 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 * @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(); this.rref();
return range(this.colCount - 1) return range(this.colCount - 1)
.map(col => { .map(column => {
const rowPivotIndex = this.cells.findIndex(it => it[col] === 1); const rowPivotIndex = this.rowWhereColSatisfies(0, column, it => it === 1);
if (rowPivotIndex < 0) return undefined; if (rowPivotIndex == null) return undefined;
const row = this.cells[rowPivotIndex]; const row = this.matrix.slice(rowPivotIndex, rowPivotIndex + 1);
if (row.slice(0, col).every(it => it === 0) && row.slice(col + 1, -1).every(it => it === 0)) if (row.map((it: number) => it === 0).sum() >= this.colCount - 1)
return row.at(-1); return row.get(row.length - 1);
return undefined; return undefined;
}); });
@ -434,7 +344,7 @@ export class Matrix {
solveBinary(): (number | undefined)[] { solveBinary(): (number | undefined)[] {
const resultsA = this.solve(); const resultsA = this.solve();
const resultsB = this.solveBinarySub(); 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
*/ */
private solveBinarySub(): (number | undefined)[] { private solveBinarySub(): (number | undefined)[] {
const results = Array<number | undefined>(this.colCount - 1).fill(undefined); const results = Array(this.colCount - 1).fill(undefined);
this.cells.forEach(row => { for (let row = 0; row < this.rowCount; row++) {
// ax = b // ax = b
const a = row.slice(0, -1); const a = this.matrix.slice(0, this.colCount - 1);
const b = row.at(-1); const b = this.matrix.get(row, this.colCount - 1);
const negSum = a.filter(it => it < 0).reduce((sum, cell) => sum + cell, 0); const sign = a.copy().sign();
const posSum = a.filter(it => it > 0).reduce((sum, cell) => sum + cell, 0); 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) { if (b === negSum) {
a.forEach((it, i) => { a.forEach((it: number, i: number) => {
if (it < 0) results[i] = 1; if (it < 0) results[i] = 1;
if (it > 0) results[i] = 0; if (it > 0) results[i] = 0;
}); });
} else if (b === posSum) { } 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] = 0;
if (it > 0) results[i] = 1; if (it > 0) results[i] = 1;
}); });
} }
}); }
return results; return results;
} }
/** private rowWhereColSatisfies(rowStart: number = 0, column: number, criterion: (cell: number) => boolean): number | null {
* Swaps the rows at the given indices. for (let row = rowStart; row < this.rowCount; row++)
* if (criterion(this.matrix.get(row, column)))
* @param rowA the index of the row to swap return row;
* @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;
}
}
/** return null;
* 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;
} }
} }

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 {formatTime} from "./Common";

View File

@ -64,14 +64,6 @@ export class Timer {
this.endTimes.push(Date.now()); 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. * Runs the given callback and adds its execution time to this timer.
* *

View File

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