Compare commits
18 Commits
vectorious
...
main
Author | SHA1 | Date |
---|---|---|
Florine W. Dekker | 5c53f07df1 | |
Florine W. Dekker | dd61229642 | |
Florine W. Dekker | 3f82aed17b | |
Florine W. Dekker | fcd0207aa3 | |
Florine W. Dekker | 144f378fc1 | |
Florine W. Dekker | 5f9b86ebed | |
Florine W. Dekker | 3f68f04d35 | |
Florine W. Dekker | 4537ad14e8 | |
Florine W. Dekker | 38032255b0 | |
Florine W. Dekker | 0c47e03a83 | |
Florine W. Dekker | 302d2e8847 | |
Florine W. Dekker | 654148d09c | |
Florine W. Dekker | 09fcff20bf | |
Florine W. Dekker | 6365f39b12 | |
Florine W. Dekker | f9fafbece2 | |
Florine W. Dekker | 09629dc783 | |
Florine W. Dekker | 3eae6ae635 | |
Florine W. Dekker | fc959d9619 |
10
README.md
10
README.md
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
);
|
Binary file not shown.
26
package.json
26
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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++) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue