Hexabaseを使って、ゲームにリアルタイム通信対戦機能を追加する

[Hexabase（ヘキサベース）](https://www.hexabase.com/)は企業においても安心して利用できるBaaS（Backend as a Service）を提供しています。多くのBaaSがそうであるように、主にフロントエンド開発者に利用してもらいたいと考えています。そこで現在、TypeScript SDKの開発が進められています。

この記事ではHexabase TypeScript SDKを使って、既存のマルバツゲームに通信対戦機能を追加したので、その方法を解説します。

## 元のゲーム

元のゲームは[arasgungore/tic\-tac\-toe: Simple tic\-tac\-toe game built using HTML, CSS, and JavaScript\.](https://github.com/arasgungore/tic-tac-toe)にて公開されているプロジェクトです。こちらはHTML/JavaScript/CSSのみで実装されたマルバツゲームです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/c62ece5c-681c-25fa-790b-b4edace73f7f.png)

このままでももちろん遊べますが、対戦相手が同じPCを見ていなければなりません。そこで、離れていても遊べるように、Hexabaseを使って通信対戦機能を追加します。

## リポジトリ

今回のコードは[hexabase/sample\-tic\-tac\-toe: Simple tic\-tac\-toe game built using HTML, CSS, and JavaScript\.](https://github.com/hexabase/sample-tic-tac-toe)にアップしてあります。

フォーク元と同じく、MIT Licenseになります。

## アーキテクチャ

アーキテクチャは以下のようになります。Hexabaseではデータストア（クラウドデータベース）のアイテム（データベースで言うレコード相当）に対して、更新通知機能があります。この機能では、コメントを登録すると、購読している別な端末に対して更新通知が送られます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d51d3c0e-e1da-dea4-682b-a7e563227315.png)


今回はこの更新通知機能を使って、ゲームの状態を通知します。

## HTMLの変更点

まず、HTML上の変更点についてです。ボタンを押して、新しいゲームを開始できるようにします。このボタンを押すと、Hexabaseのデータストアにアイテムを登録します。

```html
<button class="game--restart">New game</button><br />
```

さらに、ゲームに参加するための入力とボタンを追加します。

```html
<input type="text" id="game--id" placeholder="Enter game ID" /><br />
<button class="game--join">Join game</button>
```

さらにHexabase TypeScript SDKを読み込みます。今回はブラウザで動く版（UMD）を読み込んでいます。

```html
<script
	src="https://cdn.jsdelivr.net/npm/@hexabase/hexabase-js@latest/dist/umd/hexabase.min.js"
>
</script>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d994e70e-563b-9aee-0f84-72b6272087e1.png)


## JavaScriptの変更点

まず、変数を幾つか追加しています。

- `gameId` : ゲームID
- `gamePlayer1`: 自分がプレイヤー1（X）かどうか
- `gamePlayer2`: 自分がプレイヤー2（O）かどうか
- `gameStatus`: 2人揃ったかどうか

また、Hexabase関連の変数も追加しています。

- `API_TOKEN`: APIトークン
- `WORKSPACE_ID`: ワークスペースID
- `PROJECT_ID`: プロジェクトID
- `DATASTORE_ID`: データストアID
- `datastore`: データストア
- `item`: データストアのアイテム

### Hexabase SDKの読み込みと初期化

JavaScript SDKを読み込んだ時点で `hexabase` というグローバル変数がありますので、そこから `HexabaseClient` を取得します。

```js
const { HexabaseClient } = hexabase;
const client = new HexabaseClient();
```

### 画面読み込み時の処理

画面が読み込まれたタイミングで、APIトークンの設定・ワークスペースやプロジェクトの読み込みを行います。

データストア `datastore` は後で使うので、 `let` で定義しています。

```js
document.addEventListener('DOMContentLoaded', async () => {
    await client.setToken(API_TOKEN);
    await client.setWorkspace(WORKSPACE_ID);
    const project = await client.currentWorkspace.project(PROJECT_ID);
    datastore = await project.datastore(DATASTORE_ID);
    document.querySelector('.game--join')
        .addEventListener('click', handleJoinGame);
    document.querySelector('.game--restart')
        .addEventListener('click', handleNewGame);
});
```

### 新しいゲームを開始する

`handleNewGame` 関数で新しいゲームを開始（データストアのアイテムを作成）します。Titleは必須なので、今回は日付を入れています。

新しいゲームを作成したユーザーは、プレイヤー1（X）となります。また、 `handleSubscribe` 関数を呼び出して、データストアのアイテムに対して更新通知を購読します。

アイテムを作成したら、そのIDを通知して別なユーザー（プレイヤー2）に共有します。これはチャットなどを使う想定です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/1dd48350-c9ce-27d9-61ad-176a11960910.png)


```js
async function handleNewGame() {
	item = await datastore.item();
	item.set('Title', `Tic Tac Toe ${new Date().toLocaleString()}`);
	await item.save();
	gamePlayer1 = true;
	handleSubscribe();
	prompt('Please share this game ID with your friend', item.id);
	statusDisplay.innerHTML = currentPlayerTurn();
}
```

`handleSubscribe` 関数では、データストアの通知を処理します。 データストアアイテムの `subscribe` メソッドで通知を購読します。更新した際の `update` イベントを購読しています。2023年11月現在、 `update` 以外のイベントはありませんが、将来的に追加される可能性があります。

```js
function handleSubscribe() {
    item.subscribe('update', async (data) => {
        const action = JSON.parse(data.comment);
				// プレイヤー2がジョインした場合
        if (action.type === 'join') {
            gameStatus = 'active';
            statusDisplay.innerHTML = currentPlayerTurn();
            document.querySelectorAll('.cell')
                .forEach(cell => cell.addEventListener('click', handleCellClick));
						// 確認の通知を送る
            sendMessage({ type: 'connect' });
        }
				// プレイヤー1が確認した場合
        if (action.type === 'connect') {
            gameStatus = 'active';
            statusDisplay.innerHTML = currentPlayerTurn();
        }
				// ゲームプレイ中
        if (action.type === 'play') {
            if (action.player === 'X' && gamePlayer2 ||
							action.player === 'O' && gamePlayer1) {
                handleCellPlayed(document.querySelector(`[data-cell-index="${action.index}"]`), action.index);
                handleResultValidation();
            }
        }
    });
}
```

`sendMessage` 関数は、指定された内容をデータストアのアイテムにコメントとして登録します。コメントを登録すると、購読している別な端末に対して更新通知が送られます。

```js
function sendMessage(message) {
	// コメントを作成
	const history = item.comment();
	// コメントにメッセージを登録
	history.set('comment', JSON.stringify(message));
	// コメントを保存（コメントを登録すると、更新通知が送られる）
	history.save();
}
```

### ゲームに参加する

ゲームに参加する際には、アイテムIDを入力して、 `handleJoinGame` 関数を呼び出します。アイテムIDを指定して、データストアのアイテムを取得します。

```js
async function handleJoinGame() {
	// 入力されたIDを使ってアイテムを取得する
	const id = document.querySelector('#game--id').value;
	if (id === '') {
			alert('Please input game ID');
			return;
	}
	try {
			item = await datastore.item(id);
	} catch (e) {
			alert('Invalid game ID');
			return;
	}
	// ジョインしたユーザーはプレイヤー2（O）となる
	gamePlayer2 = true;
	// プレイヤー2がジョインしたことを通知する
	sendMessage({ type: 'join' });
	// データストアアイテムの購読を開始
	handleSubscribe();
	// セルのイベントハンドリングを有効化
	document.querySelectorAll('.cell')
			.forEach(cell => cell.addEventListener('click', handleCellClick));
}
```

### ゲーム中の操作

ゲーム中はセルをクリックするごとに `handleCellClick` が呼ばれます。これは元々の処理と変わりませんが、通知を送る部分が異なります。

また、自分自身の手番かどうかを判定し、異なる場合にはメッセージを出します。

```js
function handleCellClick(clickedCellEvent) {
    if (currentPlayer === 'O' && gamePlayer1) {
        alert('Please wait for player 1 to select');
        return;
    }
    if (currentPlayer === 'X' && gamePlayer2) {
        alert('Please wait for player 2 to select');
        return;
    }
    const clickedCell = clickedCellEvent.target;
    const clickedCellIndex = parseInt(clickedCell.getAttribute('data-cell-index'));
    if(gameState[clickedCellIndex] !== "" || !gameActive)
        return;
    handleCellPlayed(clickedCell, clickedCellIndex);
		// 選んだセルを通知する
    sendMessage({ type: 'play', index: clickedCellIndex, player: currentPlayer });
    handleResultValidation();
}
```

### ゲームの結果判定

ゲームの勝敗判定処理は `handleResultValidation` 関数で行っています。これは元々の処理と同じです。各ブラウザで勝敗を判定し、画面に表示します。

```js
function handleResultValidation() {
    let roundWon = false;
    for(let i = 0; i <= 7; i++) {
        const winCondition = winningConditions[i];
        const a = gameState[winCondition[0]];
        const b = gameState[winCondition[1]];
        const c = gameState[winCondition[2]];
        if(a === '' || b === '' || c === '')
            continue;
        if(a === b && b === c) {
            roundWon = true;
            break;
        }
    }
    if(roundWon) {
        statusDisplay.innerHTML = winningMessage();
        gameActive = false;
        return;
    }
    const roundDraw = !gameState.includes("");
    if(roundDraw) {
        statusDisplay.innerHTML = drawMessage();
        gameActive = false;
        return;
    }
    handlePlayerChange();
}
```

![3.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/4dca86dc-98e4-7b4a-50e2-b73d925938a8.gif)


## 通知処理の勘所

通知処理を使ってリアルタイム対戦ゲームを作る場合、まずお互いが接続している状態かどうか判定する必要があります。相手が参加するのを待ち、参加したらゲームを開始するという流れです。

ゲーム中については誰か、何を入力したかを通知し、それぞれのクライアントで画面表示を更新し合えば良いでしょう。ゲームの勝敗判定についても同様です。

もしゲーム状態とは別でチャットなどを追加したい場合には、送信するメッセージの種類を追加すれば良さそうです。

## まとめ

今回はHexabase TypeScript SDKを使って、既存のゲームにリアルタイム対戦機能を追加しました。勘所さえつかめば、難しくなく機能を実装できるでしょう。

オフラインゲームにチャット機能やリアルタイム対戦機能を追加すると、それまでとはまったく異なる楽しさが生まれると思います。ぜひ、試してみてください。

[Hexabase \| 新規事業向け開発・競争領域でのDX実現をサポート](https://www.hexabase.com/)