Vue 3 でマルチプレイブラウザゲームを作る (7) じゃんけん編
つづき
プレイ開始編では、プレイ開始時の画面切替において、サーバーとクライアントそれぞれの処理を作りました。
今回は、じゃんけん本編の部分を作り、ゲームを完成させます。
プレイ画面イメージ
プレイ画面では、参加者はじゃんけんで出したい手のボタンを押します。選んだ手は緑色になります。
他の参加者が全員手を出すまで待機します。
全員が手を出すと勝ち負けが表示されます。勝った場合は勝利点が 1 増えます。
負けやあいこでは勝利点は増えません。
以降、この勝負がずっと続きます。
参加者情報表示コンポーネント
プレイ画面の上部には参加者の情報(名前と勝利点)が表示されます。
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() {
// 参加者名が参加者情報と異なる場合は色づけしない
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
での参加者情報表示は以下のようになっています。
<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 箇所の記述が必要です。
<script>
import participantPanel from './participant_panel.vue'
export default {
// ====================================================================
// カスタムコンポーネント
// ====================================================================
components: {
participantPanel,
},
}
</script>
手を出す
ボタン処理
じゃんけんの手を出したときの処理を見ていきます。
参加者はグー/チョキ/パーのいずれかのボタンを押しますが、例えばグーボタンを押した時の処理が以下です。
<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.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(初期値)のままの人数を数えます。
// 手の数を数える
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 クライアントは以下の処理を行います。
// 勝敗が来た
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