LoginSignup
1
1

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

Posted at

Hexabase(ヘキサベース)は企業においても安心して利用できる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.にて公開されているプロジェクトです。こちらはHTML/JavaScript/CSSのみで実装されたマルバツゲームです。

image.png

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

リポジトリ

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

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

アーキテクチャ

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

image.png

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

HTMLの変更点

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

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

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

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

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

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

image.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 を取得します。

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

画面読み込み時の処理

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

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

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

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 以外のイベントはありませんが、将来的に追加される可能性があります。

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

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

ゲームに参加する

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

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 が呼ばれます。これは元々の処理と変わりませんが、通知を送る部分が異なります。

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

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 関数で行っています。これは元々の処理と同じです。各ブラウザで勝敗を判定し、画面に表示します。

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

通知処理の勘所

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

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

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

まとめ

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

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

Hexabase | 新規事業向け開発・競争領域でのDX実現をサポート

1
1
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
1
1