React Tutorial 五目並べ(Tic-Tac-Toe)の追加課題をやってみた
🎲

React Tutorial 五目並べ(Tic-Tac-Toe)の追加課題をやってみた

Tags
Programing
React.js
Published
Posted on 2023-11-15
Updated on 2024-09-01
Author
ホッケ肉厚です🐟
Reactは少し触った経験があるものの業務で中途半端に扱ったことしかないので、改めて学習中です。 まずは公式のReactチュートリアルでTic-Tac-Toeをやってみたのですが、追加課題に回答が用意されていなかったので、勉強ついでに自分なりの実装を載せておきます。
Reactチュートリアル本編はこちら👇
⚠️
ReactチュートリアルはReact自体のバージョンアップに伴い頻繁に内容が変わるので、最新の課題はこの記事のものと異なる可能性があります。
 

ベースコード

チュートリアル本編終了時点でのコードはこちら👇
import { useState } from "react"; function Square({ value, onSquareClick }) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } function Board({ xIsNext, squares, onPlay }) { function handleClick(i) { if (calculateWinner(squares) || squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = "X"; } else { nextSquares[i] = "O"; } onPlay(nextSquares); } const winner = calculateWinner(squares); let status; if (winner) { status = "Winner: " + winner; } else { status = "Next player: " + (xIsNext ? "X" : "O"); } 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> </> ); } 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]; function handlePlay(nextSquares) { const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; setHistory(nextHistory); setCurrentMove(nextHistory.length - 1); } function jumpTo(nextMove) { setCurrentMove(nextMove); } const moves = history.map((squares, move) => { // squaresは使われていないので let description; if (move > 0) { description = "Go to move #" + move; } 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>{moves}</ol> </div> </div> ); } 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; }

回答例

💡
コード内の記号はそれぞれ+: 行の追加-: 行の削除±: 行の修正を表しています。

1. 現在の着手の部分だけ、ボタンではなく “You are at move #…” というメッセージを表示するようにする。

着手履歴の表示を管理しているのはGameコンポーネント内のmovesなので、history.mapで現在の着手の場合のみ表示文言を変える必要があります。 historystateに格納されている配列の数(history[history.length - 1])が現在の着手数と同じなので、以下のように条件分岐を追加します。
const moves = history.map((squares, move) => { let description; + if (move === history[history.length - 1]) { + description = `You are at move #` + move; ± } else if (move > 0) { description = "Go to move #" + move; } else {           // --- other codes --- });
マス目を押す度にYou are at move #…が現在の着手として表示されればOKです。
notion image
なお、このままだとGo to move #…で過去の着手に戻った後も古いボタンが表示され続ける & 新たにマスを埋めると現状の着手履歴に更新される状態で、挙動としてかなり気持ち悪いと感じたので、ついでにGo to move #…を押した際の挙動も変更します。 ボタン押下時のトリガーはGameコンポーネント内のjumpToなので、実行時にhistorystateを最新の状態に更新します。
function jumpTo(nextMove) { + const updatedHistory = [...history.slice(0, nextMove + 1)]; + setHistory(updatedHistory); setCurrentMove(nextMove); }
これでGo to move #…の操作と同時に履歴一覧が最新化されます。
notion image

2. マス目を全部ハードコードするのではなく、Board を 2 つのループを使ってレンダーするよう書き直す。

JSXは配列がレンダリングされる際に中身を展開してくれるので、ハードコードされたSquareコンポーネントをまとめて配列に格納します。 ループを2回使えと指示があるので、1~3列のforループ >入れ子> 1~3行のforループを回し、列番号と行番号からマス目のindexを計算してハードコードしていたSquareコンポーネントの引数に渡してあげます。 なお、配列を使用すると各Squareの一意性が失われ、key属性が必要な旨のWarningが表示されので、Squareのkey属性にindexを追加することで対応しましょう。
function Board({ xIsNext, squares, onPlay }) {      // --- other codes --- + let board = []; + for (let row = 0; row < 3; row++) { + let boardRow = []; + for (let col = 0; col < 3; col++) { + const index = row * 3 + col; // rowとcolの番号からindexを逆引き + boardRow.push( + <Square + key={index} // key属性を追加してWarningを回避 + value={squares[index]} + onSquareClick={() => handleClick(index)} + /> + ); + } + + board.push( + <div key={row} className="board-row"> + {boardRow} + </div> + ); + } return ( <> <div className="status">{status}</div> + {board} - <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> </> ); }
追加課題1と挙動が同じであればOKです。

3. 手順を昇順または降順でソートできるトグルボタンを追加する。

ソートの状態を管理するstateが必要なので新たにisAscendを追加します。
export default function Game() { // --- other codes --- const currentSquares = history[currentMove]; + const [isAscend, setIsAscend] = useState(true);
履歴はmovesに格納されているので、isAscendがfalseの場合のみmovesreverseメソッドを適用すれば順番の入れ替えが可能です。 また、切り替えのボタンはbuttonタグでonClickを定義し、クリック時にisAscendを反転するかたちで実装します。 isAscendがstate管理されているので、ボタンの表示文言もisAscendの値によって書き換えが可能です。
export default function Game() {   // --- other codes --- +  function onSortHandleClick() { +  setIsAscend(!isAscend); +  }   return (   <div className="game">   <div className="game-board">   <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />   </div>   <div className="game-info"> ±  <ol>{isAscend ? moves : moves.reverse()}</ol>   </div> +  <div className="game-info"> +  <button onClick={() => onSortHandleClick()}> +  Change sort order to {isAscend ? "Descend" : "Ascend"} +  </button> +  </div>   </div>   );
ソートボタンを押して履歴の順番が入れ替わればOKです。
notion image

4-1. どちらかが勝利したときに、勝利につながった 3 つのマス目をハイライト表示する。

追加課題3までのコードだと、勝利判定用のcalculateWinnerfunctionは勝利者(X or O)しかreturnしていないので、勝利時のラインも返すようにします。 また、functionの目的が変わったので名前をobtainGameResultsに変えています。
± function obtainGameResults(squares) { // --- other codes ---     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 { winner: squares[a], winLine: lines[i] };     }     } ±   return { winner: null, winLine: null };   }
戻り値の型が変わったので、元々あったcalculateWinnerの呼び出し元を修正します。 また、新たにreturnしたwinLineBoardコンポーネント内で受け取り、それぞれのマス目が勝利に関わっているかどうかを判定しisInWinLineに格納、それをSquareコンポーネントに引数で渡します。
 function Board({ xIsNext, squares, onPlay }) { function handleClick(i) { ± if (obtainGameResults(squares).winner || squares[i]) { return; } // --- other codes --- } ± const { winner, winLine } = obtainGameResults(squares); // --- other codes --- let board = []; for (let row = 0; row < 3; row++) { let boardRow = []; for (let col = 0; col < 3; col++) { const index = row * 3 + col; + const isInWinLine = winLine?.some((mass) => mass === index) || false; boardRow.push( <Square key={index} value={squares[index]} onSquareClick={() => handleClick(index)} + isInWinLine={isInWinLine} /> ); } // --- other codes --- }
Squareコンポーネントでマス目の配色を変えるstyle属性を定義し、受け取った引数isInWinLineがtrueであれば、styleを適用します。
±  function Square({ value, onSquareClick, isInWinLine }) { + const winColor = { + backgroundColor: "#ff0000", // red + }; return ( <button className="square" onClick={onSquareClick} + style={isInWinLine ? winColor : null} > {value} </button> ); }
どちらかの勝利時にマス目の色が変わればOKです。
notion image

4-2. 引き分けになった場合は、引き分けになったという結果をメッセージに表示する。

ドローの判定方法はいくつかやり方がありそうですが、ここでは最終手(9手目)の盤面でマス目が全て埋まっている場合をドローとして扱うことにします。 メッセージの表示はNext player: …の表示領域にしたいのですが、現状この処理はBoardコンポーネント内で行われています。 現在の手数、盤面、表示領域を同じコンポーネントにまとめたいので、まずはそれらに関わるstateと処理をBoardからGameにリフトアップします。 まずはBoardからプレイヤーの表示に関わるstateを剥がします。
± function Board({ xIsNext, squares, onPlay, winLine }) { // winLineは引数で渡す     // --- other codes --- - const { winner, winLine } = obtainGameResults(squares); - let status; - if (winner) { - status = "Winner: " + winner; - } else { - status = "Next player: " + (xIsNext ? "X" : "O"); - } return ( <> - <div className="status">{status}</div> {board} </> ); }
Gameコンポーネント内で先程剥がしたstateを定義し直します。 また、ドロー判定を行うためにhistorystateから最新の盤面を取り出し、全てのマス目が埋まっているかどうかをincludesメソッドで判定しisDrawで定義します。 あとは表示文言を決める条件分岐でisDrawの場合に”Draw”をstatusに代入し、結果をGameコンポーネント内でレンダリングします。 なお、Boardコンポーネントにはこちらで定義し直したwinLineを引数として渡しています。
 export default function Game() {     // --- other codes --- + const isDraw = !history[history.length - 1].includes(null); + const { winner, winLine } = obtainGameResults(currentSquares); + let status; + if (winner) { + status = "Winner: " + winner; + } else if (isDraw) { + status = "Draw"; + } else { + status = "Next player: " + (xIsNext ? "X" : "O"); + } return ( <div className="game"> <div className="game-board"> ± <div className="status">{status}</div> <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} + winLine={winLine} /> </div> <div className="game-info"> <ol>{isAscend ? moves : moves.reverse()}</ol> </div> <div className="game-info"> <button onClick={() => onSortHandleClick()}> Change sort order to {isAscend ? "Descend" : "Ascend"} </button> </div> </div> ); }
盤面が全て埋まった時に盤面上のメッセージに”Draw”が表示されればOKです。
notion image

5. 着手履歴リストで、各着手の場所を (row, col) という形式で表示する。

追加課題4-2と同様に、選択された盤面の座標を管理するstateと盤面をクリックした時の処理を同じコンポーネント内で行いたいので、handleClickfunctionをGameコンポーネントにリフトアップします。
± function Board({ squares, winLine, onClick }) { - function handleClick(i) { - if (obtainGameResults(squares).winner || squares[i]) { - return; - } - const nextSquares = squares.slice(); - if (xIsNext) { - nextSquares[i] = "X"; - } else { - nextSquares[i] = "O"; - } - onPlay(nextSquares); - } // --- other codes --- boardRow.push( <Square key={index} value={squares[index]} ± onSquareClick={() => onClick(index)} isInWinLine={isInWinLine} /> ); // --- other codes --- return <div>{board}</div>; } // --- other codes --- export default function Game() { + function handleClick(i) { + if (obtainGameResults(currentSquares).winner || currentSquares[i]) { + return; + } + const nextSquares = currentSquares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + handlePlay(nextSquares); } // --- other codes --- return ( <div className="game"> <div className="game-board"> <div className="status">{status}</div> <Board - xIsNext={xIsNext} squares={currentSquares} - onPlay={handlePlay} + onClick={(i) => handleClick(i)} winLine={winLine} /> // --- other codes --- ); }
handleClickが発火される度に押したマス目のindexを履歴として保存しておきたいので、selectedSquaresstateを定義します。
  export default function Game() { const [history, setHistory] = useState([Array(9).fill(null)]); + const [selectedSquares, setSelectedSquares] = useState([]); function handleClick(i) { // --- other codes --- handlePlay(nextSquares); + setSelectedSquares([...selectedSquares, i]); }
履歴表示用のmovesselectedSquaresstateから各着手毎のindexを取得し、行列番号に変換してからdescriptionに追加します。
const moves = history.map((squares, move) => { + const index = selectedSquares[move - 1]; + const selectedRow = Math.floor(index / 3) + 1; + const selectedCol = (index % 3) + 1; let description; if (move === history.length - 1) { description = `You are at move #` + move; } else if (move > 0) { description = ± "Go to move #" + move + "(" + selectedRow + ", " + selectedCol + ")"; } else { description = "Go to game start"; } return ( <li key={move}> <button onClick={() => jumpTo(move)}>{description}</button> </li> ); });
なお、このままだと過去の着手履歴に飛んだ際にselectedSquaresが更新されず表示がバグります。 これはjumpTofunctionにselectedSquaresの更新処理を追加することで修正出来ます。
function jumpTo(nextMove) { const updatedHistory = [...history.slice(0, nextMove + 1)]; setHistory(updatedHistory); setCurrentMove(nextMove); + setSelectedSquares([...selectedSquares.slice(0, nextMove)]); }
(過去の着手履歴に飛んだ後も含めて)着手毎の座標が表示されればOKです。
notion image