2020-07-31 23:12:16 +02:00
|
|
|
// @ts-ignore
|
|
|
|
import {$} from "@fwdekker/template";
|
2020-08-01 14:09:44 +02:00
|
|
|
// @ts-ignore
|
|
|
|
import * as random from "fast-random";
|
2020-08-01 14:50:58 +02:00
|
|
|
import {stringToHash} from "./Common";
|
2020-08-02 14:46:38 +02:00
|
|
|
import {customDifficulty, defaultDifficulty, difficulties} from "./Difficulty";
|
2020-07-31 23:12:16 +02:00
|
|
|
import {Display} from "./Display";
|
2020-08-02 14:46:38 +02:00
|
|
|
import {Field} from "./Field";
|
2020-07-31 23:12:16 +02:00
|
|
|
import {Solver} from "./Solver";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Controls the interaction with a game of Minesweeper.
|
|
|
|
*/
|
|
|
|
export class Game {
|
|
|
|
private readonly canvas: HTMLCanvasElement;
|
2020-08-01 18:37:44 +02:00
|
|
|
private readonly difficultySelect: HTMLSelectElement;
|
2020-08-02 14:46:38 +02:00
|
|
|
private readonly customDifficultyOverlay: HTMLDivElement;
|
|
|
|
private readonly customDifficultyForm: HTMLFormElement;
|
|
|
|
private readonly customDifficultyCancelForm: HTMLFormElement;
|
|
|
|
private readonly newGameForm: HTMLFormElement;
|
2020-08-01 18:37:44 +02:00
|
|
|
private readonly restartForm: HTMLFormElement;
|
|
|
|
private readonly seedForm: HTMLFormElement;
|
2020-08-02 16:22:18 +02:00
|
|
|
private readonly undoForm: HTMLFormElement;
|
2020-08-01 18:37:44 +02:00
|
|
|
private readonly solveForm: HTMLFormElement;
|
2020-08-02 14:46:38 +02:00
|
|
|
private readonly widthInput: HTMLInputElement;
|
|
|
|
private readonly heightInput: HTMLInputElement;
|
|
|
|
private readonly minesInput: HTMLInputElement;
|
2020-08-01 14:09:44 +02:00
|
|
|
|
|
|
|
private readonly rng: any;
|
2020-08-01 14:50:58 +02:00
|
|
|
private seed: string;
|
2020-08-02 14:46:38 +02:00
|
|
|
|
|
|
|
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.
|
|
|
|
*/
|
|
|
|
constructor() {
|
|
|
|
this.canvas = $("#canvas");
|
|
|
|
|
2020-08-01 01:38:08 +02:00
|
|
|
this.field = null; // Placeholder
|
2020-07-31 23:12:16 +02:00
|
|
|
this.display = new Display(this.canvas, this.field);
|
|
|
|
this.display.startDrawLoop();
|
|
|
|
|
2020-08-01 14:09:44 +02:00
|
|
|
this.rng = random(Date.now());
|
2020-08-01 14:50:58 +02:00
|
|
|
this.seed = "" + this.rng.nextInt();
|
2020-07-31 23:12:16 +02:00
|
|
|
this.leftDown = false;
|
|
|
|
this.rightDown = false;
|
|
|
|
this.holdsAfterChord = false;
|
|
|
|
|
2020-08-02 14:46:38 +02:00
|
|
|
this.initNewField();
|
|
|
|
|
2020-07-31 23:12:16 +02:00
|
|
|
|
2020-08-01 14:42:36 +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;
|
|
|
|
this.difficultySelect.add(option);
|
|
|
|
});
|
|
|
|
this.difficultySelect.addEventListener(
|
|
|
|
"change",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
const difficulty = difficulties[this.difficultySelect.selectedIndex - 1];
|
2020-08-02 14:46:38 +02:00
|
|
|
this.difficultySelect.selectedIndex = 0;
|
2020-08-01 18:37:44 +02:00
|
|
|
if (difficulty === undefined) return;
|
2020-08-02 14:46:38 +02:00
|
|
|
|
|
|
|
if (difficulty.name !== customDifficulty.name) {
|
|
|
|
this.difficultySelect.selectedIndex = 0;
|
|
|
|
this.initNewField(difficulty.width, difficulty.height, difficulty.mineCount);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.customDifficultyOverlay.style.visibility = "unset";
|
|
|
|
this.widthInput.value = "" + (this.field?.width ?? defaultDifficulty.width);
|
|
|
|
this.heightInput.value = "" + (this.field?.height ?? defaultDifficulty.height);
|
|
|
|
this.minesInput.value = "" + (this.field?.mineCount ?? defaultDifficulty.mineCount);
|
2020-08-01 18:37:44 +02:00
|
|
|
this.setMineLimit();
|
2020-08-02 14:46:38 +02:00
|
|
|
this.widthInput.focus();
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
// Custom difficulty
|
|
|
|
this.customDifficultyOverlay = $("#customDifficultyOverlay");
|
|
|
|
this.customDifficultyForm = $("#customDifficultyForm");
|
|
|
|
this.customDifficultyForm.addEventListener(
|
|
|
|
"submit",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
this.customDifficultyOverlay.style.visibility = "hidden";
|
|
|
|
this.initNewField(+this.widthInput.value, +this.heightInput.value, +this.minesInput.value);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
this.customDifficultyCancelForm = $("#customDifficultyCancelForm");
|
|
|
|
this.customDifficultyCancelForm.addEventListener(
|
|
|
|
"submit",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
this.customDifficultyOverlay.style.visibility = "hidden";
|
2020-08-01 18:37:44 +02:00
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2020-08-01 14:42:36 +02:00
|
|
|
this.widthInput = $("#settingsWidth");
|
|
|
|
this.widthInput.addEventListener("change", _ => this.setMineLimit());
|
|
|
|
this.heightInput = $("#settingsHeight");
|
|
|
|
this.heightInput.addEventListener("change", _ => this.setMineLimit());
|
|
|
|
this.minesInput = $("#settingsMines");
|
2020-08-02 14:46:38 +02:00
|
|
|
|
|
|
|
// New game form
|
|
|
|
this.newGameForm = $("#newGameForm");
|
|
|
|
this.newGameForm.addEventListener(
|
2020-07-31 23:12:16 +02:00
|
|
|
"submit",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
2020-08-02 14:46:38 +02:00
|
|
|
|
|
|
|
this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount);
|
2020-07-31 23:12:16 +02:00
|
|
|
}
|
|
|
|
);
|
2020-08-01 14:42:36 +02:00
|
|
|
|
|
|
|
// Restart
|
|
|
|
this.restartForm = $("#restartForm");
|
2020-08-01 14:09:44 +02:00
|
|
|
this.restartForm.addEventListener(
|
|
|
|
"submit",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
2020-08-02 14:46:38 +02:00
|
|
|
this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount, this.seed);
|
2020-08-01 14:09:44 +02:00
|
|
|
}
|
|
|
|
);
|
2020-07-31 23:12:16 +02:00
|
|
|
|
2020-08-01 14:42:36 +02:00
|
|
|
// Seed
|
|
|
|
this.seedForm = $("#seedForm");
|
|
|
|
this.seedForm.addEventListener(
|
2020-07-31 23:12:16 +02:00
|
|
|
"submit",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
2020-08-01 14:42:36 +02:00
|
|
|
const input = window.prompt("Enter seed", "" + this.seed);
|
2020-08-02 14:46:38 +02:00
|
|
|
if (input !== null)
|
|
|
|
this.initNewField(this.field?.width, this.field?.height, this.field?.mineCount, input);
|
2020-08-01 14:09:44 +02:00
|
|
|
}
|
|
|
|
);
|
2020-08-01 14:42:36 +02:00
|
|
|
|
2020-08-02 16:22:18 +02:00
|
|
|
// Undo
|
|
|
|
this.undoForm = $("#undoForm");
|
|
|
|
this.undoForm.addEventListener(
|
|
|
|
"submit",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
this.field?.undo();
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2020-08-01 14:42:36 +02:00
|
|
|
// Solve
|
|
|
|
this.solveForm = $("#solveForm");
|
|
|
|
this.solveForm.addEventListener(
|
2020-08-01 14:09:44 +02:00
|
|
|
"submit",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
2020-08-02 16:22:18 +02:00
|
|
|
if (this.field !== null) {
|
|
|
|
this.field.startSequence();
|
2020-08-01 14:42:36 +02:00
|
|
|
new Solver().solve(this.field);
|
2020-08-02 16:22:18 +02:00
|
|
|
this.field.commitSequence();
|
|
|
|
}
|
2020-07-31 23:12:16 +02:00
|
|
|
}
|
|
|
|
);
|
2020-08-01 01:38:08 +02:00
|
|
|
|
2020-08-01 14:42:36 +02:00
|
|
|
// Canvas
|
2020-07-31 23:12:16 +02:00
|
|
|
this.canvas.addEventListener(
|
|
|
|
"mousemove",
|
|
|
|
event => this.display.mouseSquare = this.display.posToSquare({x: event.clientX, y: event.clientY})
|
|
|
|
);
|
|
|
|
this.canvas.addEventListener(
|
|
|
|
"mouseleave",
|
|
|
|
_ => {
|
|
|
|
this.display.mouseSquare = null;
|
|
|
|
this.leftDown = false;
|
|
|
|
this.rightDown = false;
|
|
|
|
this.holdsAfterChord = false;
|
|
|
|
this.display.mouseHoldChord = false;
|
|
|
|
}
|
|
|
|
);
|
2020-08-01 14:42:36 +02:00
|
|
|
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
|
2020-07-31 23:12:16 +02:00
|
|
|
this.canvas.addEventListener(
|
|
|
|
"mousedown",
|
|
|
|
event => {
|
2020-08-01 01:38:08 +02:00
|
|
|
event.preventDefault();
|
2020-07-31 23:12:16 +02:00
|
|
|
|
2020-08-02 16:22:18 +02:00
|
|
|
this.field?.startSequence();
|
2020-07-31 23:12:16 +02:00
|
|
|
const square = this.display.posToSquare({x: event.clientX, y: event.clientY});
|
|
|
|
switch (event.button) {
|
|
|
|
case 0:
|
|
|
|
this.leftDown = true;
|
|
|
|
break;
|
|
|
|
case 2:
|
2020-08-02 16:22:18 +02:00
|
|
|
if (!this.leftDown && square !== null) square.flag()
|
2020-07-31 23:12:16 +02:00
|
|
|
|
|
|
|
this.rightDown = true;
|
|
|
|
break;
|
|
|
|
}
|
2020-08-02 16:22:18 +02:00
|
|
|
this.field?.commitSequence();
|
2020-07-31 23:12:16 +02:00
|
|
|
|
|
|
|
this.display.mouseHoldChord = this.leftDown && this.rightDown;
|
|
|
|
}
|
|
|
|
);
|
|
|
|
this.canvas.addEventListener(
|
|
|
|
"mouseup",
|
|
|
|
event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
2020-08-02 16:22:18 +02:00
|
|
|
this.field?.startSequence();
|
2020-07-31 23:12:16 +02:00
|
|
|
const square = this.display.posToSquare({x: event.clientX, y: event.clientY});
|
|
|
|
switch (event.button) {
|
|
|
|
case 0:
|
|
|
|
if (square !== null && this.leftDown && this.rightDown)
|
|
|
|
square.chord();
|
|
|
|
else if (square !== null && !this.holdsAfterChord && this.leftDown)
|
|
|
|
square.uncover();
|
|
|
|
|
|
|
|
this.leftDown = false;
|
|
|
|
this.holdsAfterChord = this.rightDown;
|
|
|
|
break;
|
|
|
|
case 1:
|
|
|
|
if (square !== null) square.chord();
|
|
|
|
break;
|
|
|
|
case 2:
|
|
|
|
if (square !== null && this.leftDown && this.rightDown)
|
|
|
|
square.chord();
|
|
|
|
|
|
|
|
this.rightDown = false;
|
|
|
|
this.holdsAfterChord = this.leftDown;
|
|
|
|
break;
|
|
|
|
}
|
2020-08-02 16:22:18 +02:00
|
|
|
this.field?.commitSequence();
|
2020-07-31 23:12:16 +02:00
|
|
|
|
|
|
|
this.display.mouseHoldChord = this.leftDown && this.rightDown;
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-08-01 01:38:08 +02:00
|
|
|
/**
|
|
|
|
* Adjusts the limits on the mine count input field.
|
|
|
|
*/
|
|
|
|
setMineLimit(): void {
|
|
|
|
this.minesInput.max = "" + Field.maxMines(+this.widthInput.value, +this.heightInput.value);
|
|
|
|
}
|
2020-08-01 14:42:36 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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 seed the seed to generate the field width, or `null` if a new field should be chosen
|
|
|
|
*/
|
|
|
|
initNewField(
|
2020-08-02 14:46:38 +02:00
|
|
|
width: number = defaultDifficulty.width,
|
|
|
|
height: number = defaultDifficulty.height,
|
|
|
|
mineCount: number = defaultDifficulty.mineCount,
|
2020-08-01 14:50:58 +02:00
|
|
|
seed: string | null = null
|
2020-08-01 14:42:36 +02:00
|
|
|
) {
|
2020-08-01 14:50:58 +02:00
|
|
|
this.seed = seed ?? "" + this.rng.nextInt();
|
|
|
|
this.field = new Field(width, height, mineCount, isNaN(+this.seed) ? stringToHash(this.seed) : +this.seed);
|
2020-08-01 14:42:36 +02:00
|
|
|
this.display.setField(this.field);
|
|
|
|
}
|
2020-07-31 23:12:16 +02:00
|
|
|
}
|