├── src ├── scripts │ ├── UIController.js │ ├── index.js │ └── Game.js ├── index.html └── styles │ └── index.scss ├── .travis.yml ├── .gitignore ├── .browserslistrc ├── .eslintrc ├── .dependabot └── config.yml ├── .babelrc ├── README.md ├── webpack ├── webpack.config.dev.js ├── webpack.config.prod.js └── webpack.common.js └── package.json /src/scripts/UIController.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "lts/*" 4 | - "node" 5 | script: npm run build 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # production 5 | build 6 | 7 | # misc 8 | .DS_Store 9 | 10 | npm-debug.log 11 | yarn-error.log 12 | yarn.lock 13 | .yarnclean 14 | .vscode 15 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | [production staging] 2 | >5% 3 | last 2 versions 4 | Firefox ESR 5 | not ie < 11 6 | 7 | [development] 8 | last 1 chrome version 9 | last 1 firefox version 10 | last 1 edge version 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true 5 | }, 6 | "parserOptions": { 7 | "ecmaVersion": 6, 8 | "sourceType": "module" 9 | } 10 | // "rules": { 11 | // "semi": 2 12 | // } 13 | } -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "javascript" 4 | directory: "/" 5 | update_schedule: "weekly" 6 | automerged_updates: 7 | - match: 8 | dependency_type: "all" 9 | update_type: "semver:minor" 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "corejs": "3.0.0" 8 | } 9 | ] 10 | ], 11 | "plugins": [ 12 | "@babel/plugin-syntax-dynamic-import", 13 | "@babel/plugin-proposal-class-properties" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MineSweeper Game 2 | 3 | A web version of the old MineSweeper game. 4 | 5 | Check out the demo here 👉 [minesweeper.marc.dev](https://minesweeper.marc.dev) 6 | 7 | ### Installation 8 | 9 | ``` 10 | npm install 11 | ``` 12 | 13 | ### Start Dev Server 14 | 15 | ``` 16 | npm start 17 | ``` 18 | 19 | ### Build Prod Version 20 | 21 | ``` 22 | npm run build 23 | ``` 24 | 25 | ### Features: 26 | 27 | * Flag suspected mine 28 | * Recursively free up free space 29 | * Reveal a field 30 | * 5 levels 31 | * Customizable field size -------------------------------------------------------------------------------- /webpack/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const Path = require('path'); 2 | const Webpack = require('webpack'); 3 | const merge = require('webpack-merge'); 4 | const common = require('./webpack.common.js'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'development', 8 | devtool: 'cheap-eval-source-map', 9 | output: { 10 | chunkFilename: 'js/[name].chunk.js' 11 | }, 12 | devServer: { 13 | inline: true 14 | }, 15 | plugins: [ 16 | new Webpack.DefinePlugin({ 17 | 'process.env.NODE_ENV': JSON.stringify('development') 18 | }) 19 | ], 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.js$/, 24 | include: Path.resolve(__dirname, '../src'), 25 | enforce: 'pre', 26 | loader: 'eslint-loader', 27 | options: { 28 | emitWarning: true, 29 | } 30 | }, 31 | { 32 | test: /\.js$/, 33 | include: Path.resolve(__dirname, '../src'), 34 | loader: 'babel-loader' 35 | }, 36 | { 37 | test: /\.s?css$/i, 38 | use: ['style-loader', 'css-loader?sourceMap=true', 'sass-loader'] 39 | } 40 | ] 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /webpack/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const Webpack = require('webpack'); 2 | const merge = require('webpack-merge'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const common = require('./webpack.common.js'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'production', 8 | devtool: 'source-map', 9 | stats: 'errors-only', 10 | bail: true, 11 | output: { 12 | filename: 'js/[name].[chunkhash:8].js', 13 | chunkFilename: 'js/[name].[chunkhash:8].chunk.js' 14 | }, 15 | plugins: [ 16 | new Webpack.DefinePlugin({ 17 | 'process.env.NODE_ENV': JSON.stringify('production') 18 | }), 19 | new Webpack.optimize.ModuleConcatenationPlugin(), 20 | new MiniCssExtractPlugin({ 21 | filename: 'bundle.css' 22 | }) 23 | ], 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.js$/, 28 | exclude: /node_modules/, 29 | use: 'babel-loader' 30 | }, 31 | { 32 | test: /\.s?css/i, 33 | use : [ 34 | MiniCssExtractPlugin.loader, 35 | 'css-loader', 36 | 'sass-loader' 37 | ] 38 | } 39 | ] 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /webpack/webpack.common.js: -------------------------------------------------------------------------------- 1 | const Path = require('path'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | 6 | module.exports = { 7 | entry: { 8 | app: Path.resolve(__dirname, '../src/scripts/index.js') 9 | }, 10 | output: { 11 | path: Path.join(__dirname, '../build'), 12 | filename: 'js/[name].js' 13 | }, 14 | optimization: { 15 | splitChunks: { 16 | chunks: 'all', 17 | name: false 18 | } 19 | }, 20 | plugins: [ 21 | new CleanWebpackPlugin(), 22 | new CopyWebpackPlugin([ 23 | { from: Path.resolve(__dirname, '../public'), to: 'public' } 24 | ]), 25 | new HtmlWebpackPlugin({ 26 | template: Path.resolve(__dirname, '../src/index.html') 27 | }) 28 | ], 29 | resolve: { 30 | alias: { 31 | '~': Path.resolve(__dirname, '../src') 32 | } 33 | }, 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.mjs$/, 38 | include: /node_modules/, 39 | type: 'javascript/auto' 40 | }, 41 | { 42 | test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)(\?.*)?$/, 43 | use: { 44 | loader: 'file-loader', 45 | options: { 46 | name: '[path][name].[ext]' 47 | } 48 | } 49 | }, 50 | ] 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minesweeper", 3 | "version": "1.0.0", 4 | "description": "MineSweeper Game", 5 | "scripts": { 6 | "build": "cross-env NODE_ENV=production webpack --config webpack/webpack.config.prod.js --colors", 7 | "start": "webpack-dev-server --open --config webpack/webpack.config.dev.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/wbkd/webpack-starter.git" 12 | }, 13 | "keywords": [ 14 | "webpack", 15 | "startkit", 16 | "frontend", 17 | "es6", 18 | "javascript", 19 | "webdev" 20 | ], 21 | "author": "webkid.io", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/wbkd/webpack-starter/issues" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.8.4", 28 | "@babel/plugin-proposal-class-properties": "^7.8.3", 29 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 30 | "@babel/preset-env": "^7.8.4", 31 | "babel-loader": "^8.0.6", 32 | "clean-webpack-plugin": "^3.0.0", 33 | "copy-webpack-plugin": "^5.1.1", 34 | "cross-env": "^6.0.3", 35 | "css-loader": "^3.4.2", 36 | "eslint": "^6.8.0", 37 | "eslint-loader": "^3.0.3", 38 | "file-loader": "^4.3.0", 39 | "html-webpack-plugin": "^4.0.0-beta.11", 40 | "mini-css-extract-plugin": "^0.8.0", 41 | "node-sass": "^4.13.1", 42 | "sass-loader": "^8.0.2", 43 | "style-loader": "^1.1.3", 44 | "webpack": "^4.41.5", 45 | "webpack-cli": "^3.3.10", 46 | "webpack-dev-server": "^3.10.3", 47 | "webpack-merge": "^4.2.2" 48 | }, 49 | "dependencies": { 50 | "@babel/polyfill": "^7.8.3", 51 | "@fortawesome/fontawesome-free": "^5.12.1", 52 | "canvas-confetti": "^1.0.3", 53 | "core-js": "^3.6.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Web-Based MineSweeper Game | Built by Marc Backes 7 | 8 | 9 |
10 |

MineSweeper

11 |
12 | 13 | 14 |
15 | Level: 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 30 |
31 |
32 |
33 |
34 |
35 |

Start game above 👆

36 |
37 |

Game Over 😞

38 |

You won! 🎉

39 | 40 |

0

41 |
42 |
43 |
44 |
45 | 62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif; 3 | color: #555; 4 | margin: 0; 5 | padding-top: 83px; 6 | } 7 | 8 | h2 { 9 | margin: 0.5rem; 10 | } 11 | 12 | .inputGroup { 13 | margin: 1rem 0; 14 | input { 15 | padding: 0.5rem; 16 | border-radius: 5px; 17 | border: solid 1px #ddd; 18 | outline: none; 19 | font-size: 1rem; 20 | width: 3rem; 21 | text-align: center; 22 | padding-left: 15px; 23 | } 24 | label { 25 | font-size: 1rem; 26 | } 27 | > * { 28 | margin: 0 0.5rem; 29 | } 30 | } 31 | 32 | .levels { 33 | line-height: 2.5rem; 34 | 35 | input[type='radio'] { 36 | display: none; 37 | + label { 38 | border-radius: 0.5rem; 39 | font-size: 1.5rem; 40 | padding: 0.5rem 0.75rem; 41 | cursor: pointer; 42 | &:hover { 43 | background: rgba(255, 255, 255, 0.1); 44 | } 45 | } 46 | &:checked + label { 47 | background: #ddd; 48 | } 49 | } 50 | } 51 | 52 | button { 53 | background: tomato; 54 | color: #fff; 55 | outline: none; 56 | border: none; 57 | border-radius: 5px; 58 | font-size: 1rem; 59 | padding: 1rem 2rem; 60 | cursor: pointer; 61 | } 62 | 63 | .controls { 64 | background: rgb(21, 32, 43); 65 | color: #fff; 66 | width: 100%; 67 | display: flex; 68 | align-items: center; 69 | position: fixed; 70 | top: 0; 71 | z-index: 1; 72 | > * { 73 | margin: 1rem; 74 | } 75 | } 76 | 77 | .wrapper { 78 | display: flex; 79 | justify-content: center; 80 | align-items: center; 81 | flex-direction: column; 82 | min-height: 100vh; 83 | width: 100%; 84 | position: absolute; 85 | top: 0; 86 | z-index: 0; 87 | 88 | .filler { 89 | width: 100%; 90 | height: 83px; 91 | } 92 | 93 | .game { 94 | flex: 1; 95 | display: flex; 96 | justify-content: center; 97 | align-items: center; 98 | text-align: center; 99 | &.over { 100 | .board { 101 | pointer-events: none; 102 | } 103 | h2.game-over { 104 | display: block; 105 | } 106 | } 107 | 108 | &.won { 109 | .board { 110 | pointer-events: none; 111 | } 112 | h2.game-won { 113 | display: block; 114 | } 115 | } 116 | 117 | h2 { 118 | &.game-over { 119 | color: tomato; 120 | display: none; 121 | } 122 | &.game-won { 123 | color: MediumSpringGreen; 124 | display: none; 125 | } 126 | } 127 | } 128 | 129 | .board { 130 | display: grid; 131 | grid-template-columns: repeat(5, 1fr); 132 | grid-gap: 5px; 133 | margin: 1rem; 134 | 135 | .field { 136 | width: 30px; 137 | height: 30px; 138 | border-radius: 5px; 139 | background: #ddd; 140 | cursor: pointer; 141 | display: flex; 142 | justify-content: center; 143 | align-items: center; 144 | &:hover { 145 | background: #ccc; 146 | } 147 | 148 | &.reveiled { 149 | background: #eee; 150 | } 151 | 152 | &.mine { 153 | background: tomato; 154 | color: #fff; 155 | } 156 | 157 | &.flagged { 158 | color: tomato; 159 | } 160 | } 161 | } 162 | 163 | .boardStatus { 164 | color: #888; 165 | border-radius: 5px; 166 | margin: 1rem; 167 | padding: 1rem; 168 | display: none; 169 | } 170 | 171 | footer { 172 | text-align: center; 173 | color: #aaa; 174 | a { 175 | text-decoration: none; 176 | color: #aaa; 177 | &:hover { 178 | color: tomato; 179 | } 180 | } 181 | } 182 | } 183 | 184 | @media (max-width: 600px) { 185 | .controls { 186 | overflow-y: scroll; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/scripts/index.js: -------------------------------------------------------------------------------- 1 | import '../styles/index.scss' 2 | import '@fortawesome/fontawesome-free/js/all' 3 | import confetti from 'canvas-confetti' 4 | 5 | import { Game } from './Game' 6 | 7 | // Elements in the DOM that need to be accessed during the game 8 | const gameElement = document.getElementById('game') 9 | const boardElement = document.getElementById('board') 10 | const boardStatus = document.getElementById('boardStatus') 11 | const newGameButton = document.getElementById('newGameButton') 12 | const dimensionInput = document.getElementById('dimensionInput') 13 | const minesLeftHeader = document.getElementById('minesLeftHeader') 14 | const minesLeftText = document.getElementById('minesLeft') 15 | const gameStartHeader = document.getElementById('game-start') 16 | 17 | // The game variable. Here lies all the logic 18 | let game 19 | 20 | // Little bit self-promotion never hurt anyone 😉 21 | console.log('🔥 Build with love by Marc Backes 🔥') 22 | console.log(' 👉 Website: https://marc.dev') 23 | console.log(' 👉 Twitter: https://twitter.com/_marcba') 24 | console.log(' 👉 Codepen: https://codepen.io/_marcba') 25 | console.log(' 👉 Github: https://github.com/themarcba') 26 | 27 | // Reveal a field for the selected position 28 | const revealField = (x, y) => { 29 | game.reveal(x, y) 30 | if (game.isGameOver()) { 31 | gameOver() 32 | } 33 | } 34 | 35 | // Toggle the flag-status on a field for the selected position 36 | const toggleFlagField = (x, y) => { 37 | game.toggleFlag(x, y) 38 | updateUI() 39 | } 40 | 41 | // When the game is over (lost) 42 | const gameOver = () => { 43 | gameElement.classList.add('over') 44 | game.resolve() 45 | } 46 | 47 | // When the game has been won 48 | const gameWon = () => { 49 | gameElement.classList.add('won') 50 | // Throw some confetti! 🎉 51 | confetti({ 52 | particleCount: 200, 53 | spread: 100 54 | }) 55 | } 56 | 57 | // Create a new game 58 | const newGame = () => { 59 | // A 5x5 board is too small for the mine-placing rules in the Game class 60 | if (dimensionInput.value < 5) return alert('There must be at least a field dimension of 5') 61 | 62 | // Read desired board dimensions from the input field 63 | const dimensions = parseInt(dimensionInput.value) 64 | 65 | // Get the selected level from the emoji bar (radio buttons) (👶,😎,😐,🥵,😈) 66 | const level = document.querySelector('input[name="level"]:checked').value 67 | 68 | // Remove winning or losing state of the board 69 | gameElement.classList.remove('over') 70 | gameElement.classList.remove('won') 71 | 72 | // Create the Game object which contains all the logic 73 | game = new Game(dimensions, level) 74 | 75 | // Set CSS size for the board 76 | boardElement.style.gridTemplateColumns = `repeat(${dimensions}, 1fr)` 77 | 78 | // Empty board, because new elements will be created 79 | boardElement.innerHTML = '' 80 | 81 | // Create initial field elements 82 | game.fields.forEach((rowField, rowFieldIndex) => { 83 | // Rows 84 | rowField.forEach((columnField, columnFieldIndex) => { 85 | // Columns 86 | 87 | // Create & initialize a field element 88 | let newField = document.createElement('div') 89 | newField.id = `field_${rowFieldIndex}_${columnFieldIndex}` 90 | newField.className = 'field' 91 | 92 | // Left click event on field (reveal) 93 | newField.addEventListener('click', ev => { 94 | revealField(rowFieldIndex, columnFieldIndex) 95 | updateUI() 96 | }) 97 | 98 | // Right click event on field (flag) 99 | newField.addEventListener('contextmenu', ev => { 100 | ev.preventDefault() 101 | toggleFlagField(rowFieldIndex, columnFieldIndex) 102 | updateUI() 103 | }) 104 | 105 | // Append created field to board 106 | boardElement.appendChild(newField) 107 | }) 108 | }) 109 | 110 | // Update the interface! 111 | updateUI() 112 | } 113 | 114 | // Updates the interface 115 | // - Reads fields and sets CSS classes and content depending on field state 116 | // - Checks if game is won or lost to display the respective messages 117 | // - Hides "mines left" indicator when game is over 118 | const updateUI = () => { 119 | minesLeftText.innerText = game.getMineCount() - game.getFlaggedFields().length 120 | game.fields.forEach((rowField, rowFieldIndex) => { 121 | rowField.forEach((columnField, columnFieldIndex) => { 122 | const fieldElement = document.getElementById(`field_${rowFieldIndex}_${columnFieldIndex}`) 123 | const field = game.getField(rowFieldIndex, columnFieldIndex) 124 | 125 | if (field.isRevealed) { 126 | fieldElement.className = 'reveiled field' 127 | if (field.hasMine) { 128 | fieldElement.classList = 'mine field' 129 | fieldElement.innerHTML = '' 130 | } else if (field.hint) { 131 | fieldElement.innerHTML = field.hint 132 | } else { 133 | fieldElement.innerHTML = '' 134 | } 135 | } else if (field.isFlagged) { 136 | fieldElement.classList = 'flagged field' 137 | fieldElement.innerHTML = '' 138 | } else { 139 | fieldElement.innerHTML = '' 140 | } 141 | }) 142 | }) 143 | 144 | if (game.isGameWon()) { 145 | gameWon() 146 | } 147 | 148 | boardStatus.style.display = game.isGameCreated() ? 'block' : 'none' 149 | minesLeftHeader.style.display = game.isGameCreated() && !game.isGameWon() & !game.isGameOver() ? 'block' : 'none' 150 | gameStartHeader.style.display = game.isGameCreated() ? 'none' : 'block' 151 | } 152 | 153 | // Add event listeners 154 | const attachEvents = () => { 155 | // "New Game" Button 156 | newGameButton.addEventListener('click', newGame) 157 | } 158 | 159 | attachEvents() 160 | -------------------------------------------------------------------------------- /src/scripts/Game.js: -------------------------------------------------------------------------------- 1 | // Fields that are around a specific field, defined by x, y 2 | const neighborFields = (x, y) => { 3 | return [ 4 | [x - 1, y - 1], 5 | [x, y - 1], 6 | [x + 1, y - 1], 7 | [x - 1, y], 8 | [x + 1, y], 9 | [x - 1, y + 1], 10 | [x, y + 1], 11 | [x + 1, y + 1] 12 | ] 13 | } 14 | 15 | class Game { 16 | // Creates the game, but doesn't initialize the mines yet, because they are placed depending on the first click of the user 17 | constructor(dimensions = 10, difficulty = 1) { 18 | // In the worst case, there are mines everywhere, except the first clicked field and it's neighbor fields 19 | const maximumAllowedMines = dimensions * dimensions - 9 20 | 21 | // Calculate mine count depending on the number field dimensions and difficulty 22 | this.mineCount = Math.floor( 23 | dimensions * difficulty <= maximumAllowedMines ? dimensions * difficulty : maximumAllowedMines 24 | ) 25 | 26 | this.dimensions = dimensions 27 | this.gameCreated = true 28 | 29 | // Initialize fields, fill them with empty objects 30 | this.fields = new Array(dimensions).fill({}) 31 | window.__fields = this.fields 32 | this.fields.forEach((field, index) => { 33 | this.fields[index] = new Array(dimensions).fill({}) 34 | }) 35 | } 36 | 37 | // When the user makes their first click, the game starts 38 | // The positioning of the mines is determined based on this first clicked field position 39 | startGame(x, y) { 40 | this.startingPoint = [x, y] 41 | this.gameStarted = true 42 | 43 | // Set mines within the field 44 | for (let i = 0; i < this.mineCount; i++) { 45 | const [randomX, randomY] = this.getRandomFreeField() 46 | this.fields[randomX][randomY] = { hasMine: true } 47 | } 48 | } 49 | 50 | // Determines if a mine can be placed on the given position 51 | // Reasons for not being able to place a mine 52 | // - It's the first clicked field 53 | // - It's one of the neigboring fields of the first clicked field 54 | // - There is already a mine on that field 55 | canPlaceMine(x, y) { 56 | const [startX, startY] = this.startingPoint 57 | 58 | // Is it the first clicked field? 59 | const isStartingPoint = x === startX && y === startY 60 | 61 | // Is it a neighboring field? 62 | const isNeighboringField = neighborFields(startX, startY).some(([_x, _y]) => { 63 | return x == _x && y == _y 64 | }) 65 | 66 | // Does it already have a mine? 67 | const hasAlreadyMine = this.fields[x][y].hasMine === true 68 | 69 | return !isNeighboringField && !hasAlreadyMine && !isStartingPoint 70 | } 71 | 72 | // Generate a random field. Generate random field positions until a valid field is found (determined by canPlaceMine) 73 | getRandomFreeField() { 74 | let randomX, randomY 75 | // Repeat until valid field 76 | do { 77 | randomX = Math.floor(Math.random() * this.dimensions) 78 | randomY = Math.floor(Math.random() * this.dimensions) 79 | } while (!this.canPlaceMine(randomX, randomY)) 80 | 81 | return [randomX, randomY] 82 | } 83 | 84 | // Returns a list of flagged fields 85 | getFlaggedFields() { 86 | return this.fields.flat().filter(field => field.isFlagged) 87 | } 88 | 89 | // Returns a list of revealed fields 90 | getRevealedFields() { 91 | return this.fields.flat().filter(field => field.isRevealed) 92 | } 93 | 94 | // Returns the number of mines placed in the field 95 | getMineCount() { 96 | return this.mineCount 97 | } 98 | 99 | // Returns whether the game is won. 100 | // A game is won when both is true: 101 | // - All non-mined fields are revealed 102 | // - All mined fields are flagged 103 | isGameWon() { 104 | const nonMinedFields = this.fields.flat().filter(field => !field.hasMine) 105 | const minedFields = this.fields.flat().filter(field => field.hasMine) 106 | 107 | return nonMinedFields.every(field => field.isRevealed) && minedFields.every(field => field.isFlagged) 108 | } 109 | 110 | // Returns when the game is list 111 | // A game is lost when a mine has been revealed 112 | isGameOver() { 113 | return this.getRevealedFields().filter(field => field.hasMine).length > 0 114 | } 115 | 116 | // Returns if the game has been initialized 117 | isGameCreated() { 118 | return this.gameCreated 119 | } 120 | 121 | // Calculates how many neighbor fields have mines 122 | getHint(x, y) { 123 | const neighborsWithMine = neighborFields(x, y).reduce( 124 | (accumulator, [x, y]) => accumulator + (this.getField(x, y) && this.getField(x, y).hasMine ? 1 : 0), 125 | 0 126 | ) 127 | return neighborsWithMine 128 | } 129 | 130 | // Reveals a field 131 | // - Sets the revealed status to the field (isRevealed) 132 | // - If the field is not neighboring a mine, recursively reveal the surrounding fields 133 | reveal(x, y) { 134 | // If the game has not already been started, it gets started (first click) 135 | if (!this.gameStarted) this.startGame(x, y) 136 | 137 | const hint = this.getHint(x, y) 138 | this.fields[x][y] = { ...this.fields[x][y], isRevealed: true, hint } 139 | 140 | // This is the stop-condition for the recursive call 141 | if (hint === 0) { 142 | // Check all neighboring fields 143 | neighborFields(x, y).forEach(([x, y]) => { 144 | const field = this.getField(x, y) 145 | // Reveal if the field exists, is not revealed yet and has no mine 146 | if (field !== null && !field.isRevealed && !field.hasMine) { 147 | this.reveal(x, y) 148 | } 149 | }) 150 | } 151 | return this.fields[x][y] 152 | } 153 | 154 | // Resolve the entire game. All fields are being revealed 155 | resolve() { 156 | this.fields.forEach((rowField, rowFieldIndex) => { 157 | rowField.forEach((columnField, columnFieldIndex) => { 158 | const hint = this.getHint(columnFieldIndex, rowFieldIndex) 159 | this.fields[columnFieldIndex][rowFieldIndex] = { 160 | ...this.fields[columnFieldIndex][rowFieldIndex], 161 | isRevealed: true, 162 | hint 163 | } 164 | }) 165 | }) 166 | } 167 | 168 | // Flag a field. (Set the isFlagged property) 169 | flag(x, y) { 170 | if (!this.fields[x][y].isRevealed) { 171 | this.fields[x][y] = { ...this.fields[x][y], isFlagged: true } 172 | } 173 | return this.fields[x][y] 174 | } 175 | 176 | // Unflag a field. Opposite of flag() 177 | unflag(x, y) { 178 | this.fields[x][y] = { ...this.fields[x][y], isFlagged: false } 179 | return this.fields[x][y] 180 | } 181 | 182 | // Toggle the flagged-status of a field 183 | toggleFlag(x, y) { 184 | this.fields[x][y].isFlagged ? this.unflag(x, y) : this.flag(x, y) 185 | } 186 | 187 | // Return a field with the given position 188 | getField(x, y) { 189 | // Only return a field if it's in bounds 190 | if (x >= 0 && x < this.dimensions && y >= 0 && y < this.dimensions) { 191 | return this.fields[x][y] 192 | } 193 | // Return null if it doesn't exist (out of bounds) 194 | else { 195 | return null 196 | } 197 | } 198 | } 199 | 200 | export { Game } 201 | --------------------------------------------------------------------------------