diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 2c3dbb6bbc8..550cfa88408 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -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", diff --git a/client/src/pages/learn/full-stack-developer/lab-tic-tac-toe/index.md b/client/src/pages/learn/full-stack-developer/lab-tic-tac-toe/index.md new file mode 100644 index 00000000000..4e142b76455 --- /dev/null +++ b/client/src/pages/learn/full-stack-developer/lab-tic-tac-toe/index.md @@ -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. diff --git a/curriculum/challenges/_meta/lab-tic-tac-toe/meta.json b/curriculum/challenges/_meta/lab-tic-tac-toe/meta.json new file mode 100644 index 00000000000..1cd2952a19d --- /dev/null +++ b/curriculum/challenges/_meta/lab-tic-tac-toe/meta.json @@ -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" +} diff --git a/curriculum/challenges/english/25-front-end-development/lab-tic-tac-toe/67e3a6b7f60b4085588189e6.md b/curriculum/challenges/english/25-front-end-development/lab-tic-tac-toe/67e3a6b7f60b4085588189e6.md new file mode 100644 index 00000000000..e2631551e65 --- /dev/null +++ b/curriculum/challenges/english/25-front-end-development/lab-tic-tac-toe/67e3a6b7f60b4085588189e6.md @@ -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 + + + + + + Tic-Tac-Toe + + + + + + + + +
+ + + + +``` + +```css + +``` + +```jsx +const { useState } = React; + +export function Board() { + +} +``` + +# --solutions-- + +```html + + + + + + Tic-Tac-Toe + + + + + + + + +
+ + + + +``` + +```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 handleClick(index)} />; + }; + + return ( +
+

Tic-Tac-Toe

+
+ {winner ? `Winner: ${winner}` : isDraw ? "It's a Draw!" : `Next Player: ${isXNext ? 'X' : 'O'}`} +
+
+ {renderSquare(0)} + {renderSquare(1)} + {renderSquare(2)} +
+
+ {renderSquare(3)} + {renderSquare(4)} + {renderSquare(5)} +
+
+ {renderSquare(6)} + {renderSquare(7)} + {renderSquare(8)} +
+ +
+ ); +}; + +function Square({ value, onClick }) { + return ( + + ); +}; + +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; +}; +``` diff --git a/curriculum/superblock-structure/full-stack.json b/curriculum/superblock-structure/full-stack.json index 0cff406f7ea..3e7ef99d948 100644 --- a/curriculum/superblock-structure/full-stack.json +++ b/curriculum/superblock-structure/full-stack.json @@ -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" },