minesweeper/src/main/js/Game.ts

422 lines
16 KiB
TypeScript

const {$, stringToHtml} = (window as any).fwdekker;
// @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 {HighScores} from "./HighScores";
import {ModalDialog} from "./ModalDialog";
import {Preferences} from "./Preferences";
import {Solver} from "./Solver";
import {Statistics} from "./Statistics";
/**
* 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;
private readonly canvas: HTMLCanvasElement;
private readonly customDifficultyOverlay: ModalDialog;
private readonly widthInput: HTMLInputElement;
private readonly heightInput: HTMLInputElement;
private readonly minesInput: HTMLInputElement;
private readonly solvableInput: HTMLInputElement;
private readonly undo: HTMLAnchorElement;
private readonly redo: HTMLAnchorElement;
private readonly hint: HTMLAnchorElement;
private readonly solve: HTMLAnchorElement;
private readonly statisticsDiv: HTMLDivElement;
private readonly highScoresDiv: HTMLDivElement;
private readonly rng: any;
private seed: string;
private field: Field | null;
private display: Display;
private leftDown: boolean;
private rightDown: boolean;
private holdsAfterChord: boolean;
/**
* Constructs and starts a new game of Minesweeper.
*
* @param preferences the preferences to play the game under; may be changed during gameplay
*/
constructor(preferences: Preferences) {
this.canvas = $("#canvas");
this.field = null; // Placeholder until `initNewField`
this.display = new Display(this.canvas, this.field, preferences);
this.display.startDrawLoop();
this.canvas.classList.remove("hidden");
this.rng = alea("" + Date.now());
this.seed = "" + this.rng.uint32();
this.leftDown = false;
this.rightDown = false;
this.holdsAfterChord = false;
this.initNewField();
// Settings
const difficultySelect = $("#difficulty");
difficulties.forEach(it => {
const description = `${it.name}${it.description != null ? ` (${it.description})` : ""}`;
difficultySelect.appendChild(stringToHtml(`<option value=${it.name}>${description}</option>`));
});
difficultySelect.addEventListener("click", (event: MouseEvent) => event.stopPropagation());
difficultySelect.addEventListener(
"change",
() => {
const difficulty = difficulties[difficultySelect.selectedIndex - 1];
difficultySelect.selectedIndex = 0;
if (difficulty === undefined) return;
if (difficulty.name === customDifficulty.name)
this.customDifficultyOverlay.open();
else
this.initNewField(difficulty.width, difficulty.height, difficulty.mineCount, difficulty.solvable);
}
);
// 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();
},
form: $("#custom-difficulty-form"),
closeButton: $("#custom-difficulty-cancel"),
submitButton: $("#custom-difficulty-submit"),
onSubmit: () => {
this.initNewField(
+this.widthInput.value,
+this.heightInput.value,
+this.minesInput.value,
this.solvableInput.checked
);
}
});
this.widthInput = $("#settings-width");
this.widthInput.addEventListener("change", _ => this.setMineLimit());
this.heightInput = $("#settings-height");
this.heightInput.addEventListener("change", _ => this.setMineLimit());
this.minesInput = $("#settings-mines");
this.solvableInput = $("#settings-solvable");
// New game form
$("#new-game").addEventListener(
"click",
() =>
this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount, this.field?.isSolvable)
);
// Restart
$("#restart").addEventListener("click", () => this.field?.undo()); // Undoes all
// Seed
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,
seedInput.value
);
seedDialog.close();
}
});
// Undo
this.undo = $("#undo");
this.undo.addEventListener("click", () => this.field?.undo(1));
// Redo
this.redo = $("#redo");
this.redo.addEventListener("click", () => this.field?.redo(1));
// Hint
this.hint = $("#hint");
this.hint.addEventListener(
"click",
() => {
if (this.field != null) {
this.statistics.hintsRequested++;
this.display.hintSquare = Solver.getHint(this.field);
}
}
);
// Solve
this.solve = $("#solve");
this.solve.addEventListener(
"click",
() => {
if (this.field != null) {
this.statistics.solverUsages++;
Solver.solve(this.field);
}
}
);
// Preferences
const enableMarks = $("#preferences-enable-marks");
const showTooManyFlagsHints = $("#preferences-show-too-many-flags-hints");
const showAllNeighborsAreMinesHints = $("#preferences-show-all-neighbors-are-mines-hints");
const showChordableHints = $("#preferences-show-chordable-hints");
const preferencesDialog = new ModalDialog({
dialog: $("#preferences-dialog"),
openButton: $("#preferences-open"),
onOpen: () => {
enableMarks.checked = preferences.marksEnabled;
showTooManyFlagsHints.checked = preferences.showTooManyFlagsHints;
showChordableHints.checked = preferences.showChordableHints;
showAllNeighborsAreMinesHints.checked = preferences.showAllNeighborsAreMinesHints;
},
form: $("#preferences-form"),
closeButton: $("#preferences-cancel"),
submitButton: $("#preferences-submit"),
onSubmit: () => {
preferences.marksEnabled = enableMarks.checked;
preferences.showTooManyFlagsHints = showTooManyFlagsHints.checked;
preferences.showChordableHints = showChordableHints.checked;
preferences.showAllNeighborsAreMinesHints = showAllNeighborsAreMinesHints.checked;
preferencesDialog.close();
}
});
// Statistics
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();
}
});
// High scores
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 => {
const squarePos = this.display.posToSquare({x: event.clientX, y: event.clientY});
this.display.mouseSquare = this.field?.getSquareOrElse(squarePos, null) ?? null;
}
);
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());
this.canvas.addEventListener(
"mousedown",
event => {
event.preventDefault();
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;
switch (event.button) {
case 0:
this.leftDown = 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) {
this.field.toggleMark(coords);
} else {
this.field.toggleFlag(coords);
}
}
}
this.rightDown = true;
break;
}
});
this.display.hintSquare = null;
this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord;
this.display.mouseHoldChord = this.leftDown && this.rightDown;
}
);
this.canvas.addEventListener(
"mouseup",
event => {
event.preventDefault();
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;
switch (event.button) {
case 0:
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:
this.field.chord(coords);
break;
case 2:
if (this.leftDown && this.rightDown)
this.field.chord(coords);
this.rightDown = false;
this.holdsAfterChord = this.leftDown;
break;
}
});
this.display.hintSquare = null;
this.display.mouseHoldUncover = this.leftDown && !this.holdsAfterChord;
this.display.mouseHoldChord = this.leftDown && this.rightDown;
}
);
}
/**
* Adjusts the limits on the mine count input field.
*
* @private
*/
private setMineLimit(): void {
this.minesInput.max = "" + Field.maxMines(+this.widthInput.value, +this.heightInput.value);
}
/**
* Updates states of controls relating to the game.
*
* @private
*/
private updateControls(): void {
const toggleHref = (element: HTMLAnchorElement | undefined, forceOn: boolean): void => {
if (element === undefined) return;
if (forceOn) element.href = "#";
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);
}
/**
* 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 `undefined` to use a random seed
* @private
*/
private initNewField(
width: number = defaultDifficulty.width,
height: number = defaultDifficulty.height,
mineCount: number = defaultDifficulty.mineCount,
solvable: boolean = defaultDifficulty.solvable,
seed?: string
) {
this.seed = seed ?? "" + this.rng.uint32();
this.field = new Field(
width, height, mineCount, solvable,
isNaN(+this.seed) ? stringToHash(this.seed) : +this.seed,
this.statistics,
this.highScores
);
this.display.setField(this.field);
this.field.addEventListener(() => this.updateControls());
this.updateControls();
// 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;
}, 1000);
}
}