mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
569 lines
15 KiB
Markdown
569 lines
15 KiB
Markdown
---
|
|
id: 67e3a6b7f60b4085588189e6
|
|
title: Build a Tic-Tac-Toe Game
|
|
challengeType: 25
|
|
dashedName: build-a-tic-tac-toe-game
|
|
demoType: onClick
|
|
saveSubmissionToDB: true
|
|
---
|
|
|
|
# --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.
|
|
|
|
# --before-all--
|
|
|
|
```js
|
|
let clock = __FakeTimers.install();
|
|
|
|
async function reset(assert) {
|
|
const reset = document.querySelector("#reset");
|
|
assert.exists(reset, "button#reset should exist");
|
|
reset.click();
|
|
clock.tick(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;
|
|
}
|
|
```
|
|
|
|
# --after-all--
|
|
|
|
```js
|
|
clock.uninstall();
|
|
```
|
|
|
|
# --hints--
|
|
|
|
You should export a `Board` component.
|
|
|
|
```js
|
|
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
|
|
const el = document.querySelector("button.square");
|
|
el.click();
|
|
|
|
clock.tick(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
|
|
// NOTE: This test is intentionally high-up, because the latter tests rely on the functionality.
|
|
const el = document.querySelector("button.square");
|
|
el.click();
|
|
clock.tick(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
|
|
await reset(assert);
|
|
const els = document.querySelectorAll("button.square");
|
|
els[0].click();
|
|
clock.tick(50);
|
|
els[1].click();
|
|
|
|
clock.tick(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
|
|
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();
|
|
clock.tick(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
|
|
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();
|
|
clock.tick(50);
|
|
els[wrappedI].click();
|
|
clock.tick(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
|
|
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();
|
|
clock.tick(50);
|
|
assert.include(x.textContent, "X");
|
|
|
|
const o = els[i + 3];
|
|
o.click();
|
|
clock.tick(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
|
|
// 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();
|
|
clock.tick(50);
|
|
const o = els[i + 3];
|
|
o.click();
|
|
clock.tick(50);
|
|
}
|
|
|
|
const preXWin = getInnerTextExcept("button.square");
|
|
// Click winning button for X
|
|
els[2].click();
|
|
clock.tick(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();
|
|
clock.tick(50);
|
|
const o = els[i + 3];
|
|
o.click();
|
|
clock.tick(50);
|
|
}
|
|
els[6].click();
|
|
clock.tick(50);
|
|
|
|
const preOWin = getInnerTextExcept("button.square");
|
|
// Click winning button for O
|
|
els[5].click();
|
|
clock.tick(50);
|
|
|
|
const postOWin = getInnerTextExcept("button.square");
|
|
assert.notEqual(preOWin, postOWin);
|
|
// There should be a difference between `O` and `X` winning.
|
|
assert.notEqual(postXWin, postOWin);
|
|
|
|
// Test diagonal win for X
|
|
await reset(assert);
|
|
|
|
// X moves: top-left
|
|
els[0].click();
|
|
clock.tick(50);
|
|
|
|
// O moves: top-center
|
|
els[1].click();
|
|
clock.tick(50);
|
|
|
|
// X moves: center
|
|
els[4].click();
|
|
clock.tick(50);
|
|
|
|
// O moves: top-right
|
|
els[2].click();
|
|
clock.tick(50);
|
|
|
|
const preDiagonalWin = getInnerTextExcept("button.square");
|
|
// X moves: bottom-right (completes diagonal win)
|
|
els[8].click();
|
|
clock.tick(50);
|
|
|
|
const postDiagonalWin = getInnerTextExcept("button.square");
|
|
assert.notEqual(preDiagonalWin, postDiagonalWin);
|
|
assert.include(postDiagonalWin, "Winner: X");
|
|
} catch(e) {
|
|
console.error(e);
|
|
throw e;
|
|
}
|
|
```
|
|
|
|
The game should display a message indicating a draw.
|
|
|
|
```js
|
|
// Get to almost draw state
|
|
// Check dom
|
|
// Click final square
|
|
// Check dom for change
|
|
await reset(assert);
|
|
const els = document.querySelectorAll("button.square");
|
|
try {
|
|
//use already known draw states
|
|
const drawMoves = [0, 1, 2, 4, 3, 5, 7, 6, 8];
|
|
|
|
const snapshot1 = getInnerTextExcept("button.square");
|
|
els[drawMoves[0]].click();
|
|
clock.tick(50);
|
|
|
|
const snapshot2 = getInnerTextExcept("button.square");
|
|
for (let i = 1; i < drawMoves.length - 1; i++) {
|
|
els[drawMoves[i]].click();
|
|
clock.tick(50);
|
|
}
|
|
|
|
const snapshot3 = getInnerTextExcept("button.square");
|
|
els[drawMoves[drawMoves.length - 1]].click();
|
|
clock.tick(50);
|
|
|
|
const snapshot4 = getInnerTextExcept("button.square");
|
|
assert.notEqual(snapshot4, snapshot1);
|
|
assert.notEqual(snapshot4, snapshot2);
|
|
assert.notEqual(snapshot4, snapshot3);
|
|
|
|
} catch(e) {
|
|
console.error(e);
|
|
throw e;
|
|
}
|
|
```
|
|
|
|
# --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;
|
|
};
|
|
```
|