From 01d365da0dd7faad4033f75b2d95e9895bb0fb39 Mon Sep 17 00:00:00 2001 From: danielAminov Date: Thu, 18 Dec 2025 16:28:21 +0200 Subject: [PATCH] done all tasks --- README.md | 22 ++++++- package-lock.json | 38 +++++++++++- package.json | 3 +- src/App.css | 2 +- src/App.tsx | 10 ++- src/Constants.ts | 38 ++++++++++++ src/Enums.ts | 4 ++ src/Utils.ts | 7 +++ src/assets/circle.svg | 1 + src/assets/close.svg | 1 + src/components/Board.tsx | 33 ++++++---- src/components/Game.css | 17 +++++ src/components/Game.tsx | 127 +++++++++++++++++++++++++++++++++----- src/components/Square.tsx | 31 +++++++--- src/index.css | 11 +++- tsconfig.app.json | 2 +- tsconfig.node.json | 2 +- 17 files changed, 300 insertions(+), 49 deletions(-) create mode 100644 src/Constants.ts create mode 100644 src/Enums.ts create mode 100644 src/Utils.ts create mode 100644 src/assets/circle.svg create mode 100644 src/assets/close.svg create mode 100644 src/components/Game.css diff --git a/README.md b/README.md index b415aa2..c629ed1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ This project is designed for teaching and practicing the fundamentals of: - State flow between parent/child components ## πŸ› οΈ Getting Started + 1. Clone the repository + ```sh git clone https://github.com/your-username/tic-tac-toe-react.git @@ -17,19 +19,25 @@ cd tic-tac-toe-react ``` 2. Install dependencies + ```sh npm install ``` + 3. Start the development server + ```sh npm run dev ``` + Your app should be running at: + ```sh http://localhost:5173 ``` ## 🎯 Learning Goals + - useState for managing UI state - Data flow between parent and child components - Component reusability (Board/Square) @@ -40,42 +48,54 @@ http://localhost:5173 - Managing more complex state ## πŸ“Œ Tasks to Implement (Step-by-Step) + ### Task 1 β€” Make the Squares Clickable **Goal:** When clicking a square, place "X" or "O" depending on whose turn it is. **Hints:** + - Use the existing isXNext state - Update the squares array with the new value - Toggle the turn after each move ### Task 2 β€” Prevent Overwriting Moves + **Goal:** When a player gets 3 in a row, display a winner message. **Hints:** + - Check `squares[index] !== null` before updating ### Task 3 β€” Add Winner Detection + **Goal:** When a player gets 3 in a row, display a winner message. **Hints:** + - Create a `calculateWinner()` helper - Use all 8 winning combinations - Add a winner: `string | null` state in Game ### Task 4 β€” Stop Moves After Win + **Goal:** Disable the board after someone wins. **Hints:** + - If there's a winner β†’ ignore clicks - Or disable the buttons with disabled attribute ### Task 5 β€” Add a β€œPlay Again” Button + **Goal:** Disable the board after someone wins. **Hints:** + - Clear squares back to `Array(9).fill(null)` - Reset `isXNext` β†’ true ### Task 6 β€” Add a Turn Countdown Timer (useEffect) + **Goal:** Each player gets e.g. 10 seconds to play. If time runs out β†’ automatically switch turn. **Hints:** + - Create `timeLeft` state - Use `useEffect` with `setInterval` - Reset timer on every turn change -- Cleanup the interval on unmount or turn switch \ No newline at end of file +- Cleanup the interval on unmount or turn switch diff --git a/package-lock.json b/package-lock.json index 2fec462..f812950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-toastify": "^11.0.5" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -57,6 +58,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1389,6 +1391,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1399,6 +1402,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1458,6 +1462,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -1709,6 +1714,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1814,6 +1820,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1876,6 +1883,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2035,6 +2051,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2721,6 +2738,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2782,6 +2800,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2791,6 +2810,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2808,6 +2828,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2984,6 +3017,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3070,6 +3104,7 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3191,6 +3226,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 51210e2..37aa4a0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-toastify": "^11.0.5" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/src/App.css b/src/App.css index b9d355d..9954c3d 100644 --- a/src/App.css +++ b/src/App.css @@ -1,5 +1,5 @@ #root { - max-width: 1280px; + max-width: var(--app-max-width); margin: 0 auto; padding: 2rem; text-align: center; diff --git a/src/App.tsx b/src/App.tsx index 171e1ef..851a75f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,14 @@ import "./App.css"; import Game from "./components/Game"; - +import { ToastContainer, toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; function App() { - return ; + return ( + <> + + ; + + ); } export default App; diff --git a/src/Constants.ts b/src/Constants.ts new file mode 100644 index 0000000..16060dc --- /dev/null +++ b/src/Constants.ts @@ -0,0 +1,38 @@ +export const winningCombinations = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], +]; + +// UI / game constants +export const TIMER = { + TURN_SECONDS: 10, + TICK_MS: 1000, +}; + +export const MARKS = { + X: "x", + O: "o", + DISPLAY_X: "X", + DISPLAY_O: "O", +}; + +export const STRINGS = { + APP_TITLE: "Tic Tac Toe", + NEXT_PLAYER_LABEL: "Next Player:", + WINNER_TEMPLATE: (winner: string) => `the winner is ${winner} πŸŽ‰πŸŽ‰πŸ₯³`, + RESET_BUTTON: "Reset Game", + RESET_NOTICE: "Game was reset", +}; + +export const BOARD = { + ROWS: 3, + COLS: 3, + CELL_COUNT: 9, + CELL_SIZE_PX: 80, +}; diff --git a/src/Enums.ts b/src/Enums.ts new file mode 100644 index 0000000..238741e --- /dev/null +++ b/src/Enums.ts @@ -0,0 +1,4 @@ +export enum BoardIcons { + X = "../src/assets/close.svg", + O = "../src/assets/circle.svg", +} diff --git a/src/Utils.ts b/src/Utils.ts new file mode 100644 index 0000000..5d81187 --- /dev/null +++ b/src/Utils.ts @@ -0,0 +1,7 @@ +import { toast } from "react-toastify"; + +export const notify = (message: string, autoClose?: number) => + toast(message, { + position: "top-center", + autoClose: autoClose || 2500, + }); diff --git a/src/assets/circle.svg b/src/assets/circle.svg new file mode 100644 index 0000000..529893d --- /dev/null +++ b/src/assets/circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/close.svg b/src/assets/close.svg new file mode 100644 index 0000000..238b0fa --- /dev/null +++ b/src/assets/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 3989a44..208cce1 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -1,22 +1,33 @@ +import type { ReactNode } from "react"; import Square from "./Square"; +import { MARKS } from "../Constants"; +import { BoardIcons } from "../enums"; -interface BoardProps { +interface IBoardProps { squares: (string | null)[]; onSquareClick: (index: number) => void; + isDisabled?: boolean; } -export default function Board(props: BoardProps) { +export default function Board(props: IBoardProps) { return (
- props.onSquareClick(0)} /> - props.onSquareClick(1)} /> - props.onSquareClick(2)} /> - props.onSquareClick(3)} /> - props.onSquareClick(4)} /> - props.onSquareClick(5)} /> - props.onSquareClick(6)} /> - props.onSquareClick(7)} /> - props.onSquareClick(8)} /> + {props.squares.map((value: ReactNode, index: number) => ( + + ) : value == MARKS.O ? ( + O + ) : ( + "" + ) + } + onClick={() => props.onSquareClick(index)} + /> + ))}
); } diff --git a/src/components/Game.css b/src/components/Game.css new file mode 100644 index 0000000..36dda80 --- /dev/null +++ b/src/components/Game.css @@ -0,0 +1,17 @@ +.next-player-icon { + display: flex; + align-items: center; + gap: 0.5em; +} +.rest-btn { + border-radius: 0.8em; + padding: 1em 2em; + font-size: 1em; + background-color: rgba(56, 61, 124, 0.744); +} + +.rest-btn:hover { + box-shadow: 1px 1px 4px rgb(0, 0, 0); + cursor: pointer; + background-color: rgb(56, 61, 124); +} diff --git a/src/components/Game.tsx b/src/components/Game.tsx index 80be18b..3b7ef66 100644 --- a/src/components/Game.tsx +++ b/src/components/Game.tsx @@ -1,24 +1,117 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Board from "./Board"; - +import { BoardIcons } from "../enums"; +import "./game.css"; +import { notify } from "../Utils"; +import { winningCombinations, TIMER, MARKS, STRINGS } from "../Constants"; export default function Game() { - const [squares, setSquares] = useState<(string | null)[]>( - Array(9).fill(null) - ); - const [isXNext, setIsXNext] = useState(true); - - function handleSquareClick(index: number) { - // Temporary: no gameplay logic yet - console.log("Clicked square:", index); + const [squares, setSquares] = useState<(string | null)[]>( + Array(9).fill(null) + ); + const [nextIcon, setnextIcon] = useState(BoardIcons.X); + const [isGameDisabled, setIsGameDisabled] = useState(false); + const [turnTimer, setTurnTimer] = useState(TIMER.TURN_SECONDS); + const timerRef = useRef(null); + + useEffect(() => { + if (checkWinner()) { + const winnerDisplay = + nextIcon == BoardIcons.X ? MARKS.DISPLAY_O : MARKS.DISPLAY_X; + notify(STRINGS.WINNER_TEMPLATE(winnerDisplay)); + setIsGameDisabled(true); + } + }, [squares]); + + useEffect(() => { + if (isGameDisabled) { + clearTurnTimer(); + return; + } + startTurnTimer(); + return () => clearTurnTimer(); + }, [nextIcon, isGameDisabled]); + + useEffect(() => () => clearTurnTimer(), []); + + function clearTurnTimer() { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + } + + function startTurnTimer() { + clearTurnTimer(); + setTurnTimer(TIMER.TURN_SECONDS); + timerRef.current = window.setInterval(() => { + setTurnTimer((remainingTime) => { + if (remainingTime <= 1) { + clearTurnTimer(); + SwitchNextIcon(); + return 0; + } + return remainingTime - 1; + }); + }, TIMER.TICK_MS); + } + + function checkWinner(): boolean { + for (const combination of winningCombinations) { + const [a, b, c] = combination; + if ( + squares[a] !== null && + squares[a] === squares[b] && + squares[a] === squares[c] + ) + return true; + } + return false; + } + + function SwitchNextIcon(): void { + nextIcon === BoardIcons.X + ? setnextIcon(BoardIcons.O) + : setnextIcon(BoardIcons.X); + } + + function handleSquareClick(index: number): void { + if (squares[index] == null) { + setSquares( + squares.map((square, i) => + i === index ? (nextIcon === BoardIcons.X ? MARKS.X : MARKS.O) : square + ) + ); + SwitchNextIcon(); + startTurnTimer(); + } else { + notify("Square already occupied!"); + return; } + } - return ( -
-

Tic Tac Toe

+ function restGame(): void { + setSquares(Array(9).fill(null)); + setIsGameDisabled(false); + setnextIcon(BoardIcons.X); + notify(STRINGS.RESET_NOTICE); + } - -

Next Player: {isXNext ? "X" : "O"}

+ return ( +
+

{STRINGS.APP_TITLE}

-
- ); + +

+ {STRINGS.NEXT_PLAYER_LABEL} icon +

+

Turn Timer: {turnTimer}

+ +
+ ); } diff --git a/src/components/Square.tsx b/src/components/Square.tsx index 3e9f11a..842def0 100644 --- a/src/components/Square.tsx +++ b/src/components/Square.tsx @@ -1,12 +1,23 @@ -interface SquareProps { - value: string | null; - onClick: () => void; -} +import type { ReactNode } from "react"; -export default function Square(props: SquareProps) { - return ( - - ); +interface ISquareProps { + value: ReactNode; + onClick: () => void; + isDisabled: boolean; +} +const disabledStyle = { + backgroundColor: "#e0e0e0", + cursor: "not-allowed", +}; +export default function Square(props: ISquareProps) { + return ( + + ); } diff --git a/src/index.css b/src/index.css index c75067a..3886eef 100644 --- a/src/index.css +++ b/src/index.css @@ -6,15 +6,20 @@ font-family: sans-serif; } +:root { + --cell-size: 80px; + --app-max-width: 1280px; +} + .board { display: grid; - grid-template-columns: repeat(3, 80px); + grid-template-columns: repeat(3, var(--cell-size)); gap: 8px; } .square { - width: 80px; - height: 80px; + width: var(--cell-size); + height: var(--cell-size); font-size: 2rem; cursor: pointer; border: 2px solid #333; diff --git a/tsconfig.app.json b/tsconfig.app.json index a9b5a59..2f416e5 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -20,7 +20,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, diff --git a/tsconfig.node.json b/tsconfig.node.json index 8a67f62..3439137 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -18,7 +18,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true },