Skip to content

Commit

Permalink
feat: base game functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
juzorensen committed Jan 10, 2024
1 parent bdba948 commit a6cad7a
Show file tree
Hide file tree
Showing 13 changed files with 294 additions and 5 deletions.
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"usehooks-ts": "^2.9.2",
"web-vitals": "^2.1.4"
},
"scripts": {
Expand Down
17 changes: 12 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import React from 'react';
import {Board} from "./components/Board";
import {GameStateProvider} from "./components/GameState";

export default function App() {
return (
<h1 className="text-3xl font-bold underline">
Tic Tac Toe
</h1>
);
return (
<div>
<h1 className="text-3xl font-bold underline">
Tic Tac Toe
</h1>
<GameStateProvider>
<Board/>
</GameStateProvider>
</div>
);
}

14 changes: 14 additions & 0 deletions src/components/Board.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {useGameState} from "./GameState";
import {Cell} from "./Cell";

export const Board = () => {
const {board, currentPlayer, winner, computerGame, toggleComputerGame, newGame} = useGameState()

return <div>
<input type="checkbox" checked={computerGame} onChange={toggleComputerGame}/> Computer plays as O?
{!winner ? <div>Current player : {currentPlayer}</div> : <div>The winner is : {winner} <button onClick={newGame}>New Game</button></div>}
<div className="grid grid-cols-3">
{board.map((_, index) => <Cell key={index} index={index}/>)}
</div>
</div>
};
15 changes: 15 additions & 0 deletions src/components/Cell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {useGameState} from "./GameState";

type CellProps = { index: number };

export const Cell = ({index}: CellProps) => {
const {board, play} = useGameState();

const cellDisabled = board[index] !== " "

return (
<button id={`cell-${index}`} className="border border-black bg-blue-200 p-4" onClick={() => play(index)} disabled={cellDisabled}>
{board[index]}
</button>
);
};
78 changes: 78 additions & 0 deletions src/components/GameState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {createContext, PropsWithChildren, useContext, useEffect} from "react";
import {Board, Player} from "../types/board.types";
import {getNextPlayer, makeAMove} from "../services/player";
import {checkForWinner, checkForDrawnGame} from "../services/game-logic";
import {getComputerNextMove} from "../services/computer";
import {useLocalStorage} from "usehooks-ts";

type GameStateData = {
board: Board,
currentPlayer: Player,
play: (index: number) => void
computerGame: boolean
toggleComputerGame: () => void;
winner: Player | "draw" | undefined
newGame: () => void;
}
export const GameState = createContext<GameStateData | undefined>(undefined);

const INITIAL_BOARD = new Array(9).fill(" ");
const INITIAL_PLAYER = "X";

export const GameStateProvider = ({children}: PropsWithChildren<object>) => {
const [board, setBoard] = useLocalStorage<Board>("board", INITIAL_BOARD)
const [currentPlayer, setCurrentPlayer] = useLocalStorage<Player>("currentPlayer", INITIAL_PLAYER)
const [computerGame, setComputerGame] = useLocalStorage("computerGame", false)
const [winner, setWinner] = useLocalStorage<GameStateData["winner"]>("winner", undefined)

const newGame = () => {
setBoard(INITIAL_BOARD)
setCurrentPlayer(INITIAL_PLAYER)
setWinner(undefined)
}
const play: GameStateData["play"] = index => {
if (!!winner) {
return;
}

const newBoard = makeAMove(board, currentPlayer, index);
const nextPlayer = getNextPlayer(currentPlayer);
const winningPlayer = checkForWinner(newBoard);
const gameDrawn = checkForDrawnGame(newBoard);

setBoard(newBoard)
setWinner( gameDrawn ? "draw" : winningPlayer)
setCurrentPlayer(nextPlayer)
}

useEffect(() => {
if (computerGame && currentPlayer === "O") {
const computerNextMove = getComputerNextMove(board);

play(computerNextMove);
}
}, [computerGame, currentPlayer, board, play])

const toggleComputerGame: GameStateData["toggleComputerGame"] = () => setComputerGame(prevState => !prevState);

return <GameState.Provider
value={{
board,
currentPlayer,
play,
computerGame,
toggleComputerGame,
winner,
newGame
}}>{children}</GameState.Provider>
}

export const useGameState = () => {
const gameState = useContext(GameState)

if (!gameState) {
throw new Error("Wrap component in GameStateProvider before using useGameState hook")
}

return gameState;
}
36 changes: 36 additions & 0 deletions src/services/computer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {getComputerNextMove} from './computer';
import {Board} from "../types/board.types";

describe('getComputerNextMove', () => {
test('it should return a valid move on an empty board', async () => {
const board: Board = Array(9).fill(" ");
const move = getComputerNextMove(board);

expect(move).toBeGreaterThanOrEqual(0);
expect(move).toBeLessThanOrEqual(8);
});

test('it should return a valid move on a partially filled board', async () => {
const board: Board = [
" ", " ", " ",
"O", "X", " ",
"O", "X", " "
];

const move = getComputerNextMove(board);

expect([0, 1, 2, 5, 8]).toContain(move);
});

test('it should return undefined if there are no available moves', async () => {
const board: Board = [
"O", "X", "O",
"X", "O", "X",
"O", "X", "O"
];

const move = getComputerNextMove(board);

expect(move).toBeUndefined();
});
});
15 changes: 15 additions & 0 deletions src/services/computer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {Board} from "../types/board.types";

export const getComputerNextMove = (board: Board) => {
const availableMoves: number[] = [];

for (let i = 0; i < board.length; i++) {
if (board[i] === " ") {
availableMoves.push(i);
}
}

const randomIndex = Math.floor(Math.random() * availableMoves.length);

return availableMoves[randomIndex];
}
29 changes: 29 additions & 0 deletions src/services/game-logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {checkForWinner} from './game-logic';
import {Board} from "../types/board.types";

describe('checkForWinner', () => {

test('should return the winning combination if one exists', async () => {
const board: Board = ["X", "X", "X", " ", " ", " ", " ", " ", " "];

const result = checkForWinner(board);

expect(result).toBe("X");
});

test('should return blank if there is no winning combination', async () => {
const board: Board = [" ", " ", " ", " ", " ", " ", " ", " ", " "];

const result = checkForWinner(board);

expect(result).toBe(undefined);
});

test('should work as expected with different combinations', async () => {
const board: Board = ["O", " ", " ", " ", "O", " ", " ", " ", "O"];

const result = checkForWinner(board);

expect(result).toBe("O");
});
});
32 changes: 32 additions & 0 deletions src/services/game-logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {Board, Player} from "../types/board.types";

export const checkForDrawnGame = (board: Board) => !board.includes(" ")

const WINNING_COMBINATIONS = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
] as const;

export const checkForWinner = (board: Board): Player | undefined => {
const winningCombination = WINNING_COMBINATIONS.find(isWinningCombination(board));

if (!winningCombination) {
return undefined;
}

const cell = board[winningCombination[0]];

if (cell === " ") {
return undefined;
}

return cell;
}

const isWinningCombination = (board: Board) => ([a, b, c]: typeof WINNING_COMBINATIONS[number]) => board[a] !== " " && board[a] === board[b] && board[a] === board[c]
26 changes: 26 additions & 0 deletions src/services/player.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {makeAMove} from './player';
import {Board, Player} from "../types/board.types";

describe('makeAMove', () => {

test('It should not modify the board if the cell is already occupied', async () => {
const player: Player = "X";
const board: Board = [" ", "X", " ", " ", " ", " ", " ", " ", " "];
const cellIndex = 1;

const newBoard = makeAMove(board, player, cellIndex);

expect(newBoard).toEqual(board);
});

test('It should correctly modify the board when the cell is empty', async () => {
const player: Player = "X";
const board: Board = [" ", " ", " ", " ", " ", " ", " ", " ", " "];
const cellIndex = 0;
const expectedBoard: Board = ["X", " ", " ", " ", " ", " ", " ", " ", " "];

const newBoard = makeAMove(board, player, cellIndex);

expect(newBoard).toEqual(expectedBoard);
});
});
17 changes: 17 additions & 0 deletions src/services/player.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {Board, Player} from "../types/board.types";

export const makeAMove = (board: Board, player: Player, cellIndex: number): Board => {
if (board[cellIndex] !== " ") {
return board
}

const newBoard = [...board];

newBoard[cellIndex] = player

return newBoard
}

export const getNextPlayer = (lastPayer: Player): Player => {
return lastPayer === "X" ? "O" : "X"
}
5 changes: 5 additions & 0 deletions src/types/board.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type Player = "X" | "O"

export type CellValue = " " | Player;

export type Board = CellValue[]

0 comments on commit a6cad7a

Please sign in to comment.