From ed395698283a5b226964c79fee096b60d178c947 Mon Sep 17 00:00:00 2001 From: Bart Riemens Date: Mon, 14 Oct 2019 21:24:50 +0200 Subject: [PATCH] Implement tictactoe with redux --- .eslintrc.js | 4 +- package.json | 4 ++ src/App.tsx | 46 ++++++++++----- src/components/Board.tsx | 104 ++++++++++++++------------------- src/components/Cell.tsx | 20 +++---- src/global.d.ts | 7 +++ src/index.tsx | 14 ++++- src/lib/scoring.ts | 43 ++++++-------- src/react-app-env.d.ts | 1 + src/redux/game.ts | 121 +++++++++++++++++++++++++++++++++++++++ src/redux/store.ts | 15 +++++ src/types/Board.ts | 13 +++++ src/types/IBoard.ts | 3 - src/types/Player.ts | 16 ++++++ src/types/cellState.ts | 7 --- src/types/playerEnum.ts | 6 -- 16 files changed, 297 insertions(+), 127 deletions(-) create mode 100644 src/global.d.ts create mode 100644 src/redux/game.ts create mode 100644 src/redux/store.ts create mode 100644 src/types/Board.ts delete mode 100644 src/types/IBoard.ts create mode 100644 src/types/Player.ts delete mode 100644 src/types/cellState.ts delete mode 100644 src/types/playerEnum.ts diff --git a/.eslintrc.js b/.eslintrc.js index 4bb1637..8c32e38 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -45,7 +45,9 @@ module.exports = { 'function-paren-newline': 0, 'no-confusing-arrow': 0, 'react/jsx-props-no-spreading': 0, - 'no-unused-vars': 'off', + 'no-underscore-dangle': 0, + 'no-unused-vars': 0, + 'object-curly-newline': 0, '@typescript-eslint/no-unused-vars': [ 'error', { diff --git a/package.json b/package.json index 3d49fc5..8632816 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "@types/react-dom": "16.9.1", "react": "^16.10.2", "react-dom": "^16.10.2", + "react-redux": "^7.1.1", "react-scripts": "3.2.0", + "redux": "^4.0.4", "styled-components": "^5.0.0-beta.9", "typescript": "3.6.4" }, @@ -35,6 +37,8 @@ ] }, "devDependencies": { + "@types/react-redux": "^7.1.4", + "@types/redux": "^3.6.0", "@types/styled-components": "^4.1.19", "@typescript-eslint/eslint-plugin": "^2.3.3", "@typescript-eslint/parser": "^2.3.3", diff --git a/src/App.tsx b/src/App.tsx index 7d71909..01f4edb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,10 @@ import React from 'react'; +import { Action } from 'redux'; +import { connect } from 'react-redux'; import styled from 'styled-components'; import Board from './components/Board'; -import playerEnum from './types/playerEnum'; +import { Player } from './types/Player'; +import { actions, GameState } from './redux/game'; import GlobalStyle from './theming/GlobalStyle'; const StyledApp = styled.div` @@ -16,19 +19,36 @@ const StyledApp = styled.div` color: white; `; -const App: React.FC = () => { - const [currentPlayer, setCurrentPlayer] = React.useState(playerEnum.X); +interface StateProps { + currentPlayer: Player; +} - const nextPlayer = (): void => { - setCurrentPlayer((c) => (c === playerEnum.X ? playerEnum.O : playerEnum.X)); - }; +interface ActionProps { + startGame: () => Action; +} - return ( - - - - - ); +type Props = StateProps & ActionProps; + +const App: React.FC = ({ currentPlayer, startGame }) => ( + + +

{currentPlayer}

+ + +
+); + +const mapStateToProps = (state: GameState): StateProps => ({ + currentPlayer: state.currentPlayer, +}); + +const mapActionsToProps = { + startGame: actions.startGame, }; -export default App; +export default connect( + mapStateToProps, + mapActionsToProps, +)(App); diff --git a/src/components/Board.tsx b/src/components/Board.tsx index f7fc6b0..950b121 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -1,10 +1,11 @@ import React from 'react'; +import { connect } from 'react-redux'; +import { Action } from 'redux'; import styled from 'styled-components'; import Cell from './Cell'; -import { cellState, notSet } from '../types/cellState'; -import playerEnum from '../types/playerEnum'; -import { getBoardSummary, undetermined } from '../lib/scoring'; -import { IBoard } from '../types/IBoard'; +import { Player } from '../types/Player'; +import { actions, IGameState } from '../redux/game'; +import { Board } from '../types/Board'; const Table = styled.table` border: 0; @@ -29,68 +30,49 @@ const Row = styled.tr` } `; -export interface IProps { - currentPlayer: playerEnum; - nextPlayer: () => void; +export interface IStateProps { + board: Board; + currentPlayer: Player; } -export const Board: React.FC = ({ currentPlayer, nextPlayer }) => { - const [board, setBoard] = React.useState([ - [notSet, notSet, notSet], - [notSet, notSet, notSet], - [notSet, notSet, notSet], - ]); - const [activeGame, setActiveGame] = React.useState(true); - const [boardSummary, updateBoardSummary] = React.useState(getBoardSummary(board)); +export interface IActionProps { + nextPlayer: () => void; + startGame: () => Action; + makeMove: (rowIndex: number, cellIndex: number, currentPlayer: Player) => void; +} - const updateBoard = (rowIndex: number, cellIndex: number): void => { - if (!activeGame) { - return; - } - const checkCell = (rI: number, cI: number, cell: cellState): cellState => { - if (rI !== rowIndex || cI !== cellIndex) { - return cell; - } - if (cell !== notSet) { - return cell; - } - return currentPlayer; - }; +type IProps = IStateProps & IActionProps; - setBoard((brd) => [...brd.map((row, rI) => row.map((cell, cI) => checkCell(rI, cI, cell)))]); +const BoardComponent: React.FC = ({ currentPlayer, board, makeMove }) => ( + + + {board.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + makeMove(rowIndex, cellIndex, currentPlayer)} + /> + ))} + + ))} + +
+); - nextPlayer(); - }; +const mapStateToProps = (state: IGameState): IStateProps => ({ + board: state.board, + currentPlayer: state.currentPlayer, +}); - React.useEffect(() => { - updateBoardSummary(getBoardSummary(board)); - }, [board]); - - React.useEffect(() => { - if (boardSummary.winner !== undetermined && activeGame) { - setActiveGame(false); - } - }, [boardSummary]); - - console.log('boardSummary', boardSummary); - - return ( - - - {board.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - updateBoard(rowIndex, cellIndex)} - /> - ))} - - ))} - -
- ); +const mapActionsToProps: IActionProps = { + startGame: actions.startGame, + nextPlayer: actions.nextPlayer, + makeMove: actions.makeMove, }; -export default Board; +export default connect( + mapStateToProps, + mapActionsToProps, +)(BoardComponent); diff --git a/src/components/Cell.tsx b/src/components/Cell.tsx index 78570ab..fc65b4e 100644 --- a/src/components/Cell.tsx +++ b/src/components/Cell.tsx @@ -1,18 +1,18 @@ import React from 'react'; import styled from 'styled-components'; -import { cellState, notSet } from '../types/cellState'; -import playerEnum from '../types/playerEnum'; +import { Cell, empty } from '../types/Board'; +import { Player } from '../types/Player'; -export interface IProps { - value: cellState; +export interface Props { + value: Cell; onClick: () => void | Boolean; } -const renderPlayerSymbol = (value: cellState): string => { - if (value === playerEnum.X) { +const renderPlayerSymbol = (value: Cell): string => { + if (value === Player.X) { return 'X'; } - if (value === playerEnum.O) { + if (value === Player.O) { return 'O'; } return ''; @@ -24,8 +24,8 @@ export const StyledCell = styled.td` font-size: 2rem; `; -const Cell: React.FC = ({ value, onClick }) => ( - value === notSet && onClick()}>{renderPlayerSymbol(value)} +const CellComponent: React.FC = ({ value, onClick }) => ( + value === empty && onClick()}>{renderPlayerSymbol(value)} ); -export default Cell; +export default CellComponent; diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..4746ca4 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,7 @@ +export declare global { + interface Window { + __REDUX_DEVTOOLS_EXTENSION__: () => StoreEnhancer, {}>; + } +} + +export default global; diff --git a/src/index.tsx b/src/index.tsx index b597a44..58f31da 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider, ReactReduxContext } from 'react-redux'; +import { createStore } from './redux/store'; +import { actions } from './redux/game'; import App from './App'; -ReactDOM.render(, document.getElementById('root')); +const store = createStore(); + +ReactDOM.render( + + + , + document.getElementById('root'), +); + +store.dispatch(actions.startGame()); diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts index da79b4d..b8278ad 100644 --- a/src/lib/scoring.ts +++ b/src/lib/scoring.ts @@ -1,5 +1,5 @@ -import { cellState, notSet } from '../types/cellState'; -import { IBoard } from '../types/IBoard'; +import { Board, Cell, Cells, empty, Rows } from '../types/Board'; +import { Winner } from '../types/Player'; enum NoOneEnum {} type INoOne = string | NoOneEnum; @@ -8,23 +8,14 @@ export const noOne: INoOne = 'noOne'; enum UndeterminedEnum {} type IUndetermined = string | UndeterminedEnum; export const undetermined: IUndetermined = 'undetermined'; - -type IWinner = cellState | INoOne | IUndetermined; - export interface IBoardSummary { - horizontal: Array>; - vertical: Array>; - diagonal: Array>; - winner: IWinner; + horizontal: Rows; + vertical: Rows; + diagonal: Rows; + winner: Winner; } -/* - * diagonal 1-2 - * horizonal 1-3 - * vertical 1-3 - * line: X: 0, O: 1 - * */ -export const getBoardSummary = (board: IBoard): IBoardSummary => { +export const getBoardSummary = (board: Board): IBoardSummary => { const summary: IBoardSummary = { horizontal: [[], [], []], vertical: [[], [], []], @@ -44,27 +35,29 @@ export const getBoardSummary = (board: IBoard): IBoardSummary => { }), ); - const getWinnerFromLine = (line: cellState[]): IWinner => + const getWinnerFromLine = (line: Cell[]): Winner => line.reduce( - (cellWinner: IWinner, cell: cellState): IWinner => { - if (cell === notSet) { + (cellWinner: Winner, cell: Cell, index): Winner => { + if (cell === empty) { return undetermined; } - if (cellWinner === undetermined && cell !== notSet) { + if (index === 0 && cellWinner === undetermined && cell !== empty) { return cell; } + if (index > 0 && cellWinner === undetermined) { + return undetermined; + } if (cellWinner === cell) { return cell; } - return noOne; + return undetermined; }, - undetermined as IWinner, + undetermined as Winner, ); - const getWinnerFromLines = (lines: cellState[][]): IWinner => + const getWinnerFromLines = (lines: Rows): Winner => lines.reduce( - (lineWinner: IWinner, line: cellState[]): IWinner => - lineWinner !== undetermined ? lineWinner : getWinnerFromLine(line), + (lineWinner: Winner, line: Cells): Winner => (lineWinner !== undetermined ? lineWinner : getWinnerFromLine(line)), undetermined, ); diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 6431bc5..9fbe5e4 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -1 +1,2 @@ +/* eslint-disable-next-line */ /// diff --git a/src/redux/game.ts b/src/redux/game.ts new file mode 100644 index 0000000..028b09c --- /dev/null +++ b/src/redux/game.ts @@ -0,0 +1,121 @@ +import { Reducer, Action, Dispatch, MiddlewareAPI } from 'redux'; +import { Player, Winner, undetermined } from '../types/Player'; +import { Board, createEmptyBoard, Cell, empty } from '../types/Board'; +import { getBoardSummary } from '../lib/scoring'; + +export const START_GAME = 'START_GAME'; +export const END_GAME = 'END_GAME'; +export const NEXT_PLAYER = 'NEXT_PLAYER'; +export const MAKE_MOVE = 'MAKE_MOVE'; +export const UPDATE_BOARD = 'UPDATE_BOARD'; +export const DETERMINE_WINNER = 'DETERMINE_WINNER'; + +export interface MakeMoveAction extends Action { + type: typeof MAKE_MOVE; + payload: { + rowIndex: number; + cellIndex: number; + currentPlayer: Player; + }; +} + +export interface UpdateBoardAction extends Action { + type: typeof UPDATE_BOARD; + payload: { + rowIndex: number; + cellIndex: number; + currentPlayer: Player; + }; +} + +export const actions = { + startGame: (): Action => ({ type: START_GAME }), + nextPlayer: (): Action => ({ type: NEXT_PLAYER }), + endGame: (): Action => ({ type: END_GAME }), + makeMove: (rowIndex: number, cellIndex: number, currentPlayer: Player): MakeMoveAction => ({ + type: MAKE_MOVE, + payload: { rowIndex, cellIndex, currentPlayer }, + }), + updateBoard: (rowIndex: number, cellIndex: number, currentPlayer: Player): UpdateBoardAction => ({ + type: UPDATE_BOARD, + payload: { rowIndex, cellIndex, currentPlayer }, + }), + determineWinner: (): Action => ({ type: DETERMINE_WINNER }), +}; + +export interface GameState { + activeGame: Boolean; + currentPlayer: Player; + board: Board; + winner: Winner; +} + +export type IGameState = GameState; + +const defaultState: IGameState = { + currentPlayer: Player.X, + activeGame: false, + board: createEmptyBoard(), + winner: undetermined, +}; + +export const reducer: Reducer = (state: IGameState = defaultState, action: Action): IGameState => { + if (action.type === START_GAME) { + return { ...state, board: createEmptyBoard(), activeGame: true }; + } + if (action.type === END_GAME) { + return { ...state, activeGame: false }; + } + if (action.type === NEXT_PLAYER) { + if (!state.activeGame) { + return state; + } + return { ...state, currentPlayer: state.currentPlayer === Player.X ? Player.O : Player.X }; + } + if (action.type === UPDATE_BOARD) { + if (!state.activeGame) { + return state; + } + const { rowIndex, cellIndex, currentPlayer } = (action as UpdateBoardAction).payload; + + const checkCell = (rI: number, cI: number, cell: Cell): Cell => { + if (rI !== rowIndex || cI !== cellIndex) { + return cell; + } + if (cell !== empty) { + return cell; + } + return currentPlayer; + }; + + return { ...state, board: [...state.board.map((row, rI) => row.map((cell, cI) => checkCell(rI, cI, cell)))] }; + } + if (action.type === DETERMINE_WINNER) { + const boardSummary = getBoardSummary(state.board); + return { ...state, winner: boardSummary.winner }; + } + return state; +}; + +export const middleware = ({ dispatch, getState }: MiddlewareAPI, GameState>) => ( + next: Dispatch, +) => (action: Action): void => { + next(action); + + if (action.type === MAKE_MOVE) { + const { activeGame } = getState(); + if (!activeGame) { + return; + } + const { rowIndex, cellIndex, currentPlayer } = (action as MakeMoveAction).payload; + dispatch(actions.updateBoard(rowIndex, cellIndex, currentPlayer)); + dispatch(actions.nextPlayer()); + dispatch(actions.determineWinner()); + const { winner } = getState(); + if (winner !== undetermined) { + dispatch(actions.endGame()); + } + } +}; + +export default reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts new file mode 100644 index 0000000..378f0fd --- /dev/null +++ b/src/redux/store.ts @@ -0,0 +1,15 @@ +import { createStore as createReduxStore, Store, AnyAction, compose, StoreEnhancer, applyMiddleware } from 'redux'; +import { reducer, middleware } from './game'; + +const storeEnhancers: StoreEnhancer, {}>[] = [ + applyMiddleware(middleware), + window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), +].filter((m) => m); + +export const createStore = (): Store => + createReduxStore(reducer, undefined, compose.apply(compose, storeEnhancers) as StoreEnhancer< + Store, + {} + >); + +export default createStore; diff --git a/src/types/Board.ts b/src/types/Board.ts new file mode 100644 index 0000000..5e394cf --- /dev/null +++ b/src/types/Board.ts @@ -0,0 +1,13 @@ +import { Player } from './Player'; + +export enum EmptyEnum {} +export type Empty = string & EmptyEnum; +export const empty: Empty = 'empty' as Empty; + +export type Cell = Empty | Player; +export type Cells = Cell[]; +export type Row = Cell[]; +export type Rows = Row[]; + +export type Board = Rows; +export const createEmptyBoard = (): Board => [[empty, empty, empty], [empty, empty, empty], [empty, empty, empty]]; diff --git a/src/types/IBoard.ts b/src/types/IBoard.ts deleted file mode 100644 index cf26bfa..0000000 --- a/src/types/IBoard.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { cellState } from './cellState'; - -export type IBoard = Array>; diff --git a/src/types/Player.ts b/src/types/Player.ts new file mode 100644 index 0000000..cdf9cea --- /dev/null +++ b/src/types/Player.ts @@ -0,0 +1,16 @@ +export enum Player { + X = 'X', + O = 'O', +} + +enum TieEnum {} +type Tie = string | TieEnum; +export const tie: Tie = 'tie'; + +enum UndeterminedEnum {} +type Undetermined = string | UndeterminedEnum; +export const undetermined: Undetermined = 'undetermined'; + +export type Winner = Player | Tie | Undetermined; + +export default Player; diff --git a/src/types/cellState.ts b/src/types/cellState.ts deleted file mode 100644 index e5c7c6e..0000000 --- a/src/types/cellState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import playerEnum from './playerEnum'; - -export enum notSetTypeEnum {} -export type notSetType = string & notSetTypeEnum; -export const notSet: notSetType = 'notSet' as notSetType; - -export type cellState = notSetType | playerEnum; diff --git a/src/types/playerEnum.ts b/src/types/playerEnum.ts deleted file mode 100644 index eaf8599..0000000 --- a/src/types/playerEnum.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum playerEnum { - X = 'X', - O = 'O', -} - -export default playerEnum;