Set up deployment with npm

This commit is contained in:
Florine W. Dekker 2020-07-25 20:35:59 +02:00
parent 67dd5e880f
commit 65da82257b
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
11 changed files with 895 additions and 523 deletions

11
.editorconfig Normal file
View File

@ -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

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
package-lock.json binary

117
.gitignore vendored Normal file
View File

@ -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.*

121
Gruntfile.js Normal file
View File

@ -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"]);
};

21
LICENSE Normal file
View File

@ -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.

22
README.md Normal file
View File

@ -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
```

View File

@ -1,523 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="Felix W. Dekker" />
<meta name="application-name" content="Swinemeeper" />
<meta name="description" content="Just Minesweeper!" />
<meta name="theme-color" content="#0033cc" />
<title>Swinemeeper</title>
</head>
<body>
<canvas id="canvas" width="500" height="500"></canvas>
<span id="counter">0</span>
<br />
<form id="settingsForm">
<label for="settingsWidth">Width</label>
<input type="number" id="settingsWidth" value="10" />
<label for="settingsHeight">Height</label>
<input type="number" id="settingsHeight" value="10" />
<label for="settingsMines">Mines</label>
<input type="number" id="settingsMines" value="10" />
<label for="settingsSeed">Seed</label>
<input type="number" id="settingsSeed" value="" />
<button>Play</button>
</form>
<label for="logArea">Log</label>
<textarea id="logArea" cols="80" rows="20" disabled></textarea>
<script src="https://cdnjs.cloudflare.com/ajax/libs/random-js/1.0.8/random.min.js"
integrity="sha512-C/21kASTZDv173aY5ERBPWuV9JVne9nhII/ZA7NIGhuPVCtGK5WnD6wzQ7dlQajXvu3003cojqouByxh+Fh3kg=="
crossorigin="anonymous"></script>
<script>
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;
}
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();
</script>
</body>
</html>

BIN
package-lock.json generated Normal file

Binary file not shown.

33
package.json Normal file
View File

@ -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"
}
}

66
src/main/index.html Normal file
View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="Felix W. Dekker" />
<meta name="application-name" content="Swinemeeper" />
<meta name="description" content="Just Minesweeper!" />
<meta name="theme-color" content="#0033cc" />
<title>Swinemeeper</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"
crossorigin="anonymous" />
</head>
<body>
<noscript>
<span style="color: red; font-weight: bold;">
This website does not function if JavaScript is disabled.
Please check the <a href="https://www.enable-javascript.com/">
instructions on how to enable JavaScript in your web browser</a>.
</span>
</noscript>
<main style="display: none;">
<div id="nav"></div>
<div id="contents">
<div id="header"></div>
<section class="container">
<!-- Field -->
<canvas id="canvas" width="500" height="500"></canvas>
<span id="counter">0</span>
<br />
<!-- Settings -->
<form id="settingsForm">
<label for="settingsWidth">Width</label>
<input type="number" id="settingsWidth" value="10" />
<label for="settingsHeight">Height</label>
<input type="number" id="settingsHeight" value="10" />
<label for="settingsMines">Mines</label>
<input type="number" id="settingsMines" value="10" />
<label for="settingsSeed">Seed</label>
<input type="number" id="settingsSeed" value="" />
<button>Play</button>
</form>
<!-- Log -->
<label for="logArea">Log</label>
<textarea id="logArea" cols="80" rows="20" disabled></textarea>
</section>
</div>
<div id="footer"></div>
</main>
<!-- Scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/random-js/1.0.8/random.min.js"
integrity="sha512-C/21kASTZDv173aY5ERBPWuV9JVne9nhII/ZA7NIGhuPVCtGK5WnD6wzQ7dlQajXvu3003cojqouByxh+Fh3kg=="
crossorigin="anonymous"></script>
<script src="bundle.js"></script>
</body>
</html>

503
src/main/js/index.js Normal file
View File

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