🧗‍♂️

React公式チュートリアルの追加問題をやってみた。

2024/02/14に公開

あっという間にゼミに後輩が入ってくることになりました。「あ、先輩React使えないんですか?(察し)」みたいになったらもうゼミに行けなくなってしまうので、来年度もゼミに出席して大学を卒業するために、Reactの公式チュートリアルと、その追加問題に取り組んだので共有します。チュートリアル本編はすでに終わっているものとします。
https://ja.react.dev/learn/tutorial-tic-tac-toe

概要

動作イメージ

  • 1.現在の着手の部分にボタンの代わりに「You are at #OO」と表示
  • 2.マス目を2つのループで描写するよう変更
  • 3.着手履歴の表示順を昇順、降順にボタンで切り替え
  • 4.ゲームの決着がついた場合にその決めてとなった部分をハイライト表示、引き分けの場合は引き分け(Draw)と表示
  • 5.着手履歴に着手のマス目の座標を表示
    • 左上を(0,0)として座標を表示させました

全体のコード

App.js
import { useState, useEffect } from 'react';  

function Square({ value, onSquareClick, isHighlighted }) {

  return <button 
            className={isHighlighted ? "highlighted-square" : "square"}
            onClick={onSquareClick}
          >
            {value}
          </button>;
}

function Board({ xIsNext, squares, onPlay }) {
  const winner = calculateWinner(squares);
  const [winnerLine, setWinnerLine] = useState(null);

  useEffect(() => { // squaresを監視し、変更された時実行
    const winner = calculateWinner(squares);
    if (winner) {
      const lines = calculateWinnerLines(squares);
      setWinnerLine(lines);
    } else {
      setWinnerLine(null); // 勝者がいない場合はnullをセット
    }
  }, [squares]);

  let status;
  if (winner) {
    status = "Winner: " + winner;
  } else if (squares.every((square) => square !== null)){ // squaresにnullが無い(=全てのマスが埋まっている)
    status = "Draw";
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O");
  }

  function handleClick(i) {
    if (squares[i] || calculateWinner(squares)) {
      return;
    }
    const nextSquares = squares.slice(); // squaresをコピー
    if(xIsNext){
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    onPlay(nextSquares);
  }

  function renderSquare(i) {
    const isHighlighted = winnerLine && winnerLine.includes(i);
    return (
      <Square
        key={i}
        value={squares[i]}
        onSquareClick={() => handleClick(i)}
        isHighlighted={isHighlighted}
      />
    );
  }

  const boardRows = [];
  for (let i = 0; i < 3; i++) {
    const row = [];
    for (let j = 0; j < 3; j++) {
      row.push(renderSquare(i * 3 + j));
    }
    boardRows.push(<div key={i} className="board-row">{row}</div>);
  }
  return (
    <>
      <div className="status">{status}</div>
      {boardRows}
    </>
  );
}

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 i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

function calculateWinnerLines(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 i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return lines[i];
    }
  }
  return null;
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const xIsNext = currentMove % 2 === 0;
  const currentSquares = history[currentMove];
  const [isAscending, setIsAscending] = useState(true);

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
  }

  function toggleSortMode() {
    setIsAscending(!isAscending);
  }

  function calculateClickedSquare(move) {
    if(move === 0){
      return null;
    }
    const current = history[move];
    const prev = history[move - 1];
    for(let i = 0; i < current.length; i++){
      if(current[i] !== prev[i]){
        return i;
      }
    }
  }

  let sortedMoves = [...history.keys()].slice();
  if(!isAscending){
    sortedMoves = sortedMoves.reverse();
  }

  const moves = sortedMoves.map((move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move + ' (' + Math.floor(calculateClickedSquare(move) / 3) + ', ' + calculateClickedSquare(move) % 3 + ')';
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });
  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol><button onClick={toggleSortMode}>Toggle Sort Mode</button></ol>
        <ol>{moves}</ol>
        <ol>You are at move #{history.length}</ol>
      </div>
    </div>
  )
}

問題別にしたこと

1.現在の着手の部分にボタンの代わりに「You are at #OO」と表示

  • className="game-info"のタグのところにolタグを使って追加
    • olタグはordered listで順序付きリスト
  • historyの長さから現在の着手の番号を取得
    • historyには今までの着手履歴が保存されている
App.js
 <div className="game-info">
    <ol>{moves}</ol>
+   <ol>You are at move #{history.length}</ol>
 </div>

2.マス目を2つのループで描写するよう変更

  • マス描写用の関数renderSquare()を用意
    • 変数iには0~8が与えられ、対応するSquare(マス)を返す
  • renderSquare()関数を利用してforループを2回回し、9個のマスを生成する
    • 各行のマスをrowに格納し、rowをboardRowsに3行分格納する
  • ハードコードしていた部分は不要なので全て削除する
App.js
function Board({ xIsNext, squares, onPlay }) {

    // 中略

+   function renderSquare(i) {
+       return (
+       <Square
+           key={i}
+           value={squares[i]}
+           onSquareClick={() => handleClick(i)}
+       />
+       );
+   }

+   const boardRows = [];
+   for (let i = 0; i < 3; i++) {
+       const row = [];
+       for (let j = 0; j < 3; j++) {
+           row.push(renderSquare(i * 3 + j));
+       }
+       boardRows.push(<div key={i} className="board-row">{row}</div>);
+   }
+   return (
    <>
        <div className="status">{status}</div>
-       <div className="board-row">
-           <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
-           <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
-           <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
-       </div>
-       <div className="board-row">
-           <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
-           <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
-           <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
-       </div>
-       <div className="board-row">
-           <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
-           <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
-           <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
-       </div>
+       {boardRows}
    </>
    );
}

3.着手履歴の表示順を昇順、降順にボタンで切り替え

  • State変数isAscendingを追加し、昇順で表示するか否かを管理
    • toggleSortMode()関数でisAscendingのtrueとfalseを切り替え
    • toggleSortModeは専用のボタンが押された際に実行させる
  • sortedMovesにはhistoryのインデックス番号を格納
    • isAscendingがfalseだった場合にreverseして逆順(降順)に
  • sortedMovesはインデックス番号が格納されているため、map関数の引数は1つに減らす
App.js
export default function Game() {
    // 中略
+    const [isAscending, setIsAscending] = useState(true);

+    function toggleSortMode() {
+        setIsAscending(!isAscending);
+    }

+    let sortedMoves = [...history.keys()].slice();
+    if(!isAscending){
+        sortedMoves = sortedMoves.reverse();
+    }

-    const moves = history.map({squares, move}) => {
+    const moves = sortedMoves.map((move) => {
        // 中略
    });
}

4.ゲームの決着がついた場合にその決めてとなった部分をハイライト表示、引き分けの場合は引き分け(Draw)と表示

  • squares(マス目の状況)を引数として渡すと、勝敗が決まっている場合決め手となったマスの位置を返してくれるcalculateWinnerLines()関数を作成
  • マス目の状況が更新されるたびに勝敗が決まっていないか、useEffect()を使って確認
    • useEffectのインポートを忘れずに
  • マスの描写を担当するrenderSquare()関数にisHighlighted変数を追加し、calculatedWinnerLines()関数で勝敗の決め手となったとわかったマスにはhighlighted-squareというCSSクラスを適用させて、ハイライト表示
    • highlighted-squareクラスはCSSファイルに追加で記述
  • 引き分けの判定
    • 引き分けになったということは、すべてのマスが埋まった(=squaresにnullがない)ということを利用して引き分けを判定
App.js
-import { useState } from 'react';  
+import { useState, useEffect } from 'react'; 

-function Square({ value, onSquareClick }) {
+function Square({ value, onSquareClick, isHighlighted }) {
      return <button
-               className="square" 
+               className={isHighlighted ? "highlighted-square" : "square"}
                onClick={onSquareClick}
            >
                {value}
            </button>;
}

function Board({ xIsNext, squares, onPlay }) {
    const winner = calculateWinner(squares);
+   const [winnerLine, setWinnerLine] = useState(null);

+   useEffect(() => { // squaresを監視し、変更された時実行
+       const winner = calculateWinner(squares);
+       if (winner) {
+           const lines = calculateWinnerLines(squares);
+           setWinnerLine(lines);
+       } else {
+           setWinnerLine(null); // 勝者がいない場合はnullをセット
+       }
+   }, [squares]);

    let status;
    if (winner) {
        status = "Winner: " + winner;
+   } else if (squares.every((square) => square !== null)){ // squaresにnullが無い(=全てのマスが埋まっている)
+       status = "Draw";
    } else {
        status = "Next player: " + (xIsNext ? "X" : "O");
    }

    // 中略

    function renderSquare(i) {
+       const isHighlighted = winnerLine && winnerLine.includes(i);
        return (
            <Square
                key={i}
                value={squares[i]}
                onSquareClick={() => handleClick(i)}
+               isHighlighted={isHighlighted}
            />
        );
    }

    // 中略

+    function calculateWinnerLines(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 i = 0; i < lines.length; i++) {
+            const [a, b, c] = lines[i];
+            if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
+                return lines[i];
+            }
+        }
+        return null;
+    }
}
styles.css
+.highlighted-square {
+  background: #fff;
+  color: red;
+  border: 1px solid #999;
+  float: left;
+  font-size: 24px;
+  font-weight: bold;
+  line-height: 34px;
+  height: 34px;
+  margin-right: -1px;
+  margin-top: -1px;
+  padding: 0;
+  text-align: center;
+  width: 34px;
+}

5.着手履歴に着手のマス目の座標を表示

  • 左上のマスが(0,0)、その右隣が(0,1)、一番右下が(2,2)というように左上のマスを原点とする
  • 各マスには0~8の番号が左上から割り当てられていることを利用する
    • 行はマス番号を3で割った商、列はマス番号を3で割った余りでそれぞれ求められる
  • クリックされたマスの取得のためにcalculateClickedSquare()関数を用意する
    • 現在のゲーム状況とその一つ前の状況を比べ、マス目が変わっている場所がクリックされたマスであることを利用して、クリックされたマス目の番号を求めて返す
App.js
export default function Game() {

    // 中略

+    function calculateClickedSquare(move) {
+        if(move === 0){
+            return null;
+        }
+        const current = history[move];
+        const prev = history[move - 1];
+        for(let i = 0; i < current.length; i++){
+            if(current[i] !== prev[i]){
+                return i;
+            }
+        }
+    }

    // 中略

    const moves = sortedMoves.map((move) => {
        let description;
        if (move > 0) {
-           description = 'Go to move #' + move;
+           description = 'Go to move #' + move + ' (' + Math.floor(calculateClickedSquare(move) / 3) + ', ' + calculateClickedSquare(move) % 3 + ')';
        } else {
            description = 'Go to game start';
        }
        return (
            <li key={move}>
                <button onClick={() => jumpTo(move)}>{description}</button>
            </li>
        );
    });
}

おわりに

CopilotとChatGPTに大助けられしながらなんとか5番まで完走できました。チュートリアルをやるだけだと「おーなんかできた」みたいな感じで終わっちゃった気がしたのですが、追加問題を通してコードを何回も見返すことで「ここがこうなってるのか!」と理解が深まった部分もあり、勉強になりました。自由に使いこなせるようになるにはまだまだ時間がかかりそうですが、楽しかったです。

ほぼ同じ処理をしている関数があるなど、素人目に見てももっと簡単にできそうだなという部分もありますが、参考になれば幸いです。

GitHubで編集を提案

Discussion