422 lines
16 KiB
TypeScript
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);
|
|
}
|
|
}
|