Vue 3 でマルチプレイブラウザゲームを作る (7) じゃんけん編

2025/01/17に公開

つづき

プレイ開始編では、プレイ開始時の画面切替において、サーバーとクライアントそれぞれの処理を作りました。

今回は、じゃんけん本編の部分を作り、ゲームを完成させます。

プレイ画面イメージ

プレイ画面では、参加者はじゃんけんで出したい手のボタンを押します。選んだ手は緑色になります。

他の参加者が全員手を出すまで待機します。

全員が手を出すと勝ち負けが表示されます。勝った場合は勝利点が 1 増えます。

負けやあいこでは勝利点は増えません。

以降、この勝負がずっと続きます。

参加者情報表示コンポーネント

プレイ画面の上部には参加者の情報(名前と勝利点)が表示されます。

Vue の participant_panel.vue で「一人分」の情報を表示するコンポーネントを定義しています。

participant_panel.vue
<template>
    <span :class="rootClasses">
        <span>{{ participantInfo.name }}</span>
        <span></span>
        <span>{{ participantInfo.point }} 点</span>
    </span>
</template>

<style>
.rootBase {
    margin: 0 2em 0 0;
}

.playerHost {
    background-color: #99ffff;
}

.playerGuest {
    background-color: #ddaaff;
}
</style>

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

    props: ["participantInfo", "playerName"],

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

    data() {
        return {
            // ルート要素のスタイルクラス
            rootClasses: ["rootBase"],
        };
    },

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

    methods: {
        // ルート要素のスタイルクラスを更新
        updateRootClasses() {
            if (!this.playerName) {
                return;
            }

            // 参加者名が参加者情報と異なる場合は色づけしない
            if (this.playerName !== this.participantInfo.name) {
                return;
            }

            // 役割に応じた色づけをする
            let className;
            if (this.playerName === "Host") {
                className = "playerHost";
            } else {
                className = "playerGuest";
            }
            if (!this.rootClasses.includes(className)) {
                this.rootClasses.push(className);
            }
        }
    },

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

    beforeMount() {
        this.updateRootClasses();
    },

    beforeUpdate() {
        this.updateRootClasses();
    },
}
</script>

<template> ブロックを見ると分かるように、名前と勝利点を表示するだけのシンプルなコンポーネントです。

props で participantInfo と playerName を受け取っています。

participantInfo に参加者一人分の情報が入っており、participantInfo.name が参加者の名前、participantInfo.point が勝利点です。participantInfo は「誰か」一人分なので、ホストかもしれないし、1 人目のゲストかもしれないし、2 人目のゲストかもしれません。

情報を表示するだけなら participantInfo だけ受け取れば十分ですが、このコンポーネントは「自分」(ブラウザを開いている人)にだけ色づけをします。

自分かどうかを判定するために、playerName(自分の名前)も受け取っています。playerName と participantInfo.name が同じなら、受け取った「誰か」一人分の情報が「自分」の情報だと分かるので、その時だけ色づけしています。

色づけ処理を行っているのが updateRootClasses() です。

updateRootClasses()(抜粋)
updateRootClasses() {
    // 参加者名が参加者情報と異なる場合は色づけしない
    if (this.playerName !== this.participantInfo.name) {
        return;
    }

    // 役割に応じた色づけをする
    let className;
    if (this.playerName === "Host") {
        className = "playerHost";
    } else {
        className = "playerGuest";
    }
    if (!this.rootClasses.includes(className)) {
        this.rootClasses.push(className);
    }
}

playerName で色づけが必要かどうかを判定後、ホストかゲストかで付ける色を変えています。具体的には、CSS のスタイルクラスを "playerHost" か "playerGuest" のどちらかにしています。適用するスタイルクラスを格納する配列 this.rootClasses にはもともと共通クラスである "rootBase" が格納されています。"playerHost" か "playerGuest" のどちらかを追加し、合計 2 つのスタイルクラスを適用するようにしています。

色分け処理を確実に行えるよう、beforeMount() および beforeUpdate()(コンポーネントの状態が更新される際のイベントハンドラー)の両方で updateRootClasses() を呼びだしています。

繰り返しコンポーネント配置

プレイ画面の上部には参加者「全員」の情報を表示します。問題点は、その時その時でグループへの参加者の人数が異なるため、固定の HTML では表現しづらいことです。

それを解決するのが繰り返し処理を実現する Vue の v-for です。

プレイ画面 play.vue での参加者情報表示は以下のようになっています。

play.vue(抜粋)
<template>
    <p :class="playerClass">プレイ</p>
    <participantPanel v-for="participantInfo in participantInfos" :participantInfo="participantInfo"
        :playerName="playerName">
    </participantPanel>
    <p>{{ judgementMessage }}</p>
    <p>次は何を出しますか?</p>
    <p>
        <button :class="guClasses" @click="onTacticsGuClicked()" :disabled="isTacticsButtonDisabled">
            <img class="tacticsImg" src="tactics_gu.png" />
        </button>
        <button :class="chokiClasses" @click="onTacticsChokiClicked()" :disabled="isTacticsButtonDisabled">
            <img class="tacticsImg" src="tactics_choki.png" />
        </button>
        <button :class="paClasses" @click="onTacticsPaClicked()" :disabled="isTacticsButtonDisabled">
            <img class="tacticsImg" src="tactics_pa.png" />
        </button>
    </p>
    <p>{{ statusMessage }}</p>
    <p>{{ errorMessage }}</p>
</template>

<script>
import participantPanel from './participant_panel.vue'

export default {
    // ====================================================================
    // カスタムコンポーネント
    // ====================================================================

    components: {
        participantPanel,
    },

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

    data() {
        return {
            // 参加者表示用のスタイルクラス
            playerClass: null,

            // 参加者名
            playerName: null,

            // 参加者情報群
            participantInfos: null,
        };
    },

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

    beforeMount() {
        // 参加者名が来た
        this.socket.on(csConstants.socketEvents.playerName, (name) => {
            this.playerName = name;
            if (name === "Host") {
                this.playerClass = "playerHost";
            } else {
                this.playerClass = "playerGuest";
            }
        });

        // 参加者情報群が来た
        this.socket.on(csConstants.socketEvents.participantInfos, (participantInfosString) => {
            this.participantInfos = JSON.parse(participantInfosString);
        });

        // socket.on イベントハンドラー設定完了後
        this.socket.emit(csConstants.socketEvents.playReady);
    },
}
</script>

プレイ開始編で整理したように、プレイ画面の beforeMount() で playReady をサーバーに送信することにより、サーバーから

  • playerName イベント……参加者(自分)の名前
  • participantInfos イベント……参加者全員の情報(名前、勝利点)

を受信します。

playerName イベント受信時は、自分がホストなのかゲストなのかによって CSS スタイルクラスを設定し色づけをします。

// 参加者名が来た
this.socket.on(csConstants.socketEvents.playerName, (name) => {
    this.playerName = name;
    if (name === "Host") {
        this.playerClass = "playerHost";
    } else {
        this.playerClass = "playerGuest";
    }
});

participantInfos イベント受信時は、this.participantInfos にその内容を代入します。

// 参加者情報群が来た
this.socket.on(csConstants.socketEvents.participantInfos, (participantInfosString) => {
    this.participantInfos = JSON.parse(participantInfosString);
});

participantInfos は配列です。参加者が 3 人の場合は以下のように長さが 3 の配列になります。

[
    { "name": "Host", "point": 0 },
    { "name": "Guest 1", "point": 0 },
    { "name": "Guest 2", "point": 0 }
]

すると、<template> ブロックの

<participantPanel v-for="participantInfo in participantInfos" :participantInfo="participantInfo"
    :playerName="playerName">
</participantPanel>

にある v-for により、participantInfos 配列の長さ分だけ参加者情報表示コンポーネント(participantPanel)が配置されます。v-for in の後ろの participantInfos が配列で、その前にある participantInfo に要素 1 つが格納され、:participantInfo= や :playerName= で参加者情報表示コンポーネントの props に情報を渡しています。

これで、グループの人数にかかわらず、参加者全員の情報を表示できるようになります。

なお、.vue ファイルで子コンポーネントを使用するには <script> ブロックに import と components の 2 箇所の記述が必要です。

play.vue(抜粋)
<script>
import participantPanel from './participant_panel.vue'

export default {
    // ====================================================================
    // カスタムコンポーネント
    // ====================================================================

    components: {
        participantPanel,
    },
}
</script>

手を出す

ボタン処理

じゃんけんの手を出したときの処理を見ていきます。

参加者はグー/チョキ/パーのいずれかのボタンを押しますが、例えばグーボタンを押した時の処理が以下です。

play.vue(抜粋)
<template>
    <p>次は何を出しますか?</p>
    <p>
        <button :class="guClasses" @click="onTacticsGuClicked()" :disabled="isTacticsButtonDisabled">
            <img class="tacticsImg" src="tactics_gu.png" />
        </button>
    </p>
    <p>{{ statusMessage }}</p>
</template>

<script>
export default {
    methods: {
        // グーを出す
        onTacticsGuClicked() {
            this.sendTactics(csConstants.tactics.gu, this.guClasses);
        },

        // 手をサーバーに送信
        sendTactics(tactics, classes) {
            // UI 処理
            this.isTacticsButtonDisabled = true;
            this.statusMessage = "他の参加者が手を出すのを待っています...";
            classes.push("selectedButton");

            // 送信
            this.socket.emit(csConstants.socketEvents.selectTactics, tactics);
        },
    },
}
</script>

グーボタンを押すと onTacticsGuClicked() が呼ばれ、そこで出す手を gu として sendTactics() を呼んでいます。

sendTactics() では、これ以上手を出せないように this.isTacticsButtonDisabled を true にしてボタンを無効化しています(グー/チョキ/パー全部のボタンに反映)。また、グーボタンに "selectedButton" CSS スタイルクラスを適用することで、緑色にして選択したことを分かりやすく表示しています。このような UI の表示切り替えが Vue では簡単にできて便利です。

その後、ソケットでサーバーに selectTactics イベントを送信しています。

サーバー側処理

selectTactics イベントを受信したサーバー側では以下の処理を行います。繰り返しになりますが、サーバー側では Vue は使いません。

socket.js(抜粋)
// 手選択イベント
socket.on(csc.socketEvents.selectTactics, async (tactics) => {
    try {
        await onSelectTacticsRequestedAsync(io, socket, tactics);
    } catch (e) {
        notifyException(socket, e);
    }
});

// 手選択イベント
async function onSelectTacticsRequestedAsync(io, socket, tactics) {
    // グループ検索
    const groupRecord = await selectGroupBySocketIdAsync(db, socket.id);
    if (groupRecord[dbc.group.cStatus] !== dbc.group.status.playing) {
        throw new Error("プレイ中ではないのに手が選択されました。");
    }

    // 手を記録
    const memberRecord = await selectMemberBySocketIdAsync(db, socket.id);
    if (memberRecord[dbc.member.cTactics] !== csc.tactics.thinking) {
        throw new Error("既に手は選択済です。");
    }
    memberRecord[dbc.member.cTactics] = tactics;
    await updateMemberAsync(db, memberRecord);

    // 全員の手が出そろったか?
    const numThinking = await countTacticsAsync(db, groupRecord[dbc.group.cId], csc.tactics.thinking);
    if (numThinking > 0) {
        return;
    }

    // 全員に結果通知
    await notifyResultAsync(io, db, groupRecord);

    // 手を初期化
    await clearSelectionAsync(db, groupRecord[dbc.group.cId]);
}

selectMemberBySocketIdAsync() で誰が手を出したのかを特定し、その参加者の手をデータベースに書き込みます。

データベースのメンバーテーブルの構造を再掲しておきます。member_tactics カラムに受信した手の内容を書き込みます。

その後、グループ全員が手を出し終わったか確認します。countTacticsAsync() で member_tactics が thinking(初期値)のままの人数を数えます。

socket.js(抜粋)
// 手の数を数える
async function countTacticsAsync(db, groupId, tactics) {
    return await new Promise((resolve, reject) => {
        const sentence = "select count(*) from " + dbc.member.t
            + " where " + dbc.member.cGroup + " = ? and " + dbc.member.cStatus + " = ? and "
            + dbc.member.cTactics + " = ?";
        db.get(sentence, groupId, dbc.member.status.playing, tactics, (err, res) => {
            if (res) {
                resolve(res["count(*)"]);
            } else {
                reject(new Error("指定されたグループは存在しません:" + groupId));
            }
        });
    });
}

thinking の参加者が 1 人でもいたら、まだ手が出そろっていないのでゲームは進捗しません。処理を終了します。

全員の手が出そろったら、notifyResultAsync() で勝敗判定・勝利点更新・各参加者への勝敗通知を行います。勝敗通知は以下のように judgement イベントで行います。

io.to(memberRecord[dbc.member.cSocket]).emit(csc.socketEvents.judgement, csc.judgement.win);

勝利点更新は playReady イベントの時と同じく participantInfos イベントを送信することにより行います。

勝敗表示

サーバーから judgement イベントを受信した Vue クライアントは以下の処理を行います。

play.vue(抜粋)
// 勝敗が来た
this.socket.on(csConstants.socketEvents.judgement, (judgement) => {
    switch (judgement) {
        case csConstants.judgement.win:
            this.judgementMessage = "勝ち! 勝利点 +1";
            break;
        case csConstants.judgement.lose:
            this.judgementMessage = "負け...";
            break;
        case csConstants.judgement.draw:
            this.judgementMessage = "あいこ";
            break;
    }
    this.clearTactics();
    this.statusMessage = null;
});

勝敗結果に応じたメッセージを表示するとともに、clearTactics() でボタンの色を初期化し、再度ボタンを押せるようにします。

合わせて受信する participantInfos への対応は、初期設定時と同様です。

以上により次の勝負を行えるようになり、以降、この勝負がずっと続きます。

完成

今回はプレイ画面を作成し、じゃんけんの処理を行いました。これで Web じゃんけんの完成です。

Vue で作成したクライアント側の処理は

  • ラウンジ画面(ホスト、ゲスト、ホストゲスト共通部分)
  • プレイ画面
  • 参加者情報表示画面

です。いずれも、簡単な処理で表示する情報を更新できて便利でした。Vue の良さを体感できたなと思います。

おまけ

主な改訂履歴

  • 2025/01/17 初版。
  • 2025/01/19 補足編について記載。

Discussion