ホッケ肉厚です🐟
Reactは少し触った経験があるものの業務で中途半端に扱ったことしかないので、改めて学習中です。
まずは公式のReactチュートリアルでTic-Tac-Toeをやってみたのですが、追加課題に回答が用意されていなかったので、勉強ついでに自分なりの実装を載せておきます。
Reactチュートリアル本編はこちら👇
ReactチュートリアルはReact自体のバージョンアップに伴い頻繁に内容が変わるので、最新の課題はこの記事のものと異なる可能性があります。
ベースコード回答例1. 現在の着手の部分だけ、ボタンではなく “You are at move #…” というメッセージを表示するようにする。2. マス目を全部ハードコードするのではなく、Board を 2 つのループを使ってレンダーするよう書き直す。3. 手順を昇順または降順でソートできるトグルボタンを追加する。4-1. どちらかが勝利したときに、勝利につながった 3 つのマス目をハイライト表示する。4-2. 引き分けになった場合は、引き分けになったという結果をメッセージに表示する。5. 着手履歴リストで、各着手の場所を (row, col) という形式で表示する。
ベースコード
チュートリアル本編終了時点でのコードはこちら👇
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
で現在の着手の場合のみ表示文言を変える必要があります。
history
stateに格納されている配列の数(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です。なお、このままだと
Go to move #…
で過去の着手に戻った後も古いボタンが表示され続ける & 新たにマスを埋めると現状の着手履歴に更新される状態で、挙動としてかなり気持ち悪いと感じたので、ついでにGo to move #…
を押した際の挙動も変更します。
ボタン押下時のトリガーはGame
コンポーネント内のjumpTo
なので、実行時にhistory
stateを最新の状態に更新します。function jumpTo(nextMove) { + const updatedHistory = [...history.slice(0, nextMove + 1)]; + setHistory(updatedHistory); setCurrentMove(nextMove); }
これで
Go to move #…
の操作と同時に履歴一覧が最新化されます。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の場合のみmoves
にreverse
メソッドを適用すれば順番の入れ替えが可能です。
また、切り替えのボタンは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です。
4-1. どちらかが勝利したときに、勝利につながった 3 つのマス目をハイライト表示する。
追加課題3までのコードだと、勝利判定用の
calculateWinner
functionは勝利者(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したwinLine
をBoard
コンポーネント内で受け取り、それぞれのマス目が勝利に関わっているかどうかを判定し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です。
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を定義し直します。
また、ドロー判定を行うためにhistory
stateから最新の盤面を取り出し、全てのマス目が埋まっているかどうかを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です。
5. 着手履歴リストで、各着手の場所を (row, col) という形式で表示する。
追加課題4-2と同様に、選択された盤面の座標を管理するstateと盤面をクリックした時の処理を同じコンポーネント内で行いたいので、
handleClick
functionを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を履歴として保存しておきたいので、selectedSquares
stateを定義します。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]); }
履歴表示用の
moves
でselectedSquares
stateから各着手毎の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
が更新されず表示がバグります。
これはjumpTo
functionにselectedSquares
の更新処理を追加することで修正出来ます。function jumpTo(nextMove) { const updatedHistory = [...history.slice(0, nextMove + 1)]; setHistory(updatedHistory); setCurrentMove(nextMove); + setSelectedSquares([...selectedSquares.slice(0, nextMove)]); }
(過去の着手履歴に飛んだ後も含めて)着手毎の座標が表示されればOKです。