以前の記事で、React Docs(Beta)のチュートリアルで作った三目並べゲームを、TypeScript化しました。
React Docs(Beta)のチュートリアルの最後には、以下の発展問題があります。
※DeepLで翻訳
時間があるときや、新しいReactのスキルを練習したいときに、三目並べゲームにできる改善のアイデアを、難易度の高い順にいくつか挙げておきます。
- 現在の手に対してのみ、ボタンの代わりに「You are at move #...」と表示する。
- 碁盤をハードコードするのではなく、2つのループでマスを作るように書き換える。
- トグルボタンを追加して、手を昇順または降順に並べ替えられるようにする
- 誰かが勝ったとき、その原因となった3つのマスをハイライト表示する (誰も勝てなかったときは、結果が引き分けであることをメッセージで表示する)。
- 着手履歴のリストに各手順の位置を(col, row)の形式で表示する。
https://beta.reactjs.org/learn/tutorial-tic-tac-toe#wrapping-up
この記事は、React Docs(Beta)のチュートリアルの発展問題をやってみたメモです。
メモ
1. 現在の手に対してのみ、ボタンの代わりに「You are at move #...」と表示する。
currentMoveで現在の手を記録していますので、以下のように条件分岐を追加することで対応しました。
return (
  <li key={move}>
    {move === currentMove
      ? `You are at move #${currentMove}`
      : <button onClick={() => jumpTo(move)}>{ description }</button>
    }
  </li>
)
以下コード差分です。
2. 碁盤をハードコードするのではなく、2つのループでマスを作るように書き換える。
div用のfor文とSquareコンポーネント用のfor文を2つ追加し、配列にそれぞれpushする形にしました。
const boardView = []
for(let i = 0; i < 3; i++) {
  const row = []
  for(let j = 0; j < 3; j++) {
    row.push(<Square value={squares[i * 3 + j]} onSquareClick={() => handleClick(i * 3 + j)} />)
  }
  boardView.push(<div className="board-row">{row}</div>)
}
return (
  <>
    <div className="status">{status}</div>
    { boardView }
  </>
);
以下コード差分です。
3. トグルボタンを追加して、手を昇順または降順に並べ替えられるようにする
昇順と降順を制御するbooleanのstateisAscを追加
export default function Game() {
  const [history, setHistory] = useState<BoardState[]>([[null, null, null, null, null, null, null, null, null]])
  const [currentMove, setCurrentMove] = useState<number>(0)
+ const [isAsc, setIsAsc] = useState<boolean>(true)
トグルボタンを設置し、stateisAscによって並び順を変更できるようにしました。
初めolタグのreversed属性を知らなかったので、リストの数字を変更するのに少しハマりました。
return (
  <div className="game">
    <div className="game-board">
      <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
    </div>
    <div className="game-info">
+     <button onClick={() => setIsAsc(!isAsc)}>{ isAsc ? 'Asc' : 'Desc' }</button>
+     <ol reversed={!isAsc}>{ isAsc ? moves : moves.reverse() }</ol>
以下コード差分です。
4. 誰かが勝ったとき、その原因となった3つのマスをハイライト表示する (誰も勝てなかったときは、結果が引き分けであることをメッセージで表示する)。
4.1 誰かが勝ったとき、その原因となった3つのマスをハイライト表示する
勝った3つのマスはcalculateWinner関数で判定できます。変更前はsquares[a]だけ返していましたが、オブジェクトに変更して勝者playerと3つのマスlineを返すようにします。
function calculateWinner(squares: BoardState) {
  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 {
+       player: squares[a],
+       line: [a, b, c]
+     }
    }
  }
  return null
}
SquareコンポーネントのpropsにisWinnerSquareを追加、trueなら勝因となった3つのマスと判別できるようにしました。
const boardView = []
for(let i = 0; i < 3; i++) {
  const row = []
  for(let j = 0; j < 3; j++) {
+   row.push(<Square key={i * 3 + j} value={squares[i * 3 + j]} onSquareClick={() => handleClick(i * 3 + j)} isWinnerSquare={ winner ? winner.line.includes(i * 3 + j) : false } />)
  }
  boardView.push(<div key={i} className="board-row">{row}</div>)
}
以下コード差分です。
4.2 誰も勝てなかったときは、結果が引き分けであることをメッセージで表示する
引き分けの条件は、「winnerがfalse」かつ「squaresが全て埋まっている(nullの値が無い)」であるため、else ifで条件を追加し、Drawと表示されるようにしました。
  const winner = calculateWinner(squares)
  let status
  if(winner) {
    status = "Winner: " + winner.player
+ } else if(squares.filter(n => n === null).length === 0) {
+   status = "Draw"
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O")
  }
以下コード差分です。
5. 着手履歴のリストに各手順の位置を(col, row)の形式で表示する。
手の位置を記録するstatelocationを作成し、handlePlay関数で更新されるようにしました。handlePlay関数は、子のBoardコンポーネントのhandleClick関数で呼ばれます。
export default function Game() {
  const [history, setHistory] = useState<BoardState[]>([[null, null, null, null, null, null, null, null, null]])
+ const [location, setLocation] = useState<number[][]>([])
  const [currentMove, setCurrentMove] = useState<number>(0)
  const [isAsc, setIsAsc] = useState<boolean>(true)
  const xIsNext: boolean = currentMove % 2 === 0
  const currentSquares: BoardState = history[currentMove]
+ function handlePlay(nextSquares: BoardState, squareLocation: number) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]
    setHistory(nextHistory)
    setCurrentMove(nextHistory.length - 1)
+
+   const lines = [
+     [1,1],
+     [1,2],
+     [1,3],
+     [2,1],
+     [2,2],
+     [2,3],
+     [3,1],
+     [3,2],
+     [3,3]
+   ]
+   const nextLocation = [...location, lines[squareLocation]]
+   setLocation(nextLocation)
  }
着手履歴のリストの箇所で、locationを表示しています。
  const moves = history.map((squares, move) => {
    let description: string;
    if(move > 0) {
+     description = 'Go to move #' + move + ' colrow: (' + location[move - 1].join(',') + ')'
    } else {
      description = 'Go to game start'
    }
以下コード差分です。
