オセロアプリ開発: Reactで作るシンプルオセロアプリをDockerで動かすまでの手順

## はじめに
本記事では、Docker環境でReactを使用してオセロ（リバーシ）アプリを作成する手順を説明します。このアプリは、6x6のボードでプレイヤーとbotが対戦できるように設計しています。

Reactを用いたフロントエンド開発と、Dockerによるアプリケーションのコンテナ化により、効率的な開発環境を管理します。

本記事はITスクールの課題としてアウトプット学習を目的に作成したもので、個人の備忘録レベルの内容ですので、ご了承ください。

## 必要なツール

:::note
**私の環境では、M1チップ搭載のMacOSを使用していますが、今回のハンズオンに必要なツールは以下の通りです。**
・``Docker``
・``Docker Compose``
・``Node.js（バージョン14以上推奨）``
・``npm（Node Package Manager）``
:::

## 作成したオセロアプリのデモ動画

今回のReactアプリには、botとの対戦機能が追加されています。実際の対戦デモ動画になります。

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/e7ae442f-69f7-6a9a-019b-5e162ed77bda.gif)

初めてQiitaのプラットフォームに動画を埋め込んでみましたが、フロントエンドの開発の際には非常に便利ですね！

:::note warn
**追記**
**CSSの動きが想定通りではありませんが、原因を特定して今後修正していきたいと考えています。** 現時点では、具体的にどのように記述すればよいか分からなかったため、とりあえず今のまま動かしています。
:::

## ディレクトリ構成
以下のようなディレクトリ構成を持つReactアプリを想定しています。この構成により、オセロアプリのファイルを適切に配置し、Docker環境で動作させることができます。

```bash
othello/
│
├── node_modules/         # Node.jsのモジュールが格納されるディレクトリ
│
├── public/               # 公開用のファイル
│   └── index.html        # アプリケーションのエントリーポイントとなるHTMLファイル
│
├── src/                  # ソースコードのディレクトリ
│   ├── othello.js        # オセロのゲームロジック
│   ├── othello.css       # オセロのスタイルシート
│   ├── index.js          # アプリケーションのエントリーポイント
│   └── index.css         # アプリケーションのスタイルシート
│
├── .dockerignore          # Dockerビルド時に無視するファイル
├── Dockerfile             # Dockerイメージを作成するための設定ファイル
├── docker-compose.yml     # Docker Composeの設定ファイル
├── package.json           # プロジェクトの依存関係やスクリプトを管理するファイル
└── README.md              # プロジェクトの説明や使い方を記載したファイル(任意)
```

このディレクトリ構成を作成するために、事前に以下のコマンドを実行し、不要なファイルを削除して整理します。

```
npx create-react-app othello
```

:::note warn
**上記のコマンドを実行すると、デフォルトで様々なファイルが作成されますが、ここでは不要なファイルを削除して進めます。**
:::

## ステップ1: プロジェクトの作成
### Dockerfileの作成
まず、プロジェクトのルートディレクトリに以下の内容で ``Dockerfile`` を作成します。

```Dockerfile
# Dockerfile
FROM node:14

# 作業ディレクトリの設定
WORKDIR /app

# 依存関係のインストール
COPY package*.json ./
RUN npm install

# アプリケーションのソースをコピー
COPY . .

# アプリケーションをビルド
RUN npm run build

# アプリケーションを提供する
CMD ["npm", "start"]
```

この ``Dockerfile`` は、Dockerイメージを作成するための設定ファイルです。Node.js 環境を構築し、アプリケーションの依存関係をインストールし、最終的にアプリをビルドします。

### docker-compose.ymlの作成
次に、プロジェクトのルートディレクトリに以下の内容で ``docker-compose.yml`` を作成します。

```docker-compose.yml
version: '3'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app
    environment:
      - CHOKIDAR_USEPOLLING=true
```

このファイルは、Docker Composeを使用してアプリケーションのサービス構成を定義するものです。アプリケーションのビルド、ポートの公開、ボリュームの設定などを管理します。

## ステップ2: Reactアプリの作成
次に、以下のファイルをプロジェクトに追加します。

### othello.js
このファイルでは、オセロゲームのロジックを実装しています。ボードの初期化、プレイヤーの手の管理、botの動き、盤面の描画を担当しています。

```src/othello.js
import React, { useState, useEffect } from 'react';
import './othello.css'; // CSSファイルをインポートしてスタイリングを適用

const BOARD_SIZE = 6; // オセロの6x6のボードサイズ

// ボードの初期化関数
const initializeBoard = () => {
  const board = Array(BOARD_SIZE).fill(null).map(() => Array(BOARD_SIZE).fill(null));
  // 初期配置
  board[2][2] = 'O';
  board[2][3] = 'X';
  board[3][2] = 'X';
  board[3][3] = 'O';
  return board; // 初期ボードを返す
};

// Reversiコンポーネント
const Reversi = () => {
  const [board, setBoard] = useState(initializeBoard); // ボードの状態を管理
  const [isPlayerTurn, setIsPlayerTurn] = useState(true); // プレイヤーのターンを管理

  // 駒をひっくり返すロジック
  const flipDiscs = (board, row, col, player) => {
    const opponent = player === 'X' ? 'O' : 'X'; // 相手の駒を決定
    const directions = [
      [0, 1], [1, 0], [0, -1], [-1, 0], 
      [1, 1], [1, -1], [-1, 1], [-1, -1] // ひっくり返す方向
    ];

    let flipped = []; // ひっくり返した位置を保持
    directions.forEach(([dx, dy]) => {
      let tempFlips = []; // 一時的にひっくり返す位置を保持
      let x = row + dx;
      let y = col + dy;

      // ひっくり返せる駒があるか確認
      while (x >= 0 && y >= 0 && x < BOARD_SIZE && y < BOARD_SIZE) {
        if (board[x][y] === opponent) {
          tempFlips.push([x, y]);
        } else if (board[x][y] === player && tempFlips.length > 0) {
          flipped = flipped.concat(tempFlips); // ひっくり返す位置を更新
          break;
        } else {
          break; // 無効な位置または自分の駒に遭遇した場合
        }
        x += dx;
        y += dy;
      }
    });

    // 駒をひっくり返す
    flipped.forEach(([x, y]) => {
      board[x][y] = player;
    });
    return flipped.length > 0 ? [...flipped, [row, col]] : []; // ひっくり返した位置を返す
  };

  // 駒を置く処理
  const makeMove = (row, col, player) => {
    const newBoard = board.map(row => [...row]); // ボードのコピーを作成
    const flipped = flipDiscs(newBoard, row, col, player); // 駒をひっくり返す

    // 駒をひっくり返せた場合、ボードを更新
    if (flipped.length) {
      flipped.forEach(([x, y]) => {
        newBoard[x][y] = player;
      });
      setBoard(newBoard); // 新しいボードを設定
      setIsPlayerTurn(!isPlayerTurn); // ターンを交代
    }
  };

  // AIの手を決定する処理
  const aiMove = () => {
    const availableMoves = []; // AIの利用可能な手を保持
    for (let i = 0; i < BOARD_SIZE; i++) {
      for (let j = 0; j < BOARD_SIZE; j++) {
        const newBoard = board.map(row => [...row]);
        if (newBoard[i][j] === null && flipDiscs(newBoard, i, j, 'O').length) {
          availableMoves.push([i, j]); // ひっくり返せる手を追加
        }
      }
    }
    // ランダムに手を選択して実行
    if (availableMoves.length) {
      const [row, col] = availableMoves[Math.floor(Math.random() * availableMoves.length)];
      setTimeout(() => {
        makeMove(row, col, 'O'); // 3秒後にAIの手を実行
      }, 3000);
    }
  };

  // プレイヤーのターンが終了したときにAIの手を実行
  useEffect(() => {
    if (!isPlayerTurn) {
      aiMove();
    }
  }, [isPlayerTurn]);

  // セルがクリックされたときの処理
  const handleClick = (row, col) => {
    if (board[row][col] === null && isPlayerTurn) {
      makeMove(row, col, 'X'); // プレイヤーの手を実行
    }
  };

  // ボードのレンダリング
  const renderBoard = () => {
    return (
      <div className="board">
        {board.map((row, i) => (
          <div key={i} className="row">
            {row.map((cell, j) => (
              <div
                key={j}
                onClick={() => handleClick(i, j)} // セルがクリックされたときの処理
                className={`cell ${cell ? cell : ''}`} // セルのクラスを設定
              >
                {cell} {/* 駒を表示 */}
              </div>
            ))}
          </div>
        ))}
      </div>
    );
  };

  return (
    <div className="container">
      <h1 className="App">オセロ (6x6)</h1>
      <p>プレイヤー: X | 対戦相手: O</p>
      {renderBoard()} {/* ボードを描画 */}
    </div>
  );
};

export default Reversi; // Reversiコンポーネントをエクスポート
```

### othello.css
このファイルでは、オセロのゲームボードとプレイヤーの駒（XとO）のスタイルを定義しています。全体のレイアウトや色使いを設定し、視覚的な美しさを提供します。

```src/othello.css
.App {
  text-align: center;
  font-family: 'Arial', sans-serif;
  background-color: #4CAF50; /* 背景色をオセロらしい緑色に */
  padding: 20px;
  border-radius: 10px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); /* 影を少し強調 */
}

.container {
  margin: 30px auto;
  width: 360px; /* ボードサイズに合わせる */
}

.board {
  width: 100%; /* ボードの幅を親に合わせる */
  height: 100%; /* ボードの高さを親に合わせる */
  border: 5px solid #3f3f3f; /* ボードの境界線 */
  background-color: #008000; /* ボードの背景色 */
  display: flex;
  flex-direction: column; /* 列方向に配置 */
  box-sizing: border-box; /* ボードのサイズ計算をボーダーを含める */
  padding: 0; /* 内側の余白を無くす */
}

.row {
  display: flex;
  flex: 1; /* 各行が均等にスペースを取る */
}

.cell {
  width: 60px; /* セルの幅を固定 */
  height: 60px; /* セルの高さを固定 */
  display: flex;
  justify-content: center;
  align-items: center;
  border: 1px solid #3f3f3f; /* セルの境界線 */
  background-color: #008000; /* ボードのセル背景色をボードと統一 */
  cursor: pointer; /* クリック可能に */
  box-sizing: border-box; /* ボックスサイズをボーダーを含めたものに */
}

.cell.X {
  background-color: #333; /* プレイヤーXの色 */
  border-radius: 50%; /* 円形に */
  width: 50px; /* アイコンのサイズを固定 */
  height: 50px; /* アイコンのサイズを固定 */
}

.cell.O {
  background-color: #ddd; /* AIプレイヤーOの色 */
  border-radius: 50%; /* 円形に */
  width: 50px; /* アイコンのサイズを固定 */
  height: 50px; /* アイコンのサイズを固定 */
}
```

### index.js
このファイルは、Reactアプリケーションのエントリーポイントです。Reversi コンポーネントをレンダリングします。

```src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Reversi from './othello'; // 'App'を'othello'に変更

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Reversi />
  </React.StrictMode>
);
```

### index.css
このファイルは、Reactアプリケーションのアプリケーションのスタイルシートになります。

```index.css
/* src/index.css */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background-color: #f5f5f5;
}
```


## ステップ3: アプリの起動
最後に、以下のコマンドでアプリケーションを起動します。

```bash
# Docker Composeを使ってアプリケーションを起動
docker-compose up
```

ブラウザで、``http://localhost:3000/``を入力すると、以下のページが表示されていれば成功です。

![スクリーンショット 2024-11-02 8.41.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/53b4c726-9c07-8552-25ed-f75ba2150e55.png)

ここでは、``プレイヤー: X | 対戦相手: O`` と表示されています。私がプレイヤーの黒色担当になりますので、駒をひっくり返してみます。

![オセロデモ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/e5fcd9db-d7a0-0a6c-95de-f9aa744aef93.gif)

:::note warn
想定通り、私が選択した手に対してbotが相手をしていることが確認できました。
:::

## まとめ

本記事では、Dockerを利用してReactでオセロアプリを構築する方法を紹介しました。

アプリはプレイヤーとbotが対戦できる機能を持っており、シンプルなインターフェースでプレイできます。

今後はさらなる機能追加やデザインの改善を行なっていきますが、まずは自分自身が楽しんで開発を続けていきたいと思います。

## 参考記事

https://bagelee.com/programming/react-othello/

https://qiita.com/YuDachi/items/68d4a94dedbb09e9d38e

https://zenn.dev/ebiiina/scraps/6da46f891a9a7f

https://othello.ebinas.dev/