diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..4bb1637
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,58 @@
+const path = require('path');
+
+module.exports = {
+ env: {
+ browser: true,
+ es6: true,
+ jest: true,
+ },
+ extends: ['airbnb'],
+ globals: {
+ Atomics: 'readonly',
+ SharedArrayBuffer: 'readonly',
+ },
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ ecmaVersion: 2018,
+ sourceType: 'module',
+ },
+ settings: {
+ 'import/resolver': {
+ node: {
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
+ paths: [path.resolve(__dirname)],
+ },
+ },
+ },
+ overrides: [
+ {
+ files: ['**/*.tsx'],
+ rules: {
+ 'react/prop-types': 'off',
+ },
+ },
+ ],
+ plugins: ['react', '@typescript-eslint'],
+ rules: {
+ 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
+ 'react/jsx-one-expression-per-line': 0,
+ '@typescript-eslint/explicit-function-return-type': ['error'],
+ 'max-len': ['error', 120],
+ 'implicit-arrow-linebreak': 0,
+ 'function-paren-newline': 0,
+ 'no-confusing-arrow': 0,
+ 'react/jsx-props-no-spreading': 0,
+ 'no-unused-vars': 'off',
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ {
+ vars: 'all',
+ args: 'after-used',
+ ignoreRestSiblings: false,
+ },
+ ],
+ },
+};
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..8ea77ce
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "printWidth": 120,
+ "jsxBracketSameLine": true,
+ "arrowParens": "always",
+ "singleQuote": true,
+ "trailingComma": "all"
+}
diff --git a/package.json b/package.json
index 21e6d2f..3d49fc5 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"react": "^16.10.2",
"react-dom": "^16.10.2",
"react-scripts": "3.2.0",
+ "styled-components": "^5.0.0-beta.9",
"typescript": "3.6.4"
},
"scripts": {
@@ -32,5 +33,16 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "devDependencies": {
+ "@types/styled-components": "^4.1.19",
+ "@typescript-eslint/eslint-plugin": "^2.3.3",
+ "@typescript-eslint/parser": "^2.3.3",
+ "eslint": "^6.5.1",
+ "eslint-config-airbnb": "^18.0.1",
+ "eslint-plugin-import": "^2.18.2",
+ "eslint-plugin-jsx-a11y": "^6.2.3",
+ "eslint-plugin-react": "^7.16.0",
+ "eslint-plugin-react-hooks": "^1.7.0"
}
}
diff --git a/src/App.css b/src/App.css
deleted file mode 100644
index afc3885..0000000
--- a/src/App.css
+++ /dev/null
@@ -1,22 +0,0 @@
-.App {
- text-align: center;
-}
-
-.App-logo {
- height: 40vmin;
-}
-
-.App-header {
- background-color: #282c34;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- font-size: calc(10px + 2vmin);
- color: white;
-}
-
-.App-link {
- color: #09d3ac;
-}
diff --git a/src/App.tsx b/src/App.tsx
index 226ee63..7d71909 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,26 +1,34 @@
import React from 'react';
-import logo from './logo.svg';
-import './App.css';
+import styled from 'styled-components';
+import Board from './components/Board';
+import playerEnum from './types/playerEnum';
+import GlobalStyle from './theming/GlobalStyle';
+
+const StyledApp = styled.div`
+ text-align: center;
+ background-color: #282c34;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: calc(10px + 2vmin);
+ color: white;
+`;
const App: React.FC = () => {
+ const [currentPlayer, setCurrentPlayer] = React.useState(playerEnum.X);
+
+ const nextPlayer = (): void => {
+ setCurrentPlayer((c) => (c === playerEnum.X ? playerEnum.O : playerEnum.X));
+ };
+
return (
-
+
+
+
+
);
-}
+};
export default App;
diff --git a/src/components/Board.tsx b/src/components/Board.tsx
new file mode 100644
index 0000000..f7fc6b0
--- /dev/null
+++ b/src/components/Board.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+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';
+
+const Table = styled.table`
+ border: 0;
+ border-collapse: collapse;
+`;
+
+const Row = styled.tr`
+ td {
+ border: ${1 / 16}rem solid transparent;
+ border-bottom-color: white;
+ border-right-color: white;
+
+ &:nth-last-child(1) {
+ border-right-color: transparent;
+ }
+ }
+
+ &:nth-last-child(1) {
+ td {
+ border-bottom-color: transparent;
+ }
+ }
+`;
+
+export interface IProps {
+ currentPlayer: playerEnum;
+ nextPlayer: () => void;
+}
+
+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));
+
+ 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;
+ };
+
+ setBoard((brd) => [...brd.map((row, rI) => row.map((cell, cI) => checkCell(rI, cI, cell)))]);
+
+ nextPlayer();
+ };
+
+ 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)}
+ />
+ ))}
+ |
+ ))}
+
+
+ );
+};
+
+export default Board;
diff --git a/src/components/Cell.tsx b/src/components/Cell.tsx
new file mode 100644
index 0000000..78570ab
--- /dev/null
+++ b/src/components/Cell.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import styled from 'styled-components';
+import { cellState, notSet } from '../types/cellState';
+import playerEnum from '../types/playerEnum';
+
+export interface IProps {
+ value: cellState;
+ onClick: () => void | Boolean;
+}
+
+const renderPlayerSymbol = (value: cellState): string => {
+ if (value === playerEnum.X) {
+ return 'X';
+ }
+ if (value === playerEnum.O) {
+ return 'O';
+ }
+ return '';
+};
+
+export const StyledCell = styled.td`
+ width: ${50 / 16}rem;
+ height: ${50 / 16}rem;
+ font-size: 2rem;
+`;
+
+const Cell: React.FC = ({ value, onClick }) => (
+ value === notSet && onClick()}>{renderPlayerSymbol(value)}
+);
+
+export default Cell;
diff --git a/src/index.css b/src/index.css
deleted file mode 100644
index ec2585e..0000000
--- a/src/index.css
+++ /dev/null
@@ -1,13 +0,0 @@
-body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
-}
diff --git a/src/index.tsx b/src/index.tsx
index 87d1be5..b597a44 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,12 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
-import './index.css';
import App from './App';
-import * as serviceWorker from './serviceWorker';
ReactDOM.render(, document.getElementById('root'));
-
-// If you want your app to work offline and load faster, you can change
-// unregister() to register() below. Note this comes with some pitfalls.
-// Learn more about service workers: https://bit.ly/CRA-PWA
-serviceWorker.unregister();
diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts
new file mode 100644
index 0000000..da79b4d
--- /dev/null
+++ b/src/lib/scoring.ts
@@ -0,0 +1,82 @@
+import { cellState, notSet } from '../types/cellState';
+import { IBoard } from '../types/IBoard';
+
+enum NoOneEnum {}
+type INoOne = string | NoOneEnum;
+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;
+}
+
+/*
+ * diagonal 1-2
+ * horizonal 1-3
+ * vertical 1-3
+ * line: X: 0, O: 1
+ * */
+export const getBoardSummary = (board: IBoard): IBoardSummary => {
+ const summary: IBoardSummary = {
+ horizontal: [[], [], []],
+ vertical: [[], [], []],
+ diagonal: [[], []],
+ winner: undetermined,
+ };
+ board.forEach((row, rowIndex) =>
+ row.forEach((cell, cellIndex) => {
+ summary.horizontal[rowIndex][cellIndex] = cell;
+ summary.vertical[cellIndex][rowIndex] = cell;
+ if (cellIndex - rowIndex === 0) {
+ summary.diagonal[0][cellIndex] = cell;
+ }
+ if (cellIndex + rowIndex === 2) {
+ summary.diagonal[1][cellIndex] = cell;
+ }
+ }),
+ );
+
+ const getWinnerFromLine = (line: cellState[]): IWinner =>
+ line.reduce(
+ (cellWinner: IWinner, cell: cellState): IWinner => {
+ if (cell === notSet) {
+ return undetermined;
+ }
+ if (cellWinner === undetermined && cell !== notSet) {
+ return cell;
+ }
+ if (cellWinner === cell) {
+ return cell;
+ }
+ return noOne;
+ },
+ undetermined as IWinner,
+ );
+
+ const getWinnerFromLines = (lines: cellState[][]): IWinner =>
+ lines.reduce(
+ (lineWinner: IWinner, line: cellState[]): IWinner =>
+ lineWinner !== undetermined ? lineWinner : getWinnerFromLine(line),
+ undetermined,
+ );
+
+ let result = getWinnerFromLines(summary.horizontal);
+ if (result === undetermined || result === noOne) {
+ result = getWinnerFromLines(summary.vertical);
+ }
+ if (result === undetermined || result === noOne) {
+ result = getWinnerFromLines(summary.diagonal);
+ }
+ summary.winner = result;
+ return summary;
+};
+
+export default getBoardSummary;
diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts
deleted file mode 100644
index 15d90cb..0000000
--- a/src/serviceWorker.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-// This optional code is used to register a service worker.
-// register() is not called by default.
-
-// This lets the app load faster on subsequent visits in production, and gives
-// it offline capabilities. However, it also means that developers (and users)
-// will only see deployed updates on subsequent visits to a page, after all the
-// existing tabs open on the page have been closed, since previously cached
-// resources are updated in the background.
-
-// To learn more about the benefits of this model and instructions on how to
-// opt-in, read https://bit.ly/CRA-PWA
-
-const isLocalhost = Boolean(
- window.location.hostname === 'localhost' ||
- // [::1] is the IPv6 localhost address.
- window.location.hostname === '[::1]' ||
- // 127.0.0.1/8 is considered localhost for IPv4.
- window.location.hostname.match(
- /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
- )
-);
-
-type Config = {
- onSuccess?: (registration: ServiceWorkerRegistration) => void;
- onUpdate?: (registration: ServiceWorkerRegistration) => void;
-};
-
-export function register(config?: Config) {
- if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
- // The URL constructor is available in all browsers that support SW.
- const publicUrl = new URL(
- (process as { env: { [key: string]: string } }).env.PUBLIC_URL,
- window.location.href
- );
- if (publicUrl.origin !== window.location.origin) {
- // Our service worker won't work if PUBLIC_URL is on a different origin
- // from what our page is served on. This might happen if a CDN is used to
- // serve assets; see https://github.com/facebook/create-react-app/issues/2374
- return;
- }
-
- window.addEventListener('load', () => {
- const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
-
- if (isLocalhost) {
- // This is running on localhost. Let's check if a service worker still exists or not.
- checkValidServiceWorker(swUrl, config);
-
- // Add some additional logging to localhost, pointing developers to the
- // service worker/PWA documentation.
- navigator.serviceWorker.ready.then(() => {
- console.log(
- 'This web app is being served cache-first by a service ' +
- 'worker. To learn more, visit https://bit.ly/CRA-PWA'
- );
- });
- } else {
- // Is not localhost. Just register service worker
- registerValidSW(swUrl, config);
- }
- });
- }
-}
-
-function registerValidSW(swUrl: string, config?: Config) {
- navigator.serviceWorker
- .register(swUrl)
- .then(registration => {
- registration.onupdatefound = () => {
- const installingWorker = registration.installing;
- if (installingWorker == null) {
- return;
- }
- installingWorker.onstatechange = () => {
- if (installingWorker.state === 'installed') {
- if (navigator.serviceWorker.controller) {
- // At this point, the updated precached content has been fetched,
- // but the previous service worker will still serve the older
- // content until all client tabs are closed.
- console.log(
- 'New content is available and will be used when all ' +
- 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
- );
-
- // Execute callback
- if (config && config.onUpdate) {
- config.onUpdate(registration);
- }
- } else {
- // At this point, everything has been precached.
- // It's the perfect time to display a
- // "Content is cached for offline use." message.
- console.log('Content is cached for offline use.');
-
- // Execute callback
- if (config && config.onSuccess) {
- config.onSuccess(registration);
- }
- }
- }
- };
- };
- })
- .catch(error => {
- console.error('Error during service worker registration:', error);
- });
-}
-
-function checkValidServiceWorker(swUrl: string, config?: Config) {
- // Check if the service worker can be found. If it can't reload the page.
- fetch(swUrl)
- .then(response => {
- // Ensure service worker exists, and that we really are getting a JS file.
- const contentType = response.headers.get('content-type');
- if (
- response.status === 404 ||
- (contentType != null && contentType.indexOf('javascript') === -1)
- ) {
- // No service worker found. Probably a different app. Reload the page.
- navigator.serviceWorker.ready.then(registration => {
- registration.unregister().then(() => {
- window.location.reload();
- });
- });
- } else {
- // Service worker found. Proceed as normal.
- registerValidSW(swUrl, config);
- }
- })
- .catch(() => {
- console.log(
- 'No internet connection found. App is running in offline mode.'
- );
- });
-}
-
-export function unregister() {
- if ('serviceWorker' in navigator) {
- navigator.serviceWorker.ready.then(registration => {
- registration.unregister();
- });
- }
-}
diff --git a/src/theming/GlobalStyle.ts b/src/theming/GlobalStyle.ts
new file mode 100644
index 0000000..0b0a3bd
--- /dev/null
+++ b/src/theming/GlobalStyle.ts
@@ -0,0 +1,24 @@
+import { createGlobalStyle } from 'styled-components';
+
+export const GlobalStyle = createGlobalStyle`
+ body, html {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-size: 26pt;
+ }
+
+ code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+ }
+
+ .App-link {
+ color: #09d3ac;
+ }
+`;
+
+export default GlobalStyle;
diff --git a/src/types/IBoard.ts b/src/types/IBoard.ts
new file mode 100644
index 0000000..cf26bfa
--- /dev/null
+++ b/src/types/IBoard.ts
@@ -0,0 +1,3 @@
+import { cellState } from './cellState';
+
+export type IBoard = Array>;
diff --git a/src/types/cellState.ts b/src/types/cellState.ts
new file mode 100644
index 0000000..e5c7c6e
--- /dev/null
+++ b/src/types/cellState.ts
@@ -0,0 +1,7 @@
+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
new file mode 100644
index 0000000..eaf8599
--- /dev/null
+++ b/src/types/playerEnum.ts
@@ -0,0 +1,6 @@
+export enum playerEnum {
+ X = 'X',
+ O = 'O',
+}
+
+export default playerEnum;
diff --git a/tsconfig.json b/tsconfig.json
index f2850b7..0f03f1e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,16 +1,23 @@
{
"compilerOptions": {
"target": "es5",
- "lib": [
- "dom",
- "dom.iterable",
- "esnext"
- ],
+ "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "strictFunctionTypes": true,
+ "strictBindCallApply": true,
+ "strictPropertyInitialization": true,
+ "noImplicitThis": true,
+ "alwaysStrict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
@@ -19,7 +26,5 @@
"noEmit": true,
"jsx": "react"
},
- "include": [
- "src"
- ]
+ "include": ["src"]
}