From 71666bab95ba3414ed715fb58bcf77d89a6ec32f Mon Sep 17 00:00:00 2001 From: Bart Riemens Date: Sun, 3 Jan 2021 23:30:51 +0100 Subject: [PATCH] Mostly functional implementation using redux --- .editorconfig | 3 +- .eslintrc.js | 483 ++++++++++++++++++----------------- .gitignore | 1 + .prettierrc | 12 + package.json | 22 +- src/components/Board.tsx | 81 ------ src/components/GameBoard.tsx | 90 +++++++ src/components/StartGame.tsx | 26 +- src/game.tsx | 30 ++- src/index.tsx | 14 +- src/selectors.ts | 14 + src/store.ts | 217 +++++++++++----- src/styling/GlobalStyle.ts | 3 +- src/types/index.ts | 1 + src/types/types.ts | 13 + src/utils.ts | 30 +++ tsconfig.json | 3 +- 17 files changed, 624 insertions(+), 419 deletions(-) create mode 100644 .prettierrc delete mode 100644 src/components/Board.tsx create mode 100644 src/components/GameBoard.tsx create mode 100644 src/selectors.ts create mode 100644 src/types/index.ts create mode 100644 src/types/types.ts create mode 100644 src/utils.ts diff --git a/.editorconfig b/.editorconfig index e717f5e..bea5072 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,8 +6,9 @@ indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 -trim_trailing_whitespace = true +trim_trailing_whitespace = false insert_final_newline = true +quote=singleQuote [*.md] trim_trailing_whitespace = false diff --git a/.eslintrc.js b/.eslintrc.js index 03db9e7..bf08a29 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,241 +1,264 @@ module.exports = { env: { + browser: true, es2021: true, - node: true, - react: true, }, - extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], - parser: "@typescript-eslint/parser", + extends: ['plugin:react/recommended', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parser: '@typescript-eslint/parser', parserOptions: { + ecmaFeatures: { + jsx: true, + }, ecmaVersion: 12, - sourceType: "module", + sourceType: 'module', + }, + plugins: ['react', '@typescript-eslint'], + settings: { + react: { + version: 'detect', + }, }, - plugins: ["@typescript-eslint"], rules: { - "accessor-pairs": "error", - "array-bracket-newline": "error", - "array-bracket-spacing": "error", - "array-callback-return": "error", - "array-element-newline": 0, - "arrow-body-style": "error", - "arrow-parens": "error", - "arrow-spacing": "error", - "block-scoped-var": "error", - "block-spacing": "error", - "brace-style": "error", - "callback-return": "error", + // note you must disable the base rule as it can report incorrect errors + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': ['error'], + '@typescript-eslint/no-explicit-any': 0, + 'react/prop-types': 0, + 'accessor-pairs': 'error', + 'array-bracket-newline': 'error', + 'array-bracket-spacing': 'error', + 'array-callback-return': 'error', + 'array-element-newline': 0, + 'arrow-body-style': 'error', + 'arrow-parens': 0, + 'arrow-spacing': 'error', + 'block-scoped-var': 'error', + 'block-spacing': 'error', + 'brace-style': 'error', + 'callback-return': 'error', camelcase: 0, - "capitalized-comments": 0, - "class-methods-use-this": "error", - "comma-dangle": 0, - "comma-spacing": "error", - "comma-style": "error", - complexity: "error", - "computed-property-spacing": "error", - "consistent-return": "error", - "consistent-this": "error", - curly: "error", - "default-case": "error", - "default-case-last": "error", - "default-param-last": "error", - "dot-location": "error", - "dot-notation": "error", - "eol-last": "error", - eqeqeq: "error", - "func-call-spacing": "error", - "func-name-matching": "error", - "func-names": "error", - "func-style": "error", - "function-call-argument-newline": 0, - "function-paren-newline": "error", - "generator-star-spacing": "error", - "global-require": "error", - "grouped-accessor-pairs": "error", - "guard-for-in": "error", - "handle-callback-err": "error", - "id-blacklist": "error", - "id-denylist": "error", - "id-length": "error", - "id-match": "error", - "implicit-arrow-linebreak": "error", - indent: ["error", 2], - "indent-legacy": ["error", 2], - "init-declarations": "error", - "jsx-quotes": "error", - "key-spacing": "error", - "keyword-spacing": "error", - "line-comment-position": "error", - "linebreak-style": "error", - "lines-around-comment": "error", - "lines-around-directive": "error", - "lines-between-class-members": "error", - "max-classes-per-file": "error", - "max-depth": "error", - "max-len": ["error", 180], - "max-lines": "error", - "max-lines-per-function": "error", - "max-nested-callbacks": "error", - "max-params": "error", - "max-statements": 0, - "max-statements-per-line": "error", - "multiline-comment-style": 0, - "multiline-ternary": "error", - "new-cap": 0, - "new-parens": "error", - "newline-after-var": 0, - "newline-before-return": 0, - "newline-per-chained-call": 0, - "no-alert": "error", - "no-array-constructor": "error", - "no-await-in-loop": "error", - "no-bitwise": "error", - "no-buffer-constructor": "error", - "no-caller": "error", - "no-catch-shadow": "error", - "no-confusing-arrow": "error", - "no-console": "warn", - "no-constructor-return": "error", - "no-continue": "error", - "no-div-regex": "error", - "no-duplicate-imports": "error", - "no-else-return": "error", - "no-empty-function": "error", - "no-eq-null": "error", - "no-eval": "error", - "no-extend-native": "error", - "no-extra-bind": "error", - "no-extra-label": "error", - "no-extra-parens": "error", - "no-floating-decimal": "error", - "no-implicit-coercion": "error", - "no-implicit-globals": "error", - "no-implied-eval": "error", - "no-inline-comments": "error", - "no-invalid-this": "error", - "no-iterator": "error", - "no-label-var": "error", - "no-labels": "error", - "no-lone-blocks": "error", - "no-lonely-if": "error", - "no-loop-func": "error", - "no-loss-of-precision": "error", - "no-magic-numbers": 0, - "no-mixed-operators": "error", - "no-mixed-requires": "error", - "no-multi-assign": "error", - "no-multi-spaces": "error", - "no-multi-str": "error", - "no-multiple-empty-lines": "error", - "no-native-reassign": "error", - "no-negated-condition": "error", - "no-negated-in-lhs": "error", - "no-nested-ternary": "error", - "no-new": "error", - "no-new-func": "error", - "no-new-object": "error", - "no-new-require": "error", - "no-new-wrappers": "error", - "no-octal-escape": "error", - "no-param-reassign": "error", - "no-path-concat": "error", - "no-plusplus": "error", - "no-process-env": "error", - "no-process-exit": "error", - "no-promise-executor-return": "error", - "no-proto": "error", - "no-restricted-exports": "error", - "no-restricted-globals": "error", - "no-restricted-imports": "error", - "no-restricted-modules": "error", - "no-restricted-properties": "error", - "no-restricted-syntax": "error", - "no-return-assign": "error", - "no-return-await": "error", - "no-script-url": "error", - "no-self-compare": "error", - "no-sequences": "error", - "no-shadow": "error", - "no-spaced-func": "error", - "no-sync": "error", - "no-tabs": "error", - "no-template-curly-in-string": "error", - "no-ternary": "error", - "no-throw-literal": "error", - "no-trailing-spaces": "error", - "no-undef-init": "error", - "no-undefined": 0, - "no-underscore-dangle": "error", - "no-unmodified-loop-condition": "error", - "no-unneeded-ternary": "error", - "no-unreachable-loop": "error", - "no-unused-expressions": "error", - "no-use-before-define": "error", - "no-useless-backreference": "error", - "no-useless-call": "error", - "no-useless-computed-key": "error", - "no-useless-concat": "error", - "no-useless-constructor": "error", - "no-useless-rename": "error", - "no-useless-return": "error", - "no-var": "error", - "no-void": "error", - "no-warning-comments": "error", - "no-whitespace-before-property": "error", - "nonblock-statement-body-position": "error", - "object-curly-newline": "error", - "object-curly-spacing": 0, - "object-property-newline": "error", - "object-shorthand": "error", - "one-var": 0, - "one-var-declaration-per-line": 0, - "operator-assignment": "error", - "operator-linebreak": "error", - "padded-blocks": 0, - "padding-line-between-statements": "error", - "prefer-arrow-callback": "error", - "prefer-const": "error", - "prefer-destructuring": "error", - "prefer-exponentiation-operator": "error", - "prefer-named-capture-group": "error", - "prefer-numeric-literals": "error", - "prefer-object-spread": "error", - "prefer-promise-reject-errors": "error", - "prefer-reflect": "error", - "prefer-regex-literals": "error", - "prefer-rest-params": "error", - "prefer-spread": "error", - "prefer-template": "error", - "quote-props": ["error", "as-needed"], - quotes: ["error", "single"], - radix: "error", - "require-atomic-updates": "error", - "require-await": "error", - "require-jsdoc": "error", - "require-unicode-regexp": "error", - "rest-spread-spacing": "error", - semi: "error", - "semi-spacing": "error", - "semi-style": "error", - "sort-imports": 0, - "sort-keys": 0, - "sort-vars": 0, - "space-before-blocks": "error", - "space-before-function-paren": "error", - "space-in-parens": "error", - "space-infix-ops": "error", - "space-unary-ops": "error", - "spaced-comment": "error", - strict: "error", - "switch-colon-spacing": "error", - "symbol-description": "error", - "template-curly-spacing": "error", - "template-tag-spacing": "error", - "unicode-bom": "error", - "valid-jsdoc": "error", - "vars-on-top": "error", - "wrap-iife": "error", - "wrap-regex": "error", - "yield-star-spacing": "error", - yoda: "error", + 'capitalized-comments': 0, + 'class-methods-use-this': 'error', + 'comma-dangle': 0, + 'comma-spacing': 'error', + 'comma-style': 'error', + complexity: 'error', + 'computed-property-spacing': 'error', + 'consistent-return': 'error', + 'consistent-this': 'error', + curly: 'error', + 'default-case': 'error', + 'default-case-last': 'error', + 'default-param-last': 'error', + 'dot-location': 0, + 'dot-notation': 'error', + 'eol-last': 'error', + eqeqeq: 'error', + 'func-call-spacing': 'error', + 'func-name-matching': 'error', + 'func-names': 'error', + 'func-style': 'error', + 'function-call-argument-newline': 0, + 'function-paren-newline': 0, + 'generator-star-spacing': 0, + 'global-require': 'error', + 'grouped-accessor-pairs': 'error', + 'guard-for-in': 'error', + 'handle-callback-err': 'error', + 'id-blacklist': 'error', + 'id-denylist': 'error', + 'id-length': 'error', + 'id-match': 'error', + 'implicit-arrow-linebreak': 0, + indent: 0, + // indent: [ + // 'error', + // 2, + // { + // SwitchCase: 1, + // MemberExpression: 1, + // CallExpression: { arguments: 1 }, + // offsetTernaryExpressions: true, + // flatTernaryExpressions: true, + // ignoredNodes: ['ConditionalExpression'], + // }, + // ], + 'indent-legacy': 0, + 'init-declarations': 'error', + 'jsx-quotes': 'error', + 'key-spacing': 'error', + 'keyword-spacing': 'error', + 'line-comment-position': 'error', + 'linebreak-style': 'error', + 'lines-around-comment': 0, + 'lines-around-directive': 'error', + 'lines-between-class-members': 'error', + 'max-classes-per-file': 'error', + 'max-depth': 'error', + 'max-len': ['error', 180], + 'max-lines': 'error', + 'max-lines-per-function': 0, + 'max-nested-callbacks': 'error', + 'max-params': 0, + 'max-statements': 0, + 'max-statements-per-line': 'error', + 'multiline-comment-style': 0, + 'multiline-ternary': 0, + 'new-cap': 0, + 'new-parens': 'error', + 'newline-after-var': 0, + 'newline-before-return': 0, + 'newline-per-chained-call': 0, + 'no-alert': 'error', + 'no-array-constructor': 'error', + 'no-await-in-loop': 'error', + 'no-bitwise': 'error', + 'no-buffer-constructor': 'error', + 'no-caller': 'error', + 'no-catch-shadow': 'error', + 'no-confusing-arrow': 0, + 'no-console': 'warn', + 'no-constructor-return': 'error', + 'no-continue': 0, + 'no-div-regex': 'error', + 'no-duplicate-imports': 'error', + 'no-else-return': 'error', + 'no-empty-function': 'error', + 'no-eq-null': 'error', + 'no-eval': 'error', + 'no-extend-native': 'error', + 'no-extra-bind': 'error', + 'no-extra-label': 'error', + 'no-extra-parens': 0, + 'no-floating-decimal': 'error', + 'no-implicit-coercion': 'error', + 'no-implicit-globals': 'error', + 'no-implied-eval': 'error', + 'no-inline-comments': 'error', + 'no-invalid-this': 'error', + 'no-iterator': 'error', + 'no-label-var': 'error', + 'no-labels': 'error', + 'no-lone-blocks': 'error', + 'no-lonely-if': 'error', + 'no-loop-func': 'error', + 'no-loss-of-precision': 'error', + 'no-magic-numbers': 0, + 'no-mixed-operators': 0, + 'no-mixed-requires': 'error', + 'no-multi-assign': 'error', + 'no-multi-spaces': 'error', + 'no-multi-str': 'error', + 'no-multiple-empty-lines': 'error', + 'no-native-reassign': 'error', + 'no-negated-condition': 'error', + 'no-negated-in-lhs': 'error', + 'no-nested-ternary': 0, + 'no-new': 'error', + 'no-new-func': 'error', + 'no-new-object': 'error', + 'no-new-require': 'error', + 'no-new-wrappers': 'error', + 'no-octal-escape': 'error', + 'no-param-reassign': 'error', + 'no-path-concat': 'error', + 'no-plusplus': 'error', + 'no-process-env': 'error', + 'no-process-exit': 'error', + 'no-promise-executor-return': 'error', + 'no-proto': 'error', + 'no-restricted-exports': 'error', + 'no-restricted-globals': 'error', + 'no-restricted-imports': 'error', + 'no-restricted-modules': 'error', + 'no-restricted-properties': 'error', + 'no-restricted-syntax': 'error', + 'no-return-assign': 'error', + 'no-return-await': 'error', + 'no-script-url': 'error', + 'no-self-compare': 'error', + 'no-sequences': 'error', + 'no-shadow': 'error', + 'no-spaced-func': 'error', + 'no-sync': 'error', + 'no-tabs': 'error', + 'no-template-curly-in-string': 'error', + 'no-ternary': 0, + 'no-throw-literal': 'error', + 'no-trailing-spaces': 'error', + 'no-undef-init': 0, + 'no-undefined': 0, + 'no-underscore-dangle': 'error', + 'no-unmodified-loop-condition': 'error', + 'no-unneeded-ternary': 'error', + 'no-unreachable-loop': 'error', + 'no-unused-expressions': 'error', + 'no-useless-backreference': 'error', + 'no-useless-call': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-concat': 'error', + 'no-useless-constructor': 'error', + 'no-useless-rename': 'error', + 'no-useless-return': 'error', + 'no-var': 'error', + 'no-void': 'error', + 'no-warning-comments': 'error', + 'no-whitespace-before-property': 'error', + 'nonblock-statement-body-position': 'error', + 'object-curly-newline': 'error', + 'object-curly-spacing': 0, + 'object-property-newline': 0, + 'object-shorthand': 'error', + 'one-var': 0, + 'one-var-declaration-per-line': 0, + 'operator-assignment': 'error', + 'operator-linebreak': 'error', + 'padded-blocks': 0, + 'padding-line-between-statements': 'error', + 'prefer-arrow-callback': 'error', + 'prefer-const': 'error', + 'prefer-destructuring': 'error', + 'prefer-exponentiation-operator': 'error', + 'prefer-named-capture-group': 'error', + 'prefer-numeric-literals': 'error', + 'prefer-object-spread': 'error', + 'prefer-promise-reject-errors': 'error', + 'prefer-reflect': 'error', + 'prefer-regex-literals': 'error', + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'prefer-template': 'error', + 'quote-props': ['error', 'as-needed'], + quotes: ['error', 'single'], + radix: 'error', + 'require-atomic-updates': 'error', + 'require-await': 'error', + 'require-jsdoc': 0, + 'require-unicode-regexp': 'error', + 'rest-spread-spacing': 'error', + semi: 'error', + 'semi-spacing': 'error', + 'semi-style': 'error', + 'sort-imports': 0, + 'sort-keys': 0, + 'sort-vars': 0, + 'space-before-blocks': 'error', + 'space-before-function-paren': 0, + 'space-in-parens': 'error', + 'space-infix-ops': 'error', + 'space-unary-ops': 'error', + 'spaced-comment': 'error', + strict: 'error', + 'switch-colon-spacing': 'error', + 'symbol-description': 'error', + 'template-curly-spacing': 'error', + 'template-tag-spacing': 'error', + 'unicode-bom': 'error', + 'valid-jsdoc': 'error', + 'vars-on-top': 'error', + 'wrap-iife': 'error', + 'wrap-regex': 'error', + 'yield-star-spacing': 'error', + yoda: 'error', }, }; diff --git a/.gitignore b/.gitignore index de4d1f0..fde1383 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist node_modules +*bak diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..c70aadf --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": true, + "jsxBracketSameLine": true, + "arrowParens": "avoid", + "proseWrap": "preserve" +} diff --git a/package.json b/package.json index 77b8eee..b52f930 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,25 @@ { "scripts": { - "start": "parcel serve src/index.htm" + "start": "parcel serve src/index.htm", + "type": "tsc --noEmit", + "lint": "eslint --ext .ts,.tsx ./src", + "lint:fix": "eslint --ext .ts,.tsx ./src --fix", + "prettier": "prettier --write 'src/**/*.{ts,tsx,json,md,js,scss}'" }, "devDependencies": { - "typescript": "^4.0.5", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", - "@types/styled-components": "^5.1.7", "@types/react-redux": "^7.1.14", - "eslint": "^7.13.0" + "@types/styled-components": "^5.1.7", + "@typescript-eslint/eslint-plugin": "^4.11.1", + "@typescript-eslint/parser": "^4.11.1", + "eslint": "^7.17.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-react": "^7.22.0", + "eslint-plugin-react-hooks": "^4.2.0", + "typescript": "^4.0.5", + "tslib": "^2.0.3" }, "dependencies": { "@reduxjs/toolkit": "^1.5.0", @@ -17,7 +28,6 @@ "react-dom": "^17.0.1", "react-redux": "^7.2.2", "redux-devtools-extension": "^2.13.8", - "styled-components": "^5.2.1", - "tslib": "^2.0.3" + "styled-components": "^5.2.1" } } diff --git a/src/components/Board.tsx b/src/components/Board.tsx deleted file mode 100644 index 1207a30..0000000 --- a/src/components/Board.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, { FC } from "react"; -import { connect } from "react-redux"; -import styled from "styled-components"; -import { AppDispatch, AppState, CellValue, Coordinate, dig } from "../store"; - -interface StateProps { - size: number; - board: CellValue[]; - digs: Record; -} - -interface ActionProps { - dig(coordinate: Coordinate): void; -} - -type Props = ActionProps & StateProps; - -const Cell = styled.div` - display: inline-flex; - width: 32px; - height: 32px; - align-items: center; - justify-content: center; - cursor: pointer; -`; - -const Row = styled.div` - display: flex; - flex-direction: row; - - ${Cell} { - border-left: 1px solid silver; - border-top: 1px solid silver; - - &:nth-last-child(1) { - border-right: 1px solid silver; - } - } - &:nth-last-child(1) { - ${Cell} { - border-bottom: 1px solid silver; - } - } -`; - -const Board: FC = ({ dig, digs, board, size }) => { - return ( - <> - {[...Array(size).keys()].map((row) => ( - - {[...Array(size).keys()].map((col) => ( - { - dig({ row, col }); - }} - > - {/*digs[row]?.includes(col) && board[row * size + col][0] */} - {digs[row]?.includes(col) - ? "D" - : board[row * size + col].toString()} - - ))} - - ))} - - ); -}; - -const mapStateToProps = (state: AppState): StateProps => ({ - size: state.size, - board: state.board, - digs: state.digs, -}); - -const mapDispatchToProps = (dispatch: AppDispatch): ActionProps => ({ - dig: (coordinate: Coordinate) => dispatch(dig(coordinate)), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Board); diff --git a/src/components/GameBoard.tsx b/src/components/GameBoard.tsx new file mode 100644 index 0000000..1a6d3c7 --- /dev/null +++ b/src/components/GameBoard.tsx @@ -0,0 +1,90 @@ +import React, { FC } from 'react'; +import { connect } from 'react-redux'; +import styled from 'styled-components'; +import { selectBoardRows } from '../selectors'; +import { AppDispatch, AppState, dig as digAction } from '../store'; +import { Cell, Coordinate, GameStatus } from '../types'; + +interface StateProps { + size: number; + board: Cell[][]; + status: GameStatus; +} + +interface ActionProps { + dig(coordinate: Coordinate): void; +} + +type Props = ActionProps & StateProps; + +const Cell = styled.div<{ cell: Cell; status: GameStatus }>` + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border: 1px solid silver; + margin: 1px; + user-select: none; + + /* Value based style */ + ${({ cell }) => (cell.value === 'B' ? 'color: red;' : cell.value > 0 ? 'color: teal;' : 'color: white;')} + + /* Reveal based style */ + ${({ cell }) => + cell.reveal + ? `background-color: transparent; + cursor: default;` + : `background-color: silver; + cursor: pointer; + &:hover { + background-color: #838484; + } + `} + + /* Digged based style */ + ${({ cell }) => cell.digged && 'border-color: green;'} + + /* Digged based style */ + ${({ cell }) => !cell.reveal && 'text-indent: -9999px;'} /* ${({ cell }) => !cell.reveal && 'color: rgba(0,0,0,.1)'} */ + + ${({ status }) => + status !== 'RUNNING' && + ` + cursor: default; + &:hover { + background-color: initial; + } + `} +`; + +const Row = styled.div` + display: flex; + flex-direction: row; +`; + +const GameBoard: FC = ({ dig, board, status }) => ( + <> + {board.map((row, rowIndex) => ( + + {row.map(cell => ( + dig(cell)}> + {cell.value} + + ))} + + ))} + +); + +const mapStateToProps = (state: AppState): StateProps => ({ + size: state.size, + board: selectBoardRows(state), + status: state.status, +}); + +const mapDispatchToProps = (dispatch: AppDispatch): ActionProps => ({ + dig: (coordinate: Coordinate) => dispatch(digAction(coordinate)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(GameBoard); diff --git a/src/components/StartGame.tsx b/src/components/StartGame.tsx index fa8a766..c9cb11c 100644 --- a/src/components/StartGame.tsx +++ b/src/components/StartGame.tsx @@ -1,39 +1,39 @@ -import React, { FC, useState } from "react"; -import { connect } from "react-redux"; -import { AppDispatch, AppState, startGame } from "../store"; +import React, { FC, useState } from 'react'; +import { connect } from 'react-redux'; +import { AppDispatch, AppState, startGame as startGameAction } from '../store'; interface StateProps { size: number; + numberOfMines: number; } interface ActionProps { - startGame(size: number): void; + startGame(size: number, mines: number): void; } type Props = ActionProps & StateProps; -const StartGame: FC = ({ startGame, size }) => { +const StartGame: FC = ({ startGame, size, numberOfMines }) => { const [userSize, setUserSize] = useState(size); + const [userMines, setUserMines] = useState(numberOfMines); return ( <> - setUserSize(parseInt(e.target.value, 10))} - /> - + setUserSize(parseInt(ev.target.value, 10))} /> + + setUserMines(parseInt(ev.target.value, 10))} /> + ); }; const mapStateToProps = (state: AppState): StateProps => ({ size: state.size, + numberOfMines: state.numberOfMines, }); const mapDispatchToProps = (dispatch: AppDispatch): ActionProps => ({ - startGame: (size: number) => dispatch(startGame(size)), + startGame: (size: number, numberOfMines: number) => dispatch(startGameAction({ size, numberOfMines })), }); export default connect(mapStateToProps, mapDispatchToProps)(StartGame); diff --git a/src/game.tsx b/src/game.tsx index 3e92368..6714528 100644 --- a/src/game.tsx +++ b/src/game.tsx @@ -1,27 +1,29 @@ -import React, { FC } from "react"; -import { connect } from "react-redux"; -import Board from "./components/Board"; -import StartGame from "./components/StartGame"; -import { AppState } from "./store"; +import React, { FC } from 'react'; +import { connect } from 'react-redux'; +import GameBoard from './components/GameBoard'; +import StartGame from './components/StartGame'; +import { AppState } from './store'; +import { GameStatus } from './types'; interface StateProps { started: boolean; + status: GameStatus; } type Props = StateProps; -const Game: FC = ({ started }) => { - return ( - <> -

Minesweeper

- {!started && } - {started && } - - ); -}; +const Game: FC = ({ started, status }) => ( + <> +

Minesweeper

+

{status}

+ {!started && } + {started && } + +); const mapStateToProps = (state: AppState): StateProps => ({ started: state.started, + status: state.status, }); export default connect(mapStateToProps)(Game); diff --git a/src/index.tsx b/src/index.tsx index 5e7c27b..df4bb9b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,14 @@ -import React from "react"; -import ReactDOM from "react-dom"; -import { Provider } from "react-redux"; -import { store } from "./store"; -import Game from "./game"; -import GlobalStyle from "./styling/GlobalStyle"; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { store } from './store'; +import Game from './Game'; +import GlobalStyle from './styling/GlobalStyle'; ReactDOM.render( , - document.getElementById("root") + document.getElementById('root') ); diff --git a/src/selectors.ts b/src/selectors.ts new file mode 100644 index 0000000..cea043c --- /dev/null +++ b/src/selectors.ts @@ -0,0 +1,14 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { AppState } from './store'; +import { Cell } from './types'; + +export const selectAppState = (state: AppState): AppState => state; + +export const selectBoard = createSelector(selectAppState, (state: AppState): Cell[] => state.board); + +export const selectBoardRows = createSelector(selectBoard, (board: Cell[]): Cell[][] => + board.reduce((rows, cell) => { + rows[cell.row] = [...(rows[cell.row] || []), cell]; + return rows; + }, [] as Cell[][]) +); diff --git a/src/store.ts b/src/store.ts index 78e0f8a..7081a3a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,98 +1,185 @@ -import { configureStore, createAction, createReducer } from "@reduxjs/toolkit"; +import { configureStore, createAction, createReducer } from '@reduxjs/toolkit'; +import { Cell, Coordinate, GameStatus } from './types'; +import { range, rangeValues, shuffle } from './utils'; -export type CellValue = "E" | "B" | number; -export interface Coordinate { - row: number; - col: number; -} +/* ********* TYPES ********************************************************************************** */ export interface AppState { started: boolean; + status: GameStatus; size: number; - board: CellValue[]; - digs: Record; + numberOfMines: number; + board: Cell[]; } -const initialBoardSize = 10; +/* ********* FUNCTIONS ****************************************************************************** */ -const random = (min: number, max: number): number => - min + Math.floor(Math.random() * (max - min)); +const sameCoordinate = (coordinate1: Coordinate, coordinate2: Coordinate): boolean => + coordinate1.row === coordinate2.row && coordinate1.col === coordinate2.col; -const createBoard = (size: number, bombs: number = 5): CellValue[] => { - const board: CellValue[] = Array.from(new Array(size * size), () => "E"); +const getIndexFromCoordinate = (coordinate: Coordinate, size: number) => coordinate.row * size + coordinate.col; - let bombsLeft = bombs; - while (bombsLeft) { - const row = random(0, size); - const cell = random(0, size); - if (board[row * size + cell] === "B") { +const neighbouringIndexes = function* neighbouringIndexes(size: number, coordinate: Coordinate): Generator { + for (const row of range(Math.max(coordinate.row - 1, 0), Math.min(coordinate.row + 2, size))) { + for (const col of range(Math.max(coordinate.col - 1, 0), Math.min(coordinate.col + 2, size))) { + if (row !== coordinate.row || col !== coordinate.col) { + yield getIndexFromCoordinate({ row, col }, size); + } + } + } + return false; +}; + +const neighbouringCells = function* neighbouringCells( + board: Cell[], + size: number, + coordinate: Coordinate +): Generator { + for (const index of neighbouringIndexes(size, coordinate)) { + if (board[index] === undefined) { continue; } - board[row * size + cell] = "B"; - bombsLeft -= 1; + yield board[index]; } - board.forEach((cell, index) => { - if (cell === "B") { - return; - } - const row = Math.floor(index / size); - const col = index - row * size; - let bombCount = 0; - for (let y = Math.max(0, row - 1); y <= Math.min(row + 1, size); y += 1) { - for (let x = Math.max(0, col - 1); x <= Math.min(col + 1, size); x += 1) { - if (board[y * size + x] === "B") { - bombCount += 1; - } - } - } - board[index] = bombCount; - }); - - return board; + return false; }; -const sameCoordinate = (c1: Coordinate, c2: Coordinate): boolean => - c1.col === c2.col && c1.row === c2.row; +const createMines = (size: number, numberOfMines: number): boolean[] => + shuffle([...rangeValues(0, numberOfMines, true), ...rangeValues(0, size ** 2 - numberOfMines, false)]); -const dig = ( - board: CellValue[], - digs: Record, - { row, col }: Coordinate -) => { - const size = Math.sqrt(board.length); - for (let y = Math.max(0, row - 1); y <= Math.min(row + 1, size); y += 1) { - for (let x = Math.max(0, col - 1); x <= Math.min(col + 1, size); x += 1) { - if (board[y * size + x] === "B") { +const createBoard = (size: number, numberOfMines: number): Cell[] => { + const mines = createMines(size, numberOfMines); + + const countMines = (row: number, col: number): number => + [...neighbouringIndexes(size, { row, col })].reduce((bombs, index) => bombs + (mines[index] ? 1 : 0), 0); + + return range(0, size).reduce( + (board, row, rowIndex) => [ + ...board, + ...range(0, size).map( + (col, colIndex) => + ({ + row, + col, + value: mines[rowIndex * size + colIndex] ? 'B' : countMines(row, col), + digged: false, + reveal: false, + } as Cell) + ), + ], + [] as Cell[] + ); +}; + +const allNeighbouringCells = function* allNeighbouringCells( + board: Cell[], + size: number, + coordinate: Coordinate, + include: (cell: Cell) => boolean +): Generator { + const visited: number[] = []; + const todo: Cell[] = [...neighbouringCells(board, size, coordinate)]; + let cell: Cell | undefined = undefined; + while ((cell = todo.pop())) { + const index = getIndexFromCoordinate(cell, size); + if (visited.includes(index)) { + continue; + } + visited.push(index); + if (include(cell)) { + yield cell; + if (cell.value === 0) { + todo.push(...neighbouringCells(board, size, cell)); } } } }; +/* ********* DEFAULTS ******************************************************************************* */ + +const INITIAL_BOARD_SIZE = 10; +const INITIAL_NUMBER_OF_MINES = 8; + const defaultAppState: AppState = { started: true, - size: initialBoardSize, - board: createBoard(initialBoardSize), - digs: [], + status: 'RUNNING', + size: INITIAL_BOARD_SIZE, + numberOfMines: INITIAL_NUMBER_OF_MINES, + board: createBoard(INITIAL_BOARD_SIZE, INITIAL_NUMBER_OF_MINES), }; -export const startGame = createAction("START_GAME"); -export const dig = createAction("DIG"); +/* ********* ACTIONS ******************************************************************************** */ -const game = createReducer(defaultAppState, (builder) => { +export const startGame = createAction<{ size: number; numberOfMines: number }>('START_GAME'); +export const dig = createAction('DIG'); + +/* ********* REDUCER ******************************************************************************** */ + +const game = createReducer(defaultAppState, builder => { builder - .addCase(startGame, (state, action) => ({ - ...state, - started: true, - board: createBoard(action.payload), - size: action.payload, - digs: [], - })) + .addCase(startGame, (state, { payload: { size, numberOfMines } }) => + /* Set the initial state for a new game */ + ({ + ...state, + started: true, + status: 'RUNNING', + board: createBoard(size, numberOfMines), + numberOfMines, + size, + }) + ) .addCase(dig, (state, { payload: { row, col } }) => { + /* Do nothing if the game is not running */ + if (state.status !== 'RUNNING') { + return state; + } + + /* Find the cell that is clicked on */ + const cell = state.board.find(boardCell => sameCoordinate(boardCell, { row, col })); + + /* If the clicked cell is a bomb then the game is over */ + if (cell?.value === 'B') { + return { + ...state, + status: 'LOST', + board: state.board.map(boardCell => ({ + ...boardCell, + reveal: boardCell.reveal || boardCell.value === 'B', + })), + }; + } + + /* Based on the clicked cell find all the neighbouring cells that can be revealed */ + const revealingCells = + cell?.value === 0 + ? [ + ...allNeighbouringCells( + state.board, + state.size, + cell, + neighbouringCell => !neighbouringCell.reveal && neighbouringCell.value !== 'B' + ), + ] + : []; + + /* Update the board with the state after the selected cell is clicked */ + const updatedBoard = state.board.map(boardCell => + sameCoordinate(boardCell, { row, col }) + ? { ...boardCell, digged: true, reveal: true } + : revealingCells.some( + revealingCell => revealingCell.row === boardCell.row && revealingCell.col === boardCell.col + ) + ? { ...boardCell, reveal: true } + : boardCell + ); + + /* Check if there are still moves left */ + const areMovesLeft = updatedBoard.some(boardCell => typeof boardCell.value === 'number' && !boardCell.reveal); + return { ...state, - digs: state.digs[row]?.includes(col) - ? state.digs - : { ...state.digs, [row]: [...(state.digs[row] || []), col] }, + status: areMovesLeft ? 'RUNNING' : 'WON', + board: areMovesLeft ? updatedBoard : updatedBoard.map(boardCell => ({ ...boardCell, reveal: true })), }; }); }); diff --git a/src/styling/GlobalStyle.ts b/src/styling/GlobalStyle.ts index 270010a..9cf3644 100644 --- a/src/styling/GlobalStyle.ts +++ b/src/styling/GlobalStyle.ts @@ -1,10 +1,11 @@ -import { createGlobalStyle } from "styled-components"; +import { createGlobalStyle } from 'styled-components'; export const GlobalStyle = createGlobalStyle` html, body { background: black; color: white; font-family: sans-serif; + user-select: none; } `; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..fcb073f --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/types/types.ts b/src/types/types.ts new file mode 100644 index 0000000..9151eaf --- /dev/null +++ b/src/types/types.ts @@ -0,0 +1,13 @@ +export type GameStatus = 'RUNNING' | 'WON' | 'LOST'; +export type CellValue = 'B' | number; + +export interface Coordinate { + row: number; + col: number; +} + +export interface Cell extends Coordinate { + value: CellValue; + digged: boolean; + reveal: boolean; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..13befd7 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,30 @@ +export const random = (min: number, max: number): number => min + Math.floor(Math.random() * (max - min)); + +export const range = function range(from: number, to: number): number[] { + const rangeGenerator = function* rangeGenerator(fromGen: number, toGen: number): Generator { + for (let value = fromGen; value < toGen; value += 1) { + yield value; + } + return false; + }; + return [...rangeGenerator(from, to)]; +}; + +export const rangeValues = function rangeValues(from: number, to: number, value: T): T[] { + const rangeValuesGenerator = function* rangeValuesGenerator(fromGen: number, toGen: number, valueGen: T): Generator { + for (let index = fromGen; index < toGen; index += 1) { + yield valueGen; + } + return false; + }; + return [...rangeValuesGenerator(from, to, value)]; +}; + +export const shuffle = (array: T[]): T[] => { + const shuffledArray = [...array]; + for (let index1 = shuffledArray.length - 1; index1 > 0; index1 -= 1) { + const index2 = Math.floor(Math.random() * (index1 + 1)); + [shuffledArray[index1], shuffledArray[index2]] = [shuffledArray[index2], shuffledArray[index1]]; + } + return shuffledArray; +}; diff --git a/tsconfig.json b/tsconfig.json index 0dd38b1..28b56ff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES5", "lib": ["DOM", "ES2017"], "jsx": "react", "module": "esnext", @@ -14,6 +14,7 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, + "downlevelIteration": true, "noEmit": true, // "baseUrl": "./src", "importHelpers": true,