diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..fd1294f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,11 @@
+root = true
+
+[*]
+charset = utf-8
+trim_trailing_whitespace = true
+
+end_of_line = lf
+insert_final_newline = true
+
+indent_style = space
+indent_size = 4
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1a6bd45
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+package-lock.json binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e7ea1e8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,117 @@
+## Node
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.pnp.*
diff --git a/Gruntfile.js b/Gruntfile.js
new file mode 100644
index 0000000..e404c27
--- /dev/null
+++ b/Gruntfile.js
@@ -0,0 +1,121 @@
+const path = require("path");
+
+module.exports = grunt => {
+ grunt.initConfig({
+ pkg: grunt.file.readJSON("package.json"),
+ clean: {
+ default: ["dist/"],
+ },
+ copy: {
+ css: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.css", dest: "dist/"}]
+ },
+ html: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "dist/"}]
+ },
+ },
+ focus: {
+ dev: {
+ include: ["css", "html", "js", "link"],
+ },
+ },
+ replace: {
+ dev: {
+ src: ["./dist/*.html", "./dist/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>+" + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")
+ }
+ ],
+ overwrite: true
+ },
+ deploy: {
+ src: ["./dist/*.html", "./dist/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>"
+ }
+ ],
+ overwrite: true
+ },
+ },
+ watch: {
+ css: {
+ files: ["src/main/**/*.css"],
+ tasks: ["copy:css"],
+ },
+ html: {
+ files: ["src/main/**/*.html"],
+ tasks: ["copy:html"],
+ },
+ js: {
+ files: ["src/main/**/*.js"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
+ link: {
+ files: ["node_modules/@fwdekker/*/dist/**"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
+ },
+ webpack: {
+ options: {
+ entry: "./src/main/js/index.js",
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ exclude: /node_modules/,
+ },
+ ],
+ },
+ resolve: {
+ extensions: [".js"],
+ },
+ output: {
+ filename: "bundle.js",
+ path: path.resolve(__dirname, "dist/"),
+ },
+ },
+ dev: {
+ mode: "development",
+ devtool: "inline-source-map",
+ },
+ deploy: {
+ mode: "production",
+ },
+ },
+ });
+
+ grunt.loadNpmTasks("grunt-contrib-clean");
+ grunt.loadNpmTasks("grunt-contrib-copy");
+ grunt.loadNpmTasks("grunt-contrib-watch");
+ grunt.loadNpmTasks("grunt-focus");
+ grunt.loadNpmTasks("grunt-text-replace");
+ grunt.loadNpmTasks("grunt-webpack");
+
+ grunt.registerTask("dev", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:css",
+ "copy:html",
+ // Compile JS
+ "webpack:dev",
+ "replace:dev",
+ ]);
+ grunt.registerTask("dev:server", ["dev", "focus:dev"]);
+ grunt.registerTask("deploy", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:css",
+ "copy:html",
+ // Compile JS
+ "webpack:deploy",
+ "replace:deploy",
+ ]);
+
+ grunt.registerTask("default", ["dev"]);
+};
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e172412
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Felix W. Dekker
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c6577c2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+# Minesweeper
+An implementation of Minesweeper.
+
+## Development
+### Requirements
+* [npm](https://www.npmjs.com/)
+
+### Setting up
+```shell script
+# Install dependencies (only needed once)
+$> npm ci
+```
+
+### Building
+```shell script
+# Build the tool in `dist/` for development
+$> npm run dev
+# Same as above, but automatically rerun it whenever files are changed
+$> npm run dev:server
+# Build the tool in `dist/` for deployment
+$> npm run deploy
+```
diff --git a/index.html b/index.html
deleted file mode 100644
index 8616b45..0000000
--- a/index.html
+++ /dev/null
@@ -1,523 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Swinemeeper
-
-
-
-0
-
-
-
-
-
-
-
-
-
-
-
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a8ba45a
Binary files /dev/null and b/package-lock.json differ
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9a6927f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "minesweeper",
+ "version": "0.0.1",
+ "description": "Just Minesweeper!",
+ "author": "Felix W. Dekker",
+ "browser": "dist/bundle.js",
+ "repository": {
+ "type": "git",
+ "url": "git@git.fwdekker.com:FWDekker/minesweeper.git"
+ },
+ "private": true,
+ "scripts": {
+ "clean": "grunt clean",
+ "dev": "grunt dev",
+ "dev:server": "grunt dev:server",
+ "deploy": "grunt deploy"
+ },
+ "dependencies": {
+ "@fwdekker/template": "^0.0.18"
+ },
+ "devDependencies": {
+ "grunt": "^1.2.1",
+ "grunt-cli": "^1.3.2",
+ "grunt-contrib-clean": "^2.0.0",
+ "grunt-contrib-copy": "^1.0.0",
+ "grunt-contrib-watch": "^1.1.0",
+ "grunt-focus": "^1.0.0",
+ "grunt-text-replace": "^0.4.0",
+ "grunt-webpack": "^4.0.2",
+ "webpack": "^4.44.0",
+ "webpack-cli": "^3.3.12"
+ }
+}
diff --git a/src/main/index.html b/src/main/index.html
new file mode 100644
index 0000000..f9a70e8
--- /dev/null
+++ b/src/main/index.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+ Swinemeeper
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/js/index.js b/src/main/js/index.js
new file mode 100644
index 0000000..f307782
--- /dev/null
+++ b/src/main/js/index.js
@@ -0,0 +1,503 @@
+import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template";
+
+
+const logArea = document.getElementById("logArea");
+const log = (message) => {
+ logArea.value += `${message}\n`;
+ logArea.scrollTop = logArea.scrollHeight;
+}
+
+
+/**
+ * Controls the interaction with a game of Minesweeper.
+ */
+class Game {
+ /**
+ * Constructs and starts a new game of Minesweeper.
+ */
+ constructor() {
+ this.canvas = document.getElementById("canvas");
+ this.settingsForm = document.getElementById("settingsForm");
+ this.widthInput = document.getElementById("settingsWidth");
+ this.heightInput = document.getElementById("settingsHeight");
+ this.minesInput = document.getElementById("settingsMines");
+ this.seedInput = document.getElementById("settingsSeed");
+
+ this.reset();
+ this.display = new Display(this.canvas, this.field);
+ this.display.startDrawLoop();
+
+
+ this.settingsForm.addEventListener(
+ "submit",
+ event => {
+ event.preventDefault();
+ this.reset();
+ this.display.field = this.field;
+ }
+ );
+ this.canvas.addEventListener(
+ "mousemove",
+ event => this.display.mouseCell = this.display.posToCell({x: event.clientX, y: event.clientY})
+ );
+ this.canvas.addEventListener(
+ "contextmenu",
+ event => event.preventDefault()
+ );
+ this.canvas.addEventListener(
+ "mouseup",
+ event => {
+ event.preventDefault();
+ if (!this.isAlive) return;
+
+ const cell = this.display.posToCell({x: event.clientX, y: event.clientY});
+ switch (event.button) {
+ case 0:
+ if (!cell.hasFlag) {
+ if (!this.hasClicked) {
+ cell.firstUncover();
+ log("First uncover complete.");
+ } else
+ cell.uncover();
+
+ this.hasClicked = true;
+ if (cell.hasMine) {
+ this.isAlive = false;
+ log("You died!");
+ }
+ }
+ break;
+ case 1:
+ cell.chord();
+ break;
+ case 2:
+ cell.flag();
+ break;
+ }
+
+ if (this.field.isCleared()) log("Level complete!");
+ }
+ )
+ }
+
+
+ /**
+ * Resets the game, re-generating the field according to the current settings.
+ */
+ reset() {
+ this.field = new Field(
+ +this.widthInput.value,
+ +this.heightInput.value,
+ +this.minesInput.value,
+ +this.seedInput.value
+ );
+ this.isAlive = true;
+ this.hasClicked = false;
+
+ log("Let's go!");
+ }
+}
+
+/**
+ * Displays a Minesweeper field.
+ */
+class Display {
+ /**
+ * Constructs a new display.
+ *
+ * @param canvas {HTMLCanvasElement} the canvas to draw the field in
+ * @param field {Field} the field to draw
+ */
+ constructor(canvas, field) {
+ // TODO Remove this \/
+ this.frameNumber = 0;
+ this.counter = document.getElementById("counter");
+ window.setInterval(() => {
+ this.counter.innerText = "" + (this.frameNumber * 4);
+ this.frameNumber = 0;
+ }, 250);
+ // TODO Remove this /\
+
+ this.canvas = canvas;
+ this.field = field;
+
+ this.mouseCell = undefined;
+ }
+
+
+ /**
+ * Calculates the scale, which is defined as the width and height of each (square) cell in pixels.
+ *
+ * @return the scale of the display
+ */
+ calcScale() {
+ const rect = this.canvas.getBoundingClientRect();
+ return Math.min(rect.width / this.field.width, rect.height / this.field.height);
+ }
+
+ /**
+ * Returns the cell at the given coordinates, or `undefined` if there is no cell there.
+ *
+ * @param pos {{x: number, y: number}} the client-relative pixel coordinates to find the cell at
+ * @return {Cell} the cell at the given coordinates
+ */
+ posToCell(pos) {
+ const rect = this.canvas.getBoundingClientRect();
+ const scale = this.calcScale();
+
+ return this.field.getCellOrElse(
+ Math.floor((pos.x - rect.left) / scale),
+ Math.floor((pos.y - rect.top) / scale)
+ );
+ }
+
+
+ /**
+ * Invokes `#draw` in every animation frame of this window.
+ */
+ startDrawLoop() {
+ const cb = () => {
+ this.draw();
+ this.frameNumber++; // TODO Remove this
+ window.requestAnimationFrame(cb);
+ };
+ window.requestAnimationFrame(cb);
+ }
+
+ /**
+ * Draws the field.
+ */
+ draw() {
+ const ctx = this.canvas.getContext("2d", {alpha: false});
+ const rect = this.canvas.getBoundingClientRect();
+ const scale = this.calcScale();
+
+ // Clear
+ ctx.save();
+ ctx.fillStyle = "#FFF";
+ ctx.fillRect(0, 0, rect.width, rect.height);
+ ctx.restore();
+
+ // Cover cells
+ ctx.save();
+ ctx.fillStyle = "#555";
+ for (let x = 0; x < this.field.width; x++) {
+ for (let y = 0; y < this.field.height; y++) {
+ const cell = this.field.getCell(x, y);
+ if (cell.isCovered) {
+ ctx.fillRect(x * scale, y * scale, scale, scale);
+ }
+ }
+ }
+ ctx.restore();
+
+ // Fill cells
+ ctx.save();
+ ctx.fillStyle = "#000";
+ ctx.font = "30px serif";
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "center";
+ for (let x = 0; x < this.field.width; x++) {
+ for (let y = 0; y < this.field.height; y++) {
+ const cell = this.field.getCell(x, y);
+
+ const neighborMineCount = cell.getNeighborMineCount();
+ let contents;
+ if (cell.isCovered) {
+ if (cell.hasFlag)
+ contents = "⚑";
+ else
+ contents = "";
+ } else {
+ if (cell.hasMine)
+ contents = "💣";
+ else if (neighborMineCount === 0)
+ contents = "";
+ else
+ contents = "" + neighborMineCount;
+ }
+ ctx.fillText(contents, (x + 0.5) * scale, (y + 0.5) * scale, scale);
+ }
+ }
+ ctx.restore();
+
+ // Create grid
+ ctx.save();
+ ctx.strokeStyle = "#000";
+ ctx.beginPath();
+ for (let x = 0; x <= this.field.width; x++) {
+ ctx.moveTo(x * scale, 0);
+ ctx.lineTo(x * scale, this.field.height * scale);
+ }
+ for (let y = 0; y <= this.field.height; y++) {
+ ctx.moveTo(0, y * scale);
+ ctx.lineTo(this.field.width * scale, y * scale);
+ }
+ ctx.stroke();
+ ctx.restore();
+
+ // Highlight mouse cell
+ if (this.mouseCell !== undefined) {
+ ctx.save();
+ ctx.strokeStyle = "#F00";
+ ctx.strokeRect(this.mouseCell.x * scale, this.mouseCell.y * scale, scale, scale);
+ ctx.restore();
+ }
+
+ // Done
+ }
+}
+
+/**
+ * A playing field for a game of Minesweeper.
+ */
+class Field {
+ /**
+ * Constructs a new playing field for a game of Minesweeper.
+ *
+ * @param width {number} the number of cells per row in the field
+ * @param height {number} the number of rows in the field
+ * @param mineCount {number} the initial number of mines to place in the field
+ * @param seed {number|undefined} the seed to generate the field with
+ */
+ constructor(width, height, mineCount, seed = undefined) {
+ this.width = width;
+ this.height = height;
+
+ const mines = Array(width * height).fill(true, 0, mineCount).fill(false, mineCount);
+ shuffleArrayInPlace(mines, seed);
+
+ this.cells = chunkifyArray(
+ mines.map((hasMine, i) => new Cell(this, Math.floor(i / this.width), i % this.width, hasMine)),
+ this.width
+ );
+ }
+
+
+ /**
+ * Returns the cell at the given coordinates, or throws an error if there is no cell there.
+ *
+ * @param x {number} the horizontal coordinate of the cell to look up
+ * @param y {number} the vertical coordinate of the cell to look up
+ * @return {Cell} the cell at the given coordinates
+ */
+ getCell(x, y) {
+ if (x < 0 || x >= this.width) throw new Error(`x must be in range [0, ${this.width}), but was ${x}.`);
+ if (y < 0 || y >= this.height) throw new Error(`y must be in range [0, ${this.height}), but was ${y}.`);
+
+ return this.cells[x][y];
+ }
+
+ /**
+ * Returns the cell at the given coordinates, or `orElse` if there is no cell there.
+ *
+ * @param x {number} the horizontal coordinate of the cell to look up
+ * @param y {number} the vertical coordinate of the cell to look up
+ * @param orElse {*} the value to return if there is no cell at the given coordinates
+ * @return {Cell|*} the cell at the given coordinates, or `orElse` if there is no cell there
+ */
+ getCellOrElse(x, y, orElse = undefined) {
+ const row = this.cells[x];
+ return row === undefined ? orElse : row[y];
+ }
+
+ /**
+ * Returns `true` if and only if all mineless cells have been uncovered.
+ *
+ * @return `true` if and only if all mineless cells have been uncovered
+ */
+ isCleared() {
+ for (let x = 0; x < this.width; x++) {
+ for (let y = 0; y < this.height; y++) {
+ const cell = this.getCell(x, y);
+ if (cell.isCovered && !cell.hasMine)
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+/**
+ * A cell in a Minesweeper `Field`.
+ */
+class Cell {
+ /**
+ * Constructs a new cell.
+ *
+ * @param field {Field} the field in which this cell is located
+ * @param x {number} the horizontal coordinate of this cell in the field
+ * @param y {number} the vertical coordinate of this cell in the field
+ * @param hasMine {boolean} `true` if and only if this cell contains a mine
+ */
+ constructor(field, x, y, hasMine) {
+ this.field = field;
+ this.x = x;
+ this.y = y;
+
+ this.isCovered = true;
+ this.hasMine = hasMine;
+ this.hasFlag = false;
+ }
+
+
+ /**
+ * Returns the `Cell`s that are adjacent to this cell.
+ *
+ * @return {Cell[]} the `Cell`s that are adjacent to this cell
+ */
+ getNeighbors() {
+ return [
+ this.field.getCellOrElse(this.x - 1, this.y - 1),
+ this.field.getCellOrElse(this.x, this.y - 1),
+ this.field.getCellOrElse(this.x + 1, this.y - 1),
+ this.field.getCellOrElse(this.x - 1, this.y),
+ this.field.getCellOrElse(this.x + 1, this.y),
+ this.field.getCellOrElse(this.x - 1, this.y + 1),
+ this.field.getCellOrElse(this.x, this.y + 1),
+ this.field.getCellOrElse(this.x + 1, this.y + 1),
+ ].filter(it => it !== undefined);
+ }
+
+ /**
+ * Returns the number of neighbors that have a flag.
+ *
+ * @returns {number} the number of neighbors that have a flag
+ */
+ getNeighborFlagCount() {
+ return this.getNeighbors().filter(it => it.hasFlag).length;
+ }
+
+ /**
+ * Returns the number of neighbors that have a mine.
+ *
+ * @returns {number} the number of neighbors that have a mine
+ */
+ getNeighborMineCount() {
+ return this.getNeighbors().filter(it => it.hasMine).length;
+ }
+
+
+ /**
+ * Chords this cell, i.e. if this cell is covered and the number of neighboring flags equals the number in this
+ * cell, then all unflagged neighbors are uncovered.
+ */
+ chord() {
+ if (this.isCovered) return;
+ if (this.getNeighborMineCount() !== this.getNeighborFlagCount()) return;
+
+ this.getNeighbors()
+ .filter(it => it.isCovered && !it.hasFlag)
+ .forEach(it => it.uncover());
+ }
+
+ /**
+ * Uncovers this cell as in `#uncover`, but adjacent 0-mine cells are also uncovered and if this cell contains a
+ * mine the mine is moved to the first cell without a mine, starting from the top-left moving in a horizontal
+ * scanning fashion.
+ */
+ firstUncover() {
+ if (this.hasMine) {
+ this.hasMine = false;
+
+ for (let y = 0; y < this.field.height; y++) {
+ for (let x = 0; x < this.field.width; x++) {
+ if (x === this.x && y === this.y) continue;
+
+ const cell = this.field.getCell(x, y);
+ if (!cell.hasMine) {
+ cell.hasMine = true;
+ break;
+ }
+ }
+ }
+ }
+
+ this.getNeighbors()
+ .filter(it => it.getNeighborMineCount() === 0 && !it.hasMine && !it.hasFlag)
+ .forEach(it => it.uncover());
+ }
+
+ /**
+ * Adds or removes a flag at this cell.
+ */
+ flag() {
+ if (!this.isCovered) return;
+
+ this.hasFlag = !this.hasFlag;
+ }
+
+ /**
+ * Uncovers this cell, revealing the contents beneath.
+ */
+ uncover() {
+ if (!this.isCovered) return;
+
+ this.isCovered = false;
+ this.hasFlag = false;
+ if (!this.hasMine && this.getNeighborMineCount() === 0)
+ this.chord();
+ }
+}
+
+
+/**
+ * Shuffles the given array in-place.
+ *
+ * @param array {*[]} the array to shuffle
+ * @param seed {number|undefined} the seed for the random number generator
+ * @returns {*[]} the array that was given to this function to shuffle
+ */
+function shuffleArrayInPlace(array, seed = undefined) {
+ const engine = Random.engines.mt19937();
+ engine.autoSeed();
+ if (seed !== undefined) engine.seed(seed);
+ return new Random(engine).shuffle(array);
+}
+
+/**
+ * Slices `array` into chunks of `chunkSize` elements each.
+ *
+ * If `array` does not contain a multiple of `chunkSize` elements, the last chunk will contain fewer elements.
+ *
+ * @param array {*[]} the array to chunkify
+ * @param chunkSize {number} the size of each chunk
+ * @returns {*[]} an array of the extracted chunks
+ */
+function chunkifyArray(array, chunkSize) {
+ const chunks = [];
+ for (let i = 0; i < array.length; i += chunkSize)
+ chunks.push(array.slice(i, i + chunkSize));
+ return chunks;
+}
+
+
+doAfterLoad(() => {
+ // Initialize template
+ $("#nav").appendChild(nav("/Tools/Minesweeper/"));
+ $("#header").appendChild(header({
+ title: "Minesweeper",
+ description: "Just Minesweeper!"
+ }));
+ $("#footer").appendChild(footer({
+ author: "Felix W. Dekker",
+ authorURL: "https://fwdekker.com/",
+ license: "MIT License",
+ licenseURL: "https://git.fwdekker.com/FWDekker/minesweeper/src/branch/master/LICENSE",
+ vcs: "git",
+ vcsURL: "https://git.fwdekker.com/FWDekker/minesweeper/",
+ version: "v%%VERSION_NUMBER%%"
+ }));
+ $("main").style.display = null;
+
+
+ // Initialize game
+ const urlParams = new URLSearchParams(window.location.search);
+ document.getElementById("settingsSeed").value =
+ urlParams.get("seed") === null
+ ? "" + Math.floor(Math.random() * 1000000000000)
+ : urlParams.get("seed");
+
+ new Game();
+});