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のみで実装されたマルバツゲームです。
このままでももちろん遊べますが、対戦相手が同じPCを見ていなければなりません。そこで、離れていても遊べるように、Hexabaseを使って通信対戦機能を追加します。
リポジトリ
今回のコードはhexabase/sample-tic-tac-toe: Simple tic-tac-toe game built using HTML, CSS, and JavaScript.にアップしてあります。
フォーク元と同じく、MIT Licenseになります。
アーキテクチャ
アーキテクチャは以下のようになります。Hexabaseではデータストア(クラウドデータベース)のアイテム(データベースで言うレコード相当)に対して、更新通知機能があります。この機能では、コメントを登録すると、購読している別な端末に対して更新通知が送られます。
今回はこの更新通知機能を使って、ゲームの状態を通知します。
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>
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)に共有します。これはチャットなどを使う想定です。
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();
}
通知処理の勘所
通知処理を使ってリアルタイム対戦ゲームを作る場合、まずお互いが接続している状態かどうか判定する必要があります。相手が参加するのを待ち、参加したらゲームを開始するという流れです。
ゲーム中については誰か、何を入力したかを通知し、それぞれのクライアントで画面表示を更新し合えば良いでしょう。ゲームの勝敗判定についても同様です。
もしゲーム状態とは別でチャットなどを追加したい場合には、送信するメッセージの種類を追加すれば良さそうです。
まとめ
今回はHexabase TypeScript SDKを使って、既存のゲームにリアルタイム対戦機能を追加しました。勘所さえつかめば、難しくなく機能を実装できるでしょう。
オフラインゲームにチャット機能やリアルタイム対戦機能を追加すると、それまでとはまったく異なる楽しさが生まれると思います。ぜひ、試してみてください。