Adding time travel(タイムトラベルの追加)

最後の演習として、ゲームを過去の手番に「巻き戻す」ことができるようにしましょう。

着手の履歴を保持する

squares 配列をミューテート(書き換え)していた場合、タイムトラベルの実装は非常に困難になっていたことでしょう。

しかし、各着手ごとに slice() を使って squares 配列の新しいコピーを作成し、それをイミュータブルなものとして扱ってきました。このおかげで、過去のすべてのバージョンの squares 配列を保存し、すでに発生した着手の間で移動することができるようになります。

過去の squares 配列を、history という名前の別の配列に入れて、それを新たに state 変数として保持することにします。この history 配列は、最初の着手から最新の着手まで、盤面のすべての状態を表現しており、以下のような形になります。

[
  // 移動前
  [null, null, null, null, null, null, null, null, null],
  // 移動後
  [null, null, null, null, 'X', null, null, null, null],
  // 次の移動後
  [null, null, null, null, 'X', null, null, null, 'O'],
  // ...
]

もう一度 state をリフトアップ

ここからは、新しいトップレベルのコンポーネント、Game を作成して、過去の着手の一覧を表示するようにします。ゲームの履歴全体を保持する state である history は、ここに置くことにします。

history 状態を Game コンポーネントに配置することで、その子になる Board コンポーネントからは squares の state を削除できます。Square コンポーネントから Board コンポーネントに「state をリフトアップ」したときと同じように、Board からトップレベルの Game コンポーネントに state をリフトアップすることになります。これにより、Game コンポーネントは Board のデータを完全に制御し、history からの過去の盤面の状態を Board にレンダーさせることができます。

まず、export default を使って Game コンポーネントを追加し、Board コンポーネントと一部のマークアップをレンダーしてみましょう。

function Board() {
  // ...
}

export default function Game() {
  return (
    <div className="game">
      <div className="game-board">
        <Board />
      </div>
      <div className="game-info">
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  );
}

function Board() { 宣言の前にある export default キーワードを削除し、function Game() { 宣言の前に追加したことに注意してください。これにより、index.js ファイルが Board コンポーネントの代わりに Game コンポーネントをトップレベルのコンポーネントとして使用するように指示しています。Game コンポーネントが返すもう 1 つの div は、後で画面に追加するゲーム情報のためのスペースを確保しています。

Game コンポーネントに現在の手番と着手の履歴を管理するための state を追加してください。

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  // ...

[Array(9).fill(null)] は要素数が 1 の配列であり、その唯一の要素が 9 つの null が入った配列となっていることに注意してください。

現在の盤面をレンダーするには、history の最後にあるマス目の配列を読み取る必要があります。これに useState は必要ありません。レンダー中にそれを計算するだけの情報がすでにあります。

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];
  // ...

次に、Game コンポーネント内に、ゲーム内容を更新するために Board コンポーネントから呼ばれる handlePlay 関数を作成します。xIsNextcurrentSquares、そして handlePlayBoard コンポーネントに props として渡すようにします。

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    // TODO
  }

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
        //...
  )
}

次に Board コンポーネント側も編集し、渡される props によってこのコンポーネントが完全に制御されるようにしましょう。Board コンポーネントが 3 つの props を受け取るようにします。xIsNextsquares、そして、プレーヤの着手時に Board がコールして新たな盤面の状態を伝えるための onPlay 関数です。Board の冒頭で useState を呼び出している 2 行は削除してください。

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);
  }
  //...
}

Board コンポーネントは、Game コンポーネントから渡される props によって完全に制御されています。ゲームを再び動作させるために、Game コンポーネントの handlePlay 関数を実装する必要があります。

handlePlay は呼び出されたときに何をすべきでしょうか? 以前の Board は新しい square 配列を作ったら setSquares を呼び出していましたが、今では新しい配列を onPlay に渡すようになっています。

handlePlay 関数は Game の state を更新して再レンダーをトリガする必要がありますが、もう呼び出すべき setSquares 関数は存在しません。代わりに history という state 変数を使って情報を保存しているからです。history を更新して、新しい squares 配列を新しい履歴エントリとして追加するようにしましょう。また、Board が行っていたように xIsNext を切り替える必要もあります。

export default function Game() {
  //...
  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }
  //...
}

ここで、[...history, nextSquares] というコードは、history のすべての要素の後に nextSquares が繋がった新しい配列を作成します。(この ...historyスプレッド構文であり、「history のすべての項目をここに列挙せよ」のように読みます。)

例えば、history[[null,null,null], ["X",null,null]]nextSquares["X",null,"O"] の場合、新しい [...history, nextSquares] 配列は [[null,null,null], ["X",null,null], ["X",null,"O"]] になります。

この時点で、state が Game コンポーネントに移動し終わり、UI はリファクタリング前と同様に完全に動作するようになっているはずです。ここでのコードは以下のようになります。

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 [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{/*TODO*/}</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;
}

ここまでの状態をCodeSandbox に保存しています。わからなくなった方は下記からコピーできます。 https://codesandbox.io/s/tic-tac-toe-part3-5-w52k8q?file=/App.js

過去の着手の表示

注意
このセクションを完了するとエラーが表示されます。 次のセクションでエラーは解消するので安心してください。 エラーの中身が同じかどうかを確認しましょう。

三目並べのゲームの履歴が記録されるようになったので、プレーヤに過去の着手のリストを表示することができます。

<button> などの React 要素は普通の JavaScript オブジェクトでもありますので、アプリケーション内でそれらを受け渡しすることができます。React で複数のアイテムをレンダーするには、React 要素の配列を使うことができます。

すでに state として着手履歴の配列である history がありますので、ここでそれを React 要素の配列に変換します。JavaScript では、配列を別の配列に変換するために、配列の map メソッド を使うことができます。

[1, 2, 3].map((x) => x * 2) // [2, 4, 6]

着手の historymap で変換して、画面上のボタンを表す React 要素の配列にし、過去の着手に「ジャンプ」できるボタンを表示するようにしましょう。Game コンポーネントで history をマップしてみましょう。

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  function jumpTo(nextMove) {
    // TODO
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li>
        <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>
  );
}

コードは以下のようになります。ただし Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of `Game`. というエラーが、開発者ツールのコンソールに表示されていることに注意してください。このエラーは次のセクションで修正します。 error

map に渡される関数の内部で history を反復処理する部分では、squares 引数が history の各要素を順に受け取り、move 引数が配列のインデックス 0, 1, 2, … を順に受け取ります。(大抵は、実際の配列の中身が必要になりますが、今回の着手リストのレンダーで必要なのはインデックスの方だけです。)

三目並べゲームの履歴にある着手のそれぞれについて、ボタン <button> の入ったリストアイテム <li> を作成します。ボタンには jumpTo という関数を呼び出す onClick ハンドラがありますが、これはまだ実装していません。

現時点では、ゲーム内で起きた着手の一覧に加え、開発者ツールのコンソールにエラーが表示されているはずです。この "key" に関するエラーの意味についてこれから説明します。

ここまでの状態をCodeSandbox に保存しています。わからなくなった方は下記からコピーできます。 https://codesandbox.io/s/tic-tac-toe-part3-6-cf4jqq?file=/App.js

key を選ぶ

注意
この章ではコードの追加はありません。

リストをレンダーすると、React はレンダーされたリストの各アイテムに関するとある情報を保持します。そのリストが更新されると、React は何が変更されたのかを判断する必要があります。例えばリストのアイテムを追加したのかもしれませんし、削除、並べ替え、あるいは項目の中身の更新を行ったのかもしれません。

次のような状況から:

<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>

以下に遷移したと想像してください:

<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>

人間がこれを読めばおそらく、カウントの数字が変わっていることに加えて、Alexa と Ben の順番が入れ替わり、Claudia が Alexa と Ben の間に挿入された、と言うことでしょう。しかし React はコンピュータプログラムでありあなたの意図を知ることはできません。なのでリストの各項目を兄弟間で区別するために、それぞれのリスト項目に key プロパティを指定する必要があります。データがデータベースから取得されている場合、Alexa、Ben、Claudia のデータベース ID を key として使用できます。

<li key={user.id}>
  {user.name}: {user.taskCount} tasks left
</li>

リストが再レンダーされると、React は各リスト項目の key を見て、以前のリストの項目に一致する key があるか探します。現在のリストに以前に存在しなかった key がある場合、React は対応するコンポーネントを作成します。現在のリストから以前のリストに存在した key が消えている場合、React は対応する既存のコンポーネントを破棄します。2 つの key が一致した場合、対応するコンポーネントは移動されます。

key は、各コンポーネントを識別するための情報を React に与えます。これにより、再レンダー間で state を維持できるのです。コンポーネントの key が変更されると、コンポーネントは破棄され、新しい state で再作成されます。

key は React における、特別に予約されたプロパティです。要素が作成されるとき、React は key プロパティを抽出し、返される要素に key を直接格納します。key が props として渡されているかのように見えますが、key は React が自動的に使用して、どのコンポーネントを更新するかを自動的に決定します。子コンポーネント側が、親コンポーネントが指定した key が何であるかを知る方法はありません。

動的なリストを作成する際には、適切な key を割り当てることを強くお勧めします。適切な key がない場合は、key を含めるようデータ構造の再設計を検討してください。

key が指定されていない場合、React はエラーを報告し、デフォルトでは配列のインデックスを key として使用します。配列のインデックスを key として使用すると、リストの項目を並べ替えたり、挿入・削除したりする際に問題が生じます。明示的に key={i} を渡すとエラーを止めることはできますが、配列のインデックスを使うのと同じ問題になるだけですので、ほとんどの場合お勧めできません。

key はグローバルに一意である必要はなく、コンポーネントとその兄弟間で一意であれば十分です。

タイムトラベルの実装

三目並べゲームの履歴では、過去の各着手に、一意の ID が関連付けられています。何番目の着手かを表す連続した数値です。着手は並び変わったり、削除されたり、途中に挿入されることはないため、手番のインデックスを key として使用することは安全です。

Game 関数内で、<li key={move}> とすることで key を追加できます。これでゲームをリロードすると、React の "key" エラーが消えるはずです。

const moves = history.map((squares, move) => {
  //...
  return (
    <li key={move}>
      <button onClick={() => jumpTo(move)}>{description}</button>
    </li>
  );
});
// App.js
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 [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  function jumpTo(nextMove) {
    // TODO
  }

  const moves = history.map((squares, move) => {
    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;
}

jumpTo を実装する前に、Game コンポーネントに、現在ユーザが見ているのが何番目の着手であるのかを管理させる必要があります。これを行うために、currentMove という名前の新しい state 変数を定義し、デフォルト値を 0 に設定します:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[history.length - 1];
  //...
}

次に、Game 内の jumpTo 関数を更新して、currentMove を更新するようにします。currentMove を変更する数値が偶数の場合は、xIsNexttrue に設定します。

export default function Game() {
  // ...
  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
  }
  //...
}

次に、マス目をクリックしたときに呼ばれる GamehandlePlay 関数を 2 か所変更しましょう。

  • 過去に戻ってその時点から新しい着手を行う場合、その時点までの履歴を維持して残りは消去したいでしょう。nextSquareshistory のすべての履歴(... スプレッド構文)の後に追加するのではなく、履歴の一部である history.slice(0, currentMove + 1) の後に追加するようにして、履歴のうち着手時点までの部分のみが保持されるようにします。
  • 着手が起きるたびに、最新の履歴エントリを指し示すように currentMove を更新する必要があります。
function handlePlay(nextSquares) {
  const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
  setHistory(nextHistory);
  setCurrentMove(nextHistory.length - 1);
  setXIsNext(!xIsNext);
}

最後に、Game コンポーネントを変更し、常に最後の着手をレンダーする代わりに、現在選択されている着手をレンダーするようにします:

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

  // ...
}

ゲーム履歴内にある任意の着手をクリックすると、三目並べの盤面が即座に更新され、その着手の発生後に対応する盤面が表示されるようになります。

// App.js
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 [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];

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

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
  }

  const moves = history.map((squares, move) => {
    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;
}

ここまでの状態をCodeSandbox に保存しています。わからなくなった方は下記からコピーできます。 https://codesandbox.io/s/tic-tac-toe-part3-7-slq73p?file=/App.js

最後のお掃除

コードを注意深く見ると、currentMove が偶数のときは常に xIsNext === true であり、currentMove が奇数のとき xIsNext === false であることに気付くかもしれません。言い換えると、currentMove の値さえ知っていれば、xIsNext が何であるべきなのかも常に分かるということです。

これらを両方とも state に格納する理由はありません。実際、冗長な state は常に避けるようにしてください。state に格納するものを単純化すると、バグが減り、コードが理解しやすくなります。xIsNext を別の state 変数として保存するのではなく、currentMove に基づいて求めるように Game を変更しましょう。

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);
  }
  // ...
}

xIsNext の state 宣言や setXIsNext の呼び出しはもはや必要ありません。これにより、コンポーネントのコーディング中にミスがあっても、xIsNextcurrentMove と同期しなくなることはありません。

まとめ

おめでとうございます! 以下のような機能を持つ三目並べのゲームが作成できました。

  • 三目並べをプレイできる
  • プレーヤがゲームに勝ったときにそれを判定して表示する
  • ゲームの進行に伴って履歴を保存する
  • プレーヤがゲームの履歴を振り返り、盤面の以前のバージョンを確認できる

よくできました! これで、React の仕組みについてかなりの理解が得られたことを願っています。

最終結果はこちらで確認できます:

// App.js
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) => {
    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;
}

まだ時間がある方や、新しく手に入れた React のスキルを練習したい方向けに、三目並べゲームをさらに改善するためのアイディアをいくつか以下に示します。難易度の低い順にリストアップしています:

  1. 現在の着手の部分だけ、ボタンではなく "You are at move #..." というメッセージを表示するようにする。
  2. マス目を全部ハードコードするのではなく、Board を 2 つのループを使ってレンダーするよう書き直す。
  3. 手順を昇順または降順でソートできるトグルボタンを追加する。
  4. どちらかが勝利したときに、勝利につながった 3 つのマス目をハイライト表示する。引き分けになった場合は、引き分けになったという結果をメッセージに表示する。
  5. 着手履歴リストで、各着手の場所を (row, col) という形式で表示する。

このチュートリアルを通じて、React のコンセプトである React 要素、コンポーネント、props、state に触れてきました。ゲーム制作においてこれらの概念がどのように機能するかが分かったので、次は React の流儀をチェックして、アプリの UI を構築する際にこれらの React のコンセプトがどのように機能するのかを確認してください。

最終的なコードはこちら https://codesandbox.io/s/tic-tac-toe-part3-8-82d63t?file=/App.js

results matching ""

    No results matching ""