Closed11

React公式チュートリアルの三目並べを改造する

AlmaAlma

チュートリアル通りに作るのは完了。


とのことなのでまずはこれに取り組んでいく

AlmaAlma

1: 現在の着手だけYou are at mov #..."というメッセージを表示するようにする

  • movesの各moveに対してmap関数でボタンを割り当てていく際に、そのmoveがcurrentMove(現在の手番)と一致していた場合はボタンではなくメッセージを返すように条件分岐のコードを追加。
export default function Game() {
 // 中略
  const moves = history.map((squares, move) => {
    if (move === currentMove) { // 追加したコード
      return <li key={move}>You are at move #{currentMove}</li>;
    }
    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>
    );
  });

AlmaAlma

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

  • map関数で行数×列数の2重ループを回す
      {[...Array(3)].map((_, i) => (
        <div className="board-row" key={i}>
          {[...Array(3)].map((_, j) => {
            const index = i * 3 + j;
            return (
              <Square
                key={index}
                value={squares[index]}
                onSquareClick={() => {
                  handleClick(index);
                }}
              />
            );
          })}
        </div>
      ))}
  • JSX内でのループはforループではなくmapを使う
  • Array(3)[...Array(3)]の違い
    • Array(3)は長さが3の新しい配列を作成するが、この配列の各要素は空スロットになってしまい、そのままでは.map()などの配列メソッドを使用することができない
    • そのため、スプレッド構文で展開して長さが3で各要素がundefinedの新しい配列を作成する必要がある
  • map関数の第一引数は配列の値、第二引数は配列の要素のインデックスを取得する
    • たとえば(_, i)の場合、_が配列の各要素(undefined)、iが配列のインデックス(0~2)
AlmaAlma

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

  • 昇順/降順の状態を保持するstateを定義する
  • 昇順/降順のstateを切り替える関数を定義する
  • ボタンを追加し、クリック時にstateを切り替える関数を呼び出すようにする
  • olタグにreversed要素を追加し、これもstateに沿って昇順/降順が切り替わるようにする
export default function Game() {
// 中略
  const [ascendingOrder, setAscendingOrder] = useState(true); // 昇順/降順の状態を保持するstate
// 中略
  function toggleOrder() { // 昇順・降順の状態を切り替える関数
    setAscendingOrder(!ascendingOrder);
  }
// 中略
  if (!ascendingOrder) { // 降順ならmovesの順番を反転させる
    moves = moves.reverse(); 
  }
// 中略
  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <div>
          <button onClick={toggleOrder}> // ボタンクリック時に昇順/降順を切り替える
            {ascendingOrder ? "Descending Order" : "Ascending Order"} // 昇順ならボタンには「降順」と表示する
          </button>
        </div>
        <ol reversed={!ascendingOrder}>{moves}</ol> // olタグの順番も反転させる
      </div>
    </div>
  );
}

AlmaAlma

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

  • SquareコンポーネントにisWinnerSquareという引数を渡すようにする。これは勝利につながった3つのマス目に該当するときのみtrueとなる真偽値
    • CSSファイルに.winnerというクラスを定義し、isWinneSquareがtrueのSquareにのみ適用されるようにする。つまり、勝利につながったマスにのみ背景色がつくようにする
App.js Squareコンポーネント
function Square({ value, onSquareClick, isWinnerSquare }) { // isWinnerSquareを追加
  return (
    <button
      className={`square ${isWinnerSquare ? "winner" : ""}`} // 勝利マスにのみwinnerのスタイルを当てる
      onClick={onSquareClick}
    >
      {value}
    </button>
  );
}
styles.css
.winner {
    /* 勝利につながったマス目のスタイルを定義 */
    background-color: #FF9966; /* オレンジ色の背景色を設定 */
}
  • ではそのisWinnerSquareの判定をどうすれば良いか。
  • Gameコンポーネント内には勝利判定する関数calculateWinner関数が既にある。これがwinnerを返すとき、つまり勝者が決まったタイミングで、一緒にそのときのline(勝利につながった3つのマスの番号の配列)も返すようにすれば良さそう
App.js Gameコンポーネント
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 { winner: squares[a], line: lines[i] }; // winnerだけでなくそのときのlineも返すようにする
    }
  }
  return { winner: null, line: null }; // 勝敗が未決のときはどちらもnull
}
  • あとはBoardコンポーネントでGameコンポーネントから勝利マスの番号の情報を受け取って、該当するSquareにのみisWinnerSquareをtrueにして渡せば良さそう
  • 新しくrenderSquareというマスを描画する関数を用意し、その中で勝利につながったSqareにのみisWinnerSquareをtrueにして渡すようにする
App.js Boardコンポーネント
function Board({ xIsNext, squares, onPlay }) {
  const { winner, line } = calculateWinner(squares);
// 中略
  function handleClick(i) {
    if (winner || squares[i] ) { // calculateWinner(squares) をwinnerに変更
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    onPlay(nextSquares);
  }

// マス目をレンダーする関数
  function renderSquare(i) {
    const isWinnerSquare = winner && line && line.includes(i); // 勝利マスに該当するか
    return (
      <Square
        key={i}
        value={squares[i]}
        onSquareClick={() => handleClick(i)}
        isWinnerSquare={isWinnerSquare} // 勝利マスならtrueにする
      />
    );
  }

return (
    <>
      <div className="status">{status}</div>
      {[...Array(3)].map((_, i) => (
        <div className="board-row" key={i}>
          {[...Array(3)].map((_, j) => {
            const index = i * 3 + j;
            return renderSquare(index); // 関数に変更
          })}
        </div>
      ))}
    </>
  );
}

注意点
勝利につながった3つのマスは計算可能(勝敗を判定する関数内で勝者が確定したときに、そのときのマスの配列を見るだけでOK)なので、stateで管理する必要はない。計算可能な値は都度計算し、余分なstateは定義しない

AlmaAlma

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

  • 引き分けを判定する方針は2つ思いついた。
    • 手番で判定する方法
      • 手番の数が最大で、かつ勝者がまだ決まっていないときに引き分けとする
      • 実装は楽そう
      • 手番の数が固定されていることを前提としているため、将来的にゲームのルールが変更される(e.g. 盤面が広くなる、パスなどのルールを追加する)ときは修正が必要になってしまう
    • 盤面の状態で判定する方法
      • 盤面がすべて埋まっており、かつ勝者がまだ決まっていないときに引き分けとする
      • 実装は前者よりはめんどくさそう
      • 手番の数に依存しないため、柔軟性がある(ただし、盤面が埋まっていなくても引き分けになり得るようなルールに変更された場合はこちらも修正は必要になる)
  • めんどくさいので「三目並べを作る」というテーマである以上盤面を広くするつもりはないのと、特段パスなどのルールの追加も想定していないため、前者を採用した
App.js Boardコンポーネント
function Board({ xIsNext, squares, onPlay, isDraw }) { // isDrawを追加
  const { winner, line } = calculateWinner(squares);
  let status;
  if (isDraw) { // もし引き分けになったらDrawと表示する
    status = "Draw";
  } else if (winner) {
    status = "Winner: " + winner;
  } else {
    status = "Next player: " + (xIsNext ? "x" : "o");
  }
App.js Gameコンポーネント
export default function Game() {
// 中略
// 最大手番で、かつ勝者が決まっていない場合に引き分けとする
  const isDraw = currentMove === 9 && !calculateWinner(currentSquares).winner;

AlmaAlma

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

  • row, colは0スタートではなく1行目はcol: 1のように表示することにする
  • rowとcolはその着手のindexから算出できる
    • row: indexを3で割った商に+1する(+1するのはindexが0からスタートしているため)
      • たとえば1列目の各マスのindexは0~2だが、これらはどれも3で割った商が0になる。これに+1して1にすればよい
    • col: indexを3で割った余りに+1する
      • たとえば1列目の各マスのindexは0, 3, 6だが、これらはどれも3で割った余りが0になる。これに+1して1にすればよい
  • その手番の着手のindexをどう取得するか
    • それ自体は保存しておらず、各手番の盤面の状態のみを保存しているため、1つ前の手番の盤面の状態と比較して差分となる着手のindexを取得するようにする
      • findIndex()関数を使用してindexを取得する
        • findIndex()関数はArray インスタンスのメソッドで、配列内の指定された条件に一致する最初の要素のインデックスを返す
  let moves = history.map((squares, move) => {
    let description;
    if (move > 0) { // 最初の手番だと直前の盤面が存在していないため差分を計算できない
      const clickedSquare = squares.findIndex(
        (value, index) => value !== history[move - 1][index] // 1つ前の手番の盤面と一致しないマスのindexを取得する
      );
      const row = Math.floor(clickedSquare / 3) + 1; // 列数で割った商+1
      const col = (clickedSquare % 3) + 1; // 列数で割った余り+1
      description =
        move === currentMove // 現在の着手かでメッセージを分岐
          ? `You are at move #${currentMove} (row: ${row}, col: ${col})`
          : `Go to move #${move} (row: ${row}, col: ${col})`;
    } else { // 最初の手番の場合
      description = "Go to game start";
    }
    return (
      <li key={move}>
        {move === currentMove ? ( // 現在の着手かでボタンかメッセージかの表示を分岐
          <span>{description}</span>
        ) : (
          <button onClick={() => jumpTo(move)}>{description}</button>
        )}
      </li>
    );
  });

AlmaAlma

追加で改造してみた
ex.1 現在の着手のマスをハイライトする

  • SquareコンポーネントにisCurrentSquareという引数を渡すようにする。これは現在の着手のマスであるときのみtrueになる真偽値
    • CSSファイルに.currentというクラスを定義し、isCurrentSquareがtrueのSquareにのみ適用されるようにする。つまり、現在の着手のマスにのみ背景色がつくようにする
  • ただし、isWinnerSquareであるときはそちらが優先されるため、isWinnerSquareがfalseのときにのみスタイルが当たるようにする
App.js Squareコンポーネント
function Square({ value, onSquareClick, isWinnerSquare, isCurrentSquare }) {
  return (
    <button
      className={`square ${isWinnerSquare ? "winner" : ""} ${
        !isWinnerSquare && isCurrentSquare ? "current" : "" // 勝利時でなく、最新の着手であればスタイル適用
      }`}
      onClick={onSquareClick}
    >
      {value}
    </button>
  );
}
style.css
.current {
  background-color: #87dc86; /* 緑色の背景色を設定 */
}
  • BoardコンポーネントのpropsにcurrentMoveIndexを追加する
    • Gameコンポーネントで最新の着手のマスが何番目かを計算してBoardに渡す
  • Boardコンポーネントで各マスをレンダーするとき、そのマスのインデックスが最新の着手のインデックスと一致していればisCurrentMoveをtrueにしてSquareに渡す
App.js Boardコンポーネント
function Board({ xIsNext, squares, onPlay, isDraw, currentMoveIndex }) { // currentMoveIndexを追加
// 中略
  // マス目をレンダーする関数
  function renderSquare(i) {
    const isWinnerSquare = winner && line && line.includes(i);
    const isCurrentSquare = i === currentMoveIndex; // そのマスのインデックスと最新の着手のインデックスが一致しているか
    return (
      <Square
        key={i}
        value={squares[i]}
        onSquareClick={() => handleClick(i)}
        isWinnerSquare={isWinnerSquare}
        isCurrentSquare={isCurrentSquare} // Squareコンポーネントに渡す
      />
    );
  }
  • GameコンポーネントにcurrentMoveIndexを定義し、Boaedコンポーネントに渡す
    • 前の盤面との差分から最新の着手を計算し、そのマスのインデックスを取得する
    • 初手は前の盤面が存在しないため、currentMoveIndexをnullとする
App.js Gameコンポーネント
export default function Game() {
// 中略
  const currentMoveIndex = currentMove 
    ? history[currentMove].findIndex( // 現在の盤面から条件に合う要素のインデックスを取得する
                (value, index) => value !== history[currentMove - 1][index] // 盤面の各マスを見て、その値が1つ前の盤面の同じマスと一致しない = 最新の着手
      )
    : null; // 着手が無い(前の盤面が存在しない)場合はnull
// 中略
        <Board
          xIsNext={xIsNext}
          squares={currentSquares}
          onPlay={handlePlay}
          isDraw={isDraw}
          currentMoveIndex={currentMoveIndex} // Boardコンポーネントに渡す
        />
// 後略

AlmaAlma

ex.2 最初の盤面では"Go to game start"という文面を出さないようにする

  • 最初の画面に戻るためのボタンのラベルだけど、最初の画面では押せないので不自然
  • 表示内容を"Now, let's get started"に修正
App.js Gameコンポーネント
let moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      const clickedSquare = squares.findIndex(
        (value, index) => value !== history[move - 1][index]
      );
      const row = Math.floor(clickedSquare / 3) + 1;
      const col = (clickedSquare % 3) + 1;
      description =
        move === currentMove
          ? `You are at move #${currentMove} (row: ${row}, col: ${col})`
          : `Go to move #${move} (row: ${row}, col: ${col})`;
    } else {
      if (currentMove) {
        description = "Go to game start";
      } else { // 着手がまだ存在しない場合(最初の盤面では)
        description = "Now, let's get started";  // プレイスタートの文を表示
      }
    }

AlmaAlma

追加でできそうなこと

  • ×の勝利時と⚪︎の勝利時で勝利につながった 3 つのマス目をハイライトの色を変える
    (e.g. ×は青系で⚪︎は赤系など)
このスクラップは2024/04/24にクローズされました