○×ゲームをReact Hooksで実装する

6 min read読了の目安(約5700字

始めに

Reactのチュートリアルには○×ゲームを題材に説明されていますが、昔ながらのクラスベースのものになっています。そこでReact Hooksで作ってみたのでその内容について紹介します。
作ったものはこちらになります。チュートリアルとはいくつか違うところがあります。

  • 履歴機能をなくし、リセットだけできるようにした
  • マスをクリックできる場合はカーソルアイコンにする

またいくつかライブラリを使用しています。

  • lodash
  • classnames
  • prop-types

なお、次から説明する内容はJavaScriptのソースのみで、CSSについてはJSFiddleに書かれている内容を参照してください。

実装

盤面コンポーネント(Board)の作成

まずは盤面部分を作成します。tableという変数に以下のような2次元配列データが入っていたら画像のようなテーブルが表示されるようにします。

// null, '×', '○'のどれかが入る
const table = [
  [null, null, null],
  [null, '×', '○'],
  [null, null, null],
];

細かい調整として、マス目はdisabledじゃないときと、中身がnullの時だけカーソル表示にしてクリックできるようにします。

Board.js
const Board = (props) => {
  return (
    <div className="board">
      {props.table.map((row, i) => (
      	<div key={i} className="board__line">
      	  {row.map((col, j) => (
            <div
              key={j}
              className={classNames('board-item', {
              	'-clickable': !props.disabled && col == null,
              })}
              onClick={() => {
              	if (!props.disabled && col == null) {
                  props.onSelectItem(i, j);
                }
              }}
            >
              <div className="board-item__content">{col}</div>
            </div>
          ))}
      	</div>
      ))}
    </div>
  );
};
Board.propTypes = {
  table: PropTypes.array.isRequired,
  disabled: PropTypes.bool.isRequired,
  onSelectItem: PropTypes.func.isRequired,
};

○×の配置ロジックの実装

Boardコンポーネントを使って○×を埋めていきます。

  • 盤面データの初期化
    • リセット機能のことも考え、初期化用のメソッドを使って初期化します
  • ステップに応じて手番を決める
    • プレイヤーリストを定義して、今が何ステップ目かを記録することで次は誰のターン化を算出することができます
  • 盤面データの更新
    • Boardコンポーネントから選択されたセル情報を受け取ったらそこに値を入れますが、ImmutableにするためにcloneDeepして変更したもので更新します
App.js
const SIZE = 3;
const PLAYERS = ['X', '◯'];

/**
 * 最初の盤面データを生成する
 * @param size - 縦横それぞれの方向の個数
 * @return 盤面データ
 */
const createInitialBoardData = (size) => {
  return Array(size).fill().map(() => Array(size).fill(null));
};

const App = () => {
  const [step, setStep] = useState(0);
  const [boardData, setBoardData] = useState(createInitialBoardData(SIZE));
  const player = PLAYERS[step % PLAYERS.length];
  
  return (
    <div className="game">
      <div className="game__item">
        <Board
          table={boardData}
          onSelectItem={(i, j) => {
            if (boardData[i][j] != null) {
              return;
            }
            const newBoardData = _.cloneDeep(boardData);
            newBoardData[i][j] = player;
            setBoardData(newBoardData);
            setStep(step + 1);
          }}
        />        
      </div>
    </div>
  );
};

勝者判定をする

盤面データを渡して、勝者を判定します。愚直に縦のライン、横のライン、斜めのラインで同じ要素が置かれていないかチェックします。

勝者判定
/**
 * 勝者の判定をする
 * @param boardData - 盤面データ
 * @return 勝者(nullの場合はまだ未決定)
 */
const judgeWinner = (boardData) => {
  // 横ラインの勝者判定
  for (let i = 0; i < boardData.length; i++) {
    const putPlayer = boardData[i][0];
    // 始めが誰も置いていない場合は調べても無駄なのでスキップ
    if (putPlayer == null) {
      continue;
    }
    const isThePlayerWon = boardData[i]
      .every((player) => player === putPlayer);
    if (isThePlayerWon) {
      return putPlayer;
    }
  }
  
  // 縦ラインの勝者判定
  for (let j = 0; j < boardData[0].length; j++) {
    const putPlayer = boardData[0][j];
    // 始めが誰も置いていない場合は調べても無駄なのでスキップ
    if (putPlayer == null) {
      continue;
    }
    const isThePlayerWon = _.times(boardData[0].length)
      .map((i) => boardData[i][j])
      .every((player) => player === putPlayer);
    if (isThePlayerWon) {
      return putPlayer;
    }
  }
  
  // 右下ラインの勝者判定
  {
    const putPlayer = boardData[0][0];
    if (putPlayer != null) {
      const isThePlayerWon = _.times(boardData.length)
        .map((i) => boardData[i][i])
        .every((player) => player === putPlayer);
      if (isThePlayerWon) {
        return putPlayer;
      }
    }
  }
  
  // 右上ラインの勝者判定
  {
    const putPlayer = boardData[boardData.length - 1][0];
    if (putPlayer != null) {
      const isThePlayerWon = _.times(boardData.length)
        .map((i) => boardData[boardData.length - 1 - i][i])
        .every((player) => player === putPlayer);
      if (isThePlayerWon) {
        return putPlayer;
      }
    }
  }
  
  // 誰も見つけられなかったらnullを返す
  return null;
}

ステータス表示、リセット機能を足す

最後に勝者などのステータス表示やリセット機能を足して完成です。

App.js
const App = () => {
  const [step, setStep] = useState(0);
  const [boardData, setBoardData] = useState(createInitialBoardData(SIZE));
  const player = PLAYERS[step % PLAYERS.length];
+ const winner = judgeWinner(boardData);
  
+ const statusLabel = (() => {
+   if (winner != null) {
+     return `winner: ${winner}`;
+   }
+   
+   if (step >= SIZE * SIZE) {
+     return 'draw';
+   }
+   
+   return `next player: ${player}`;
+ })();
  
  return (
    <div className="game">
      <div className="game__item">
        <Board
	  table={boardData}
+         disabled={winner != null}
          onSelectItem={(i, j) => {
            if (boardData[i][j] != null) {
            	return;
            }
            const newBoardData = _.cloneDeep(boardData);
            newBoardData[i][j] = player;
            setBoardData(newBoardData);
            setStep(step + 1);
          }}
        />        
      </div>
+     <div className="game__item">
+       <div>{statusLabel}</div>
+       <button
+         onClick={() => {
+           setBoardData(createInitialBoardData(SIZE));
+           setStep(0);
+         }}
+       >
+         reset
+       </button>
+     </div>
    </div>
  );
};

終わりに

以上がReact Hooksを使った○×ゲームの実装でした。結構簡単な実装なのであまりReact Hooksの恩恵はありませんが、何かのお役に立てたら幸いです。