mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-01-08 03:04:00 -05:00
feat(curriculum): add lab-tic-tac-toe (#59439)
Co-authored-by: Naomi <accounts+github@nhcarrigan.com> Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
@@ -3560,9 +3560,12 @@
|
||||
"In these lecture videos, you will learn about routing in React, React frameworks, and dependency management tools."
|
||||
]
|
||||
},
|
||||
"vyzp": {
|
||||
"title": "281",
|
||||
"intro": []
|
||||
"lab-tic-tac-toe": {
|
||||
"title": "Build a Tic-Tac-Toe Game",
|
||||
"intro": [
|
||||
"In this lab, you'll build a Tic-Tac-Toe game using React.",
|
||||
"You'll practice managing state, handling user interactions, and updating the UI dynamically."
|
||||
]
|
||||
},
|
||||
"lecture-react-strategies-and-debugging": {
|
||||
"title": "React Strategies and Debugging",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Introduction to the Build a Tic-Tac-Toe Game
|
||||
block: lab-tic-tac-toe
|
||||
superBlock: full-stack-developer
|
||||
---
|
||||
|
||||
## Introduction to the Build a Tic-Tac-Toe Game
|
||||
|
||||
In this lab, you'll build a Tic-Tac-Toe game using React. You'll practice managing state, handling user interactions, and updating the UI dynamically.
|
||||
16
curriculum/challenges/_meta/lab-tic-tac-toe/meta.json
Normal file
16
curriculum/challenges/_meta/lab-tic-tac-toe/meta.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Build a Tic-Tac-Toe Game",
|
||||
"usesMultifileEditor": true,
|
||||
"dashedName": "lab-tic-tac-toe",
|
||||
"superBlock": "full-stack-developer",
|
||||
"challengeOrder": [
|
||||
{
|
||||
"id": "67e3a6b7f60b4085588189e6",
|
||||
"title": "Build a Tic-Tac-Toe Game"
|
||||
}
|
||||
],
|
||||
"helpCategory": "JavaScript",
|
||||
"isUpcomingChange": false,
|
||||
"blockLayout": "link",
|
||||
"blockType": "lab"
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
---
|
||||
id: 67e3a6b7f60b4085588189e6
|
||||
title: Build a Tic-Tac-Toe Game
|
||||
challengeType: 25
|
||||
dashedName: build-a-tic-tac-toe-game
|
||||
demoType: onClick
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab.
|
||||
|
||||
**User Stories:**
|
||||
|
||||
1. You should create a `Board` component that renders nine `button` elements each with a class of `square` in a 3x3 grid.
|
||||
2. Clicking `button.square` elements should alternate between displaying an `X` then `O` within the element.
|
||||
3. Once a player has won the game, clicking on any `button.square` should not cause any further changes.
|
||||
4. You should create a `button#reset` element that resets the game when clicked.
|
||||
5. A message should be displayed indicating either `X` or `O` as the winner, or neither if the result is a draw.
|
||||
|
||||
# --hints--
|
||||
|
||||
You should export a `Board` component.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
const script = [...document.querySelectorAll("script")].find((s) => s.dataset.src === "index.jsx").innerText;
|
||||
|
||||
const exports = {};
|
||||
const a = eval(script);
|
||||
|
||||
assert.property(exports, "Board");
|
||||
}
|
||||
```
|
||||
|
||||
You should have nine `button.square` elements.
|
||||
|
||||
```js
|
||||
const els = document.querySelectorAll("button.square");
|
||||
assert.equal(els.length, 9);
|
||||
```
|
||||
|
||||
The `button.square` elements should be in a 3x3 grid.
|
||||
|
||||
```js
|
||||
// TODO: Maybe enforce buttons to be same size?
|
||||
const els = document.querySelectorAll("button.square");
|
||||
const squares = Array.from(els);
|
||||
|
||||
const coords = squares.map((square) => {
|
||||
const rect = square.getBoundingClientRect();
|
||||
return rect;
|
||||
});
|
||||
|
||||
const xTolerance = coords[0].width / 10;
|
||||
const yTolerance = coords[0].height / 10;
|
||||
|
||||
try {
|
||||
assert.isBelow(coords[0].x, coords[1].x, "First square should be to the left of the second square");
|
||||
assert.isBelow(coords[1].x, coords[2].x, "Second square should be to the left of the third square");
|
||||
assert.approximately(coords[0].y, coords[1].y, yTolerance, "First square should be at the same height as the second square");
|
||||
assert.approximately(coords[1].y, coords[2].y, yTolerance, "Second square should be at the same height as the third square");
|
||||
|
||||
assert.isBelow(coords[3].x, coords[4].x, "Fourth square should be to the left of the fifth square");
|
||||
assert.isBelow(coords[4].x, coords[5].x, "Fifth square should be to the left of the sixth square");
|
||||
assert.approximately(coords[3].y, coords[4].y, yTolerance, "Fourth square should be at the same height as the fifth square");
|
||||
assert.approximately(coords[4].y, coords[5].y, yTolerance, "Fifth square should be at the same height as the sixth square");
|
||||
|
||||
assert.isBelow(coords[6].x, coords[7].x, "Seventh square should be to the left of the eighth square");
|
||||
assert.isBelow(coords[7].x, coords[8].x, "Eighth square should be to the left of the ninth square");
|
||||
assert.approximately(coords[6].y, coords[7].y, yTolerance, "Seventh square should be at the same height as the eighth square");
|
||||
assert.approximately(coords[7].y, coords[8].y, yTolerance, "Eighth square should be at the same height as the ninth square");
|
||||
|
||||
|
||||
assert.isBelow(coords[0].y, coords[3].y, "First square should be above the fourth square");
|
||||
assert.isBelow(coords[3].y, coords[6].y, "Fourth square should be above the seventh square");
|
||||
assert.approximately(coords[0].x, coords[3].x, xTolerance, "First square should be at the same width as the fourth square");
|
||||
assert.approximately(coords[3].x, coords[6].x, xTolerance, "Fourth square should be at the same width as the seventh square");
|
||||
|
||||
assert.isBelow(coords[1].y, coords[4].y, "Second square should be above the fifth square");
|
||||
assert.isBelow(coords[4].y, coords[7].y, "Fifth square should be above the eighth square");
|
||||
assert.approximately(coords[1].x, coords[4].x, xTolerance, "Second square should be at the same width as the fifth square");
|
||||
assert.approximately(coords[4].x, coords[7].x, xTolerance, "Fifth square should be at the same width as the eighth square");
|
||||
|
||||
assert.isBelow(coords[2].y, coords[5].y, "Third square should be above the sixth square");
|
||||
assert.isBelow(coords[5].y, coords[8].y, "Sixth square should be above the ninth square");
|
||||
assert.approximately(coords[2].x, coords[5].x, xTolerance, "Third square should be at the same width as the sixth square");
|
||||
assert.approximately(coords[5].x, coords[8].x, xTolerance, "Sixth square should be at the same width as the ninth square");
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
The first click of a `button.square` element should result in `X` being displayed within the element.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
const el = document.querySelector("button.square");
|
||||
el.click();
|
||||
|
||||
await delay(50);
|
||||
|
||||
try {
|
||||
assert.include(el.textContent, "X");
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clicking on the `button#reset` element should reset the game.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
// NOTE: This test is intentionally high-up, because the latter tests rely on the functionality.
|
||||
const el = document.querySelector("button.square");
|
||||
el.click();
|
||||
await delay(50);
|
||||
|
||||
try {
|
||||
await reset(assert);
|
||||
assert.notInclude(el.textContent, "X");
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The second click of a `button.square` element should result in `O` being displayed within the element.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
await reset(assert);
|
||||
const els = document.querySelectorAll("button.square");
|
||||
els[0].click();
|
||||
await delay(50);
|
||||
els[1].click();
|
||||
|
||||
await delay(50);
|
||||
|
||||
try {
|
||||
assert.include(els[1].textContent, "O");
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All subsequent clicks of a `button.square` element should alternate between displaying `X` and `O` within the element.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
await reset(assert);
|
||||
const els = document.querySelectorAll("button.square");
|
||||
try {
|
||||
for (let i = 3; i < els.length + 3; i++) {
|
||||
const wrappedI = i % els.length;
|
||||
els[wrappedI].click();
|
||||
await delay(50);
|
||||
assert.include(els[wrappedI].textContent, (i - 3) % 2 === 0 ? "X" : "O");
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clicking on an already used `button.square` element should result in no change.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
await reset(assert);
|
||||
const els = document.querySelectorAll("button.square");
|
||||
try {
|
||||
for (let i = 3; i < els.length + 3; i++) {
|
||||
const wrappedI = i % els.length;
|
||||
// Click button twice to ensure it does not change
|
||||
els[wrappedI].click();
|
||||
await delay(50);
|
||||
els[wrappedI].click();
|
||||
await delay(50);
|
||||
assert.include(els[wrappedI].textContent, (i - 3) % 2 === 0 ? "X" : "O");
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clicking on a `button.square` element after the game has ended should result in no change.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
await reset(assert);
|
||||
const els = document.querySelectorAll("button.square");
|
||||
try {
|
||||
// Win game, then click empty square and ensure no change
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = els[i];
|
||||
x.click();
|
||||
await delay(50);
|
||||
assert.include(x.textContent, "X");
|
||||
|
||||
const o = els[i + 3];
|
||||
o.click();
|
||||
await delay(50);
|
||||
if (i === 2) {
|
||||
assert.notInclude(o.textContent, "O");
|
||||
} else {
|
||||
assert.include(o.textContent, "O");
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The game should display a message indicating the winner to be `X` or `O`.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
// Get to almost winning state
|
||||
// Check dom
|
||||
// Click winning square
|
||||
// Check dom for change
|
||||
await reset(assert);
|
||||
const els = document.querySelectorAll("button.square");
|
||||
try {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const x = els[i];
|
||||
x.click();
|
||||
await delay(50);
|
||||
const o = els[i + 3];
|
||||
o.click();
|
||||
await delay(50);
|
||||
}
|
||||
|
||||
const preXWin = getInnerTextExcept("button.square");
|
||||
// Click winning button for X
|
||||
els[2].click();
|
||||
await delay(50);
|
||||
|
||||
const postXWin = getInnerTextExcept("button.square");
|
||||
assert.notEqual(preXWin, postXWin);
|
||||
|
||||
await reset(assert);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const x = els[i];
|
||||
x.click();
|
||||
await delay(50);
|
||||
const o = els[i + 3];
|
||||
o.click();
|
||||
await delay(50);
|
||||
}
|
||||
els[6].click();
|
||||
await delay(50);
|
||||
|
||||
const preOWin = getInnerTextExcept("button.square");
|
||||
// Click winning button for O
|
||||
els[5].click();
|
||||
await delay(50);
|
||||
|
||||
const postOWin = getInnerTextExcept("button.square");
|
||||
assert.notEqual(preOWin, postOWin);
|
||||
// There should be a difference between `O` and `X` winning.
|
||||
assert.notEqual(postXWin, postOWin);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The game should display a message indicating a draw.
|
||||
|
||||
```js
|
||||
async () => {
|
||||
// Get to almost draw state
|
||||
// Check dom
|
||||
// Click final square
|
||||
// Check dom for change
|
||||
await reset(assert);
|
||||
const els = document.querySelectorAll("button.square");
|
||||
try {
|
||||
for (let i = 3; i < els.length + 2; i++) {
|
||||
const wrappedI = i % els.length;
|
||||
els[wrappedI].click();
|
||||
await delay(50);
|
||||
}
|
||||
|
||||
const pre = getInnerTextExcept("button.square");
|
||||
els[2].click();
|
||||
await delay(50);
|
||||
|
||||
const post = getInnerTextExcept("button.square");
|
||||
assert.notEqual(pre, post);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# --before-all--
|
||||
|
||||
```js
|
||||
async function delay(time) {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
async function reset(assert) {
|
||||
const reset = document.querySelector("#reset");
|
||||
assert.exists(reset, "button#reset should exist");
|
||||
reset.click();
|
||||
await delay(50);
|
||||
}
|
||||
|
||||
// Gets the text of the document excluding that of the matching selector.
|
||||
function getInnerTextExcept(removingSelector) {
|
||||
const body = document.body.cloneNode(true);
|
||||
|
||||
const squareElements = body.querySelectorAll(`${removingSelector},script`);
|
||||
squareElements.forEach(element => {
|
||||
element.parentNode.removeChild(element)
|
||||
});
|
||||
|
||||
return body.innerText;
|
||||
}
|
||||
```
|
||||
|
||||
# --seed--
|
||||
|
||||
## --seed-contents--
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Tic-Tac-Toe</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.5/babel.min.js"></script>
|
||||
<script
|
||||
data-plugins="transform-modules-umd"
|
||||
type="text/babel"
|
||||
src="index.jsx"
|
||||
></script>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
data-plugins="transform-modules-umd"
|
||||
type="text/babel"
|
||||
data-presets="react"
|
||||
data-type="module"
|
||||
>
|
||||
import { Board } from './index.jsx';
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<Board />);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
```
|
||||
|
||||
```css
|
||||
|
||||
```
|
||||
|
||||
```jsx
|
||||
const { useState } = React;
|
||||
|
||||
export function Board() {
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
# --solutions--
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Tic-Tac-Toe</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.5/babel.min.js"></script>
|
||||
<script
|
||||
data-plugins="transform-modules-umd"
|
||||
type="text/babel"
|
||||
src="index.jsx"
|
||||
></script>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
data-plugins="transform-modules-umd"
|
||||
type="text/babel"
|
||||
data-presets="react"
|
||||
data-type="module"
|
||||
>
|
||||
import { Board } from './index.jsx';
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<Board />);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
```
|
||||
|
||||
```css
|
||||
* {
|
||||
font-family: "Secular One", sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 10px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.board-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.square {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 1.5em;
|
||||
margin: 5px;
|
||||
background: #fff;
|
||||
border: 1px solid #999;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.square:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#reset {
|
||||
margin-top: 20px;
|
||||
padding: 10px 20px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
export function Board() {
|
||||
const [squares, setSquares] = React.useState(Array(9).fill(null));
|
||||
const [isXNext, setIsXNext] = React.useState(true);
|
||||
const winner = calculateWinner(squares);
|
||||
const isDraw = squares.every(square => square !== null) && !winner;
|
||||
|
||||
function handleClick(index) {
|
||||
if (squares[index] || winner || isDraw) return;
|
||||
const nextSquares = squares.slice();
|
||||
nextSquares[index] = isXNext ? 'X' : 'O';
|
||||
setSquares(nextSquares);
|
||||
setIsXNext(!isXNext);
|
||||
};
|
||||
|
||||
function resetGame() {
|
||||
setSquares(Array(9).fill(null));
|
||||
setIsXNext(true);
|
||||
};
|
||||
|
||||
function renderSquare(index) {
|
||||
return <Square value={squares[index]} onClick={() => handleClick(index)} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="board">
|
||||
<h1>Tic-Tac-Toe</h1>
|
||||
<div className="status">
|
||||
{winner ? `Winner: ${winner}` : isDraw ? "It's a Draw!" : `Next Player: ${isXNext ? 'X' : 'O'}`}
|
||||
</div>
|
||||
<div className="board-row">
|
||||
{renderSquare(0)}
|
||||
{renderSquare(1)}
|
||||
{renderSquare(2)}
|
||||
</div>
|
||||
<div className="board-row">
|
||||
{renderSquare(3)}
|
||||
{renderSquare(4)}
|
||||
{renderSquare(5)}
|
||||
</div>
|
||||
<div className="board-row">
|
||||
{renderSquare(6)}
|
||||
{renderSquare(7)}
|
||||
{renderSquare(8)}
|
||||
</div>
|
||||
<button id="reset" onClick={resetGame}>Reset Game</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function Square({ value, onClick }) {
|
||||
return (
|
||||
<button className="square" onClick={onClick}>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
function calculateWinner(squares) {
|
||||
const lines = [
|
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||
[0, 4, 8], [2, 4, 6]
|
||||
];
|
||||
for (let line of lines) {
|
||||
const [a, b, c] = line;
|
||||
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
|
||||
return squares[a];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
```
|
||||
@@ -1087,15 +1087,18 @@
|
||||
{
|
||||
"dashedName": "lab-event-rsvp"
|
||||
},
|
||||
{
|
||||
"dashedName": "lab-currency-converter"
|
||||
},
|
||||
{
|
||||
"dashedName": "lecture-working-with-data-fetching-and-memoization-in-react"
|
||||
},
|
||||
{
|
||||
"dashedName": "lab-currency-converter"
|
||||
},
|
||||
{
|
||||
"dashedName": "lecture-routing-react-frameworks-and-dependency-management-tools"
|
||||
},
|
||||
{
|
||||
"dashedName": "lab-tic-tac-toe"
|
||||
},
|
||||
{
|
||||
"dashedName": "lecture-react-strategies-and-debugging"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user