├── src ├── index.scss ├── react-app-env.d.ts ├── index.tsx ├── _mixins.scss ├── ticTacToe │ ├── Player.ts │ ├── Player.test.ts │ ├── EventEmitter.ts │ ├── TicTacToe.test.ts │ ├── PlayersManager.ts │ ├── PlayersManager.test.ts │ └── TicTacToe.ts ├── GameContext.tsx ├── board │ ├── Slot.test.tsx │ ├── Board.test.tsx │ ├── circle.svg │ ├── __snapshots__ │ │ └── Slot.test.tsx.snap │ ├── Board.tsx │ ├── x.svg │ └── Slot.tsx ├── _variables.scss ├── leaderBoard │ ├── leader-board.scss │ ├── LeaderBoard.test.js │ ├── __snapshots__ │ │ └── LeaderBoard.test.js.snap │ └── LeaderBoard.js ├── App.test.js ├── storage │ ├── Storage.test.js │ └── Storage.tsx ├── circle.svg ├── routes │ ├── Routes.js │ └── Routes.test.js ├── App.scss ├── logo.svg ├── setup │ ├── Setup.test.tsx │ └── Setup.js ├── x.svg └── App.tsx ├── .vscode └── settings.json ├── public ├── favicon.ico ├── circle.svg ├── index.html └── x.svg ├── .eslintrc.json ├── .gitignore ├── testHelpers └── LocalStorageMock.js ├── .travis.yml ├── tsconfig.json ├── .editorconfig ├── README.md ├── .github └── workflows │ ├── blank.yml │ └── codeql-analysis.yml └── package.json /src/index.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fernandosouza/react-tic-tac-toe/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true 7 | } 8 | }, 9 | "globals": { 10 | "jest": "readonly" 11 | } 12 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Routes from './routes/Routes'; 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById('root') 8 | ); 9 | -------------------------------------------------------------------------------- /src/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin background-transition($time: 110ms, $effect: linear) { 2 | -webkit-transition: background-color $time $effect; 3 | -ms-transition: background-color $time $effect; 4 | transition: background-color $time $effect; 5 | } 6 | -------------------------------------------------------------------------------- /src/ticTacToe/Player.ts: -------------------------------------------------------------------------------- 1 | class Player { 2 | public id: number; 3 | public name: string; 4 | 5 | constructor(opts: {id: number, name: string}) { 6 | this.id = opts.id; 7 | this.name = opts.name || ''; 8 | } 9 | } 10 | 11 | export default Player; 12 | -------------------------------------------------------------------------------- /src/GameContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import TicTacToe, { ITicTacToe } from './ticTacToe/TicTacToe'; 3 | 4 | export const GameContext = createContext<{ 5 | game: ITicTacToe 6 | }>({ 7 | game: new TicTacToe() 8 | }); 9 | GameContext.displayName = 'GameContext'; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # ignoring css 20 | src/**/*.css 21 | -------------------------------------------------------------------------------- /src/ticTacToe/Player.test.ts: -------------------------------------------------------------------------------- 1 | import Player from './Player'; 2 | 3 | describe('Player', () => { 4 | it('should create a Player with name and ID', () => { 5 | const player = new Player({ 6 | id: 1, 7 | name: 'Fernando' 8 | }); 9 | expect(player.id).toBe(1); 10 | expect(player.name).toBe('Fernando'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/board/Slot.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Slot } from './Slot'; 3 | import { create } from 'react-test-renderer'; 4 | import 'jest-styled-components'; 5 | 6 | describe('Slot', () => { 7 | it('renders slot', () => { 8 | const slot = create().toJSON(); 9 | expect(slot).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /testHelpers/LocalStorageMock.js: -------------------------------------------------------------------------------- 1 | class LocalStorageMock { 2 | constructor() { 3 | this.store = {}; 4 | } 5 | 6 | clear() { 7 | this.store = {}; 8 | } 9 | 10 | getItem(key) { 11 | return this.store[key]; 12 | } 13 | 14 | setItem(key, value) { 15 | this.store[key] = value.toString(); 16 | } 17 | }; 18 | 19 | global.localStorage = new LocalStorageMock; 20 | -------------------------------------------------------------------------------- /src/board/Board.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Board from './Board'; 3 | import renderer from 'react-test-renderer'; 4 | import { Slot } from './Slot'; 5 | 6 | describe('Board', () => { 7 | it('renders 9 slots', () => { 8 | const board = renderer.create( 9 | 10 | ); 11 | expect(board.root.findAllByType(Slot).length).toBe(9); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "11" 4 | install: 5 | - yarn 6 | before_script: 7 | - yarn run build-css 8 | script: 9 | - travis_wait yarn test 10 | before_deploy: 11 | - yarn build 12 | deploy: 13 | provider: pages 14 | skip_cleanup: true 15 | github_token: $GITHUB_TOKEN # Set in the settings page of your repository, as a secure variable 16 | keep_history: true 17 | local-dir: ./build 18 | on: 19 | branch: master -------------------------------------------------------------------------------- /src/_variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --board-size: 200px; 3 | --grid-border-color: rgba(255, 255, 255, .1); 4 | --grid-border: 1px solid var(--grid-border-color); 5 | --base-color: #34495e; 6 | --base-color-lighter: #465f78; 7 | --danger-color: #e74c3c; 8 | --success-color: #27ae60; 9 | } 10 | 11 | @media only screen and (min-device-width : 768px) { 12 | :root { 13 | --board-size: 500px; 14 | } 15 | } 16 | 17 | $base-color: #34495e; 18 | $danger-color: #e74c3c; 19 | $success-color: #27ae60; 20 | -------------------------------------------------------------------------------- /src/leaderBoard/leader-board.scss: -------------------------------------------------------------------------------- 1 | @import "../variables.scss"; 2 | @import "../mixins.scss"; 3 | 4 | .leader-board { 5 | font-size: 1em; 6 | font-weight: 300; 7 | width: 250px; 8 | margin: 0 auto; 9 | 10 | ul { 11 | list-style-type: none; 12 | padding: 0; 13 | } 14 | 15 | li { 16 | border-bottom: 1px solid $base-color + 40%; 17 | padding: 20px; 18 | } 19 | } 20 | 21 | @media only screen and (min-device-width : 768px) { 22 | .leader-board { 23 | width: 500px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,py}] 14 | charset = utf-8 15 | 16 | # 2 space indentation 17 | [*.{js,css}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | # Matches the exact files either package.json or .travis.yml 22 | [{package.json,.travis.yml}] 23 | indent_style = space 24 | indent_size = 2 25 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import '../testHelpers/LocalStorageMock'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import App from './App.tsx'; 5 | import { MemoryRouter as Router } from 'react-router-dom'; 6 | import TicTacToe from './ticTacToe/TicTacToe'; 7 | 8 | const params = { 9 | firstPlayer: 'Fernando', 10 | secondPlayer: 'Souza' 11 | }; 12 | 13 | let game; 14 | 15 | beforeEach(() => { 16 | game = new TicTacToe(); 17 | }) 18 | 19 | it('renders without crashing', () => { 20 | const div = document.createElement('div'); 21 | ReactDOM.render(, div); 22 | }); 23 | -------------------------------------------------------------------------------- /src/storage/Storage.test.js: -------------------------------------------------------------------------------- 1 | import '../../testHelpers/LocalStorageMock'; 2 | import Storage from './Storage'; 3 | 4 | describe('Storage', () => { 5 | it('should create a empty local storage', () => { 6 | const storage = new Storage('storage'); 7 | const data = storage.getData(); 8 | expect(data.length).toBe(0); 9 | }); 10 | 11 | it('should update the storage', () => { 12 | const storage = new Storage('storage2'); 13 | storage.update([1, 2, 3]); 14 | const data = storage.getData(); 15 | expect(data.length).toBe(3); 16 | expect(data[0]).toBe(1); 17 | expect(data[1]).toBe(2); 18 | expect(data[2]).toBe(3); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/storage/Storage.tsx: -------------------------------------------------------------------------------- 1 | class Storage { 2 | private storageName: string; 3 | 4 | constructor(storageName = 'gameLeaderBoard', initialValue = '[]') { 5 | this.storageName = storageName; 6 | if (!localStorage.getItem(storageName)) { 7 | localStorage.setItem(storageName, initialValue); 8 | } 9 | } 10 | 11 | getData(): V | null { 12 | const storedData = localStorage.getItem(this.storageName); 13 | if (storedData) { 14 | return JSON.parse(storedData || ''); 15 | } 16 | return null; 17 | } 18 | 19 | update(data: any) { 20 | localStorage.setItem(this.storageName, JSON.stringify(data)); 21 | } 22 | } 23 | 24 | export default Storage; 25 | -------------------------------------------------------------------------------- /public/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/board/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/ticTacToe/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | export type EventTypes = 'gameEnd'; 2 | 3 | export class EventEmitter { 4 | private subscribers: Map; 5 | 6 | constructor() { 7 | this.subscribers = new Map(); 8 | } 9 | 10 | on(event: EventTypes, fn: Function) { 11 | if (!this.subscribers.get(event)) { 12 | this.subscribers.set(event, []); 13 | } 14 | 15 | this.subscribers.get(event)!.push(fn); 16 | } 17 | 18 | off(event: EventTypes, fn: Function) { 19 | if (this.subscribers.get(event)) { 20 | this.subscribers.set(event, this.subscribers.get(event)!.filter(subscribed => fn !== subscribed)); 21 | } 22 | } 23 | 24 | dispatch(event: EventTypes, arg: any) { 25 | if (this.subscribers.get(event)) { 26 | this.subscribers.get(event)!.forEach(fn => fn.call(null, arg)); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/routes/Routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from '../App.tsx'; 3 | import Setup from '../setup/Setup'; 4 | import LeaderBoard from '../leaderBoard/LeaderBoard'; 5 | import { 6 | BrowserRouter as Router, 7 | Route 8 | } from 'react-router-dom'; 9 | 10 | export default () => { 11 | return ( 12 | 13 |
14 | { 15 | return ; 16 | }} 17 | /> 18 | { 19 | return ; 20 | }} 21 | /> 22 | { 23 | return ; 24 | }} 25 | /> 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/board/__snapshots__/Slot.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Slot renders slot 1`] = ` 4 | .c0 { 5 | --dimensions: calc(var(--board-size) / 3); 6 | display: -webkit-box; 7 | display: -webkit-flex; 8 | display: -ms-flexbox; 9 | display: flex; 10 | -webkit-align-items: center; 11 | -webkit-box-align: center; 12 | -ms-flex-align: center; 13 | align-items: center; 14 | -webkit-box-pack: center; 15 | -webkit-justify-content: center; 16 | -ms-flex-pack: center; 17 | justify-content: center; 18 | background-color: var(--base-color); 19 | border: var(--grid-border); 20 | height: var(--dimensions); 21 | width: var(--dimensions); 22 | -webkit-transition: background-color 150ms linear; 23 | transition: background-color 150ms linear; 24 | cursor: pointer; 25 | } 26 | 27 | .c0:hover { 28 | background-color: #3e5368; 29 | } 30 | 31 | 25 | aganst you partner or start a 26 | 30 | new game 31 | 32 | ? 33 |

34 | 35 |
    36 |
  • 37 | Fernando 38 |
  • 39 |
  • 40 | Souza 41 |
  • 42 |
43 | 44 | `; 45 | 46 | exports[`renders correctly 1`] = ` 47 |
50 |

51 | Leaderboard 52 |

53 |
54 |

55 | We do not have leaders to how. Why don't you 56 | 60 | play 61 | 62 | to see if you can put your name here? 63 |

64 | 68 | New game 69 | 70 |
71 |
    72 |
  • 73 | Fernando 74 |
  • 75 |
  • 76 | Souza 77 |
  • 78 |
79 |
80 | `; 81 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 9 * * 0' 11 | 12 | jobs: 13 | analyse: 14 | name: Analyse 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | # Override language selection by uncommenting this and choosing your languages 34 | # with: 35 | # languages: go, javascript, csharp, python, cpp, java 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v1 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /src/leaderBoard/LeaderBoard.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Storage from '../storage/Storage'; 3 | import { Link } from 'react-router-dom' 4 | 5 | class LeaderBoard extends Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = {}; 9 | } 10 | 11 | /** 12 | * @inheritdoc 13 | */ 14 | componentWillMount() { 15 | let storage = new Storage().getData(); 16 | this.setState({ 17 | leaderBoard: storage 18 | }); 19 | } 20 | 21 | /** 22 | * Renders winner congratulation message. 23 | * @private 24 | */ 25 | renderWinnerMessage_() { 26 | let { winner } = this.props.match.params; 27 | 28 | if (!winner) { 29 | return ( 30 |
31 |

32 | We do not have leaders to how. 33 | Why don't you play to see if you can put 34 | your name here? 35 |

36 | New game 37 |
38 | ) 39 | } 40 | 41 | return ( 42 |
43 |

44 | Congratulations, {winner}!!! 45 | Now you are in our leaderboard. 46 |

47 | 48 |

49 | Are you ready to aganst you partner or start a 51 | new game? 52 |

53 |
54 | ); 55 | } 56 | 57 | /** 58 | * @inheritdoc 59 | */ 60 | render() { 61 | let leaderBoard = this.state.leaderBoard; 62 | return ( 63 |
64 |

Leaderboard

65 | {this.renderWinnerMessage_()} 66 |
    67 | {leaderBoard.map((leader, key) => { 68 | return
  • {leader}
  • 69 | })} 70 |
71 |
72 | ); 73 | } 74 | } 75 | 76 | export default LeaderBoard 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tic-tac-toe", 3 | "version": "2.0.0", 4 | "private": true, 5 | "homepage": "https://fernandosouza.github.io/react-tic-tac-toe/", 6 | "author": "Fernando Souza (http://github.com/fernandosouza/)", 7 | "keywords": [ 8 | "react", 9 | "game", 10 | "tic-tac-toe" 11 | ], 12 | "bugs": "https://github.com/fernandosouza/react-tic-tac-toe/issues", 13 | "dependencies": { 14 | "@testing-library/react": "10.0.4", 15 | "@types/enzyme": "3.10.5", 16 | "@types/enzyme-adapter-react-16": "1.0.6", 17 | "@types/jest": "25.2.1", 18 | "@types/react-test-renderer": "16.9.2", 19 | "@types/styled-components": "5.1.0", 20 | "@types/testing-library__react": "10.0.1", 21 | "react": "16.13.1", 22 | "react-dom": "16.13.1", 23 | "react-router-dom": "5.0.0", 24 | "underscore": "1.10.2" 25 | }, 26 | "devDependencies": { 27 | "@types/react-router-dom": "5.1.5", 28 | "enzyme": "3.11.0", 29 | "enzyme-adapter-react-16": "1.12.1", 30 | "jest-styled-components": "7.0.2", 31 | "node-sass": "4.14.1", 32 | "react-router-test-context": "0.1.0", 33 | "react-scripts": "3.4.1", 34 | "react-test-renderer": "16.13.1", 35 | "styled-components": "5.1.1", 36 | "typescript": "3.9.7" 37 | }, 38 | "scripts": { 39 | "start": "react-scripts start", 40 | "build": "react-scripts build", 41 | "test": "react-scripts test --env=jsdom", 42 | "eject": "react-scripts eject", 43 | "coverage": "npm test -- --coverage --watchAll=false", 44 | "build-css": "node-sass src/ -o src/", 45 | "watch-css": "npm run build-css && node-sass src/ -o src/ --watch --recursive" 46 | }, 47 | "jest": { 48 | "collectCoverageFrom": [ 49 | "**/**/*.{js,jsx,ts,tsx}", 50 | "!**/node_modules/**", 51 | "!**/vendor/**", 52 | "!**/testHelpers/**" 53 | ] 54 | }, 55 | "browserslist": { 56 | "production": [ 57 | ">0.2%", 58 | "not dead", 59 | "not op_mini all" 60 | ], 61 | "development": [ 62 | "last 1 chrome version", 63 | "last 1 firefox version", 64 | "last 1 safari version" 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/setup/Setup.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Setup from './Setup'; 3 | import Enzyme, { mount } from 'enzyme'; 4 | import TicTacToe from '../ticTacToe/TicTacToe'; 5 | 6 | import Adapter from 'enzyme-adapter-react-16'; 7 | 8 | Enzyme.configure({ adapter: new Adapter() }); 9 | 10 | const match = { 11 | params: { 12 | firstPlayer: 'A', 13 | secondPlayer: 'B' 14 | } 15 | } 16 | 17 | describe('Setup', () => { 18 | const game = new TicTacToe(); 19 | 20 | it('should get the button disabled if the first player`s name is missing', () => { 21 | const wrapper = mount(); 22 | const playerTwoInput = wrapper.find('input[type="text"]').at(1); 23 | 24 | playerTwoInput.simulate('change', { target: { value: 'Souza' } }); 25 | 26 | expect(wrapper.find('button').prop('disabled')).toBeTruthy(); 27 | }); 28 | 29 | it('should get the button disabled if the second player`s name is missing', () => { 30 | const wrapper = mount(); 31 | const playerOneInput = wrapper.find('input[type="text"]').at(0); 32 | 33 | playerOneInput.simulate('change', { target: { value: 'Fernando' } }); 34 | 35 | expect(wrapper.find('button').prop('disabled')).toBeTruthy(); 36 | }); 37 | 38 | it('should get the buttom disabled if players` names are equal', () => { 39 | const wrapper = mount(); 40 | const playerOneInput = wrapper.find('input[type="text"]').at(0); 41 | const playerTwoInput = wrapper.find('input[type="text"]').at(1); 42 | 43 | playerOneInput.simulate('change', { target: { value: 'Fernando' } }); 44 | playerTwoInput.simulate('change', { target: { value: 'Fernando' } }); 45 | 46 | expect(wrapper.find('button').prop('disabled')).toBeTruthy(); 47 | }); 48 | 49 | it('should get the buttom disabled if players` names are equal', () => { 50 | const wrapper = mount(); 51 | const playerOneInput = wrapper.find('input[type="text"]').at(0); 52 | const playerTwoInput = wrapper.find('input[type="text"]').at(1); 53 | 54 | playerOneInput.simulate('change', { target: { value: 'Fernando' } }); 55 | playerTwoInput.simulate('change', { target: { value: 'Fernando' } }); 56 | 57 | expect(wrapper.find('button').prop('disabled')).toBeTruthy(); 58 | }); 59 | 60 | it('should get the buttom enabled if players` names are valid', () => { 61 | const wrapper = mount(); 62 | const playerOneInput = wrapper.find('input[type="text"]').at(0); 63 | const playerTwoInput = wrapper.find('input[type="text"]').at(1); 64 | 65 | playerOneInput.simulate('change', { target: { value: 'Fernando' } }); 66 | playerTwoInput.simulate('change', { target: { value: 'Souza' } }); 67 | 68 | expect(wrapper.find('button').prop('disabled')).toBeFalsy(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/board/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/board/Slot.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, FC, useEffect } from 'react'; 2 | import styled, { css, keyframes } from 'styled-components'; 3 | 4 | import { ReactComponent as X } from './x.svg'; 5 | import { ReactComponent as Circle } from './circle.svg'; 6 | import { GameContext } from '../GameContext'; 7 | import { WinnerSlots } from '../ticTacToe/TicTacToe'; 8 | 9 | const scale = keyframes` 10 | 0% { 11 | transform: scale(1); 12 | } 13 | 14 | 100% { 15 | transform: scale(1.1); 16 | } 17 | `; 18 | 19 | const Player1 = styled(X) <{ winner: boolean }>` 20 | width: 100px; 21 | height: 100px; 22 | 23 | ${props => props.winner && css` 24 | animation: ${scale} infinite alternate ease-in-out .54s; 25 | `} 26 | ` 27 | 28 | const Player2 = styled(Circle) <{ winner: boolean }>` 29 | width: 100px; 30 | height: 100px; 31 | 32 | ${props => props.winner && css` 33 | animation: ${scale} infinite alternate ease-in-out .54s; 34 | `} 35 | ` 36 | 37 | const SlotWrapper = styled.button<{ player: boolean }>` 38 | --dimensions: calc(var(--board-size) / 3); 39 | 40 | /*TODO: How to move this block to a shared place? */ 41 | display: flex; 42 | align-items: center; 43 | justify-content: center; 44 | 45 | background-color: var(--base-color); 46 | border: var(--grid-border); 47 | height: var(--dimensions); 48 | width: var(--dimensions); 49 | transition: background-color 150ms linear; 50 | cursor: pointer; 51 | 52 | ${props => !props.player && css` 53 | :hover { 54 | background-color: #3e5368; 55 | } 56 | `} 57 | `; 58 | 59 | export const Slot: FC<{ index: number }> = props => { 60 | const [player, setPlayer] = useState(null); 61 | const [winner, setWinner] = useState(false); 62 | const gameContext = useContext(GameContext); 63 | 64 | const onSlotClick = (index: number) => { 65 | gameContext.game!.fillSlot(index); 66 | if (gameContext.game!.getBoard().get(index)) { 67 | setPlayer(gameContext.game!.getBoard().get(index)); 68 | } 69 | }; 70 | 71 | useEffect(() => { 72 | const gameEndSubscriber = (winner: WinnerSlots) => { 73 | if (winner.slots.includes(props.index)) { 74 | setWinner(true); 75 | } 76 | } 77 | gameContext.game!.on('gameEnd', gameEndSubscriber); 78 | return () => { 79 | gameContext.game!.off('gameEnd', gameEndSubscriber); 80 | } 81 | }, [gameContext.game, props.index]); 82 | 83 | return ( 84 | onSlotClick(props.index)} 89 | > 90 | { 91 | { 92 | 1: , 93 | 2: 94 | //@ts-ignore 95 | }[player] 96 | } 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ContextType } from 'react'; 2 | import Board from './board/Board'; 3 | import Storage from './storage/Storage'; 4 | import { Link } from 'react-router-dom'; 5 | import './App.scss'; 6 | import { GameContext } from './GameContext'; 7 | import Player from './ticTacToe/Player'; 8 | import { GameBoard } from './ticTacToe/TicTacToe'; 9 | 10 | interface AppProps { 11 | match: { 12 | params: { 13 | firstPlayer: string, 14 | secondPlayer: string 15 | } 16 | } 17 | } 18 | interface AppState { 19 | winner: { 20 | player: Player | null 21 | }, 22 | filledSlots: GameBoard | null 23 | winnerSlots: Player[] | null 24 | } 25 | 26 | /** 27 | * Initialize the game asking for players information. Manage players 28 | * turns and set in the board filled slots. 29 | * @author Fernando Souza nandosouzafilho@gmail.com 30 | **/ 31 | // ITicTacToe 32 | class App extends Component { 33 | static contextType = GameContext; 34 | context!: ContextType; 35 | private storage: Storage; 36 | 37 | constructor(props: AppProps) { 38 | super(props); 39 | 40 | this.state = { 41 | winner: { 42 | player: null 43 | }, 44 | filledSlots: null, 45 | winnerSlots: [] 46 | }; 47 | this.storage = new Storage(); 48 | } 49 | 50 | componentDidMount() { 51 | if (this.hasNoPlayers_()) { 52 | this.setPlayersFromURL_(); 53 | } 54 | 55 | this.setState({ 56 | filledSlots: this.context.game.getBoard() 57 | }); 58 | } 59 | 60 | /** 61 | * Uses url parameters to create players. 62 | * @private 63 | **/ 64 | setPlayersFromURL_() { 65 | const { firstPlayer, secondPlayer } = this.props.match.params; 66 | this.context.game.playersManager.addPlayer(firstPlayer); 67 | this.context.game.playersManager.addPlayer(secondPlayer); 68 | } 69 | 70 | /** 71 | * Checks if players was not already defined. 72 | * @returns {Boolean} 73 | * @private 74 | **/ 75 | hasNoPlayers_() { 76 | return this.context.game.playersManager 77 | .checkErros() 78 | .some(error => error.code === 'no_players'); 79 | } 80 | 81 | /** 82 | * @inheritdoc 83 | **/ 84 | render() { 85 | const leaderboardMessage = () => { 86 | if (this.state.winner.player) { 87 | return ( 88 |

89 | Congratulations {this.state.winner.player.name}. 90 | See leaderboard 91 | 92 |

93 | ); 94 | } 95 | } 96 | 97 | return ( 98 | <> 99 | 100 | 101 | 102 | New game 103 | 104 | 105 |
106 | {leaderboardMessage()} 107 |
108 | 109 | ); 110 | } 111 | } 112 | 113 | export default App; 114 | -------------------------------------------------------------------------------- /src/ticTacToe/TicTacToe.test.ts: -------------------------------------------------------------------------------- 1 | import TicTacToe from './TicTacToe'; 2 | 3 | describe('Game', () => { 4 | let game: TicTacToe; 5 | 6 | beforeEach(() => { 7 | game = new TicTacToe('A', 'B'); 8 | }); 9 | 10 | it('should initialize with a empty board', () => { 11 | let board = game.getBoard(); 12 | 13 | expect(board.size).toBe(0); 14 | }); 15 | 16 | it('should fill a giving slot with the current player id', () => { 17 | let board; 18 | 19 | game.fillSlot(0); 20 | game.fillSlot(1); 21 | board = game.getBoard(); 22 | 23 | expect(board.get(0)).toBe(1); 24 | expect(board.get(1)).toBe(2); 25 | }); 26 | 27 | it('should not fill the same slot twice', () => { 28 | let board; 29 | 30 | game.fillSlot(0); 31 | game.fillSlot(0); 32 | board = game.getBoard(); 33 | 34 | expect(board.get(0)).toBe(1); 35 | expect(board.get(0)).toBe(1); 36 | }); 37 | 38 | it('should not exceed 9 game turns', () => { 39 | let board; 40 | 41 | game.fillSlot(0); 42 | game.fillSlot(1); 43 | game.fillSlot(2); 44 | game.fillSlot(3); 45 | game.fillSlot(4); 46 | game.fillSlot(5); 47 | game.fillSlot(6); 48 | game.fillSlot(7); 49 | game.fillSlot(8); 50 | game.fillSlot(9); 51 | board = game.getBoard(); 52 | 53 | expect(board.size).toBe(9); 54 | }); 55 | 56 | it('should call a registered callback as soon as the game finish', () => { 57 | const onGameFinishMock = jest.fn(); 58 | game.on('gameEnd', onGameFinishMock); 59 | 60 | game.fillSlot(0); 61 | game.fillSlot(3); 62 | game.fillSlot(1); 63 | game.fillSlot(4); 64 | game.fillSlot(2); 65 | 66 | expect(onGameFinishMock.mock.calls).toHaveLength(1); 67 | }); 68 | 69 | it('should pass the winner as argument to the registered callback', () => { 70 | const onGameFinishMock = jest.fn(); 71 | game.on('gameEnd', onGameFinishMock); 72 | 73 | game.fillSlot(0); 74 | game.fillSlot(3); 75 | game.fillSlot(1); 76 | game.fillSlot(4); 77 | game.fillSlot(2); 78 | 79 | expect(onGameFinishMock.mock.calls[0][0].player).toHaveProperty('name', 'A'); 80 | }); 81 | 82 | it('should pass undefined as argument to the registered callback if no one won', () => { 83 | const onGameFinishMock = jest.fn(); 84 | game.on('gameEnd', onGameFinishMock); 85 | 86 | game.fillSlot(0); 87 | game.fillSlot(2); 88 | game.fillSlot(1); 89 | game.fillSlot(3); 90 | game.fillSlot(5); 91 | game.fillSlot(7); 92 | game.fillSlot(6); 93 | game.fillSlot(8); 94 | game.fillSlot(4); 95 | 96 | expect(onGameFinishMock.mock.calls).toHaveLength(1); 97 | expect(onGameFinishMock.mock.calls[0][0]).toBeUndefined(); 98 | }); 99 | 100 | it('should clear board', () => { 101 | game.fillSlot(0); 102 | game.fillSlot(2); 103 | 104 | expect(game.getBoard().size).toBe(2); 105 | 106 | game.clearBoard(); 107 | 108 | expect(game.getBoard().size).toBe(0); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/ticTacToe/PlayersManager.ts: -------------------------------------------------------------------------------- 1 | import Player from './Player'; 2 | 3 | interface PlayerManagerError { 4 | code: string 5 | } 6 | 7 | export interface IPlayersManager { 8 | checkErros: () => PlayerManagerError[] 9 | addPlayer: (playerName: string) => void; 10 | getCurrentPlayer: () => Player; 11 | nextPlayerTurn: () => Player; 12 | switchPlayer: (playerIndex: number) => Player; 13 | } 14 | 15 | /** 16 | * Manages players providing a public API for adding players and sets which one 17 | * is in the turn. 18 | **/ 19 | class PlayersManager implements IPlayersManager { 20 | private erros_: PlayerManagerError[] = []; 21 | private players_: Player[] = []; 22 | private currentPlayerIndex_ = 0; 23 | 24 | constructor(players: Player[] = []) { 25 | this.createPlayers_(players); 26 | } 27 | 28 | /** 29 | * Adds a single player to the array of players by crating a new Player 30 | * instance. 31 | * @param {String} playerName The player data to create a new Player instance. 32 | **/ 33 | addPlayer(playerName: string) { 34 | if (!playerName) { 35 | throw Error('A player name should be provided'); 36 | } 37 | this.players_.push(new Player({ 38 | name: playerName, 39 | id: this.players_.length + 1 40 | })); 41 | } 42 | 43 | /** 44 | * Checks if there are erros regarding players. 45 | * @returns {Array} Array of errors 46 | **/ 47 | checkErros(): PlayerManagerError[] { 48 | this.erros_ = []; 49 | if (!this.players_.length) { 50 | this.erros_.push({ code: 'no_players' }); 51 | return this.erros_; 52 | } 53 | if ( 54 | this.players_[0].name.toLowerCase() === 55 | this.players_[1].name.toLowerCase() 56 | ) { 57 | this.erros_.push({ code: 'duplicated_names' }); 58 | } 59 | return this.erros_; 60 | } 61 | 62 | /** 63 | * Method used by the class constructor to create fill the initial list of 64 | * players. 65 | * @param {Array} players A array of players data. 66 | * @private 67 | **/ 68 | createPlayers_(players: Player[]) { 69 | this.players_ = players.map(player => { 70 | return new Player(player); 71 | }); 72 | } 73 | 74 | /** 75 | * Returns the current player. 76 | * @returns {Object} Player 77 | **/ 78 | getCurrentPlayer() { 79 | return this.players_[this.currentPlayerIndex_]; 80 | } 81 | 82 | /** 83 | * Returns the list of players. 84 | * @returns {Array} The list of players 85 | **/ 86 | getPlayers() { 87 | return [...this.players_]; 88 | } 89 | 90 | /** 91 | * Changes the game turn to the next available player. Select the first player 92 | * of the array if no next one is found. 93 | **/ 94 | nextPlayerTurn() { 95 | let currentPlayerIndex = this.currentPlayerIndex_; 96 | currentPlayerIndex++; 97 | 98 | if (currentPlayerIndex >= this.players_.length) { 99 | currentPlayerIndex = 0; 100 | } 101 | 102 | return this.switchPlayer(currentPlayerIndex); 103 | } 104 | 105 | /** 106 | * Changes the current player by providing its index. 107 | * @param {number} index The player index. 108 | **/ 109 | switchPlayer(index: number) { 110 | if (index !== 0 && this.players_.length < index) { 111 | throw Error('Player not found'); 112 | } 113 | this.currentPlayerIndex_ = index; 114 | return this.players_[index]; 115 | } 116 | } 117 | 118 | export default PlayersManager; 119 | -------------------------------------------------------------------------------- /src/ticTacToe/PlayersManager.test.ts: -------------------------------------------------------------------------------- 1 | import PlayersManager from './PlayersManager'; 2 | import Player from './Player'; 3 | 4 | const playerMock = new Player({ 5 | id: 1, 6 | name: 'Fernando', 7 | }); 8 | 9 | const playersMock = [new Player({ 10 | id: 2, 11 | name: 'Souza' 12 | }), playerMock]; 13 | 14 | describe('PlayersManager', () => { 15 | it('should a player be a instance of Player', () => { 16 | const playersManager = new PlayersManager([playerMock]); 17 | const currentPlayer = playersManager.getCurrentPlayer(); 18 | expect(currentPlayer instanceof Player).toBe(true); 19 | }); 20 | 21 | it('should not throws an error if no parameter is passed to the constructor', () => { 22 | expect(() => { 23 | new PlayersManager(); 24 | }).not.toThrowError(); 25 | }); 26 | 27 | it('should create a Player Manager instance with a player from constructor', () => { 28 | const playersManager = new PlayersManager([playerMock]); 29 | const currentPlayer = playersManager.getCurrentPlayer(); 30 | expect(currentPlayer.name).toBe('Fernando'); 31 | }); 32 | 33 | it('should current player be the first item of the array', () => { 34 | const playersManager = new PlayersManager(playersMock); 35 | const currentPlayer = playersManager.getCurrentPlayer(); 36 | expect(currentPlayer.name).toBe('Souza'); 37 | }); 38 | 39 | it('should add a new player from public API', () => { 40 | const playersManager = new PlayersManager(playersMock); 41 | expect(playersManager.getPlayers().length).toBe(2); 42 | playersManager.addPlayer('Fernando'); 43 | expect(playersManager.getPlayers().length).toBe(3); 44 | }); 45 | 46 | it('should change the current player', () => { 47 | const playersManager = new PlayersManager(playersMock); 48 | let currentPlayer = playersManager.getCurrentPlayer(); 49 | expect(currentPlayer.name).toBe('Souza'); 50 | playersManager.switchPlayer(1); 51 | 52 | currentPlayer = playersManager.getCurrentPlayer(); 53 | expect(currentPlayer.name).toBe('Fernando'); 54 | }); 55 | 56 | it('should throws an error if switchPlayer() could not find the specified player', () => { 57 | const playersManager = new PlayersManager(playersMock); 58 | let currentPlayer = playersManager.getCurrentPlayer(); 59 | expect(currentPlayer.name).toBe('Souza'); 60 | 61 | expect(() => { 62 | playersManager.switchPlayer(3); 63 | }).toThrowError('Player not found'); 64 | }); 65 | 66 | it('should switch to the next available player', () => { 67 | const playersManager = new PlayersManager(playersMock); 68 | let currentPlayer = playersManager.getCurrentPlayer(); 69 | expect(currentPlayer.name).toBe('Souza'); 70 | 71 | playersManager.nextPlayerTurn(); 72 | currentPlayer = playersManager.getCurrentPlayer(); 73 | expect(currentPlayer.name).toBe('Fernando'); 74 | }); 75 | 76 | it('should select the first player of the list if the current player is the last one', () => { 77 | const playersManager = new PlayersManager(playersMock); 78 | let currentPlayer = playersManager.getCurrentPlayer(); 79 | expect(currentPlayer.name).toBe('Souza'); 80 | 81 | playersManager.nextPlayerTurn(); 82 | currentPlayer = playersManager.getCurrentPlayer(); 83 | expect(currentPlayer.name).toBe('Fernando'); 84 | 85 | playersManager.nextPlayerTurn(); 86 | currentPlayer = playersManager.getCurrentPlayer(); 87 | expect(currentPlayer.name).toBe('Souza'); 88 | }); 89 | 90 | it('should not allow duplicated names', () => { 91 | const players = [ 92 | new Player({ name: 'fernando', id: 1 }), 93 | new Player({ name: 'Fernando', id: 2 }) 94 | ]; 95 | const playersManager = new PlayersManager(players); 96 | playersManager.getCurrentPlayer(); 97 | expect(playersManager.checkErros()).toHaveLength(1); 98 | }); 99 | 100 | it('should duplicated error code be `duplicated_names`', () => { 101 | const players = [ 102 | new Player({ name: 'fernando', id: 1 }), 103 | new Player({ name: 'Fernando', id: 2 }) 104 | ]; 105 | const playersManager = new PlayersManager(players); 106 | playersManager.getCurrentPlayer(); 107 | expect(playersManager.checkErros()[0]).toEqual({ 108 | code: 'duplicated_names' 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/setup/Setup.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { GameContext } from '../GameContext'; 3 | import styled from 'styled-components'; 4 | import { ReactComponent as X } from '../x.svg'; 5 | import { ReactComponent as Circle } from '../circle.svg'; 6 | 7 | const SetupPage = styled.div` 8 | display: flex; 9 | width: 100%; 10 | `; 11 | 12 | const Input = styled.input` 13 | width: 200px; 14 | margin: 10px auto; 15 | display: block; 16 | font-size: 16px; 17 | outline: 0; 18 | &::placeholder { 19 | color: #fff; 20 | } 21 | 22 | background: var(--base-color); 23 | border: 1px solid var(--base-color-lighter); 24 | border-radius: 5px; 25 | color: #fff; 26 | font-weight: 200; 27 | padding: 10px; 28 | 29 | &:focus { 30 | background: var(--base-color-lighter); 31 | } 32 | `; 33 | 34 | const Player = styled.div` 35 | margin: 20px 0; 36 | display: flex; 37 | 38 | svg { 39 | margin: 0 auto; 40 | width: 140px; 41 | height: 140px; 42 | } 43 | `; 44 | 45 | const Collumn = styled.div` 46 | flex-grow: 1; 47 | width: 50%; 48 | ` 49 | 50 | 51 | /** 52 | * Component responsible for getting players` name and passes it to the parent 53 | * component through a function named `onFinishSetup`. 54 | **/ 55 | class Setup extends Component { 56 | static contextType = GameContext; 57 | 58 | constructor(props) { 59 | super(props); 60 | 61 | this.onPlayerTwoNameChange_ = this.onPlayerTwoNameChange_.bind(this); 62 | this.onPlayerOneNameChange_ = this.onPlayerOneNameChange_.bind(this); 63 | this.onFormSubmit_ = this.onFormSubmit_.bind(this); 64 | 65 | this.state = { 66 | playerOneName: '', 67 | playerTwoName: '', 68 | } 69 | } 70 | 71 | componentDidMount() { 72 | this.context.game.clearBoard(); 73 | } 74 | 75 | /** 76 | * Express conditions to disable form submission. 77 | * @returns {boolean} true for disable and false for enable 78 | * @private 79 | **/ 80 | disableForm_() { 81 | let { playerOneName, playerTwoName } = this.state; 82 | return !playerOneName || !playerTwoName || playerOneName === playerTwoName; 83 | } 84 | 85 | /** 86 | * Listens to the form submission and informs players` name to the 87 | * parent component. 88 | * @param {event} event The event object 89 | * @private 90 | **/ 91 | onFormSubmit_(event) { 92 | event.preventDefault(); 93 | let { playerOneName, playerTwoName } = this.state; 94 | if (!this.context.game.playersManager_.checkErros().lenght) { 95 | this.props.history.push(`/firstPlayer/${playerOneName}/secondPlayer/${playerTwoName}`); 96 | } 97 | } 98 | 99 | /** 100 | * Stores the first player's name in the state object. 101 | * @param {event} event The event object 102 | * @private 103 | **/ 104 | onPlayerOneNameChange_(event) { 105 | this.setState({ 106 | playerOneName: event.target.value 107 | }); 108 | } 109 | 110 | /** 111 | * Stores the second player's name in the state object. 112 | * @param {event} event The event object 113 | * @private 114 | **/ 115 | onPlayerTwoNameChange_(event) { 116 | this.setState({ 117 | playerTwoName: event.target.value 118 | }); 119 | } 120 | 121 | /** 122 | * @inheritdoc 123 | **/ 124 | render() { 125 | let disabled = this.disableForm_(); 126 | 127 | return ( 128 | <> 129 | 130 | 131 | 132 | 133 | 134 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 152 | 153 | 154 | 155 | 156 | ); 157 | } 158 | } 159 | 160 | export default Setup; 161 | -------------------------------------------------------------------------------- /src/ticTacToe/TicTacToe.ts: -------------------------------------------------------------------------------- 1 | import PlayersManager, { IPlayersManager } from './PlayersManager'; 2 | import Player from './Player'; 3 | import { EventEmitter } from './EventEmitter'; 4 | 5 | export type WinnerSlots = { player: Player, slots: any }; 6 | export type GameBoard = Map; 7 | export interface ITicTacToe extends EventEmitter { 8 | fillSlot: (index: number) => void, 9 | getBoard: () => GameBoard 10 | clearBoard: () => void; 11 | playersManager: IPlayersManager; 12 | } 13 | 14 | class TicTacToe extends EventEmitter implements ITicTacToe { 15 | private board_: GameBoard; 16 | playersManager: IPlayersManager; 17 | 18 | constructor(playerOne?: string, playerTwo?: string) { 19 | super(); 20 | this.board_ = new Map(); 21 | this.playersManager = new PlayersManager(); 22 | if (playerOne && playerTwo) { 23 | this.playersManager.addPlayer(playerOne); 24 | this.playersManager.addPlayer(playerTwo); 25 | } 26 | } 27 | 28 | checkSlots_(slots: number[], playerId: number) { 29 | if (slots.length < 3) { 30 | return; 31 | } 32 | 33 | if ( 34 | this.checkSlot_(slots[0], playerId) && 35 | this.checkSlot_(slots[1], playerId) && 36 | this.checkSlot_(slots[2], playerId) 37 | ) { 38 | return slots; 39 | } 40 | } 41 | 42 | /** 43 | * Checks if there is any matched column by a given player. 44 | * @param {Number} playerId The player id to be checked. 45 | * @returns {boolean} Returns true if a matched column is found, otherwise 46 | * it returns false. 47 | * @private 48 | **/ 49 | checkColumns_(playerId: number) { 50 | return ( 51 | this.checkSlots_([0, 3, 6], playerId) || 52 | this.checkSlots_([1, 4, 7], playerId) || 53 | this.checkSlots_([2, 5, 8], playerId) 54 | ); 55 | } 56 | 57 | /** 58 | * Checks if the diagonal line starting from the top-left slot has been 59 | * filled by the current player. 60 | * @param {Number} playerId The player id to be checked. 61 | * @returns {boolean} Returns true if the diagonal line has been filled by 62 | * a player, otherwise, false. 63 | * @private 64 | **/ 65 | checkDiagonalUpLeft_(playerId: number) { 66 | return this.checkSlots_([0, 4, 8], playerId); 67 | } 68 | 69 | /** 70 | * Checks if the diagonal line starting from the top-right slot has been 71 | * filled by the current player. 72 | * @param {Number} playerId The player id to be checked. 73 | * @returns {boolean} Returns true if the diagonal line has been filled by 74 | * a player, otherwise, false. 75 | * @private 76 | **/ 77 | checkDiagonalUpRight_(playerId: number) { 78 | return this.checkSlots_([2, 4, 6], playerId); 79 | } 80 | 81 | /** 82 | * Walks through Board lines looking for unmatched slots in order to 83 | * determine if the current player got a line matching. Returns true if 84 | * it has, otherwise returns false. 85 | * @param {Number} playerId The player id to be checked 86 | * @returns {boolean} Returns true if a matched line is found, otherwise, false. 87 | * @private 88 | **/ 89 | checkLines_(playerId: number) { 90 | return ( 91 | this.checkSlots_([0, 1, 2], playerId) || 92 | this.checkSlots_([3, 4, 5], playerId) || 93 | this.checkSlots_([6, 7, 8], playerId) 94 | ); 95 | } 96 | 97 | /** 98 | * Checks if the slot was filled by an given player. 99 | * @param {Number} index The slot index. 100 | * @param {Number} currentPlayerId The player id. 101 | * @private 102 | **/ 103 | checkSlot_(index: number, currentPlayerId: number) { 104 | return this.board_.get(index) === currentPlayerId; 105 | } 106 | 107 | /** 108 | * Fills a specific board slot and also checks if there is a winner, 109 | * if it has, do not call the next game turn and end the game. 110 | * @param {Number} index the slot index. 111 | **/ 112 | fillSlot(index: number) { 113 | const currentPlayer_ = this.playersManager.getCurrentPlayer(); 114 | if (this.board_.get(index)) { 115 | return; 116 | } 117 | 118 | if (this.board_.size < 9) { 119 | let currentPlayer = currentPlayer_; 120 | this.board_.set(index, currentPlayer.id); 121 | } 122 | 123 | let winner = this.getWinner_(); 124 | 125 | if (winner || this.board_.size === 9) { 126 | this.dispatch('gameEnd', winner); 127 | } else { 128 | this.playersManager.nextPlayerTurn(); 129 | } 130 | } 131 | 132 | /** 133 | * Returns the list of filled slots. 134 | * @returns {Map} The game board. 135 | **/ 136 | getBoard(): GameBoard { 137 | return new Map(this.board_); 138 | } 139 | 140 | clearBoard() { 141 | this.board_.clear(); 142 | } 143 | 144 | /** 145 | * Checks all the possibilities of have a winner and return the Player instance. 146 | * @returns {Object|undefined} Returns the currentPlayer if a winner is found 147 | * @private 148 | **/ 149 | getWinner_(): WinnerSlots | undefined { 150 | const currentPlayer = this.playersManager.getCurrentPlayer(); 151 | let playerId = currentPlayer.id; 152 | let hasWinner = 153 | this.checkLines_(playerId) || 154 | this.checkColumns_(playerId) || 155 | this.checkDiagonalUpLeft_(playerId) || 156 | this.checkDiagonalUpRight_(playerId); 157 | 158 | if (hasWinner) { 159 | return { 160 | player: currentPlayer, 161 | slots: hasWinner 162 | }; 163 | } 164 | } 165 | } 166 | 167 | export default TicTacToe; 168 | --------------------------------------------------------------------------------