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(``)); }); 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.customDifficultyOverlay.close(); } }); 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", (event: MouseEvent) => { event.preventDefault(); this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount, this.field?.isSolvable); } ); // Restart $("#restart").addEventListener( "click", (event: MouseEvent) => { event.preventDefault(); 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", (event: MouseEvent) => { event.preventDefault(); return this.field?.undo(1); } ); // Redo this.redo = $("#redo"); this.redo.addEventListener( "click", (event: MouseEvent) => { event.preventDefault(); return this.field?.redo(1); } ); // Hint this.hint = $("#hint"); this.hint.addEventListener( "click", (event: MouseEvent) => { event.preventDefault(); if (this.field != null) { this.statistics.hintsRequested++; this.display.hintSquare = Solver.getHint(this.field); } } ); // Solve this.solve = $("#solve"); this.solve.addEventListener( "click", (event: MouseEvent) => { event.preventDefault(); 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); } }