├── src ├── react-app-env.d.ts ├── assets │ ├── fonts │ │ ├── prstartk.woff │ │ ├── prstartk.woff2 │ │ ├── Digital-Dismay.woff │ │ └── Digital-Dismay.woff2 │ ├── variables.sass │ ├── mixins.sass │ └── global.sass ├── components │ ├── Game │ │ ├── Header │ │ │ ├── Emoji │ │ │ │ ├── assets │ │ │ │ │ ├── images │ │ │ │ │ │ ├── cool.png │ │ │ │ │ │ ├── dead.png │ │ │ │ │ │ ├── smile.png │ │ │ │ │ │ └── sweating.png │ │ │ │ │ └── styles.sass │ │ │ │ └── index.tsx │ │ │ ├── Scoreboard │ │ │ │ ├── index.tsx │ │ │ │ └── assets │ │ │ │ │ └── styles.sass │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── assets │ │ │ └── styles.sass │ │ └── Board │ │ │ ├── assets │ │ │ ├── images │ │ │ │ ├── flag.svg │ │ │ │ └── mine.svg │ │ │ └── styles.sass │ │ │ ├── index.tsx │ │ │ └── hooks.ts │ ├── Menu │ │ ├── Help │ │ │ ├── assets │ │ │ │ └── styles.sass │ │ │ └── index.tsx │ │ ├── assets │ │ │ └── styles.sass │ │ ├── Settings │ │ │ ├── assets │ │ │ │ └── styles.sass │ │ │ └── index.tsx │ │ └── index.tsx │ ├── App │ │ ├── assets │ │ │ └── styles.sass │ │ └── index.tsx │ ├── Window │ │ ├── assets │ │ │ ├── images │ │ │ │ └── close.svg │ │ │ └── styles.sass │ │ └── index.tsx │ └── Modal │ │ ├── assets │ │ └── styles.sass │ │ └── index.tsx ├── setupTests.js ├── index.tsx ├── context │ ├── lib │ │ ├── presets.json │ │ ├── __tests__ │ │ │ ├── board-generator.test.ts │ │ │ └── game.test.ts │ │ ├── board-generator.ts │ │ └── game.ts │ └── world.tsx ├── types.ts └── logo.svg ├── screenshot.png ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── manifest.json └── index.html ├── .travis.yml ├── .editorconfig ├── .gitignore ├── README.md ├── tsconfig.json └── package.json /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/screenshot.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/assets/fonts/prstartk.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/src/assets/fonts/prstartk.woff -------------------------------------------------------------------------------- /src/assets/fonts/prstartk.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/src/assets/fonts/prstartk.woff2 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 14 3 | 4 | install: 5 | - npm install 6 | 7 | script: 8 | - npm run test -------------------------------------------------------------------------------- /src/assets/fonts/Digital-Dismay.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/src/assets/fonts/Digital-Dismay.woff -------------------------------------------------------------------------------- /src/assets/fonts/Digital-Dismay.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/src/assets/fonts/Digital-Dismay.woff2 -------------------------------------------------------------------------------- /src/assets/variables.sass: -------------------------------------------------------------------------------- 1 | 2 | 3 | $backgroundMainColor: #008381 4 | 5 | $borderPrimaryColor: #ffffff 6 | $borderSecondaryColor: #7b7b7b -------------------------------------------------------------------------------- /src/components/Game/Header/Emoji/assets/images/cool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/src/components/Game/Header/Emoji/assets/images/cool.png -------------------------------------------------------------------------------- /src/components/Game/Header/Emoji/assets/images/dead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/src/components/Game/Header/Emoji/assets/images/dead.png -------------------------------------------------------------------------------- /src/components/Game/Header/Emoji/assets/images/smile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/src/components/Game/Header/Emoji/assets/images/smile.png -------------------------------------------------------------------------------- /src/components/Menu/Help/assets/styles.sass: -------------------------------------------------------------------------------- 1 | .about 2 | width: 300px 3 | font-size: 14px 4 | text-align: center 5 | 6 | &__link 7 | color: #000 8 | -------------------------------------------------------------------------------- /src/components/Game/Header/Emoji/assets/images/sweating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ufocoder/minesweeper/HEAD/src/components/Game/Header/Emoji/assets/images/sweating.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | end_of_line = crlf 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /src/components/App/assets/styles.sass: -------------------------------------------------------------------------------- 1 | .wrapper 2 | display: table-cell 3 | width: 100vw 4 | height: 100vh 5 | vertical-align: middle 6 | 7 | .app 8 | display: flex 9 | justify-content: center 10 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/components/Window/assets/images/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/mixins.sass: -------------------------------------------------------------------------------- 1 | @mixin borders($size, $topLeftColor, $bottomRightColor) 2 | border-width: $size 3 | border-style: solid 4 | border-left-color: $topLeftColor 5 | border-top-color: $topLeftColor 6 | border-right-color: $bottomRightColor 7 | border-bottom-color: $bottomRightColor -------------------------------------------------------------------------------- /src/components/Menu/assets/styles.sass: -------------------------------------------------------------------------------- 1 | .menu 2 | display: flex 3 | flex-direction: row 4 | 5 | &__item 6 | font-size: 14px 7 | background: transparent 8 | border: 0 9 | 10 | &:focus 11 | margin-bottom: -1px 12 | outline: none 13 | background: rgba(0,0,0, 0.1) -------------------------------------------------------------------------------- /src/components/Game/Header/Scoreboard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './assets/styles.sass'; 3 | 4 | interface ScoreboardProps { 5 | score: number; 6 | } 7 | 8 | const Scoreboard = ({ score }: ScoreboardProps) =>
{score}
; 9 | 10 | export default Scoreboard; 11 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './components/App'; 5 | 6 | import './assets/global.sass'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root-app'), 13 | ); 14 | -------------------------------------------------------------------------------- /src/context/lib/presets.json: -------------------------------------------------------------------------------- 1 | { 2 | "easy": { 3 | "rows": 10, 4 | "cols": 10, 5 | "mines": 10 6 | }, 7 | "medium": { 8 | "rows": 16, 9 | "cols": 16, 10 | "mines": 40 11 | }, 12 | "hard": { 13 | "rows": 30, 14 | "cols": 30, 15 | "mines": 100 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/components/Modal/assets/styles.sass: -------------------------------------------------------------------------------- 1 | 2 | $modalOverlayBackgroundColor: rgba(0, 0, 0, .45) 3 | 4 | .modal 5 | position: absolute 6 | display: flex 7 | width: 100vw 8 | height: 100vh 9 | align-items: center 10 | justify-content: center 11 | left: 0 12 | top: 0 13 | z-index: 9 14 | 15 | &__overlay 16 | position: absolute 17 | background: $modalOverlayBackgroundColor 18 | top: 0 19 | left: 0 20 | width: 100% 21 | height: 100% 22 | 23 | &__content 24 | z-index: 10 -------------------------------------------------------------------------------- /src/components/Game/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Header from './Header'; 4 | import Board from './Board'; 5 | 6 | import './assets/styles.sass'; 7 | 8 | const Game = () => { 9 | return ( 10 |
11 |
12 |
13 |
14 | 15 |
16 | 17 |
18 |
19 | ); 20 | }; 21 | 22 | export default Game; 23 | -------------------------------------------------------------------------------- /src/assets/global.sass: -------------------------------------------------------------------------------- 1 | @import './variables.sass'; 2 | 3 | @font-face 4 | font-family: Digital Dismay 5 | src: url("./fonts/Digital-Dismay.woff") format("woff"), url("./fonts/Digital-Dismay.woff2") format("woff2") 6 | 7 | @font-face 8 | font-family: prstartk 9 | src: url("./fonts/prstartk.woff") format("woff"), url("./fonts/prstartk.woff2") format("woff2") 10 | 11 | *, 12 | *::before, 13 | *::after 14 | box-sizing: border-box 15 | 16 | body 17 | margin: 0 18 | background-color: $backgroundMainColor 19 | font-family: Arial, Helvetica, sans-serif 20 | 21 | -------------------------------------------------------------------------------- /src/components/Game/assets/styles.sass: -------------------------------------------------------------------------------- 1 | @import 'src/assets/mixins.sass'; 2 | @import 'src/assets/variables.sass'; 3 | 4 | $gameBorderSize: 2px 5 | $gameBackground: #b3b3b3 6 | 7 | .game 8 | @include borders($gameBorderSize, $borderPrimaryColor, $borderSecondaryColor) 9 | 10 | display: flex 11 | flex-direction: column 12 | background: $gameBackground 13 | user-select: none 14 | 15 | &__header 16 | display: flex 17 | flex-direction: row 18 | justify-content: space-between 19 | align-items: center 20 | padding: 4px 21 | margin-bottom: 7px 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Minesweeper", 3 | "name": "Minesweeper Game", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Game/Board/assets/images/flag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minesweeper 2 | [![Build Status](https://travis-ci.org/ufocoder/minesweeper.svg?branch=master)](https://travis-ci.org/ufocoder/minesweeper) 3 | 4 | A minesweeper game. React application with render optimizations. 5 | Build with [Create React App](https://github.com/facebook/create-react-app), [Typescript](https://www.typescriptlang.org/), [Sass](https://sass-lang.com/). 6 | 7 | ## How to run locally 8 | 9 | ``` 10 | git clone git@github.com:ufocoder/minesweeper.git 11 | npm install 12 | npm run start 13 | ``` 14 | 15 | ## Demo 16 | 17 | You can play online [here](https://minesweeper.ufocoder.vercel.app/) 18 | 19 | ![](./screenshot.png) 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "target": "es5", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react" 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Game/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useWorldDispatch, useWorldState } from 'src/context/world'; 3 | 4 | import EmojiStatus from './Emoji'; 5 | import Scoreboard from './Scoreboard'; 6 | 7 | const Header = () => { 8 | const { 9 | preset: { mines }, 10 | marked, 11 | status, 12 | } = useWorldState(); 13 | 14 | const { resetWorld } = useWorldDispatch(); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default Header; 26 | -------------------------------------------------------------------------------- /src/components/Game/Header/Emoji/assets/styles.sass: -------------------------------------------------------------------------------- 1 | @import 'src/assets/mixins.sass'; 2 | @import 'src/assets/variables.sass'; 3 | 4 | $emojiSize: 40px 5 | $emojiBorderSize: 4px 6 | $emojiBackground: #c0c0c0x 7 | 8 | .emoji-status 9 | @include borders($emojiBorderSize, $borderPrimaryColor, $borderSecondaryColor) 10 | 11 | background: $emojiBackground 12 | height: $emojiSize 13 | width: $emojiSize 14 | padding: 4px 15 | 16 | &__image 17 | height: 100% 18 | user-select: none 19 | 20 | &:active 21 | @include borders($emojiBorderSize, $borderSecondaryColor, $borderPrimaryColor) 22 | 23 | .emoji-status__image 24 | margin: 1px 0px 0px 1px 25 | 26 | &:hover 27 | cursor: pointer 28 | -------------------------------------------------------------------------------- /src/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Game from 'src/components/Game'; 3 | import Menu from 'src/components/Menu'; 4 | import Window from 'src/components/Window'; 5 | import { WorldProvider } from 'src/context/world'; 6 | 7 | import './assets/styles.sass'; 8 | 9 | const App = () => { 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /src/components/Menu/Help/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './assets/styles.sass'; 4 | 5 | const Help = () => { 6 | return ( 7 |
8 |

You have to discover all the free squares without exploding the mines in the grid.

9 |

10 | 11 | Read on wikipedia 12 | 13 |

14 |

15 | 16 | Sources 17 | 18 |

19 |
20 | ); 21 | }; 22 | 23 | export default Help; 24 | -------------------------------------------------------------------------------- /src/components/Game/Header/Scoreboard/assets/styles.sass: -------------------------------------------------------------------------------- 1 | @import 'src/assets/mixins.sass'; 2 | @import 'src/assets/variables.sass'; 3 | 4 | $scoreBorderSize: 1px 5 | $scoreHeight: 50px 6 | $scoreFontColor: #ff0101 7 | $scoreShadowColor: #510402 8 | 9 | .scoreboard 10 | @include borders($scoreBorderSize, $borderPrimaryColor, $borderPrimaryColor) 11 | 12 | background: #010000 13 | font-family: Digital Dismay 14 | font-size: $scoreHeight 15 | line-height: $scoreHeight 16 | color: $scoreFontColor 17 | position: relative 18 | height: $scoreHeight 19 | width: $scoreHeight * 1.5 20 | text-align: right 21 | z-index: 1 22 | 23 | &::after 24 | position: absolute 25 | text-align: right 26 | right: 0 27 | top: 0 28 | width: 100% 29 | color: $scoreShadowColor 30 | z-index: -1 31 | content: "888" -------------------------------------------------------------------------------- /src/components/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useMemo } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import './assets/styles.sass'; 5 | 6 | const rootModalElement = document.getElementById('root-modal'); 7 | 8 | const Modal: FC = ({ children }) => { 9 | const element = useMemo(() => document.createElement('div'), []); 10 | 11 | useEffect(() => { 12 | rootModalElement!.appendChild(element); 13 | 14 | return () => { 15 | rootModalElement!.removeChild(element); 16 | }; 17 | }, [element]); 18 | 19 | return ReactDOM.createPortal( 20 |
21 |
{children}
22 |
23 |
, 24 | element, 25 | ); 26 | }; 27 | 28 | export default Modal; 29 | -------------------------------------------------------------------------------- /src/components/Game/Header/Emoji/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Status } from 'src/types'; 4 | 5 | import './assets/styles.sass'; 6 | 7 | interface StatusProps { 8 | status: Status; 9 | onClick: () => void; 10 | } 11 | 12 | type EmojiSrc = { 13 | [key in Status]: string; 14 | }; 15 | 16 | const emojiSrc: EmojiSrc = { 17 | alive: require('./assets/images/smile.png').default, 18 | touch: require('./assets/images/sweating.png').default, 19 | dead: require('./assets/images/dead.png').default, 20 | win: require('./assets/images/cool.png').default, 21 | }; 22 | 23 | const EmojiStatus = ({ status, onClick }: StatusProps) => { 24 | return ( 25 |
26 | {`You 27 |
28 | ); 29 | }; 30 | 31 | export default EmojiStatus; 32 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Preset = { 2 | rows: number; 3 | cols: number; 4 | mines: number; 5 | }; 6 | 7 | export type PresetMap = { 8 | [key: string]: Preset; 9 | }; 10 | 11 | export enum Status { 12 | alive = 'alive', 13 | touch = 'touch', 14 | dead = 'dead', 15 | win = 'win', 16 | } 17 | 18 | export type Position = { 19 | x: number; 20 | y: number; 21 | }; 22 | 23 | export enum Content { 24 | blank = 'blank', 25 | digit = 'digit', 26 | bombed = 'bombed', 27 | } 28 | 29 | export enum Visibility { 30 | marked = 'marked', 31 | visible = 'visible', 32 | hidden = 'hidden', 33 | } 34 | 35 | export type Cell = { 36 | content: Content; 37 | visibility: Visibility; 38 | digit?: number; 39 | }; 40 | 41 | export type Board = Cell[][]; 42 | 43 | export type World = { 44 | marked: number; 45 | hidden: number; 46 | tries: number; 47 | readonly preset: Preset; 48 | board: Cell[][]; 49 | status: Status; 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/assets/styles.sass: -------------------------------------------------------------------------------- 1 | @import 'src/assets/mixins.sass'; 2 | @import 'src/assets/variables.sass'; 3 | 4 | 5 | $formFieldHeight: 20px 6 | $formFieldLabelWidth: 120px 7 | $formButtonBorderSize: 2px 8 | 9 | 10 | .settings 11 | &__legend 12 | font-size: 14px 13 | 14 | &__comment 15 | font-size: 13px 16 | margin-bottom: 10px 17 | 18 | 19 | &__fieldset 20 | margin-bottom: 10px 21 | 22 | .settings-form 23 | &__field 24 | display: flex 25 | flex-direction: row 26 | margin-bottom: 10px 27 | 28 | &__field-label 29 | flex-basis: $formFieldLabelWidth 30 | font-size: 12px 31 | line-height: $formFieldHeight 32 | 33 | &__field-input 34 | flex: 1 35 | width: 100% 36 | font-size: 14px 37 | line-height: $formFieldHeight 38 | height: $formFieldHeight 39 | 40 | &__buttons 41 | display: flex 42 | justify-content: flex-end 43 | 44 | &__button 45 | @include borders($formButtonBorderSize, $borderPrimaryColor, $borderSecondaryColor) 46 | 47 | margin-right: 5px 48 | 49 | &:last-child 50 | margin-right: 0 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minesweeper", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/classnames": "^2.2.11", 10 | "@types/react": "^16.9.56", 11 | "@types/react-dom": "^16.9.9", 12 | "classnames": "^2.2.6", 13 | "immer": "^7.0.14", 14 | "node-sass": "^4.0.0", 15 | "react": "^17.0.1", 16 | "react-dom": "^17.0.1", 17 | "react-scripts": "4.0.0", 18 | "typescript": "^4.0.5" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | Minesweeper 15 | 16 | 17 | 18 |
19 |
20 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/context/lib/__tests__/board-generator.test.ts: -------------------------------------------------------------------------------- 1 | import { Content, Visibility } from 'src/types'; 2 | import { createBoard } from '../board-generator'; 3 | 4 | const generateNumber = (min: number, max: number) => min + Math.round(Math.random() * (max - min)); 5 | 6 | describe('Board generator', () => { 7 | describe('Method `createBoard`', () => { 8 | const preset = { cols: 5, rows: 5, mines: 5 }; 9 | 10 | test('base usage', () => { 11 | const board = createBoard(preset); 12 | 13 | expect(board.length).toEqual(preset.rows); 14 | 15 | board.forEach((row) => { 16 | expect(row.length).toEqual(preset.cols); 17 | 18 | row.forEach((cell) => { 19 | expect(cell.visibility).toEqual(Visibility.hidden); 20 | }); 21 | }); 22 | }); 23 | 24 | test('exclude specified position', () => { 25 | const position = { 26 | x: generateNumber(0, preset.cols - 1), 27 | y: generateNumber(0, preset.rows - 1), 28 | }; 29 | 30 | const board = createBoard(preset, position); 31 | 32 | expect(board[position.y][position.x].content).not.toEqual(Content.bombed); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/Window/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react'; 2 | 3 | import './assets/styles.sass'; 4 | 5 | interface WindowProps { 6 | title: string; 7 | style?: React.CSSProperties; 8 | onClose?: () => void; 9 | } 10 | 11 | const Window: FC = ({ title, children, style, onClose }) => { 12 | useEffect(() => { 13 | const hanldeDocumentKeydown = (e: KeyboardEvent) => { 14 | if (e.key === 'Escape') { 15 | onClose!(); 16 | } 17 | }; 18 | 19 | if (onClose) { 20 | document.addEventListener('keydown', hanldeDocumentKeydown); 21 | } 22 | 23 | return () => { 24 | if (onClose) { 25 | document.removeEventListener('keydown', hanldeDocumentKeydown); 26 | } 27 | }; 28 | }, [onClose]); 29 | 30 | return ( 31 |
32 |
33 |
{title}
34 | {onClose ? ( 35 |
36 | 37 |
38 | ) : null} 39 |
40 |
41 | {children} 42 |
43 |
44 | ); 45 | }; 46 | 47 | export default Window; 48 | -------------------------------------------------------------------------------- /src/components/Game/Board/assets/images/mine.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/Window/assets/styles.sass: -------------------------------------------------------------------------------- 1 | @import 'src/assets/mixins.sass'; 2 | @import 'src/assets/variables.sass'; 3 | 4 | $windowBorderSize: 2px 5 | $windowBackgroundColor: #b3b3b3 6 | 7 | $windowHeaderHeight: 20px 8 | $windowHeaderBackgroundColor: linear-gradient(90deg, navy, #1084d0) 9 | $windowHeaderFontColor: #ffffff 10 | $windowHeaderFontSize: 12px 11 | $windowMenuFontSize: 14px 12 | 13 | .window 14 | @include borders($windowBorderSize, $borderPrimaryColor, $borderSecondaryColor) 15 | 16 | display: flex 17 | flex-direction: column 18 | background: $windowBackgroundColor 19 | user-select: none 20 | 21 | &__header 22 | background: $windowHeaderBackgroundColor 23 | color: $windowHeaderFontColor 24 | padding: 3px 0px 3px 5px 25 | height: $windowHeaderHeight 26 | display: flex 27 | justify-content: space-between 28 | align-items: center 29 | 30 | &__header-text 31 | font-size: $windowHeaderFontSize 32 | font-family: sans-serif 33 | 34 | &__button-close 35 | border: 0 36 | background: silver 37 | box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf 38 | background-image: url("./images/close.svg") 39 | background-repeat: no-repeat 40 | background-position: center center 41 | height: $windowHeaderHeight 42 | width: $windowHeaderHeight 43 | float: right 44 | 45 | &:focus 46 | outline: none 47 | 48 | &__menu 49 | display: flex 50 | flex-direction: row 51 | 52 | &__item 53 | font-size: $windowMenuFontSize 54 | margin-right: 5px 55 | 56 | &:hover 57 | cursor: pointer 58 | 59 | &::first-letter 60 | text-decoration: underline 61 | -------------------------------------------------------------------------------- /src/components/Menu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { useWorldDispatch } from 'src/context/world'; 4 | import Modal from 'src/components/Modal'; 5 | import Help from 'src/components/Menu/Help'; 6 | import Settings from 'src/components/Menu/Settings'; 7 | import Window from 'src/components/Window'; 8 | import presets from 'src/context/lib/presets.json'; 9 | 10 | import './assets/styles.sass'; 11 | 12 | const Menu = () => { 13 | const [showSettingsModal, setShowSettingsModal] = useState(false); 14 | const [showHelpModal, setShowHelpModal] = useState(false); 15 | const { createWorld } = useWorldDispatch(); 16 | 17 | return ( 18 | <> 19 |
20 | 23 | 26 |
27 | 28 | {showSettingsModal && ( 29 | 30 | setShowSettingsModal(false)}> 31 | { 34 | setShowSettingsModal(false); 35 | createWorld(preset); 36 | }} 37 | onCancel={() => setShowSettingsModal(false)} 38 | /> 39 | 40 | 41 | )} 42 | 43 | {showHelpModal && ( 44 | 45 | setShowHelpModal(false)}> 46 | 47 | 48 | 49 | )} 50 | 51 | ); 52 | }; 53 | 54 | export default Menu; 55 | -------------------------------------------------------------------------------- /src/components/Game/Board/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import { useWorldState } from 'src/context/world'; 5 | import { useBoard } from './hooks'; 6 | import { Cell, Content, Status, Visibility } from 'src/types'; 7 | 8 | import './assets/styles.sass'; 9 | 10 | interface BroardCellProps { 11 | x: number; 12 | y: number; 13 | cell: Cell; 14 | } 15 | 16 | const BroardCell = React.memo(({ x, y, cell }: BroardCellProps) => { 17 | return ( 18 | 31 | {cell.visibility === Visibility.visible && cell.content === Content.digit && cell.digit} 32 | 33 | ); 34 | }); 35 | 36 | interface BroardRowProps { 37 | row: Cell[]; 38 | y: number; 39 | } 40 | 41 | const BoardRow = React.memo(({ row, y }: BroardRowProps) => ( 42 | 43 | {row.map((cell, x) => ( 44 | 45 | ))} 46 | 47 | )); 48 | 49 | const Board = () => { 50 | const { board, status } = useWorldState(); 51 | 52 | useBoard(); 53 | 54 | return ( 55 | 60 | 61 | {board.map((row, y) => ( 62 | 63 | ))} 64 | 65 |
66 | ); 67 | }; 68 | 69 | export default Board; 70 | -------------------------------------------------------------------------------- /src/context/lib/board-generator.ts: -------------------------------------------------------------------------------- 1 | import { Board, Content, Visibility, Preset, Position } from 'src/types'; 2 | 3 | const createRandomNumberSet = (max: number, limit: number, exclude?: number) => { 4 | const numbers = Array.from(Array(max)).map((_, key) => key); 5 | 6 | for (let i = numbers.length - 1; i > 0; i--) { 7 | const j = Math.floor(Math.random() * (i + 1)); 8 | [numbers[i], numbers[j]] = [numbers[j], numbers[i]]; 9 | } 10 | 11 | if (exclude !== undefined) { 12 | const index = numbers.indexOf(exclude); 13 | if (index !== -1) { 14 | numbers.splice(index, 1); 15 | } 16 | } 17 | 18 | return new Set(numbers.splice(0, limit)); 19 | }; 20 | 21 | const calculateDigits = (x: number, y: number, board: Board) => { 22 | const positions = [ 23 | [y - 1, x - 1], 24 | [y - 1, x], 25 | [y - 1, x + 1], 26 | [y, x - 1], 27 | [y, x + 1], 28 | [y + 1, x - 1], 29 | [y + 1, x], 30 | [y + 1, x + 1], 31 | ]; 32 | 33 | return positions.reduce((neighbors, [y, x]) => { 34 | if (board[y] && board[y][x] && board[y][x].content === Content.bombed) { 35 | return neighbors + 1; 36 | } 37 | 38 | return neighbors; 39 | }, 0); 40 | }; 41 | 42 | export const createBoard = ({ rows, cols, mines }: Preset, position?: Position): Board => { 43 | const bombedNumbers = position 44 | ? createRandomNumberSet(rows * cols, mines, cols * position.y + position.x) 45 | : createRandomNumberSet(rows * cols, mines); 46 | 47 | const board = Array.from(Array(rows), (_, y) => 48 | Array.from(Array(cols)).map((_, x) => { 49 | return { 50 | content: bombedNumbers.has(y * cols + x) ? Content.bombed : Content.blank, 51 | visibility: Visibility.hidden, 52 | }; 53 | }), 54 | ) as Board; 55 | 56 | board.forEach((row, y) => 57 | row.forEach((cell, x) => { 58 | if (cell.content === Content.bombed) { 59 | return; 60 | } 61 | 62 | const neighbors = calculateDigits(x, y, board); 63 | 64 | if (neighbors) { 65 | cell.content = Content.digit; 66 | cell.digit = neighbors; 67 | } 68 | }), 69 | ); 70 | 71 | return board; 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/Game/Board/assets/styles.sass: -------------------------------------------------------------------------------- 1 | @import 'src/assets/mixins.sass'; 2 | @import 'src/assets/variables.sass'; 3 | 4 | $boardBorderSize: 1px 5 | $boardBackground: #7b7b7b 6 | 7 | $cellSize: 27px 8 | $cellBorderSize: 3px 9 | $cellBackground: #bdbdbd 10 | $cellHoverBackground: #b1b1b1 11 | $cellFontSize: 12px 12 | 13 | $cellColorDigit1: #2000ff 14 | $cellColorDigit2: #057b03 15 | $cellColorDigit3: #ff0101 16 | $cellColorDigit4: #09007b 17 | $cellColorDigit5: #7b0100 18 | $cellColorDigit6: #057b7b 19 | $cellColorDigit7: #000000 20 | $cellColorDigit8: #7b7b7b 21 | 22 | .board 23 | border-spacing: 0 24 | border-collapse: separate 25 | border: $boardBorderSize solid $boardBackground 26 | 27 | &--active 28 | .board__cell--hidden 29 | &:hover 30 | background: $cellHoverBackground 31 | cursor: pointer 32 | 33 | .board__cell--marked 34 | &:hover 35 | cursor: pointer 36 | 37 | &__cell 38 | background: $cellBackground 39 | width: $cellSize 40 | height: $cellSize 41 | min-width: $cellSize 42 | min-height: $cellSize 43 | line-height: $cellSize - ($cellBorderSize + $boardBorderSize) * 2 44 | font-size: $cellFontSize 45 | font-family: prstartk 46 | text-align: center 47 | 48 | &--visible 49 | border: $boardBorderSize solid $boardBackground 50 | border-top: 0 51 | border-left: 0 52 | padding-top: $cellBorderSize - $boardBorderSize 53 | padding-left: $cellBorderSize - $boardBorderSize 54 | 55 | &--bombed 56 | background: url('./images/mine.svg') center center no-repeat 57 | background-size: $cellSize * 3 / 4 58 | 59 | &--marked 60 | @include borders($cellBorderSize, $borderPrimaryColor, $borderSecondaryColor) 61 | 62 | background: url('./images/flag.svg') center center no-repeat 63 | background-size: $cellSize * 1 / 2 64 | 65 | &--hidden 66 | @include borders($cellBorderSize, $borderPrimaryColor, $borderSecondaryColor) 67 | 68 | &--digit-1 69 | color: $cellColorDigit1 70 | 71 | &--digit-2 72 | color: $cellColorDigit2 73 | 74 | &--digit-3 75 | color: $cellColorDigit3 76 | 77 | &--digit-4 78 | color: $cellColorDigit4 79 | 80 | &--digit-5 81 | color: $cellColorDigit5 82 | 83 | &--digit-6 84 | color: $cellColorDigit6 85 | 86 | &--digit-7 87 | color: $cellColorDigit7 88 | 89 | &--digit-8 90 | color: $cellColorDigit8 91 | 92 | 93 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/Game/Board/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useWorldDispatch, useWorldState } from 'src/context/world'; 3 | import { Position, Status } from 'src/types'; 4 | 5 | const MOUSE_LEFT_BUTTON = 0; 6 | const MOUSE_RIGHT_BUTTON = 2; 7 | 8 | const isСellularElement = (element: HTMLElement) => element.hasAttribute('data-cell'); 9 | 10 | const extractPosition = (element: HTMLElement) => ({ 11 | x: parseInt(element.getAttribute('data-x') || '0', 10), 12 | y: parseInt(element.getAttribute('data-y') || '0', 10), 13 | }); 14 | 15 | export type UseBoardProps = { 16 | status: Status; 17 | onBoardUntouch: () => void; 18 | onBoardTouch: () => void; 19 | onCellMark: (position: Position) => void; 20 | onCellOpen: (position: Position) => void; 21 | }; 22 | 23 | export const useBoard = () => { 24 | const { status } = useWorldState(); 25 | const { touchBoard, untouchBoard, openBoardCell, markBoardCell } = useWorldDispatch(); 26 | 27 | useEffect(() => { 28 | const handler = window.oncontextmenu; 29 | 30 | window.oncontextmenu = (e: MouseEvent) => { 31 | const element = e.target as HTMLElement; 32 | if (isСellularElement(element)) { 33 | return false; 34 | } 35 | }; 36 | 37 | return () => { 38 | window.oncontextmenu = handler; 39 | }; 40 | }, []); 41 | 42 | useEffect(() => { 43 | const handleDocumentMouseDown = (e: MouseEvent) => { 44 | const element = e.target as HTMLElement; 45 | 46 | if (status !== Status.alive) { 47 | return; 48 | } 49 | 50 | if (isСellularElement(element)) { 51 | touchBoard(); 52 | } 53 | }; 54 | 55 | document.addEventListener('mousedown', handleDocumentMouseDown); 56 | 57 | return () => { 58 | document.removeEventListener('mousedown', handleDocumentMouseDown); 59 | }; 60 | }, [status, touchBoard]); 61 | 62 | useEffect(() => { 63 | const handleDocumentMouseUp = (e: MouseEvent) => { 64 | const element = e.target as HTMLElement; 65 | 66 | if (status !== Status.touch) { 67 | return; 68 | } 69 | 70 | untouchBoard(); 71 | 72 | if (isСellularElement(element)) { 73 | const position = extractPosition(element); 74 | 75 | if (e.button === MOUSE_RIGHT_BUTTON) { 76 | markBoardCell(position); 77 | return; 78 | } 79 | 80 | if (e.button === MOUSE_LEFT_BUTTON) { 81 | openBoardCell(position); 82 | return; 83 | } 84 | } 85 | }; 86 | 87 | document.addEventListener('mouseup', handleDocumentMouseUp); 88 | 89 | return () => { 90 | document.removeEventListener('mouseup', handleDocumentMouseUp); 91 | }; 92 | }, [status, markBoardCell, openBoardCell, untouchBoard]); 93 | }; 94 | -------------------------------------------------------------------------------- /src/context/world.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, createContext, useContext, useReducer } from 'react'; 2 | import { createWorld, markBoardCell, openBoardCell, resetWorld, touchWorld, untouchWorld } from 'src/context/lib/game'; 3 | import { World, Preset, Position } from 'src/types'; 4 | 5 | import presets from 'src/context/lib/presets.json'; 6 | 7 | const WorldStateContext = createContext(undefined); 8 | const WorldDispatchContext = createContext(undefined); 9 | 10 | type Action = 11 | | { type: 'createWorld'; payload: { preset: Preset } } 12 | | { type: 'resetWorld' } 13 | | { type: 'touchWorld' } 14 | | { type: 'untouchWorld' } 15 | | { type: 'markBoardCell'; payload: { position: Position } } 16 | | { type: 'openBoardCell'; payload: { position: Position } }; 17 | 18 | type Dispatch = (action: Action) => void; 19 | 20 | function worldReducer(state: World, action: Action) { 21 | switch (action.type) { 22 | case 'createWorld': 23 | return createWorld(action.payload.preset); 24 | 25 | case 'resetWorld': 26 | return resetWorld(state); 27 | 28 | case 'touchWorld': 29 | return touchWorld(state); 30 | 31 | case 'untouchWorld': 32 | return untouchWorld(state); 33 | 34 | case 'markBoardCell': 35 | return markBoardCell(state, action.payload.position); 36 | 37 | case 'openBoardCell': 38 | return openBoardCell(state, action.payload.position); 39 | 40 | default: 41 | throw new Error(`Unhandled action`); 42 | } 43 | } 44 | 45 | const useWorldState = () => { 46 | const context = useContext(WorldStateContext); 47 | 48 | if (context === undefined) { 49 | throw new Error('useWorldState must be used within a WorldProvider'); 50 | } 51 | 52 | return context; 53 | }; 54 | 55 | const useWorldDispatch = () => { 56 | const dispatch = useContext(WorldDispatchContext); 57 | 58 | if (dispatch === undefined) { 59 | throw new Error('useWorldDispatch must be used within a WorldProvider'); 60 | } 61 | 62 | return { 63 | createWorld: (preset: Preset) => dispatch({ type: 'createWorld', payload: { preset } }), 64 | resetWorld: () => dispatch({ type: 'resetWorld' }), 65 | touchBoard: () => dispatch({ type: 'touchWorld' }), 66 | untouchBoard: () => dispatch({ type: 'untouchWorld' }), 67 | openBoardCell: (position: Position) => dispatch({ type: 'openBoardCell', payload: { position } }), 68 | markBoardCell: (position: Position) => dispatch({ type: 'markBoardCell', payload: { position } }), 69 | }; 70 | }; 71 | 72 | const WorldProvider: FC = ({ children }) => { 73 | const [state, dispatch] = useReducer(worldReducer, createWorld(presets.easy)); 74 | 75 | return ( 76 | 77 | {children} 78 | 79 | ); 80 | }; 81 | 82 | export { WorldProvider, useWorldState, useWorldDispatch }; 83 | -------------------------------------------------------------------------------- /src/components/Menu/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | import presets from 'src/context/lib/presets.json'; 4 | import './assets/styles.sass'; 5 | 6 | interface Values { 7 | rows: number; 8 | cols: number; 9 | mines: number; 10 | } 11 | 12 | interface FieldProps { 13 | label: string; 14 | value: number; 15 | onChange: (value: string) => void; 16 | } 17 | 18 | const MAX_ROWS = 100; 19 | const MAX_COLS = 100; 20 | 21 | const MAX_LENGTH = 3; 22 | 23 | const Field = ({ label, value, onChange }: FieldProps) => { 24 | return ( 25 | 35 | ); 36 | }; 37 | 38 | interface SettingsProps { 39 | values: Values; 40 | onCancel: () => void; 41 | onSubmit: (values: Values) => void; 42 | } 43 | 44 | const FormSettings = ({ values, onCancel, onSubmit }: SettingsProps) => { 45 | const [formValues, setFormValues] = useState({ ...values }); 46 | const maxMines = Math.max(formValues.rows * formValues.cols - 1, 0); 47 | const canBeSubmitted = formValues.mines > 0; 48 | 49 | const handleFormSubmit = useCallback( 50 | (e: React.FormEvent) => { 51 | e.preventDefault(); 52 | onSubmit(formValues); 53 | }, 54 | [onSubmit, formValues], 55 | ); 56 | 57 | const handleCancelClick = useCallback( 58 | (e) => { 59 | e.preventDefault(); 60 | onCancel(); 61 | }, 62 | [onCancel], 63 | ); 64 | 65 | const createFieldChangeHandler = (fieldName: string, limit: number) => (fieldValue: string) => { 66 | const newFormValues = { 67 | ...formValues, 68 | [fieldName]: Math.max(Math.min(parseInt(fieldValue, 10) || 0, limit), 0), 69 | }; 70 | 71 | if (maxMines > newFormValues.rows * newFormValues.cols - 1) { 72 | newFormValues.mines = Math.max(newFormValues.rows * newFormValues.cols - 1, 0); 73 | } 74 | 75 | setFormValues(newFormValues); 76 | }; 77 | 78 | return ( 79 | <> 80 |
81 | Choose preset 82 | 83 | 86 | 89 | 92 |
93 | 94 |
95 | Create own preset 96 | 97 |
98 | 103 | 108 | 113 | 114 |
115 | 118 | 121 |
122 | 123 |
124 | 125 | ); 126 | }; 127 | 128 | export default FormSettings; 129 | -------------------------------------------------------------------------------- /src/context/lib/game.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | 3 | import { World, Visibility, Preset, Status, Position, Content } from 'src/types'; 4 | import { createBoard } from './board-generator'; 5 | 6 | const isCellExists = (x: number, y: number, world: World): boolean => Boolean(world.board[y] && world.board[y][x]); 7 | 8 | export const createWorld = (preset: Preset): World => ({ 9 | preset, 10 | tries: 0, 11 | marked: 0, 12 | hidden: preset.rows * preset.cols, 13 | status: Status.alive, 14 | board: createBoard(preset), 15 | }); 16 | 17 | export const resetWorld = (world: World): World => createWorld(world.preset); 18 | 19 | export const touchWorld = (world: World): World => 20 | produce(world, (draftWorld) => { 21 | if (draftWorld.status === Status.alive) { 22 | draftWorld.status = Status.touch; 23 | } 24 | }); 25 | 26 | export const untouchWorld = (world: World): World => 27 | produce(world, (draftWorld) => { 28 | if (draftWorld.status === Status.touch) { 29 | draftWorld.status = Status.alive; 30 | } 31 | }); 32 | 33 | export const markBoardCell = (world: World, { x, y }: Position): World => 34 | produce(world, (draftWorld) => { 35 | if (world.status !== Status.alive) { 36 | return; 37 | } 38 | 39 | if (!isCellExists(x, y, world)) { 40 | return; 41 | } 42 | 43 | const cell = draftWorld.board[y][x]; 44 | 45 | if (cell.visibility === Visibility.visible) { 46 | return; 47 | } 48 | 49 | if (cell.visibility === Visibility.marked) { 50 | draftWorld.marked -= 1; 51 | draftWorld.board[y][x].visibility = Visibility.hidden; 52 | 53 | return; 54 | } 55 | 56 | if (cell.visibility === Visibility.hidden && draftWorld.marked < draftWorld.preset.mines) { 57 | draftWorld.marked += 1; 58 | draftWorld.board[y][x].visibility = Visibility.marked; 59 | 60 | return; 61 | } 62 | }); 63 | 64 | const getNeigborsPositions = (x: number, y: number): [number, number][] => [ 65 | [y - 1, x - 1], 66 | [y - 1, x], 67 | [y - 1, x + 1], 68 | [y, x - 1], 69 | [y, x + 1], 70 | [y + 1, x - 1], 71 | [y + 1, x], 72 | [y + 1, x + 1], 73 | ]; 74 | 75 | const revealNeighbors = (x: number, y: number, world: World) => { 76 | let queuePositions: [number, number][] = [[x, y]]; 77 | 78 | while (queuePositions.length > 0) { 79 | const position = queuePositions.shift(); 80 | 81 | if (!position) { 82 | continue; 83 | } 84 | 85 | if (!isCellExists(position[0], position[1], world)) { 86 | continue; 87 | } 88 | 89 | const cell = world.board[position[1]][position[0]]; 90 | 91 | if (cell.visibility === Visibility.visible || cell.visibility === Visibility.marked) { 92 | continue; 93 | } 94 | 95 | if (cell.content === Content.digit) { 96 | cell.visibility = Visibility.visible; 97 | 98 | world.hidden -= 1; 99 | } 100 | 101 | if (cell.content === Content.blank) { 102 | cell.visibility = Visibility.visible; 103 | world.hidden -= 1; 104 | 105 | queuePositions = queuePositions.concat(getNeigborsPositions(position[1], position[0])); 106 | } 107 | } 108 | }; 109 | 110 | export const openBoardCell = (world: World, { x, y }: Position): World => 111 | produce(world, (draftWorld) => { 112 | if (world.status !== Status.alive) { 113 | return; 114 | } 115 | 116 | const drafCell = draftWorld.board[y][x]; 117 | 118 | if (draftWorld.status === Status.dead || draftWorld.status === Status.win) { 119 | return; 120 | } 121 | 122 | if (drafCell.visibility === Visibility.visible) { 123 | return; 124 | } 125 | 126 | if (drafCell.content === Content.bombed && world.tries > 0) { 127 | draftWorld.status = Status.dead; 128 | draftWorld.tries += 1; 129 | 130 | draftWorld.board.forEach((row) => 131 | row.forEach((cell) => { 132 | if (cell.content === Content.bombed) { 133 | cell.visibility = Visibility.visible; 134 | } 135 | }), 136 | ); 137 | 138 | return; 139 | } 140 | 141 | if (drafCell.content === Content.bombed) { 142 | draftWorld.board = createBoard(world.preset, { x, y }); 143 | } 144 | 145 | revealNeighbors(x, y, draftWorld); 146 | 147 | draftWorld.tries += 1; 148 | draftWorld.status = draftWorld.hidden === world.preset.mines ? Status.win : Status.alive; 149 | }); 150 | -------------------------------------------------------------------------------- /src/context/lib/__tests__/game.test.ts: -------------------------------------------------------------------------------- 1 | import { createWorld, markBoardCell, openBoardCell, resetWorld, touchWorld, untouchWorld } from 'src/context/lib/game'; 2 | import { Content, Position, Status, Visibility, World } from 'src/types'; 3 | 4 | const generateNumber = (min: number, max: number) => min + Math.round(Math.random() * (max - min)); 5 | 6 | describe('Game lib', () => { 7 | const preset = { rows: 10, cols: 10, mines: 10 }; 8 | 9 | describe('world methods', () => { 10 | test('Method `createWorld`', () => { 11 | const world = createWorld(preset); 12 | 13 | expect(world.preset).toEqual(preset); 14 | expect(world.marked).toEqual(0); 15 | expect(world.hidden).toEqual(preset.rows * preset.cols); 16 | expect(world.status).toEqual(Status.alive); 17 | }); 18 | 19 | test('Method `resetWorld`', () => { 20 | const world = createWorld(preset); 21 | 22 | world.status = Status.dead; 23 | 24 | expect(world).not.toEqual(resetWorld(world)); 25 | }); 26 | 27 | test('Method `touchWorld`', () => { 28 | const world = createWorld(preset); 29 | const touchedWorld = touchWorld(world); 30 | 31 | expect(touchedWorld.status).toEqual(Status.touch); 32 | }); 33 | 34 | test('Method `untouchWorld`', () => { 35 | const world = createWorld(preset); 36 | 37 | world.status = Status.touch; 38 | 39 | expect(untouchWorld(world).status).toEqual(Status.alive); 40 | }); 41 | }); 42 | 43 | describe('board methods', () => { 44 | test('Method `markBoardCell`', () => { 45 | const world = createWorld(preset); 46 | const position = { 47 | x: generateNumber(0, preset.cols - 1), 48 | y: generateNumber(0, preset.rows - 1), 49 | }; 50 | 51 | const newWorld = markBoardCell(world, position); 52 | const newCell = newWorld.board[position.y][position.x]; 53 | 54 | expect(newWorld.marked).toEqual(1); 55 | expect(newCell.visibility).toEqual(Visibility.marked); 56 | }); 57 | 58 | describe('Method `openBoardCell`', () => { 59 | const bomb = () => ({ content: Content.bombed, visibility: Visibility.hidden }); 60 | const blank = () => ({ content: Content.blank, visibility: Visibility.hidden }); 61 | const digit = (digit: number) => ({ content: Content.digit, visibility: Visibility.hidden, digit }); 62 | 63 | const originalWorld: World = { 64 | marked: 0, 65 | hidden: 16, 66 | tries: 0, 67 | preset: { rows: 4, cols: 4, mines: 4 }, 68 | board: [ 69 | [bomb(), digit(1), blank(), blank()], 70 | [digit(1), digit(1), digit(1), digit(1)], 71 | [digit(1), digit(1), digit(3), bomb()], 72 | [digit(1), bomb(), digit(3), bomb()], 73 | ], 74 | status: Status.alive, 75 | }; 76 | 77 | const copyWorld = () => JSON.parse(JSON.stringify(originalWorld)); 78 | 79 | test('open blank cell', () => { 80 | const position = { x: 3, y: 0 }; 81 | const world = copyWorld(); 82 | const newWorld = openBoardCell(world, position); 83 | 84 | expect(newWorld.tries).toEqual(1); 85 | expect(newWorld.status).toEqual(Status.alive); 86 | expect(newWorld.board[0][1].visibility).toEqual(Visibility.visible); 87 | expect(newWorld.board[0][2].visibility).toEqual(Visibility.visible); 88 | expect(newWorld.board[0][3].visibility).toEqual(Visibility.visible); 89 | expect(newWorld.board[1][1].visibility).toEqual(Visibility.visible); 90 | expect(newWorld.board[1][2].visibility).toEqual(Visibility.visible); 91 | expect(newWorld.board[1][3].visibility).toEqual(Visibility.visible); 92 | }); 93 | 94 | test('open digit cell', () => { 95 | const position = { x: 1, y: 0 }; 96 | const world = copyWorld(); 97 | const newWorld = openBoardCell(world, position); 98 | 99 | expect(newWorld.tries).toEqual(1); 100 | expect(newWorld.status).toEqual(Status.alive); 101 | expect(newWorld.board[0][1].visibility).toEqual(Visibility.visible); 102 | }); 103 | 104 | test('open bombed cell', () => { 105 | const position = { x: 0, y: 0 }; 106 | const world = copyWorld(); 107 | 108 | // recreate world after first try 109 | const newWorld = openBoardCell(world, position); 110 | const newCell = newWorld.board[position.y][position.x]; 111 | 112 | expect(newWorld.tries).toEqual(1); 113 | expect(newWorld.status).toEqual(Status.alive); 114 | expect(newCell.content).not.toEqual(Content.bombed); 115 | 116 | // dead after next try 117 | const bombPositions: Position[] = []; 118 | 119 | newWorld.board.forEach((row, y) => { 120 | row.forEach((cell, x) => { 121 | if (cell.content === Content.bombed) { 122 | bombPositions.push({ x, y }); 123 | } 124 | }); 125 | }); 126 | 127 | const deadWorld = openBoardCell(newWorld, bombPositions[0]); 128 | 129 | expect(deadWorld.tries).toEqual(2); 130 | expect(deadWorld.status).toEqual(Status.dead); 131 | 132 | bombPositions.forEach(({ x, y }) => { 133 | expect(deadWorld.board[y][x].visibility).toEqual(Visibility.visible); 134 | }); 135 | }); 136 | }); 137 | }); 138 | }); 139 | --------------------------------------------------------------------------------