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(); +});