🍵

Vue 3 でマルチプレイブラウザゲームを作る (5) ラウンジ(ゲスト)編

2025/01/11に公開

つづき

データ編では、サーバー側のデータ構造を定め、ラウンジ画面に対応するサーバー処理を作成しました。

今回は、クライアント側ラウンジ画面のうち、ゲストのラウンジ画面を作っていきます。

ゲストのラウンジ画面イメージ

ラウンジ(ホスト)編でも説明しましたが、再度、ゲストのラウンジ画面のイメージです。

ゲストは、ホストからメールなどでグループ専用 URL を教えてもらい、その URL にアクセスすると、ゲストのラウンジ画面が表示されます。

ゲストが操作できることは無く、ホストがプレイ開始するのをただ待ちます。

グループへの参加者数はリアルタイムで更新されます。

ゲストのラウンジ画面作成

Vue でゲストのラウンジ画面を作ると以下のようになります(Web じゃんけんのソースコードは GitHub に上げてあります)。

lounge_guest.vue
<template>
    <div>
        <p id="loungeTitle">ラウンジ(ゲスト)</p>
        <p>ホストが参加者を募集中です。現在の参加者:{{ numParticipants }}</p>
        <p>グループ:{{ groupUuid }}</p>
        <p>ホストがプレイ開始するのを待っています...</p>
        <p>{{ errorMessage }}</p>
    </div>
</template>

<style>
p#loungeTitle {
    background-color: #ddaaff;
}
</style>

<script>
import loungeBase from "./lounge_base.vue";

export default {
    // ====================================================================
    // 継承
    // ====================================================================

    extends: loungeBase,

    // ====================================================================
    // 構築時受領
    // ====================================================================

    props: ["groupConst"],

    // ====================================================================
    // イベントハンドラー
    // ====================================================================

    beforeMount() {
        // グループ UUID
        this.groupUuid = this.groupConst;

        // ソケットインスタンス作成時に接続しに行く
        this.socket = io();

        // 接続時イベント
        this.socket.on("connect", () => {
            // 既存グループ参加依頼
            this.socket.emit(csConstants.socketEvents.joinGroup, this.groupConst);
        });

        // 人数通知が来た
        this.socket.on(csConstants.socketEvents.numParticipants, (numParticipants) => {
            this.numParticipants = numParticipants;
        });

        // ホスト・ゲスト共通イベント
        this.setSocketOn();
    },
}
</script>

<template> ブロックで HTML を作っています。ピュア HTML とリアクティブ変数表示だけなので、特に難しいところはないと思います。

<style> ブロックでタイトル部分に色を付けています。実は、ホストのラウンジ画面lounge_host.vue)にも p#loungeTitle という同じ名前のスタイルがありますが、異なる色を付けています。別の .vue ファイルであれば互いに干渉しません。

継承

<script> ブロックに

extends: loungeBase,

があります。スルーしていましたが、実はホストのラウンジ画面lounge_host.vue)にも同じ物がありました。

ホストとゲストのラウンジ画面には一部共通する要素があるので、その共通部分を loungeBase に書いています。ホストとゲストのラウンジ画面は loungeBase から継承することで、共通部分以外のみを書けば良いようになっています。

共通部分の loungeBase は以下のようになっています。

lounge_base.vue
<template>
</template>

<style></style>

<script>
export default {
    // ====================================================================
    // 構築時受領
    // ====================================================================

    props: ["vueApp"],

    // ====================================================================
    // リアクティブ
    // ====================================================================

    data() {
        return {
            // グループ UUID
            groupUuid: null,

            // 参加人数
            numParticipants: 1,

            // エラーメッセージ
            errorMessage: null,

            // ソケット通信用
            socket: null,
        };
    },

    // ====================================================================
    // 関数
    // ====================================================================

    methods: {
        // ソケットイベントハンドラー(ホストとゲストの共通部分)を設定
        setSocketOn() {
            // プレイ開始通知が来た
            this.socket.on(csConstants.socketEvents.startPlay, () => {
                let vueApp = this.vueApp;
                // ToDo: socket.on イベントハンドラー解除
                vueApp.unmount();

                // 新しいコンポーネント
                const options = loadModuleOptions();
                const { loadModule } = window["vue3-sfc-loader"];
                let props = {};
                props["socket"] = this.socket;
                vueApp = Vue.createApp(Vue.defineAsyncComponent(() => loadModule("./play.vue", options)), props);
                props["vueApp"] = vueApp;
                vueApp.mount(document.body);
            });

            // エラー通知が来た
            this.socket.on(csConstants.socketEvents.errorMessage, (errorMessage) => {
                this.errorMessage = errorMessage;
            });
        },
    },
}
</script>

グループ UUID や参加人数など、ホストとゲストの両方が知る必要のある情報用の変数を定義しています。

また、次回以降で作成予定の、プレイ開始時の処理も共通なので、loungeBase で定義しています。

props

lounge_guest.vuelounge_base.vue<script> ブロックには props があります。

ゲストのラウンジ画面の props は

props: ["groupConst"],

です。props はコンポーネント作成元から引き継ぐことのできる特別な変数です。

client.js がゲストのラウンジ画面を作成している部分は以下のようになっています。

client.js(抜粋)
component = "./lounge_guest.vue";
props["groupConst"] = group;
vueApp = Vue.createApp(Vue.defineAsyncComponent(() => loadModule(component, options)), props);

ゲストがアクセスするグループ専用 URL には http://(サーバー URL(ローカルホストの場合は localhost:3000))/public/game.html?group=599e15af-ff3d-40fc-92de-85791a0a5aa6 のようにグループ UUID が含まれており、画面作成時に props["groupConst"] にグループ UUID を代入しています。その上でコンポーネント作成時に props を渡すことにより、その値を lounge_guest.vue から参照できるようになります。

lounge_guest.vue で props["groupConst"] の値を参照する時は

const hoge = this.groupConst;

のようにします。

ゲストのラウンジ画面の処理

以上を踏まえた上で、ゲストのラウンジ画面表示直前の処理である beforeMount() を見ていきます。

this.groupUuid = this.groupConst;

this.groupConst が props の値(グループ UUID)で、それを継承元の loungeBase で定義されている this.groupUuid に代入しています。groupUuid はリアクティブなので、自動的に <template> ブロックの <p> 要素の表示に反映されます。

this.socket.on("connect", () => {
    // 既存グループ参加依頼
    this.socket.emit(csConstants.socketEvents.joinGroup, this.groupConst);
});

socket.on("connect") はホストのラウンジ画面でも出てきましたが、ソケット接続が完了した時の処理を定義します。

ただし処理内容はホストのラウンジ画面とは異なります。ホストでは新規グループの作成依頼をしていましたが、ゲストの場合は「既にホストが作成済のグループに参加させてもらえるように依頼」します。

サーバーから csConstants.socketEvents.numParticipants イベント(参加人数通知イベント)を受信した時の処理は、ホストのラウンジ画面よりもやることが少ないです。

this.socket.on(csConstants.socketEvents.numParticipants, (numParticipants) => {
    this.numParticipants = numParticipants;
});

loungeBase で定義されている this.numParticipants に代入するのみです。これも自動的に画面表示に反映されます。

サーバー側

対応するサーバー側の処理も見ていきます。繰り返しになりますが、サーバー側では Vue は使いません。

ソケットで joinGroup イベントを受信した時に onJoinGroupRequestedAsync() を実行します。

socket.js(抜粋)
// 既存グループ参加イベント
socket.on(csc.socketEvents.joinGroup, async (group) => {
    try {
        await onJoinGroupRequestedAsync(io, socket, group);
    } catch (e) {
        notifyException(socket, e);
    }
});

// 既存グループ参加イベント
async function onJoinGroupRequestedAsync(io, socket, groupUuid) {
    if (!groupUuid) {
        throw new Error("グループが指定されていません。");
    }
    const db = new sqlite3.Database(dbc.path);

    // グループ検索
    const groupRecord = await selectGroupByUuidAsync(db, groupUuid);

    // グループメンバーテーブルにゲストユーザーを登録
    await insertMemberAsync(db, groupRecord[dbc.group.cId], socket.id);

    // ソケット上のグループを登録
    socket.join(groupUuid);

    // 参加人数をグループ全員に通知
    await notifyNumParticipantsAsync(io, db, groupRecord);
}

グループ UUID が指定されているので該当するグループを特定し、データベースのメンバーテーブルにそのグループの参加者として登録します。

メンバーテーブルの構造を再掲しておきます。例えばグループ内で 3 人目のゲストだった場合、「参加順」は 3 となり、「名前」は "Guest 3" になります。この段階では「出した手」「勝利点」は初期値です。

その後、ソケットとしてのグルーピングを行うために

socket.join(groupUuid);

をしています。こうすることで、

// 参加人数をグループ全員に通知
async function notifyNumParticipantsAsync(io, db, groupRecord) {
    const numParticipants = await countMemberAsync(db, groupRecord[dbc.group.cId]);
    io.to(groupRecord[dbc.group.cUuid]).emit(csc.socketEvents.numParticipants, numParticipants);
}

io.to(グループ UUID).emit() でそのグループ全員にソケットでイベントを送ることができます。新規グループ作成依頼を受信した際にホストもソケットのグループに登録していたので、新たにグループに参加者が増える度に、1 回 emit() するだけでグループ全員に参加人数を通知できます。

エラー処理

例えばゲストがグループ専用 URL をコピペミスして、存在しないグループ UUID が指定された場合、サーバー側でグループ検索に失敗します。

// 指定されたグループ UUID でグループを検索し、レコードを返す
async function selectGroupByUuidAsync(db, uuid) {
    return await new Promise((resolve, reject) => {
        const sentence = "select * from " + dbc.group.t + " where " + dbc.group.cUuid + " = ?";
        db.get(sentence, uuid, (err, res) => {
            if (res) {
                resolve(res);
            } else {
                reject(new Error("指定されたグループは存在しません:" + uuid));
            }
        });
    });
}

データベースに該当するグループ UUID のレコードが存在しないので、res が空になり、reject となって例外が発生します。

その例外を

catch (e) {
    notifyException(socket, e);
}

の部分でキャッチし、

// エラーをクライアントに通知
function notifyException(socket, e) {
    console.log("エラー:" + e.message);
    socket.emit(csc.socketEvents.errorMessage, e.message);
}

socket.emit() で csc.socketEvents.errorMessage(エラーメッセージイベント)として通知します。

クライアント側は、ホストゲスト共通部分の lounge_base.vue でエラーメッセージイベントを受信し、画面に表示します。

ラウンジ(ゲスト)編まとめ

今回は、ホストとゲストのラウンジ画面共通部分を整理し、共通部分から継承してゲストのラウンジ画面を作りました。

次回

主な改訂履歴

  • 2025/01/11 初版。
  • 2025/01/14 次回について記載。

Discussion