Upgrade template to v3

This commit is contained in:
Florine W. Dekker 2022-11-26 12:08:20 +01:00
parent 0e0238687a
commit b4f2586aa8
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
15 changed files with 456 additions and 573 deletions

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "minesweeper",
"version": "0.82.16",
"version": "0.83.0",
"description": "Just Minesweeper!",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",
@ -17,21 +17,21 @@
},
"dependencies": {
"alea": "^1.0.1",
"canvas-confetti": "^1.5.1"
"canvas-confetti": "^1.6.0"
},
"devDependencies": {
"grunt": "^1.4.1",
"grunt": "^1.5.3",
"grunt-cli": "^1.4.3",
"grunt-contrib-clean": "^2.0.0",
"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.2.6",
"ts-node": "^10.5.0",
"typescript": "^4.5.5",
"webpack": "^5.69.1",
"webpack-cli": "^4.9.2"
"ts-loader": "^9.4.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.3",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.0"
}
}

View File

@ -1,71 +1,42 @@
form {
display: inline;
}
form button.cancel {
background-color: #606c76;
border-color: #606c76;
}
/* Controls */
.row.controls {
text-align: center;
.controls-group {
display: flex;
flex-direction: column;
gap: 1em;
}
.row.controls input, .row.controls button {
display: inline;
.controls {
display: flex;
gap: 1em;
flex-wrap: wrap;
align-items: start;
justify-content: center;
justify-items: center;
}
.row.controls select, .row.controls input {
.controls select {
width: unset;
margin-bottom: 0;
}
/* Canvas */
#canvasContainer {
#canvas-container {
text-align: center;
}
#canvas {
display: inline;
box-sizing: border-box;
border: 4mm ridge #bdbdbd;
}
#canvas.invisible {
visibility: hidden !important;
}
/* Overlay */
.overlayWrapper {
z-index: 20;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
/* Dialogs */
#high-scores-dialog article,
#statistics-dialog article {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
}
.overlay {
padding: 6rem;
min-width: 33%;
max-height: 80%;
overflow: auto;
background-color: white;
}
#statisticsOverlay td {
#statistics-dialog td {
text-align: right;
}

View File

@ -8,18 +8,24 @@
<meta name="description" content="Just Minesweeper!" />
<meta name="theme-color" content="#0033cc" />
<meta name="fwd:auto:show-main" />
<meta name="fwd:nav:target" content="#nav" />
<meta name="fwd:nav:highlight-path" content="/Tools/Minesweeper/" />
<meta name="fwd:footer:target" content="#footer" />
<meta name="fwd:footer:vcs-url" content="https://git.fwdekker.com/tools/minesweeper/" />
<meta name="fwd:footer:version" content="v%%VERSION_NUMBER%%" />
<title>Minesweeper | FWDekker</title>
<link rel="stylesheet" href="https://static.fwdekker.com/fonts/roboto/roboto.css" />
<link rel="stylesheet" href="https://static.fwdekker.com/fonts/fork-awesome/1.x.x/fork-awesome.css" />
<link rel="stylesheet" href="https://static.fwdekker.com/lib/template/2.x.x/template.css" />
<link rel="stylesheet" href="https://static.fwdekker.com/lib/template/3.x.x/template.css?v=%%VERSION_NUMBER%%" />
<!--suppress HtmlUnknownTarget -->
<link rel="stylesheet" href="main.css?v=%%VERSION_NUMBER%%" />
<script async src="https://stats.fwdekker.com/count.js"
data-goatcounter="https://stats.fwdekker.com/count"></script>
</head>
<body>
<noscript>
<noscript class="fwd-js-notice">
<img src="https://stats.fwdekker.com/count?p=/tools/minesweeper/" alt="Counting pixel" />
<p>
@ -28,184 +34,166 @@
instructions on how to enable JavaScript in your web browser</a>.
</p>
</noscript>
<main class="hidden">
<div id="nav"></div>
<div id="contents">
<div id="header"></div>
<nav id="nav"></nav>
<main class="container hidden">
<div role="document">
<section>
<header class="fwd-header">
<hgroup>
<h1><a href=".">Minesweeper</a></h1>
<h2>Just Minesweeper!</h2>
</hgroup>
</header>
<section class="container">
<!-- Controls -->
<div class="row controls">
<div class="column">
<!-- Preferences -->
<form id="preferencesOpenForm">
<button><i class="fa fa-cogs"></i>&emsp;Preferences</button>
</form>
<!-- Statistics -->
<form id="statisticsOpenForm">
<button><i class="fa fa-tachometer"></i>&emsp;Statistics</button>
</form>
<!-- High scores -->
<form id="highScoresOpenForm">
<button><i class="fa fa-trophy"></i>&emsp;High scores</button>
</form>
</div>
</div>
<div class="row controls">
<div class="column">
<!-- Difficulty -->
<label for="difficulty"></label>
<select id="difficulty">
<option>Select difficulty</option>
</select>
<!-- New game -->
<form id="newGameForm">
<button><i class="fa fa-random"></i>&emsp;New game</button>
</form>
<!-- Restart -->
<form id="restartForm">
<button><i class="fa fa-sync"></i>&emsp;Restart game</button>
</form>
<!-- Seed -->
<form id="seedOpenForm">
<button><i class="fa fa-tree"></i>&emsp;Enter seed</button>
</form>
</div>
</div>
<div class="row controls">
<div class="column">
<!-- Undo -->
<form id="undoForm">
<button><i class="fa fa-undo"></i>&emsp;Undo</button>
</form>
<!-- Redo -->
<form id="redoForm">
<button><i class="fa fa-repeat"></i>&emsp;Redo</button>
</form>
<!-- Hint -->
<form id="hintForm">
<button><i class="fa fa-lightbulb-o"></i>&emsp;Hint</button>
</form>
<!-- Solver -->
<form id="solveForm">
<button><i class="fa fa-key"></i>&emsp;Solve</button>
</form>
</div>
</div>
<br />
<!-- Field -->
<div class="row">
<div class="column">
<!-- Canvas -->
<div id="canvasContainer">
<canvas id="canvas" class="invisible" width="1" height="1">
Your browser must support the &lt;canvas&gt; element to run this game.
</canvas>
<article>
<header class="controls-group">
<div class="controls">
<a role="button" href="#" id="preferences-open"><i class="fa fa-cogs"></i>&emsp;Preferences</a>
<a role="button" href="#" id="statistics-open"><i class="fa fa-tachometer"></i>&emsp;Statistics</a>
<a role="button" href="#" id="high-scores-open">
<i class="fa fa-trophy"></i>&emsp;High scores</a>
</div>
<div class="controls">
<a role="button" href="#" id="new-game"><i class="fa fa-random"></i>&emsp;New game</a>
<a role="button" href="#" id="restart"><i class="fa fa-sync"></i>&emsp;Restart game</a>
<a role="button" href="#" id="seed-open"><i class="fa fa-tree"></i>&emsp;Enter seed</a>
</div>
<div class="controls">
<!--suppress HtmlFormInputWithoutLabel -->
<select id="difficulty">
<option>Select difficulty</option>
</select>
</div>
</header>
<div id="canvas-container">
<canvas id="canvas" class="hidden" width="1" height="1">
Your browser must support the &lt;canvas&gt; element to run this game.
</canvas>
</div>
</div>
<footer class="controls">
<a role="button" href="#" id="undo"><i class="fa fa-undo"></i>&emsp;Undo</a>
<a role="button" href="#" id="redo"><i class="fa fa-repeat"></i>&emsp;Redo</a>
<a role="button" href="#" id="hint"><i class="fa fa-lightbulb-o"></i>&emsp;Hint</a>
<a role="button" href="#" id="solve"><i class="fa fa-key"></i>&emsp;Solve</a>
</footer>
</article>
</section>
<footer id="footer"></footer>
</div>
<div id="footer"></div>
<!-- Custom difficulty overlay -->
<div class="overlayWrapper hidden" id="customDifficultyOverlay">
<div class="overlay">
<h2>Custom difficulty</h2>
<form id="customDifficultyForm">
<label for="settingsWidth">Width</label>
<input type="number" id="settingsWidth" min="3" max="99" value="9" />
<!-- Custom difficulty dialog -->
<dialog id="custom-difficulty-dialog">
<article>
<header>
<hgroup>
<h1>Custom difficulty</h1>
<h2>Configure a custom difficulty.</h2>
</hgroup>
</header>
<form id="custom-difficulty-form">
<label for="settings-width">Width</label>
<input type="number" id="settings-width" min="3" max="99" value="9" autofocus />
<label for="settingsHeight">Height</label>
<input type="number" id="settingsHeight" min="3" max="99" value="9" />
<label for="settings-height">Height</label>
<input type="number" id="settings-height" min="3" max="99" value="9" />
<label for="settingsMines">Mines</label>
<input type="number" id="settingsMines" min="0" value="10" />
<label for="settings-mines">Mines</label>
<input type="number" id="settings-mines" min="0" value="10" />
<label for="settingsSolvable">Ensure solvability (slow for complex settings)</label>
<input type="checkbox" id="settingsSolvable" />
<br /><br />
<button>New game</button>
<input type="checkbox" id="settings-solvable" />
<label for="settings-solvable">Ensure solvability. <small>(Slow for complex settings.)</small></label>
</form>
<form id="customDifficultyCancelForm">
<button class="cancel">Cancel</button>
</form>
</div>
</div>
<!-- Seed input overlay -->
<div class="overlayWrapper hidden" id="seedOverlay">
<div class="overlay">
<h2>Seed</h2>
<form id="seedForm">
<footer>
<a role="button" href="#" id="custom-difficulty-cancel" class="secondary">Cancel</a>
<a role="button" href="#" id="custom-difficulty-submit">New game</a>
</footer>
</article>
</dialog>
<!-- Seed input dialog -->
<dialog id="seed-dialog">
<article>
<header>
<hgroup>
<h1>Seed</h1>
<h2>
Enter a number to generate a minefield with.
Given a difficulty level and first click position, the same seed always generates the exact same
minefield.
</h2>
</hgroup>
</header>
<form id="seed-form">
<!--suppress HtmlFormInputWithoutLabel Only one input, so header already explains everything -->
<input id="seed" />
<button>New game</button>
<input id="seed" autofocus />
</form>
<form id="seedCancelForm">
<button class="cancel">Cancel</button>
</form>
</div>
</div>
<!-- Preferences overlay -->
<div class="overlayWrapper hidden" id="preferencesOverlay">
<div class="overlay">
<h2>Preferences</h2>
<form id="preferencesForm">
<label for="preferencesEnableMarks">Enable question marks</label>
<input type="checkbox" id="preferencesEnableMarks" />
<label for="preferencesShowTooManyFlagsHints">Highlight squares with too many flags around them</label>
<input type="checkbox" id="preferencesShowTooManyFlagsHints" />
<footer>
<a role="button" href="#" id="seed-cancel" class="secondary">Cancel</a>
<a role="button" href="#" id="seed-submit">New game</a>
</footer>
</article>
</dialog>
<!-- Preferences dialog -->
<dialog id="preferences-dialog">
<article>
<header>
<hgroup>
<h1>Preferences</h1>
<h2>
Configure the gameplay to your liking.
</h2>
</hgroup>
</header>
<form id="preferences-form">
<input role="switch" type="checkbox" id="preferences-enable-marks" autofocus />
<label for="preferences-enable-marks">Right-clicking a square twice places a question mark.</label>
<br /><br />
<button>Save</button>
<input role="switch" type="checkbox" id="preferences-show-too-many-flags-hints" />
<label for="preferences-show-too-many-flags-hints">
Highlight squares with too many flags around them.
</label>
</form>
<form id="preferencesCancelForm">
<button class="cancel">Cancel</button>
</form>
</div>
</div>
<!-- Statistics overlay -->
<div class="overlayWrapper hidden" id="statisticsOverlay">
<div class="overlay">
<h2>Statistics</h2>
<div id="statisticsDiv"></div>
<form id="statisticsResetForm">
<button>Reset</button>
</form>
<form id="statisticsCloseForm">
<button class="cancel">Close</button>
</form>
</div>
</div>
<!-- High scores overlay -->
<div class="overlayWrapper hidden" id="highScoresOverlay">
<div class="overlay">
<h2>High scores</h2>
<div id="highScoresDiv"></div>
<form id="highScoresResetForm">
<button>Reset</button>
</form>
<form id="highScoresCloseForm">
<button class="cancel">Close</button>
</form>
</div>
</div>
<footer>
<a role="button" href="#" id="preferences-cancel" class="secondary">Cancel</a>
<a role="button" href="#" id="preferences-submit">Save</a>
</footer>
</article>
</dialog>
<!-- Statistics dialog -->
<dialog id="statistics-dialog">
<article tabindex="-1" autofocus>
<header>
<hgroup>
<h1>Statistics</h1>
<h2>Your achievements expressed in numbers.</h2>
</hgroup>
</header>
<div id="statistics-div"></div>
<footer>
<a role="button" href="#" id="statistics-close" class="secondary">Close</a>
<a role="button" href="#" id="statistics-reset" class="secondary">Reset</a>
</footer>
</article>
</dialog>
<!-- High scores dialog -->
<dialog id="high-scores-dialog">
<article tabindex="-1" autofocus>
<header>
<hgroup>
<h1>High scores</h1>
<h2>Your best moments playing Minesweeper.</h2>
</hgroup>
</header>
<div id="high-scores-div"></div>
<footer>
<a role="button" href="#" id="high-scores-close" class="secondary">Close</a>
<a role="button" href="#" id="high-scores-reset" class="secondary">Reset</a>
</footer>
</article>
</dialog>
</main>
<script src="https://static.fwdekker.com/lib/template/2.x.x/template.js"></script>
<script src="https://static.fwdekker.com/lib/template/2.x.x/storage.js"></script>
<script src="https://static.fwdekker.com/lib/template/3.x.x/template.js?v=%%VERSION_NUMBER%%"></script>
<!--suppress HtmlUnknownTarget -->
<script src="bundle.js?v=%%VERSION_NUMBER%%"></script>
</body>

View File

@ -119,7 +119,7 @@ export function waitForForkAwesome(onSuccess: () => void, onFailure: () => void,
ctx.textBaseline = "middle";
const startTime = performance.now();
const failTime = timeout === null ? null : startTime + timeout;
const failTime = timeout == null ? null : startTime + timeout;
requestAnimationFrame(fontOnload);
/**
@ -129,7 +129,7 @@ export function waitForForkAwesome(onSuccess: () => void, onFailure: () => void,
*/
function fontOnload(time: number): void {
const currentCount = getPixelCount();
if (failTime !== null && time > failTime) onFailure();
if (failTime != null && time > failTime) onFailure();
else if (currentCount < targetPixelCount) requestAnimationFrame(fontOnload);
else onSuccess();
}

View File

@ -156,7 +156,7 @@ export class Display {
* @private
*/
private getFieldOffset(): { x: number, y: number } {
if (this.field === null) return {x: 0, y: 0};
if (this.field == null) return {x: 0, y: 0};
if (this.field.width >= this.minSquareWidth) return {x: 0, y: 0};
return {x: Math.floor((this.minSquareWidth - this.field.width) * this.scale / 2), y: 0};
}
@ -169,7 +169,7 @@ export class Display {
setField(field: Field | null): void {
this.hintSquare = null;
this.field = field;
if (this.field === null) return;
if (this.field == null) return;
this.canvas.width = Math.max(this.minSquareWidth, this.field.width) * this.scale;
this.canvas.height = this.field.height * this.scale + this.scale;
@ -213,7 +213,7 @@ export class Display {
const {x, y} = this.getFieldOffset();
this.clearCanvas(ctx);
if (this.field === null) return;
if (this.field == null) return;
ctx.save();
ctx.translate(x, y);
@ -247,7 +247,7 @@ export class Display {
* @private
*/
private drawGrid(ctx: CanvasRenderingContext2D): void {
if (this.field === null) return;
if (this.field == null) return;
ctx.save();
ctx.beginPath();
@ -271,7 +271,7 @@ export class Display {
* @private
*/
private drawCovers(ctx: CanvasRenderingContext2D): void {
if (this.field === null) return;
if (this.field == null) return;
ctx.save();
ctx.fillStyle = "#555";
@ -279,7 +279,7 @@ export class Display {
.filter(it => it.isCovered)
.filter(it => {
// True if square should be covered
if (this.field!.hasLost || this.field!.hasWon || this.mouseSquare === null)
if (this.field!.hasLost || this.field!.hasWon || this.mouseSquare == null)
return true;
if (this.mouseHoldUncover && this.mouseSquare === it)
return it.hasFlag || it.hasMark;
@ -299,7 +299,7 @@ export class Display {
* @private
*/
private drawHints(ctx: CanvasRenderingContext2D): void {
if (this.field === null) return;
if (this.field == null) return;
if (this.preferences.showTooManyFlagsHints) {
ctx.save();
@ -311,7 +311,7 @@ export class Display {
ctx.restore();
}
if (this.hintSquare !== null) {
if (this.hintSquare != null) {
ctx.save();
ctx.fillStyle = "rgba(0, 255, 0, 0.3)";
ctx.fillRect(this.hintSquare.x * this.scale, this.hintSquare.y * this.scale, this.scale, this.scale);
@ -326,11 +326,11 @@ export class Display {
* @private
*/
private drawSymbols(ctx: CanvasRenderingContext2D): void {
if (this.field === null) return;
if (this.field == null) return;
ctx.save();
this.field.squareList.forEach(square => {
if (this.field === null) return;
if (this.field == null) return;
let icon;
if (square.hasFlag)
@ -354,7 +354,7 @@ export class Display {
* @private
*/
private drawStatusBar(ctx: CanvasRenderingContext2D): void {
if (this.field === null) return;
if (this.field == null) return;
ctx.save();
ctx.fillStyle = "#000";
@ -378,7 +378,7 @@ export class Display {
// Deaths
let deathsSymbol;
if (this.field.hasLost) {
if (this.loseTime === null)
if (this.loseTime == null)
this.loseTime = Date.now();
deathsSymbol = Math.floor((Date.now() - this.loseTime) / 1000) % 2 === 0
@ -422,10 +422,10 @@ export class Display {
* @private
*/
private drawWinConfetti(): void {
if (this.field === null) return;
if (this.field == null) return;
const rect = this.canvas.getBoundingClientRect();
if (this.field.hasWon && this.winTime === null) {
if (this.field.hasWon && this.winTime == null) {
confetti({
origin: {
x: (rect.left + rect.width / 2) / document.documentElement.clientWidth,

View File

@ -1,3 +1,5 @@
const {MemoryStorage} = (window as any).fwdekker.storage;
// @ts-ignore
import alea from "alea";
import {Action, ActionHistory} from "./Action";
@ -7,8 +9,6 @@ import {HighScores} from "./HighScores";
import {Solver} from "./Solver";
import {Statistics} from "./Statistics";
import {Timer} from "./Timer";
// @ts-ignore
const {MemoryStorage} = window.fwdekker.storage;
/**
@ -127,13 +127,15 @@ export class Field {
/**
* Returns the square at the given coordinates, or `orElse` if there is no square there.
* 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.
*
* @param coords the coordinates of the square to look up
* @param orElse the value to return if there is no square at the given coordinates
* @returns the square at the given coordinates, or `orElse` if there is no square there
* @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(coords: { x: number, y: number }, orElse: any = null): Square | any {
getSquareOrElse<T = void>(coords: { x: number, y: number }, orElse?: T): Square | T {
return this.squares[coords.y]?.[coords.x] ?? orElse;
}
@ -523,15 +525,15 @@ export class Square {
get neighbors(): Square[] {
if (this._neighbors === undefined) {
this._neighbors = [
this.field.getSquareOrElse({x: this.x - 1, y: this.y - 1}),
this.field.getSquareOrElse({x: this.x, y: this.y - 1}),
this.field.getSquareOrElse({x: this.x + 1, y: this.y - 1}),
this.field.getSquareOrElse({x: this.x - 1, y: this.y}),
this.field.getSquareOrElse({x: this.x + 1, y: this.y}),
this.field.getSquareOrElse({x: this.x - 1, y: this.y + 1}),
this.field.getSquareOrElse({x: this.x, y: this.y + 1}),
this.field.getSquareOrElse({x: this.x + 1, y: this.y + 1}),
].filter(it => it !== null);
this.field.getSquareOrElse({x: this.x - 1, y: this.y - 1}, null),
this.field.getSquareOrElse({x: this.x, y: this.y - 1}, null),
this.field.getSquareOrElse({x: this.x + 1, y: this.y - 1}, null),
this.field.getSquareOrElse({x: this.x - 1, y: this.y}, null),
this.field.getSquareOrElse({x: this.x + 1, y: this.y}, null),
this.field.getSquareOrElse({x: this.x - 1, y: this.y + 1}, null),
this.field.getSquareOrElse({x: this.x, y: this.y + 1}, null),
this.field.getSquareOrElse({x: this.x + 1, y: this.y + 1}, null),
].filter((it): it is Square => it !== null);
}
return this._neighbors!;

View File

@ -1,16 +1,16 @@
// @ts-ignore
const {$} = window.fwdekker;
const {$, stringToHtml} = (window as any).fwdekker;
// @ts-ignore
import alea from "alea";
import {blurActiveElement, stringToHash} from "./Common";
import {stringToHash} from "./Common";
import {customDifficulty, defaultDifficulty, difficulties} from "./Difficulty";
import {Display} from "./Display";
import {Field} from "./Field";
import {HighScores} from "./HighScores";
import {ModalDialog} from "./ModalDialog";
import {Preferences} from "./Preferences";
import {Solver} from "./Solver";
import {Statistics} from "./Statistics";
import {Overlay} from "./UI";
/**
@ -22,33 +22,13 @@ export class Game {
private statisticsTimer: number | undefined;
private readonly canvas: HTMLCanvasElement;
private readonly difficultySelect: HTMLSelectElement;
private readonly newGameForm: HTMLFormElement;
private readonly restartForm: HTMLFormElement;
private readonly seedOverlay: Overlay;
private readonly seedOpenForm: HTMLFormElement;
private readonly seedInput: HTMLFormElement;
private readonly undoForm: HTMLFormElement;
private readonly redoForm: HTMLFormElement;
private readonly hintForm: HTMLFormElement;
private readonly solveForm: HTMLFormElement;
private readonly customDifficultyOverlay: Overlay;
private readonly customDifficultyOverlay: ModalDialog;
private readonly widthInput: HTMLInputElement;
private readonly heightInput: HTMLInputElement;
private readonly minesInput: HTMLInputElement;
private readonly solvableInput: HTMLInputElement;
private readonly preferencesOverlay: Overlay;
private readonly enableMarksInput: HTMLInputElement;
private readonly showTooManyFlagsHintsInput: HTMLInputElement;
private readonly preferencesOpenForm: HTMLFormElement;
private readonly statisticsOverlay: Overlay;
private readonly statisticsDiv: HTMLDivElement;
private readonly statisticsResetForm: HTMLFormElement;
private readonly statisticsOpenForm: HTMLFormElement;
private readonly highScoresOverlay: Overlay;
private readonly highScoresDiv: HTMLDivElement;
private readonly highScoresResetForm: HTMLFormElement;
private readonly highScoresOpenForm: HTMLFormElement;
private readonly rng: any;
private seed: string;
@ -71,7 +51,7 @@ export class Game {
this.field = null; // Placeholder until `initNewField`
this.display = new Display(this.canvas, this.field, preferences);
this.display.startDrawLoop();
this.canvas.classList.remove("invisible");
this.canvas.classList.remove("hidden");
this.rng = alea("" + Date.now());
this.seed = "" + this.rng.uint32();
@ -83,44 +63,40 @@ export class Game {
// Settings
this.difficultySelect = $("#difficulty");
const difficultySelect = $("#difficulty");
difficulties.forEach(it => {
const option = document.createElement("option");
option.value = it.name;
option.innerHTML = `${it.name}${it.description !== null ? ` (${it.description})` : ""}`;
this.difficultySelect.add(option);
const description = `${it.name}${it.description != null ? ` (${it.description})` : ""}`;
difficultySelect.appendChild(stringToHtml(`<option value=${it.name}>${description}</option>`));
});
this.difficultySelect.addEventListener(
difficultySelect.addEventListener("click", (event: MouseEvent) => event.stopPropagation());
difficultySelect.addEventListener(
"change",
event => {
event.preventDefault();
const difficulty = difficulties[this.difficultySelect.selectedIndex - 1];
this.difficultySelect.selectedIndex = 0;
() => {
const difficulty = difficulties[difficultySelect.selectedIndex - 1];
difficultySelect.selectedIndex = 0;
if (difficulty === undefined) return;
if (difficulty.name !== customDifficulty.name) {
this.difficultySelect.selectedIndex = 0;
if (difficulty.name === customDifficulty.name)
this.customDifficultyOverlay.open();
else
this.initNewField(difficulty.width, difficulty.height, difficulty.mineCount, difficulty.solvable);
return;
}
}
);
this.customDifficultyOverlay.show();
// Custom difficulty
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.solvableInput.checked = this.field?.isSolvable ?? defaultDifficulty.solvable;
this.setMineLimit();
this.widthInput.focus();
}
);
// Custom difficulty
this.customDifficultyOverlay = new Overlay(
$("#customDifficultyOverlay"),
$("#customDifficultyForm"),
$("#customDifficultyCancelForm"),
() => {
},
form: $("#custom-difficulty-form"),
closeButton: $("#custom-difficulty-cancel"),
submitButton: $("#custom-difficulty-submit"),
onSubmit: () => {
this.initNewField(
+this.widthInput.value,
+this.heightInput.value,
@ -128,218 +104,135 @@ export class Game {
this.solvableInput.checked
);
}
);
});
this.widthInput = $("#settingsWidth");
this.widthInput = $("#settings-width");
this.widthInput.addEventListener("change", _ => this.setMineLimit());
this.heightInput = $("#settingsHeight");
this.heightInput = $("#settings-height");
this.heightInput.addEventListener("change", _ => this.setMineLimit());
this.minesInput = $("#settingsMines");
this.solvableInput = $("#settingsSolvable");
this.minesInput = $("#settings-mines");
this.solvableInput = $("#settings-solvable");
// New game form
this.newGameForm = $("#newGameForm");
this.newGameForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount, this.field?.isSolvable);
blurActiveElement();
}
$("#new-game").addEventListener(
"click",
() =>
this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount, this.field?.isSolvable)
);
// Restart
this.restartForm = $("#restartForm");
this.restartForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.field?.undo(); // Undoes all
blurActiveElement();
}
);
$("#restart").addEventListener("click", () => this.field?.undo()); // Undoes all
// Seed
this.seedInput = $("#seed");
this.seedOpenForm = $("#seedOpenForm");
this.seedOpenForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.seedOverlay.show();
this.seedInput.value = this.seed;
this.seedInput.focus();
setTimeout(() => this.seedInput.select(), 0);
}
);
this.seedOverlay = new Overlay(
$("#seedOverlay"),
$("#seedForm"),
$("#seedCancelForm"),
() => {
const seedInput = $("#seed");
const seedDialog = new ModalDialog({
dialog: $("#seed-dialog"),
openButton: $("#seed-open"),
onOpen: () => seedInput.value = this.seed,
form: $("#seed-form"),
closeButton: $("#seed-cancel"),
submitButton: $("#seed-submit"),
onSubmit: () => {
this.initNewField(
this.field?.width,
this.field?.height,
this.field?.mineCount,
this.field?.isSolvable,
this.seedInput.value
seedInput.value
);
seedDialog.close();
}
);
});
// Undo
this.undoForm = $("#undoForm");
this.undoForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.field?.undo(1);
blurActiveElement();
}
);
$("#undo").addEventListener("click", () => this.field?.undo(1));
// Redo
this.redoForm = $("#redoForm");
this.redoForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.field?.redo(1);
blurActiveElement();
}
);
$("#redo").addEventListener("click", () => this.field?.redo(1));
// Hint
this.hintForm = $("#hintForm");
this.hintForm.addEventListener(
"submit",
event => {
event.preventDefault();
if (this.field !== null) {
$("#hint").addEventListener(
"click",
() => {
if (this.field != null) {
this.statistics.hintsRequested++;
this.display.hintSquare = Solver.getHint(this.field);
}
blurActiveElement();
}
);
// Solve
this.solveForm = $("#solveForm");
this.solveForm.addEventListener(
"submit",
event => {
event.preventDefault();
if (this.field !== null) {
$("#solve").addEventListener(
"click",
() => {
if (this.field != null) {
this.statistics.solverUsages++;
Solver.solve(this.field);
}
blurActiveElement();
}
);
// Preferences
this.enableMarksInput = $("#preferencesEnableMarks");
this.showTooManyFlagsHintsInput = $("#preferencesShowTooManyFlagsHints");
this.preferencesOpenForm = $("#preferencesOpenForm");
this.preferencesOpenForm.addEventListener(
"submit",
event => {
event.preventDefault();
const enableMarksInput = $("#preferences-enable-marks");
const showTooManyFlagsHintsInput = $("#preferences-show-too-many-flags-hints");
const preferencesDialog = new ModalDialog({
dialog: $("#preferences-dialog"),
openButton: $("#preferences-open"),
onOpen: () => {
enableMarksInput.checked = preferences.marksEnabled;
showTooManyFlagsHintsInput.checked = preferences.showTooManyFlagsHints;
},
form: $("#preferences-form"),
closeButton: $("#preferences-cancel"),
submitButton: $("#preferences-submit"),
onSubmit: () => {
preferences.marksEnabled = enableMarksInput.checked;
preferences.showTooManyFlagsHints = showTooManyFlagsHintsInput.checked;
this.enableMarksInput.checked = preferences.marksEnabled;
this.showTooManyFlagsHintsInput.checked = preferences.showTooManyFlagsHints;
this.preferencesOverlay.show();
blurActiveElement();
preferencesDialog.close();
}
);
this.preferencesOverlay = new Overlay(
$("#preferencesOverlay"),
$("#preferencesForm"),
$("#preferencesCancelForm"),
() => {
preferences.marksEnabled = this.enableMarksInput.checked;
preferences.showTooManyFlagsHints = this.showTooManyFlagsHintsInput.checked;
}
);
});
// Statistics
this.statisticsDiv = $("#statisticsDiv");
this.statisticsOpenForm = $("#statisticsOpenForm");
this.statisticsOpenForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.statisticsOverlay.show();
blurActiveElement();
}
);
this.statisticsOverlay = new Overlay(
$("#statisticsOverlay"),
null,
$("#statisticsCloseForm")
);
this.statisticsResetForm = $("#statisticsResetForm");
this.statisticsResetForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.statisticsDiv = $("#statistics-div");
new ModalDialog({
dialog: $("#statistics-dialog"),
openButton: $("#statistics-open"),
onOpen: () => this.updateStatistics(),
closeButton: $("#statistics-close"),
submitButton: $("#statistics-reset"),
onSubmit: () => {
if (!window.confirm("Are you sure you want to reset all statistics? This cannot be undone."))
return;
this.statistics.clear();
this.updateStatistics();
}
);
this.updateStatistics();
});
// High scores
this.highScoresDiv = $("#highScoresDiv");
this.highScoresOpenForm = $("#highScoresOpenForm");
this.highScoresOpenForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.highScoresDiv.innerHTML = this.highScores.generateHtmlReport();
this.highScoresOverlay.show();
blurActiveElement();
}
);
this.highScoresOverlay = new Overlay(
$("#highScoresOverlay"),
null,
$("#highScoresCloseForm")
);
this.highScoresResetForm = $("#highScoresResetForm");
this.highScoresResetForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.highScoresDiv = $("#high-scores-div");
new ModalDialog({
dialog: $("#high-scores-dialog"),
openButton: $("#high-scores-open"),
onOpen: () => this.highScoresDiv.innerHTML = this.highScores.generateHtmlReport(),
closeButton: $("#high-scores-close"),
submitButton: $("#high-scores-reset"),
onSubmit: () => {
if (!window.confirm("Are you sure you want to reset all high scores? This cannot be undone."))
return;
this.highScores.clear();
this.highScoresDiv.innerHTML = this.highScores.generateHtmlReport();
}
);
});
// Canvas
this.canvas.addEventListener(
"mousemove",
event => {
this.display.mouseSquare = this.field?.getSquareOrElse(
this.display.posToSquare({x: event.clientX, y: event.clientY}),
null
);
const squarePos = this.display.posToSquare({x: event.clientX, y: event.clientY});
this.display.mouseSquare = this.field?.getSquareOrElse(squarePos, null) ?? null;
}
);
this.canvas.addEventListener(
@ -357,11 +250,11 @@ export class Game {
"mousedown",
event => {
event.preventDefault();
if (this.field === null) return;
if (this.field == null) return;
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 == null || !this.field.hasSquareAt(coords)) return;
switch (event.button) {
case 0:
@ -370,7 +263,7 @@ export class Game {
case 2:
if (!this.leftDown) {
const square = this.field.getSquareOrElse(coords);
if (square !== null) {
if (square != null) {
if (square.hasFlag) {
this.field.toggleFlag(coords);
if (preferences.marksEnabled)
@ -397,11 +290,11 @@ export class Game {
"mouseup",
event => {
event.preventDefault();
if (this.field === null) return;
if (this.field == null) return;
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 == null || !this.field.hasSquareAt(coords)) return;
switch (event.button) {
case 0:
@ -459,7 +352,7 @@ export class Game {
* @param height the height of the field
* @param mineCount the number of mines to place in the field
* @param solvable whether the field is guaranteed to be solvable
* @param seed the seed to generate the field width, or `null` if a new field should be chosen
* @param seed the seed to generate the field width, or `undefined` to use a random seed
* @private
*/
private initNewField(
@ -467,7 +360,7 @@ export class Game {
height: number = defaultDifficulty.height,
mineCount: number = defaultDifficulty.mineCount,
solvable: boolean = defaultDifficulty.solvable,
seed: string | null = null
seed?: string
) {
this.seed = seed ?? "" + this.rng.uint32();
this.field = new Field(
@ -482,13 +375,11 @@ export class Game {
let lastTime: number | null = null;
window.clearInterval(this.statisticsTimer);
this.statisticsTimer = window.setInterval(() => {
if (this.field === null) return;
if (this.field == null) return;
const elapsedTime = this.field.elapsedTime;
this.statistics.timeSpent += elapsedTime - (lastTime ?? 0);
lastTime = elapsedTime;
this.updateStatistics();
}, 1000);
}
}

View File

@ -1,7 +1,7 @@
const {LocalStorage} = (window as any).fwdekker.storage;
import {formatTime} from "./Common";
import {difficulties, Difficulty} from "./Difficulty";
// @ts-ignore
const {Storage, LocalStorage} = window.fwdekker.storage;
/**

View File

@ -1,5 +1,5 @@
// @ts-ignore
const {$, doAfterLoad, footer, header, nav} = window.fwdekker;
const {doAfterLoad} = (window as any).fwdekker;
import {waitForForkAwesome} from "./Common";
import {BasicIconFont, ForkAwesomeFont} from "./Display";
import {Game} from "./Game";
@ -7,17 +7,6 @@ import {Preferences} from "./Preferences";
doAfterLoad(() => {
// Initialize template
$("#nav").appendChild(nav("/Tools/Minesweeper/"));
$("#header").appendChild(header({title: "Minesweeper"}));
$("#footer").appendChild(footer({
vcsURL: "https://git.fwdekker.com/tools/minesweeper/",
version: "v%%VERSION_NUMBER%%"
}));
$("main").classList.remove("hidden");
// Start game
const preferences = new Preferences();
waitForForkAwesome(
() => {

128
src/main/js/ModalDialog.ts Normal file
View File

@ -0,0 +1,128 @@
const {$} = (window as any).fwdekker;
import {blurActiveElement} from "./Common";
/**
* A modal dialog displayed in HTML.
*/
export class ModalDialog {
private readonly dialog: HTMLElement;
private readonly openButton?: HTMLElement;
private readonly onOpen?: () => void;
/**
* Constructs a new modal dialog wrapper.
*
* @param dialog the dialog maintained by this instance
* @param openButton the element that opens the dialog when clicked
* @param onOpen the callback to invoke when the dialog is opened
* @param form the form contained in the dialog
* @param closeButton the element that closes the dialog's form when clicked
* @param submitButton the element that submits the dialog's form when clicked
* @param onSubmit the callback to invoke when the dialog's form is submitted
*/
constructor(
{
dialog,
openButton,
onOpen,
form,
closeButton,
submitButton,
onSubmit
}: {
dialog: HTMLElement,
openButton?: HTMLElement,
onOpen?: (() => void),
form?: HTMLFormElement,
closeButton?: HTMLElement,
submitButton?: HTMLElement,
onSubmit?: (() => void)
}
) {
this.dialog = dialog;
this.openButton = openButton;
this.onOpen = onOpen;
document.addEventListener(
"click",
event => {
if (!(event.target instanceof Node)) return;
console.log("close");
if (event.target !== openButton && !this.dialog.contains(event.target) || this.dialog === event.target)
this.close();
}
);
document.addEventListener(
"keydown",
event => {
if (event.key === "Escape" && this.isOpen())
this.close();
}
);
openButton?.addEventListener(
"click",
event => {
event.preventDefault();
this.open();
}
);
closeButton?.addEventListener(
"click",
event => {
event.preventDefault();
this.close();
}
);
form?.addEventListener(
"submit",
event => {
event.preventDefault();
onSubmit?.();
}
);
submitButton?.addEventListener(
"click",
event => {
event.preventDefault();
onSubmit?.();
}
);
}
/**
* Opens the dialog.
*/
open(): void {
console.log("opening");
blurActiveElement();
setTimeout(() => $("[autofocus]", this.dialog)?.focus(), 100);
this.dialog.setAttribute("open", "true");
this.onOpen?.();
}
/**
* Closes the dialog.
*/
close(): void {
if (this.isOpen()) {
this.dialog.removeAttribute("open");
setTimeout(() => this.openButton?.focus(), 100);
}
}
/**
* Returns `true` if and only if this dialog is currently open.
*/
isOpen(): boolean {
return this.dialog.hasAttribute("open");
}
}

View File

@ -1,6 +1,6 @@
const {LocalStorage} = (window as any).fwdekker.storage;
import {BasicIconFont, IconFont} from "./Display";
// @ts-ignore
const {Storage, LocalStorage} = window.fwdekker.storage;
/**

View File

@ -220,7 +220,8 @@ export class Solver {
if (!adjacentSquaresOnly)
matrix.push(Array(unknowns.length).fill(1).concat(field.mineCount - field.flagCount));
return new Matrix(matrix).solveBinary()
return (new Matrix(matrix))
.solveBinary()
.map((it, i) => it === undefined ? undefined : [it, unknowns[i]]);
}

View File

@ -1,6 +1,6 @@
const {LocalStorage} = (window as any).fwdekker.storage;
import {formatTime} from "./Common";
// @ts-ignore
const {Storage, LocalStorage} = window.fwdekker.storage;
/**

View File

@ -1,87 +0,0 @@
import {blurActiveElement} from "./Common";
/**
* An overlay displayed in HTML.
*/
export class Overlay {
private readonly overlay: HTMLDivElement;
private readonly submitForm: HTMLFormElement | null;
private readonly cancelForm: HTMLFormElement | null;
/**
* Constructs a new overlay.
*
* @param overlay the overlay element to show and hide
* @param submitForm the form that invokes `onSubmit` and closes the overlay when submitted
* @param cancelForm the form that closes the overlay when submitted
* @param onSubmit the callback to invoke when the form is submit
*/
constructor(
overlay: HTMLDivElement,
submitForm: HTMLFormElement | null,
cancelForm: HTMLFormElement | null,
onSubmit: (() => void) | null = null
) {
this.overlay = overlay;
overlay.addEventListener(
"mousedown",
event => {
if (event.target === overlay)
this.hide();
}
);
document.addEventListener(
"keydown",
event => {
if (event.key === "Escape" && this.isVisible())
this.hide();
}
);
this.submitForm = submitForm;
submitForm?.addEventListener(
"submit",
event => {
event.preventDefault();
this.hide();
onSubmit?.();
}
);
this.cancelForm = cancelForm;
cancelForm?.addEventListener(
"submit",
event => {
event.preventDefault();
this.hide();
}
);
}
/**
* Shows the overlay.
*/
show(): void {
this.overlay.classList.remove("hidden");
}
/**
* Hides the overlay.
*/
hide(): void {
this.overlay.classList.add("hidden");
blurActiveElement();
}
/**
* Returns `true` if and only if this overlay is currently visible.
*/
isVisible(): boolean {
return !this.overlay.classList.contains("hidden");
}
}