Vue 3 でマルチプレイブラウザゲームを作る (3) ラウンジ(ホスト)編
つづき
分担編では、サーバーとクライアントの役割分担を決め、サーバーを構築しました。
今回は、クライアント(ブラウザ側)画面のうち、ホストのラウンジ画面を作っていきます。
ラウンジイメージ
Web じゃんけんは複数名でプレイするので、参加者を集めるラウンジ画面が必要です。
最初にサーバーの game.html
にアクセスした参加者がグループのホストになります。ホストのラウンジ画面にはそのグループ専用の URL が表示されます。グループ専用 URL は、game.html
の引数でグループ UUID を指定したものです。
ホストは表示されたグループ専用 URL を、一緒にプレイしたい人にメールなどで送ったり、あるいは表示されている QR コードを読み取ってもらったりします。その URL にアクセスした参加者はゲストとなり、同じグループでゲームに参加します。
ゲストのラウンジ画面には、ホストがプレイ開始するのを待つ旨のメッセージが表示されます。
ホストのラウンジ画面には、自分とゲスト合わせて何人が参加しているかが分かり、2 人以上参加者が集まると「プレイ開始」ボタンを押せるようになります。
参加者集めの主な流れを図にすると以下のようになります。
実行
Web じゃんけんのソースコードは GitHub に上げてありますので、一度実行してみるとイメージが掴みやすいと思います。
ソースコードにはモジュールが含まれていないので、WSL で webroot フォルダーに移動し、以下でインストールします。
npm ci
npm ci
は package-lock.json
を参照し、プロジェクトで使用しているモジュールを復元してくれるコマンドです。
インストール後、Node.js サーバーを稼働させます。
node server.js
初回実行時はデータベースが作成されます。「稼働開始。ポート番号:3000」と表示されたら準備完了です。
ブラウザで http://localhost:3000/public/game.html にアクセスするとホストのラウンジ画面が表示され、そこに表示されている URL に別タブや別ブラウザでアクセスするとゲストのラウンジ画面が表示されます。
ソケット
ホストのラウンジ画面には変動する要素が 2 つあります。
- グループ専用 URL(グループ UUID から作成されるもの)
- グループに参加している人数
これらの情報はページ表示後に更新する必要がありますが、後からサーバーとクライアント(ブラウザ)で情報をやりとりするために WebSocket(以降「ソケット」)を使います。更新すべき情報には
- サーバー → クライアントへ送信
- クライアント → サーバーへ送信
の双方向があります。
情報更新用に使用する技術として、クライアントからサーバーに問い合わせるだけだったならおそらく Ajax が簡単です。Ajax を双方向に拡張した Ajax + Comet というものもあったようですが、現代ではソケットが使えるようになったので、今回はソケットを使用します。
先ほどの流れ図にデータの流れも加え、ソケットで送信している部分を緑色で表示すると以下のようになります。
ホストのラウンジ画面
以上を踏まえて、Vue でホストのラウンジ画面を作ると以下のようになります。
<template>
<div>
<p id="loungeTitle">ラウンジ(ホスト)</p>
<p>グループを作ります。一緒にプレイしたい人に、以下の URL にアクセスしてもらってください。</p>
<div id="showQr"></div>
<p>{{ invitationUrl }} <button @click="onCopyClicked">コピー</button></p>
<button @click="onPlayClicked" :disabled="isPlayButtonDisabled">{{ numParticipants }} 人でプレイ開始</button>
<p>{{ errorMessage }}</p>
</div>
</template>
<style>
p#loungeTitle {
background-color: #99ffff;
}
</style>
<script>
import loungeBase from "./lounge_base.vue";
export default {
// ====================================================================
// 継承
// ====================================================================
extends: loungeBase,
// ====================================================================
// リアクティブ
// ====================================================================
data() {
return {
// 招待 URL
invitationUrl: null,
// プレイ開始ボタン無効化
isPlayButtonDisabled: true,
};
},
// ====================================================================
// 関数
// ====================================================================
methods: {
// グループ UUID から招待用 URL を得る
uuidToInvitationUrl(uuid) {
let pathnamePos = location.href.indexOf(location.pathname);
let base = location.href.substring(0, pathnamePos + location.pathname.length);
return base + "?" + csConstants.params.group + "=" + uuid;
},
onCopyClicked() {
// 非同期メソッドだが待つ必要は無い
navigator.clipboard.writeText(this.invitationUrl);
},
onPlayClicked() {
this.socket.emit(csConstants.socketEvents.startPlay);
}
},
// ====================================================================
// イベントハンドラー
// ====================================================================
beforeMount() {
// ソケットインスタンス作成時に接続しに行く
this.socket = io();
// 接続時イベント
this.socket.on("connect", () => {
// 新規グループ作成依頼
this.socket.emit(csConstants.socketEvents.newGroup);
});
// グループ UUID 通知が来た
this.socket.on(csConstants.socketEvents.groupUuid, (uuid) => {
this.groupUuid = uuid;
this.invitationUrl = this.uuidToInvitationUrl(uuid);
document.getElementById("showQr").textContent = "";
new QRCode(document.getElementById("showQr"), this.invitationUrl);
});
// 人数通知が来た
this.socket.on(csConstants.socketEvents.numParticipants, (numParticipants) => {
this.numParticipants = numParticipants;
this.isPlayButtonDisabled = numParticipants === 1;
});
// ホスト・ゲスト共通イベント
this.setSocketOn();
},
}
</script>
<template>
ブロックで HTML を作っていますが、初期状態では(まだサーバーからグループ UUID が通知されていないので)QR コードや URL 表示は空です。
<script>
ブロックに beforeMount() がありますが、これは Vue のイベントハンドラーの 1 つで、ブラウザにコンポーネントが表示される直前に呼ばれます。ここで以下の処理をしていきます。
this.socket = io();
上記で、ソケットでサーバーに接続します。
this.socket.on("connect", () => {
this.socket.emit(csConstants.socketEvents.newGroup);
});
socket.on("connect") でソケット接続が完了した時の処理を定義します。socket.emit() はサーバーにデータを送信する関数で、「新しいグループを作ってほしい」旨の依頼を送信しています。
ソケットによる送信では、「イベント名」と「引数」の 2 つを送信できます。今回はイベント名が csConstants.socketEvents.newGroup(実際には "newGroup" という文字列)で、引数は不要なので省略しています。
注意点として、beforeMount() のタイミングではあくまでも処理内容を定義しているだけなので、このタイミングでは socket.emit() は実行されません。実行されるのはソケット接続完了時なので、なんらかの原因で接続に失敗すると socket.emit() は実行されないことになります。また、接続完了までに多少時間がかかる場合もあるので、実行順序が重要な処理を書く際は注意が必要です。
タイミングについては以降の socket.on() も同様の考え方です。
this.socket.on(csConstants.socketEvents.groupUuid, (uuid) => {
this.groupUuid = uuid;
this.invitationUrl = this.uuidToInvitationUrl(uuid);
document.getElementById("showQr").textContent = "";
new QRCode(document.getElementById("showQr"), this.invitationUrl);
});
サーバーから csConstants.socketEvents.groupUuid イベント(グループ UUID 通知イベント)を受信した時の処理です。
ソケット受信時も送信時同様「イベント名」と「引数」の 2 つを受信します。今回はイベント名が csConstants.socketEvents.groupUuid(実際には "groupUuid" という文字列)で、引数("599e15af-ff3d-40fc-92de-85791a0a5aa6" などといった実際の UUID)が uuid に格納されます。
ここでグループ UUID を元にグループ専用 URL を作成します。グループ UUID が "599e15af-ff3d-40fc-92de-85791a0a5aa6" ならグループ専用 URL は "http://localhost:3000/public/game.html?group=599e15af-ff3d-40fc-92de-85791a0a5aa6" になります。作成したグループ専用 URL を this.invitationUrl に代入しているので、<template>
ブロックの <p>
要素内でグループ専用 URL が表示されます。
QR コードも作成しています。
this.socket.on(csConstants.socketEvents.numParticipants, (numParticipants) => {
this.numParticipants = numParticipants;
this.isPlayButtonDisabled = numParticipants === 1;
});
サーバーから csConstants.socketEvents.numParticipants イベント(参加人数通知イベント)を受信した時の処理です。
引数で人数を受信し、this.numParticipants に代入しているので、<template>
ブロックの <button>
要素に参加人数が表示されます。
また、人数が 2 人以上になったら this.isPlayButtonDisabled が false になるのでボタンが押せるようになります。
サーバーとクライアントの定数共通化
ソケットでサーバーに "newGroup" イベントを送信しましたが、サーバー側でもそれに対応して "newGroup" イベントを受信した時の処理を書くことになります(次回以降)。
イベント名はサーバーとクライアントで同じにする必要があり、文字列を即値で書いているとスペルミスなどによるバグの温床になります。
Web じゃんけんでは、サーバーとクライアントが共通で使用する定数を cs_constants.js
にまとめています。
class socketEvents {
// 新規グループ作成
newGroup = "newGroup";
// グループ UUID
groupUuid = "groupUuid";
// 参加人数
numParticipants = "numParticipants";
}
class csConstants {
// ソケット通信イベント名
static socketEvents = new socketEvents();
}
try {
module.exports = csConstants;
} catch (e) {
}
クライアント側は game.html
から cs_constants.js
を読み込んでいるので、csConstants.socketEvents.newGroup のように記述ができます。
サーバー側は module.exports したものをインポートすることにより、似たような記述が可能になります。ただし、クライアント側は module.exports の部分でエラーになるので、try~catch でエラーを封じています。
即値ではなくなったので、スペルミスをした時はコンソールに undefined と表示され、ミスを見つけやすくなります。
JavaScript 初心者としては共通化は苦労しました。
この手法も、一段目(csConstants)は static なのに二段目(socketEvents)は static ではなく、統一されていないのでやや微妙ではあります。
連想配列を enum のように使う手法もありますが、インポート・エクスポートの記述が増えるので断念しました。
より良い手法をご存じの方はご教示いただけると幸いです。
game.html
ホストのラウンジ画面(lounge_host.vue
)を読み込んでいる game.html
についてです。
基本は概要編の hello_world.html
と同じですが、処理が少し増えたので主要部分を client.js
に分離しました。
その結果、game.html
はやることがシンプルになっています。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta content="initial-scale=1.0, width=device-width" name="viewport">
<title>Web じゃんけん</title>
<style>
.loading {
width: 60px;
height: 60px;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
border-radius: 50%;
border: solid 20px;
border-color: #25ccff #2a97f3 #2a97f3;
animation: rotation 1s infinite linear;
}
@keyframes rotation {
100% {
transform: rotate(360deg);
}
}
</style>
<script defer src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script defer src="https://unpkg.com/socket.io-client@4.8.1/dist/socket.io.js"></script>
<script defer src="https://unpkg.com/vue3-sfc-loader"></script>
<script defer src="https://code.jquery.com/jquery-2.2.4.min.js"
integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script defer src="./cs_constants.js"></script>
<script defer src="./client.js"></script>
</head>
<body>
<div class="loading"></div>
<noscript>
<div>JavaScript が無効のためページを表示できません。JavaScript を有効にしてください。</div>
</noscript>
</body>
</html>
実行に必要なスクリプトファイルをひたすら読み込み、その間は <body>
内の <div>
でプログレスリングを回し続けます。準備が終わると Vue により <body>
配下が
lounge_host.vue
に置換されます。
分離した client.js
は以下です。
function loadModuleOptions() {
return {
moduleCache: {
vue: Vue
},
async getFile(url) {
const res = await fetch(url);
if (!res.ok) {
throw Object.assign(new Error(res.statusText + " " + url), { res });
}
return {
getContentData: asBinary => asBinary ? res.arrayBuffer() : res.text(),
}
},
addStyle(textContent) {
const style = Object.assign(document.createElement("style"), { textContent });
const ref = document.head.getElementsByTagName("style")[0] || null;
document.head.insertBefore(style, ref);
},
}
}
async function client() {
const options = loadModuleOptions();
const { loadModule } = window["vue3-sfc-loader"];
// アプリとコンポーネント
let vueApp;
let component;
let props = {};
const params = (new URL(location.href)).searchParams;
const group = params.get(csConstants.params.group);
if (group) {
// グループが指定されている場合はゲスト
component = "./lounge_guest.vue";
props["groupConst"] = group;
} else {
// グループが指定されていない場合はホスト
component = "./lounge_host.vue";
}
// document.body 配下をコンポーネントで置換
vueApp = Vue.createApp(Vue.defineAsyncComponent(() => loadModule(component, options)), props);
props["vueApp"] = vueApp;
vueApp.mount(document.body);
}
client();
loadModuleOptions() は Hello, world! の時と変わりません。
client() の Vue.createApp() の前に変更があります。
URL の引数にグループ UUID が指定されている場合はゲストのラウンジ画面(次回以降作成予定)を表示する必要があるので、読み込むファイル名を lounge_guest.vue
にしています。引数がなければホストのラウンジ画面なので lounge_host.vue
です。この読み分けをした後でコンポーネントを作成しています。
ラウンジ(ホスト)編まとめ
今回は、サーバーとクライアントの通信手段を定めたうえで、クライアント側のラウンジ(ホスト)画面を作成しました。
次回
主な改訂履歴
- 2025/01/06 初版。
- 2025/01/08 次回について記載。
Discussion