// @ts-ignore const {$} = window.fwdekker; // @ts-ignore 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"; import {Solver} from "./Solver"; import {Statistics} from "./Statistics"; import {Overlay} from "./UI"; /** * 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 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 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; 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("invisible"); this.rng = alea("" + Date.now()); this.seed = "" + this.rng.uint32(); this.leftDown = false; this.rightDown = false; this.holdsAfterChord = false; this.initNewField(); // Settings this.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); }); this.difficultySelect.addEventListener( "change", event => { event.preventDefault(); const difficulty = difficulties[this.difficultySelect.selectedIndex - 1]; this.difficultySelect.selectedIndex = 0; if (difficulty === undefined) return; if (difficulty.name !== customDifficulty.name) { this.difficultySelect.selectedIndex = 0; this.initNewField(difficulty.width, difficulty.height, difficulty.mineCount, difficulty.solvable); return; } 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; this.setMineLimit(); this.widthInput.focus(); } ); // Custom difficulty this.customDifficultyOverlay = new Overlay( $("#customDifficultyOverlay"), $("#customDifficultyForm"), $("#customDifficultyCancelForm"), () => { this.initNewField( +this.widthInput.value, +this.heightInput.value, +this.minesInput.value, this.solvableInput.checked ); } ); 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( "submit", event => { event.preventDefault(); this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount, this.field?.isSolvable); blurActiveElement(); } ); // Restart this.restartForm = $("#restartForm"); this.restartForm.addEventListener( "submit", event => { event.preventDefault(); this.field?.undo(); // Undoes all blurActiveElement(); } ); // 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"), () => { this.initNewField( this.field?.width, this.field?.height, this.field?.mineCount, this.field?.isSolvable, this.seedInput.value ); } ); // Undo this.undoForm = $("#undoForm"); this.undoForm.addEventListener( "submit", event => { event.preventDefault(); this.field?.undo(1); blurActiveElement(); } ); // Redo this.redoForm = $("#redoForm"); this.redoForm.addEventListener( "submit", event => { event.preventDefault(); this.field?.redo(1); blurActiveElement(); } ); // 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(); } ); // Solve this.solveForm = $("#solveForm"); this.solveForm.addEventListener( "submit", event => { event.preventDefault(); if (this.field !== null) { this.statistics.solverUsages++; (new Solver()).solve(this.field); } blurActiveElement(); } ); // Preferences this.enableMarksInput = $("#preferencesEnableMarks"); this.showTooManyFlagsHintsInput = $("#preferencesShowTooManyFlagsHints"); this.preferencesOpenForm = $("#preferencesOpenForm"); this.preferencesOpenForm.addEventListener( "submit", event => { event.preventDefault(); this.enableMarksInput.checked = preferences.marksEnabled; this.showTooManyFlagsHintsInput.checked = preferences.showTooManyFlagsHints; this.preferencesOverlay.show(); blurActiveElement(); } ); 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(); 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 this.canvas.addEventListener( "mousemove", event => { this.display.mouseSquare = this.field?.getSquareOrElse( this.display.posToSquare({x: event.clientX, y: event.clientY}), 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 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 * @private */ private initNewField( width: number = defaultDifficulty.width, height: number = defaultDifficulty.height, mineCount: number = defaultDifficulty.mineCount, solvable: boolean = defaultDifficulty.solvable, seed: string | null = null ) { 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); // 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); } }