minesweeper/src/main/js/Game.ts

495 lines
17 KiB
TypeScript
Raw Normal View History

2020-07-31 23:12:16 +02:00
// @ts-ignore
const {$} = window.fwdekker;
// @ts-ignore
2020-08-03 17:25:03 +02:00
import alea from "alea";
import {blurActiveElement, stringToHash} from "./Common";
import {customDifficulty, defaultDifficulty, difficulties} from "./Difficulty";
import {Display} from "./Display";
import {Field} from "./Field";
import {HighScores} from "./HighScores";
import {Preferences} from "./Preferences";
2020-07-31 23:12:16 +02:00
import {Solver} from "./Solver";
import {Statistics} from "./Statistics";
2020-08-04 21:41:10 +02:00
import {Overlay} from "./UI";
2020-07-31 23:12:16 +02:00
/**
* Controls the interaction with a game of Minesweeper.
*/
export class Game {
private readonly statistics = new Statistics();
private readonly highScores = new HighScores();
private statisticsTimer: number | undefined;
2020-08-08 02:41:21 +02:00
2020-07-31 23:12:16 +02:00
private readonly canvas: HTMLCanvasElement;
2020-08-01 18:37:44 +02:00
private readonly difficultySelect: HTMLSelectElement;
private readonly newGameForm: HTMLFormElement;
2020-08-01 18:37:44 +02:00
private readonly restartForm: HTMLFormElement;
2020-08-04 21:41:10 +02:00
private readonly seedOverlay: Overlay;
private readonly seedOpenForm: HTMLFormElement;
private readonly seedInput: HTMLFormElement;
2020-08-02 16:22:18 +02:00
private readonly undoForm: HTMLFormElement;
2020-08-07 00:19:17 +02:00
private readonly redoForm: HTMLFormElement;
2020-08-09 17:38:39 +02:00
private readonly hintForm: HTMLFormElement;
2020-08-01 18:37:44 +02:00
private readonly solveForm: HTMLFormElement;
2020-08-04 21:41:10 +02:00
private readonly customDifficultyOverlay: Overlay;
private readonly widthInput: HTMLInputElement;
private readonly heightInput: HTMLInputElement;
private readonly minesInput: HTMLInputElement;
private readonly solvableInput: HTMLInputElement;
2020-08-12 19:06:16 +02:00
private readonly preferencesOverlay: Overlay;
private readonly enableMarksInput: HTMLInputElement;
2020-08-25 16:36:06 +02:00
private readonly showTooManyFlagsHintsInput: HTMLInputElement;
2020-08-12 19:06:16 +02:00
private readonly preferencesOpenForm: HTMLFormElement;
2020-08-08 02:41:21 +02:00
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;
2020-08-01 14:50:58 +02:00
private seed: string;
private field: Field | null;
private display: Display;
2020-07-31 23:12:16 +02:00
private leftDown: boolean;
private rightDown: boolean;
private holdsAfterChord: boolean;
/**
* Constructs and starts a new game of Minesweeper.
2020-08-10 19:56:28 +02:00
*
2020-08-12 19:06:16 +02:00
* @param preferences the preferences to play the game under; may be changed during gameplay
2020-07-31 23:12:16 +02:00
*/
2020-08-25 16:36:06 +02:00
constructor(preferences: Preferences) {
2020-07-31 23:12:16 +02:00
this.canvas = $("#canvas");
2020-08-08 02:41:21 +02:00
this.field = null; // Placeholder until `initNewField`
2020-08-25 16:36:06 +02:00
this.display = new Display(this.canvas, this.field, preferences);
2020-07-31 23:12:16 +02:00
this.display.startDrawLoop();
2021-04-28 13:51:53 +02:00
this.canvas.classList.remove("invisible");
2020-07-31 23:12:16 +02:00
2020-08-03 17:25:03 +02:00
this.rng = alea("" + Date.now());
this.seed = "" + this.rng.uint32();
2020-07-31 23:12:16 +02:00
this.leftDown = false;
this.rightDown = false;
this.holdsAfterChord = false;
this.initNewField();
2020-07-31 23:12:16 +02:00
// Settings
2020-08-01 18:37:44 +02:00
this.difficultySelect = $("#difficulty");
difficulties.forEach(it => {
const option = document.createElement("option");
option.value = it.name;
option.innerHTML = `${it.name}${it.description !== null ? ` (${it.description})` : ""}`;
2020-08-01 18:37:44 +02:00
this.difficultySelect.add(option);
});
this.difficultySelect.addEventListener(
"change",
event => {
event.preventDefault();
const difficulty = difficulties[this.difficultySelect.selectedIndex - 1];
this.difficultySelect.selectedIndex = 0;
2020-08-01 18:37:44 +02:00
if (difficulty === undefined) return;
if (difficulty.name !== customDifficulty.name) {
this.difficultySelect.selectedIndex = 0;
this.initNewField(difficulty.width, difficulty.height, difficulty.mineCount, difficulty.solvable);
return;
}
2020-08-04 21:41:10 +02:00
this.customDifficultyOverlay.show();
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;
2020-08-01 18:37:44 +02:00
this.setMineLimit();
this.widthInput.focus();
}
);
// Custom difficulty
2020-08-04 21:41:10 +02:00
this.customDifficultyOverlay = new Overlay(
$("#customDifficultyOverlay"),
$("#customDifficultyForm"),
$("#customDifficultyCancelForm"),
() => {
this.initNewField(
+this.widthInput.value,
+this.heightInput.value,
+this.minesInput.value,
this.solvableInput.checked
);
}
);
2020-08-01 18:37:44 +02:00
this.widthInput = $("#settingsWidth");
this.widthInput.addEventListener("change", _ => this.setMineLimit());
this.heightInput = $("#settingsHeight");
this.heightInput.addEventListener("change", _ => this.setMineLimit());
this.minesInput = $("#settingsMines");
this.solvableInput = $("#settingsSolvable");
// New game form
this.newGameForm = $("#newGameForm");
this.newGameForm.addEventListener(
2020-07-31 23:12:16 +02:00
"submit",
event => {
event.preventDefault();
this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount, this.field?.isSolvable);
blurActiveElement();
2020-07-31 23:12:16 +02:00
}
);
// Restart
this.restartForm = $("#restartForm");
this.restartForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.field?.undo(); // Undoes all
blurActiveElement();
}
);
2020-07-31 23:12:16 +02:00
// Seed
2020-08-04 21:41:10 +02:00
this.seedInput = $("#seed");
this.seedOpenForm = $("#seedOpenForm");
this.seedOpenForm.addEventListener(
2020-07-31 23:12:16 +02:00
"submit",
event => {
event.preventDefault();
2020-08-04 21:41:10 +02:00
this.seedOverlay.show();
this.seedInput.value = this.seed;
this.seedInput.focus();
setTimeout(() => this.seedInput.select(), 0);
2020-08-04 21:41:10 +02:00
}
);
this.seedOverlay = new Overlay(
$("#seedOverlay"),
$("#seedForm"),
$("#seedCancelForm"),
() => {
this.initNewField(
this.field?.width,
this.field?.height,
this.field?.mineCount,
this.field?.isSolvable,
2020-08-04 21:41:10 +02:00
this.seedInput.value
);
}
);
2020-08-02 16:22:18 +02:00
// Undo
this.undoForm = $("#undoForm");
this.undoForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.field?.undo(1);
blurActiveElement();
2020-08-02 16:22:18 +02:00
}
);
2020-08-07 00:19:17 +02:00
// Redo
this.redoForm = $("#redoForm");
this.redoForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.field?.redo(1);
blurActiveElement();
2020-08-07 00:19:17 +02:00
}
);
2020-08-09 17:38:39 +02:00
// Hint
this.hintForm = $("#hintForm");
this.hintForm.addEventListener(
"submit",
event => {
event.preventDefault();
if (this.field !== null) {
this.statistics.hintsRequested++;
this.display.hintSquare = (new Solver()).getHint(this.field);
}
blurActiveElement();
2020-08-09 17:38:39 +02:00
}
);
// Solve
this.solveForm = $("#solveForm");
this.solveForm.addEventListener(
"submit",
event => {
event.preventDefault();
2020-08-09 17:38:39 +02:00
if (this.field !== null) {
this.statistics.solverUsages++;
(new Solver()).solve(this.field);
}
blurActiveElement();
2020-07-31 23:12:16 +02:00
}
);
2020-08-12 19:06:16 +02:00
// Preferences
this.enableMarksInput = $("#preferencesEnableMarks");
2020-08-25 16:36:06 +02:00
this.showTooManyFlagsHintsInput = $("#preferencesShowTooManyFlagsHints");
2020-08-12 19:06:16 +02:00
this.preferencesOpenForm = $("#preferencesOpenForm");
this.preferencesOpenForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.enableMarksInput.checked = preferences.marksEnabled;
2020-08-25 16:36:06 +02:00
this.showTooManyFlagsHintsInput.checked = preferences.showTooManyFlagsHints;
2020-08-12 19:06:16 +02:00
this.preferencesOverlay.show();
blurActiveElement();
2020-08-12 19:06:16 +02:00
}
);
this.preferencesOverlay = new Overlay(
$("#preferencesOverlay"),
$("#preferencesForm"),
$("#preferencesCancelForm"),
2020-08-25 16:36:06 +02:00
() => {
preferences.marksEnabled = this.enableMarksInput.checked;
preferences.showTooManyFlagsHints = this.showTooManyFlagsHintsInput.checked;
}
2020-08-12 19:06:16 +02:00
);
2020-08-08 02:41:21 +02:00
// Statistics
this.statisticsDiv = $("#statisticsDiv");
this.statisticsOpenForm = $("#statisticsOpenForm");
this.statisticsOpenForm.addEventListener(
"submit",
event => {
event.preventDefault();
this.statisticsOverlay.show();
blurActiveElement();
2020-08-08 02:41:21 +02:00
}
);
this.statisticsOverlay = new Overlay(
$("#statisticsOverlay"),
null,
2020-08-08 02:41:21 +02:00
$("#statisticsCloseForm")
);
this.statisticsResetForm = $("#statisticsResetForm");
this.statisticsResetForm.addEventListener(
"submit",
event => {
event.preventDefault();
if (!window.confirm("Are you sure you want to reset all statistics? This cannot be undone."))
return;
this.statistics.clear();
}
);
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();
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
2020-07-31 23:12:16 +02:00
this.canvas.addEventListener(
"mousemove",
2020-08-04 20:26:05 +02:00
event => {
this.display.mouseSquare = this.field?.getSquareOrElse(
this.display.posToSquare({x: event.clientX, y: event.clientY}),
null
);
}
2020-07-31 23:12:16 +02:00
);
this.canvas.addEventListener(
"mouseleave",
_ => {
this.display.mouseSquare = null;
this.leftDown = false;
this.rightDown = false;
this.holdsAfterChord = false;
this.display.mouseHoldChord = false;
}
);
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
2020-07-31 23:12:16 +02:00
this.canvas.addEventListener(
"mousedown",
event => {
event.preventDefault();
if (this.field === null) return;
this.field.runUndoably(() => {
2020-08-04 20:26:05 +02:00
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (this.field === null || !this.field.hasSquareAt(coords)) return;
switch (event.button) {
case 0:
this.leftDown = true;
break;
case 2:
2020-08-12 18:52:02 +02:00
if (!this.leftDown) {
const square = this.field.getSquareOrElse(coords);
if (square !== null) {
2020-08-12 18:52:02 +02:00
if (square.hasFlag) {
this.field.toggleFlag(coords);
2020-08-12 19:06:16 +02:00
if (preferences.marksEnabled)
this.field.toggleMark(coords);
2020-08-12 18:52:02 +02:00
} else if (square.hasMark) {
this.field.toggleMark(coords);
} else {
this.field.toggleFlag(coords);
}
}
}
this.rightDown = true;
break;
}
});
2020-07-31 23:12:16 +02:00
2020-08-09 17:38:39 +02:00
this.display.hintSquare = null;
this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord;
2020-07-31 23:12:16 +02:00
this.display.mouseHoldChord = this.leftDown && this.rightDown;
}
);
this.canvas.addEventListener(
"mouseup",
event => {
event.preventDefault();
if (this.field === null) return;
this.field.runUndoably(() => {
2020-08-04 20:26:05 +02:00
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (this.field === null || !this.field.hasSquareAt(coords)) return;
switch (event.button) {
case 0:
2020-08-04 20:26:05 +02:00
if (this.leftDown && this.rightDown)
this.field.chord(coords);
else if (!this.holdsAfterChord && this.leftDown)
this.field.uncover(coords);
this.leftDown = false;
this.holdsAfterChord = this.rightDown;
break;
case 1:
2020-08-04 20:26:05 +02:00
this.field.chord(coords);
break;
case 2:
2020-08-04 20:26:05 +02:00
if (this.leftDown && this.rightDown)
this.field.chord(coords);
this.rightDown = false;
this.holdsAfterChord = this.leftDown;
break;
}
});
2020-07-31 23:12:16 +02:00
2020-08-09 17:38:39 +02:00
this.display.hintSquare = null;
this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord;
2020-07-31 23:12:16 +02:00
this.display.mouseHoldChord = this.leftDown && this.rightDown;
}
);
}
/**
* Adjusts the limits on the mine count input field.
2020-08-08 02:41:21 +02:00
*
* @private
*/
2020-08-08 02:41:21 +02:00
private setMineLimit(): void {
this.minesInput.max = "" + Field.maxMines(+this.widthInput.value, +this.heightInput.value);
}
2020-08-08 02:41:21 +02:00
/**
* Updates the statistics report in the statistics overview.
*
* @private
*/
private updateStatistics(): void {
this.statisticsDiv.innerHTML = this.statistics.generateHtmlReport();
}
/**
* Initializes a new field according to the given parameters
*
* @param width the width of the field
* @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
2020-08-08 02:41:21 +02:00
* @private
*/
2020-08-08 02:41:21 +02:00
private initNewField(
width: number = defaultDifficulty.width,
height: number = defaultDifficulty.height,
mineCount: number = defaultDifficulty.mineCount,
solvable: boolean = defaultDifficulty.solvable,
2020-08-01 14:50:58 +02:00
seed: string | null = null
) {
2020-08-03 17:25:03 +02:00
this.seed = seed ?? "" + this.rng.uint32();
this.field = new Field(
width, height, mineCount, solvable,
2020-08-08 02:41:21 +02:00
isNaN(+this.seed) ? stringToHash(this.seed) : +this.seed,
this.statistics,
this.highScores
);
this.display.setField(this.field);
2020-08-08 02:41:21 +02:00
// Start timer for statistics
let lastTime: number | null = null;
window.clearInterval(this.statisticsTimer);
this.statisticsTimer = window.setInterval(() => {
if (this.field === null) return;
const elapsedTime = this.field.elapsedTime;
this.statistics.timeSpent += elapsedTime - (lastTime ?? 0);
lastTime = elapsedTime;
this.updateStatistics();
}, 1000);
}
2020-07-31 23:12:16 +02:00
}