├── 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 |
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Minesweeper
2 | [](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 | 
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 |
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 |

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 |
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 |
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 |
93 |
94 |
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 |
--------------------------------------------------------------------------------