5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

リプレイ機能付きオセロを作って、AWS(EC2/RDS)にデプロイしてみた

5
Posted at

本記事は、BIPROGY / ユニアデックス社内AWSコミュニティ「BIPROGY AWS SPARK」の定期投稿企画第4回目の記事です。他の定期投稿企画の記事は、インデックスページをご覧ください。

はじめに

私は普段業務でAWSのインフラ環境を構築することが多いのですが、肝心のインフラ上で稼働するアプリケーションについては触れる機会がほとんどないので、アプリも作って動かしてみたいなと思うことがあります。
そこで今回は、自分で簡単なWebアプリ(オセロ)をローカル環境で実装してみて、それをAWS上にデプロイしてみました。
本記事では、オセロを実装してからAWSにデプロイするまでの過程を紹介したいと思います。

作ったもの

オセロの概要

一つの画面上で、盤面に交互に石を置いていくシンプルなオセロです。
これだけだとシンプルすぎるので、今回はリプレイ機能をつけました。リプレイ機能によって、対戦終了後にその試合の棋譜(指し手を記録したもの)が保存されて、後から対局を見返すことができます。

構成

フロントエンド:HTML・CSS・JavaScript
バックエンド:Node.js

ゲーム画面

対局開始
image.png

交互に石を置いていきます
image.png

ひっくり返せる場所にしか石を置くことはできません
image.png

置くところがないとパスになります
image.png

盤面が埋まる or どちらも置く場所がない で対局終了
対局終了時にリプレイが保存されます
image.png

リプレイのリンクをクリックして、その対局を見返すことができます
image.png

作成過程

作業はチャットAI(Gemini)に聞きながら進めました。

オセロの実装

まずはローカルPC環境でオセロを実装してみました。

任意のパスにフォルダ(othello-app)を配置し、その中で以下のコマンドを実行します。

# プロジェクトの初期化
npm init -y

# Webサーバー用パッケージ(Express)のインストール
npm install express

# Node.jsからMySQLを操作するための mysql2 というパッケージのインストール
npm install mysql2

オセロのロジックを記述したserver.js(付録にコードを添付)を作成し、othello-app直下に格納します。

public という名前のフォルダを作成し、その中にゲーム画面を描画する index.html(付録にコードを添付)を格納します。

ここまででファイルの配置は以下になっております。

├── node_modules/
├── public/
│   └── index.html   <-- 画面
├── package.json
└── server.js        <-- サーバー

データベースの準備

リプレイ機能に必要な対局の情報はデータベース上のテーブルに保存します。
今回データベースにはMySQLを使用しました。
今回はパソコンにMySQLを直接インストールするのではなく、DockerでMySQLを起動します。
パソコンにDocker Desktopをインストールして、以下のコマンドをターミナルで実行することで、一発でMySQLを起動できます。

docker run --name othello-db -e MYSQL_ROOT_PASSWORD=【任意の文字列】 -e MYSQL_DATABASE=othello_db -p 3306:3306 -d mysql:latest

💡 このコマンドがやっていること:

  • --name othello-db: コンテナの名前を othello-db に指定

  • -e MYSQL_ROOT_PASSWORD=【任意の文字列】: 管理者(root)ユーザーのパスワードを設定

  • -e MYSQL_DATABASE=othello_db: 起動時に othello_db という名前のデータベースを自動で作成

  • -p 3306:3306: パソコンの3306番ポートとDocker内のMySQLを繋ぐ

次に起動したデータベースに接続し、SQLを実行してテーブルを作ります。

データベースへの接続、SQLの実行にはVS Codeの拡張機能「MySQL」を使用しました。
具体的には以下の手順で実施しました。

  1. VS Codeで拡張機能「MySQL」をインストールします

  2. VS Codeの左端にデータベースのアイコン(樽のようなマーク)が表示されるのでクリックします

  3. 「接続の追加」を押し、下図の通りに入力した後、「接続」を押します
    image.png

  4. 左側のメニューの othello_db > Query 右の「+(クエリを作成)」を押し、エディタを開きます
    image.png

  5. 以下のSQLコードを貼り付け、エディタの上部にある 「▷(実行)」 を押します

    -- 対戦そのものを記録するテーブル
    CREATE TABLE games (
        game_id INT AUTO_INCREMENT PRIMARY KEY,
        played_at DATETIME DEFAULT CURRENT_TIMESTAMP
    );
    
  6. 以下のSQLコードを貼り付け、エディタの上部にある 「▷(実行)」 を押します

    -- 1手ごとのログを記録するテーブル
    CREATE TABLE game_moves (
        move_id INT AUTO_INCREMENT PRIMARY KEY,
        game_id INT,
        move_number INT,
        player INT,
        move_row INT,
        move_col INT,
        board_state TEXT, -- ★ ここにその瞬間の8x8の盤面データをまるごと保存します
        FOREIGN KEY (game_id) REFERENCES games(game_id)
    );
    
  7. 左側のメニューの othello_db > Tables の中に games と game_moves が表示されれば成功です!
    image.png

ローカル環境で動作するオセロの完成

ここまでの準備ができたら、ターミナルでサーバーを起動します。

Windows (コマンドプロンプト) の場合:

set DB_HOST=localhost
set DB_USER=root
set DB_PASSWORD=【MySQLの管理者ユーザーパスワード】
set DB_NAME=othello_db
node server.js

Mac / Linux / Git Bash の場合:

DB_HOST=localhost DB_USER=root DB_PASSWORD=【MySQLの管理者ユーザーパスワード】 DB_NAME=othello_db node server.js

サーバを起動した状態でブラウザを開き、 http://localhost:3000 にアクセスしてみます。
オセロ盤面が表示され、オセロをプレイできます。

今回作成した server.js 内のデータベース接続設定では、パスワードや接続先(エンドポイント)をソースコードに直接書き込まず(ハードコードせず)、サーバー起動時に、process.env.DB_HOST などの環境変数から読み込むように設計しています。

実務のインフラ構築においても、認証情報をコードに含めないことはセキュリティ(機密情報の漏洩防止)の観点で、重要なベストプラクティスです。

インフラ環境の構築

次にオセロをデプロイするAWSの環境を準備します。
今回はパブリックにアプリ(オセロ)を載せるEC2を配置し、プライベートにオセロの対局情報を保存するデータベース(RDS)を配置する構成とします。

オセロアプリ構成図.png

本記事ではアプリケーションのデプロイを優先し、EC2をパブリックサブネットに配置しています。そのため、EC2へインターネットから直接アクセスできる構成となっています。この構成は学習・検証用途を想定したものであり、本番環境向けの構成ではありません。

実際の業務システム(本番環境など)においては、セキュリティ向上のため、EC2もプライベートサブネットに配置する構成が一般的です。その場合、パブリックサブネットに ALB(Application Load Balancer)を配置して外部からの通信を受け付け、プライベートサブネット上のEC2へ転送します。

また、サーバー管理時には SSH(22番ポート)をインターネットへ公開せずに、AWS Systems Manager Session Manager を利用して接続することで、サーバーを直接公開することなく安全に運用できます。

以下の手順でAWS環境を作成しました。

  1. VPCを作成
    • VPC名: othello-vpc
    • IPv4 CIDR ブロック: 10.0.0.0/16
  2. 3つのサブネットを作成
    • パブリックサブネット(EC2用)
      • サブネット名: othello-public-sn
      • アベイラビリティーゾーン: ap-northeast-1a
      • IPv4 CIDR ブロック: 10.0.1.0/24
    • プライベートサブネット1(RDS用)
      • サブネット名: othello-private-sn-1a
      • アベイラビリティーゾーン: ap-northeast-1a
      • IPv4 CIDR ブロック: 10.0.2.0/24
    • プライベートサブネット2(RDS用)
      • サブネット名: othello-private-sn-1c
      • アベイラビリティーゾーン: ap-northeast-1c
      • IPv4 CIDR ブロック: 10.0.3.0/24
  3. インターネットゲートウェイを作成
    • 名前: othello-igw
  4. ルートテーブルを作成&サブネットに紐づけ
    • パブリックサブネット用ルートテーブル
      • ルートテーブル名: othello-public-rt
      • ルート➀
        • 送信先: 0.0.0.0/0
        • ターゲット: othello-igw
      • 関連付けるサブネット: othello-public-sn
    • プライベートサブネット用ルートテーブル
      • ルートテーブル名: othello-private-rt
      • 関連付けるサブネット: othello-private-sn-1a, othello-private-sn-1c
  5. EC2インスタンスを作成
    • 基本設定
      • サーバ名: othello-server
      • OS(AMI): Amazon Linux 2023 AMI
      • インスタンスタイプ: t3.micro
    • キーペア ※新規作成
      • キーペア名: othello-key
    • ネットワーク設定
      • サブネット: othello-public-sn
      • パブリックIPの自動割り当て: 有効化
    • セキュリティグループ ※新規作成
      • セキュリティグループ名: othello-ec2-sg
      • 説明: Allow HTTP and SSH access for Othello Web Server
      • インバウンドルール1(SSH用・管理)
        • タイプ: SSH(ポート 22
        • ソースタイプ: マイ IP(自分のPCからのみ操作可能)
      • インバウンドルール2(オセロアプリ用)
        • タイプ: カスタム TCP ➔ ポート範囲に 3000 と入力
        • ソースタイプ: マイ IP(自分のPCからのみ操作可能) ※今回は自分自身のPCからのみアクセスを許可しています。もしスマホ等の別端末からもアクセスできるようにするには「任意の場所 (0.0.0.0/0)」を指定する必要があります。
  6. DBサブネットグループの作成
    • 名前: othello-rds-subnet-group
    • 説明: Subnet group for othello database
    • 追加するサブネット: othello-private-sn-1a, othello-private-sn-1c
  7. RDS用セキュリティグループの作成
    • セキュリティグループ名: othello-rds-sg
    • 説明: Allow MySQL access from EC2 only
    • インバウンドルール
      • タイプ: MySQL/Aurora(ポート 3306
      • ソース: othello-ec2-sg
  8. RDSインスタンスの作成
    • エンジンタイプ: MySQL
    • テンプレート: 無料利用枠
    • DB インスタンス識別子: othello-database
    • マスターユーザー名: admin
    • マスターパスワード: 任意のパスワード
    • インスタンスタイプ: db.t3.micro
    • VPC: othello-vpc
    • DBサブネットグループ: othello-rds-subnet-group
    • パブリックアクセス: なし
    • セキュリティグループ: othello-rds-sg
    • 追加設定
      • 最初のデータベース名: othello_db

Tera TermでEC2に接続

AWS環境を作成したら、次はTera TermでEC2にSSHログインします。

Tera Termをインストールし、起動します。

「新しい接続」というウィンドウで、ホストにはEC2の「パブリック IPv4 アドレス」を入力し、サービスはSSH(ポートは 22)になっていることを確認して「OK」を押します。
image.png

「SSH認証」という画面に切り替わります。
ユーザー名に ec2-user と入力し、パスフレーズ: 空欄のままにします。
認証方式は「RSA/DSA/ECDSA/ED25519鍵を使う」を選択し、秘密鍵として、ダウンロードしたキーペア(othello-key.pem)を指定します。
「OK」を押します。
image.png

EC2に接続できました。
teraterm接続画面.png

EC2上にオセロアプリを配置

いよいよEC2上にオセロアプリをデプロイします。
まず、以下のコマンドを実行してNode.jsをインストールします。

# 1. nvm(バージョン管理ツール)のダウンロード
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

# 2. 設定の反映
source ~/.bashrc

# 3. Node.jsのインストール
nvm install --lts

# 4. 確認(バージョンが表示されればOK)
node -v
npm -v

アプリ用のフォルダを作って移動します。

mkdir othello-app
cd othello-app

オセロアプリに必要なパッケージ(サーバー用の express と、MySQL接続用の mysql2)をインストールします。

npm init -y
npm install express mysql2

ローカルPCのエクスプローラーでserver.js があるフォルダを開きます。
server.js をTera Termのターミナルウィンドウ内にドラッグ&ドロップします。
ファイル転送用のウィンドウが表示されるので、送信先をothello-appとします。
「OK」を押してファイルを転送します。
image.png

次にothello-app内にpublicというフォルダを作成し、ローカルPC内のindex.htmlを、上と同じ方法で転送します(送信先はothello-app/public)。

これでEC2にオセロアプリをデプロイできました。

RDS上にデータベースとテーブルを作成

最後にRDS上にデータベースとテーブルを作成します。
引き続きTera TermでEC2にログインした状態で作業していきます。

まず、EC2にMySQLクライアントをインストールします。

sudo dnf install mariadb105 -y

次にEC2からRDSにログインします。

mysql -h 【RDSのエンドポイント】 -u admin -p

上のコマンドを打つと Enter password: とパスワードを求められるので、RDS作成時に指定したマスターパスワード を入力して Enter を押します。

RDSにログインできました。
image.png

RDSにログインした状態で以下のSQLコマンドを実行し、データベースとテーブルを作成します。


-- ① データベース(スキーマ)の作成
CREATE DATABASE othello_db;

-- ② 作成したデータベースの使用を宣言
USE othello_db;

-- ③ 試合用のテーブルを作成
CREATE TABLE games (
    game_id INT AUTO_INCREMENT PRIMARY KEY,
    played_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- ④ 各手のログを保存するテーブルを作成
CREATE TABLE game_moves (
    move_id INT AUTO_INCREMENT PRIMARY KEY,
    game_id INT,
    move_number INT,
    player INT,
    move_row INT,
    move_col INT,
    board_state TEXT,
    FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
);

RDSからログアウトします。

exit

アプリを起動

最後にアプリを起動してプレイしてみます。

othello-app直下で以下のコマンドを実行し、アプリを起動します。

DB_HOST="【RDSのエンドポイント】" DB_USER="admin" DB_PASSWORD="【RDSのマスターパスワード】" DB_NAME="othello_db" node server.js

上記のコマンドだと、Tera TermなどのSSH接続を閉じた瞬間にNode.jsサーバーも終了してしまい、アプリにつながらなくなってしまいます。
以下のコマンドでバックグラウンド実行すれば、この問題を解消できます。

nohup DB_HOST="【RDSのエンドポイント】" DB_USER="admin" DB_PASSWORD="【RDSのマスターパスワード】" DB_NAME="othello_db" node server.js > app.log 2>&1 &

これでブラウザ(http://【EC2のパブリックIP】:3000)を開くと、オセロアプリの画面が立ち上がりました!

💰 試してみる方へ(料金の注意点)
AWSには新規アカウント向けの無料利用枠もありますが、基本的には料金が発生します。

特に注意したいのが、EC2やRDSを 「停止(Stop)」しただけでは、データを保存するストレージ(ディスク)の料金がかかり続けてしまう 点です。
さらにRDSの場合、 「停止しても1週間(7日間)経つと自動で勝手に起動する」 という仕様があるため、放置しているといつの間にかインスタンスの実行料金まで発生してしまいます。

うっかり翌月にびっくりしないよう、検証が終わったらリソースを「停止」ではなく、しっかり 「削除(EC2は終了)」 するのを忘れないようにしてください!

作ってみた感想

とても楽しかったです。
実際にアプリを作ってみて、それをAWS上にデプロイすることで、サービスの利用イメージやサービス間の連携について、一層理解を深められました。

今回様々なコマンドを実行するにあたり、たくさんのエラーがでましたが、チャットAIに聞けばエラーの原因と解決策を教えてくれるので、特に途中で躓くこともなくアプリを完成させることができました。

私が大学生の時は、プログラミングのレポート課題で一日中バグやエラーと格闘していた記憶があるので、その時と比べると本当に便利になったなと感じました。

おわりに

本記事では、オセロアプリを実装して、AWS上にデプロイするまでの過程を紹介しました。

次回は、このアプリへの機能(Amazon Cognitoによる認証機能など)の追加や、サーバーレス構成への拡張を試したいと思います。

📁 付録:ソースコード一覧

今回作成したコードです。

server.js
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
const PORT = 3000;

app.use(express.json());
app.use(express.static('public'));

// ==========================================
// データベース接続設定 (MySQLに接続)
// ==========================================
const db = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  port: 3306,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

// ==========================================
// オセロのロジックと状態管理
// ==========================================
const EMPTY = 0; const BLACK = 1; const WHITE = 2;
let board, turn, movesHistory, gameMessage;

// ゲームを初期状態にリセットする関数
function resetGame() {
    board = Array.from({ length: 8 }, () => Array(8).fill(EMPTY));
    board[3][3] = WHITE; board[3][4] = BLACK; board[4][3] = BLACK; board[4][4] = WHITE;
    turn = BLACK;
    movesHistory = [];
    gameMessage = "● 黒の番です";
}
resetGame(); // 最初の一回を実行

const DIRECTIONS = [[-1,0],[1,0],[0,-1],[0,1],[-1,1],[1,1],[-1,-1],[1,-1]];

function getValidFlips(row, col, player) {
    if (board[row][col] !== EMPTY) return [];
    let allFlips = [];
    for (const [dRow, dCol] of DIRECTIONS) {
        let r = row + dRow, c = col + dCol, flips = [];
        while (r >= 0 && r < 8 && c >= 0 && c < 8 && board[r][c] === (player === BLACK ? WHITE : BLACK)) {
            flips.push([r, c]); r += dRow; c += dCol;
        }
        if (r >= 0 && r < 8 && c >= 0 && c < 8 && board[r][c] === player) {
            allFlips = allFlips.concat(flips);
        }
    }
    return allFlips;
}

function hasValidMoves(player) {
    for (let r = 0; r < 8; r++) {
        for (let c = 0; c < 8; c++) {
            if (getValidFlips(r, c, player).length > 0) return true;
        }
    }
    return false;
}

function countStones() {
    let black = 0, white = 0;
    for (let r = 0; r < 8; r++) {
        for (let c = 0; c < 8; c++) {
            if (board[r][c] === BLACK) black++;
            if (board[r][c] === WHITE) white++;
        }
    }
    return { black, white };
}

// ゲーム終了時にMySQLへリプレイを保存する関数
async function saveReplayToDB(history) {
    try {
        const [gameResult] = await db.query('INSERT INTO games () VALUES ()');
        const gameId = gameResult.insertId;

        for (const move of history) {
            await db.query(
                'INSERT INTO game_moves (game_id, move_number, player, move_row, move_col, board_state) VALUES (?, ?, ?, ?, ?, ?)',
                [gameId, move.move_number, move.player, move.row, move.col, move.board_state]
            );
        }
        console.log(`💾 ゲームID: ${gameId} のリプレイ(全盤面記録版)をMySQLに保存しました!`);
    } catch (err) {
        console.error('❌ データベース保存エラー:', err);
    }
}

// ==========================================
// APIエンドポイント (通常プレイ用)
// ==========================================
app.get('/api/board', (req, res) => {
    res.json({ board, turn, movesHistory, gameMessage });
});

app.post('/api/move', async (req, res) => {
    const { row, col } = req.body;
    const flips = getValidFlips(row, col, turn);

    if (flips.length === 0) return res.status(400).json({ error: 'そこには置けません' });

    board[row][col] = turn;
    for (const [r, c] of flips) board[r][c] = turn;

    // 現在の盤面状態(2次元配列)を文字列(JSON)にして履歴に保存
    const boardCopySnapshot = JSON.stringify(board);
    movesHistory.push({ move_number: movesHistory.length + 1, player: turn, row, col, board_state: boardCopySnapshot });

    let nextPlayer = (turn === BLACK) ? WHITE : BLACK;
    if (hasValidMoves(nextPlayer)) {
        turn = nextPlayer;
        gameMessage = `${turn === BLACK ? '' : ''} ${(turn === BLACK) ? "" : ""}の番です`;
    } else if (hasValidMoves(turn)) {
        gameMessage = `⚠️ ${(nextPlayer === BLACK) ? "" : ""}がパス! 引き続き ${turn === BLACK ? '' : ''} ${(turn === BLACK) ? "" : ""}の番です`;
    } else {
        const { black, white } = countStones();
        gameMessage = `試合終了! 【黒】:${black} 枚 vs 【白】:${white} 枚 で ${black > white ? "黒の勝ち!" : white > black ? "白の勝ち!" : "引き分け!"}`;
        turn = 0;

        // ゲームが完全に終了したので保存
        await saveReplayToDB(movesHistory);
    }

    res.json({ board, turn, movesHistory, gameMessage });
});

// 新しくゲームをやり直すAPI
app.post('/api/reset', (req, res) => {
    resetGame();
    res.json({ board, turn, movesHistory, gameMessage });
});

// ==========================================
// APIエンドポイント (リプレイ機能用)
// ==========================================
// 過去の対戦一覧を取得
app.get('/api/games', async (req, res) => {
    try {
        const [rows] = await db.query('SELECT * FROM games ORDER BY played_at DESC');
        res.json(rows);
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

// 特定のゲームの1手ずつのログを取得
app.get('/api/replay/:gameId', async (req, res) => {
    try {
        const [rows] = await db.query('SELECT * FROM game_moves WHERE game_id = ? ORDER BY move_number ASC', [req.params.gameId]);
        res.json(rows);
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

app.listen(PORT, () => {
    console.log(`🚀 Server running`);
});
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>オセロ</title>
    <style>
        body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; background: #fafafa; }
        .container { display: flex; gap: 40px; margin-top: 20px; }
        #board { display: grid; grid-template-columns: repeat(8, 50px); grid-template-rows: repeat(8, 50px); gap: 2px; background: #333; padding: 2px; }
        .cell { background: #007a3d; display: flex; justify-content: center; align-items: center; cursor: pointer; }
        .stone { width: 40px; height: 40px; border-radius: 50%; display: none; }
        .black { background: black; display: block; }
        .white { background: white; display: block; box-shadow: 0 2px 4px rgba(0,0,0,0.3); }
        .history-panel { width: 250px; background: #eee; padding: 15px; border-radius: 5px; }
        button { margin: 10px 0; padding: 8px 15px; font-size: 16px; cursor: pointer; }
        li { cursor: pointer; color: blue; text-decoration: underline; margin-bottom: 5px; }
        /* 位置調整のみを行い、背景の干渉をなくします */
        .coord-container {
            position: relative;
            padding-top: 30px;  /* 上の文字が入るスペース */
            padding-left: 30px; /* 左の文字が入るスペース */
        }
        .coord-top {
            position: absolute;
            top: 0;
            left: 30px;
            width: 400px; /* 盤面の幅に合わせる */
            height: 30px;
            display: grid;
            grid-template-columns: repeat(8, 1fr);
            align-items: center;
            justify-items: center;
            font-weight: bold;
            color: #333;
        }
        .coord-left {
            position: absolute;
            top: 30px;
            left: 0;
            width: 30px;
            height: 400px; /* 盤面の高さに合わせる */
            display: grid;
            grid-template-rows: repeat(8, 1fr);
            align-items: center;
            justify-items: center;
            font-weight: bold;
            color: #333;
        }
    </style>
</head>
<body>
    <h1>オセロ</h1>
    <h2 id="info">読み込み中...</h2>

    <div class="container">
        <div>
            <div class="coord-container">
                <div class="coord-top">
                    <div>A</div><div>B</div><div>C</div><div>D</div><div>E</div><div>F</div><div>G</div><div>H</div>
                </div>
                <div class="coord-left">
                    <div>1</div><div>2</div><div>3</div><div>4</div><div>5</div><div>6</div><div>7</div><div>8</div>
                </div>
                
                <div id="board"></div>
            </div>
            <button onclick="resetGame()">新しくゲームを始める</button>
            <div id="replay-controls" style="display: none; margin-top: 10px;">
                <button id="prev-move-btn" style="background: #2196F3; color: white;">◀ 一手戻す</button>
                <button id="next-move-btn" style="background: #ff9800; color: white;">▶ 一手進める</button>
            </div>
        </div>

        <div class="history-panel">
            <h3>過去の対戦履歴</h3>
            <button onclick="loadHistoryList()">履歴を更新</button>
            <ul id="history-list"></ul>
        </div>
    </div>

    <script>
        const boardElement = document.getElementById('board');
        const infoElement = document.getElementById('info');
        const historyListElement = document.getElementById('history-list');
        const replayControls = document.getElementById('replay-controls');
        const nextMoveBtn = document.getElementById('next-move-btn');

        let isReplayMode = false;
        let replayMoves = [];
        let replayCurrentIndex = 0;
        let localBoard = [];

        async function updateBoard() {
            if (isReplayMode) return;
            const res = await fetch('/api/board');
            const data = await res.json();
            render(data.board, data.gameMessage);
        }

        function render(boardData, message) {
            boardElement.innerHTML = '';
            infoElement.innerText = message;

            for (let r = 0; r < 8; r++) {
                for (let c = 0; c < 8; c++) {
                    const cell = document.createElement('div');
                    cell.className = 'cell';
                    if (!isReplayMode) {
                        cell.onclick = () => makeMove(r, c);
                    }

                    const stone = document.createElement('div');
                    stone.className = 'stone';
                    if (boardData[r][c] === 1) stone.classList.add('black');
                    if (boardData[r][c] === 2) stone.classList.add('white');

                    cell.appendChild(stone);
                    boardElement.appendChild(cell);
                }
            }
        }

        async function makeMove(row, col) {
            const res = await fetch('/api/move', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ row, col })
            });
            if (res.ok) {
                const data = await res.json();
                render(data.board, data.gameMessage);
                if (data.turn === 0) loadHistoryList(); // ゲーム終了したらリスト更新
            } else {
                alert("そこには置けません!");
            }
        }

        async function resetGame() {
            isReplayMode = false;
            replayControls.style.display = 'none';
            const res = await fetch('/api/reset', { method: 'POST' });
            const data = await res.json();
            render(data.board, data.gameMessage);
        }

        // 過去の対戦リストをMySQLから取得して表示
        async function loadHistoryList() {
            const res = await fetch('/api/games');
            const games = await res.json();
            historyListElement.innerHTML = '';
            games.forEach(g => {
                const li = document.createElement('li');
                li.innerText = `ゲームID: ${g.game_id} (${new Date(g.played_at).toLocaleString()})`;
                li.onclick = () => startReplay(g.game_id);
                historyListElement.appendChild(li);
            });
        }
        
        // リプレイ再生モードの開始
        async function startReplay(gameId) {
            isReplayMode = true;
            replayControls.style.display = 'block';
            
            // サーバーから1手ずつのログを取得
            const res = await fetch(`/api/replay/${gameId}`);
            replayMoves = await res.json();
            
            // 盤面を初期状態にリセット
            localBoard = Array.from({ length: 8 }, () => Array(8).fill(0));
            localBoard[3][3] = 2; localBoard[3][4] = 1; localBoard[4][3] = 1; localBoard[4][4] = 2;
            replayCurrentIndex = 0;
            
            render(localBoard, `🎬 リプレイモード (ゲームID: ${gameId})`);

            // ボタンの要素を取得
            const prevMoveBtn = document.getElementById('prev-move-btn');
            const nextMoveBtn = document.getElementById('next-move-btn');

            // 共通の描画関数(進む・戻るのメッセージ共通化のため)
            function renderReplayStep(index) {
                if (index === 0) {
                    // 0手目は初期盤面
                    const initialBoard = Array.from({ length: 8 }, () => Array(8).fill(0));
                    initialBoard[3][3] = 2; initialBoard[3][4] = 1; initialBoard[4][3] = 1; initialBoard[4][4] = 2;
                    render(initialBoard, `🎬 リプレイ開始時点 (初期盤面)`);
                } else {
                    const move = replayMoves[index - 1];
                    const savedBoard = JSON.parse(move.board_state);
                    const playerColor = (move.player === 1) ? "● 黒" : "○ 白";
                    const colLetter = String.fromCharCode(65 + move.move_col); 
                    const rowNumber = move.move_row + 1; 
                    render(savedBoard, `🎬 リプレイ再生中 [${index}手目]: ${playerColor} が 【${colLetter}${rowNumber}】 に置きました`);
                }
            }

            // 「◀ 一手戻す」ボタンのクリックイベント
            prevMoveBtn.onclick = () => {
                if (replayCurrentIndex > 0) {
                    replayCurrentIndex--;
                    renderReplayStep(replayCurrentIndex);
                } else {
                    alert("これ以上戻せません。初期盤面です。");
                }
            };

            // 「▶ 次の一手を進める」ボタンのクリックイベント
            nextMoveBtn.onclick = () => {
                if (replayCurrentIndex < replayMoves.length) {
                    replayCurrentIndex++;
                    renderReplayStep(replayCurrentIndex);
                } else {
                    alert("リプレイは終了しました。");
                }
            };
        }

        // 初期読み込み
        updateBoard();
        loadHistoryList();
    </script>
</body>
</html>

BIPROGYグループの技術への取り組み

We Are Hiring!

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?