本系列文已改編成書「甚麼?網頁也可以做派對遊戲?使用 Vue 和 babylon.js 打造 3D 派對遊戲吧!」
書中不只重構了程式架構、改善了介面設計,還新增了 2 個新遊戲呦!ˋ( ° ▽、° )
新遊戲分別使用了陀螺儀與震動回饋,趕快買書來研究研究吧!ლ(╹∀╹ლ)
在此感謝深智數位的協助,歡迎大家前往購書,鱈魚感謝大家 (。・∀・)。
助教:「所以到底差在哪啊?沒圖沒真相,被你坑了都不知道。(´。_。`)」
鱈魚:「你對我是不是有甚麼很深的偏見啊 (っ °Д °;)っ,來人啊,上連結!」
遊戲大廳基本樣式完成了,現在讓我們開張讓玩家們加入吧!ヽ(✿゚▽゚)ノ
來實作首頁的「加入遊戲」按鈕,預期按下按鈕後會跳出一個視窗,讓玩家輸入房間編號。
利用 Quasar 提供的 Dialog 達成功能,首先依照說明在 main 中安裝。
src\main.ts
...
import { Quasar, Notify, Dialog } from 'quasar'
...
createApp(App)
.use(Quasar, {
plugins: {
Notify, Dialog
},
lang: quasarLang,
})
...
接著依照規定新增基本內容。
src\components\dialog-join-party.vue
<template>
<q-dialog
ref="dialogRef"
@hide="onDialogHide"
/>
</template>
<script lang='ts' setup>
import { } from 'vue';
import { useDialogPluginComponent } from 'quasar';
const emit = defineEmits([...useDialogPluginComponent.emits]);
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
useDialogPluginComponent();
</script>
<style scoped lang='sass'>
</style>
新增儲存房號的變數與提交的 function。
...
<script lang='ts' setup>
...
const $q = useQuasar();
const targetRoomId = ref('');
async function submit() {
if (!/^[0-9]{6}$/.test(targetRoomId.value)) {
$q.notify({
type: 'negative',
message: '請輸入 6 位數字'
});
return;
}
/** 產生 loading 效果 */
const notifyRef = $q.notify({
type: 'ongoing',
message: '加入房間中'
});
onDialogOK();
}
</script>
...
最後完成 template 與 CSS。
<template>
<q-dialog
ref="dialogRef"
class="rounded-5xl"
@hide="onDialogHide"
>
<div class="card flex flex-col p-14 gap-8">
<div class="text-3xl text-center">
輸入派對房間 ID
</div>
<q-input
v-model="targetRoomId"
type="number"
color="secondary"
outlined
rounded
placeholder="請輸入 6 位數字"
input-class="text-center"
@keyup.enter="submit"
/>
<q-btn
unelevated
rounded
color="secondary"
class="p-4"
@click="submit"
>
加入
</q-btn>
</div>
</q-dialog>
</template>
<script lang='ts' setup>
...
</script>
<style scoped lang='sass'>
.card
border-radius: 2.5rem !important
background: rgba(white, 0.9)
backdrop-filter: blur(6px)
</style>
簡單完成,現在讓我們在首頁新增 joinParty function 並綁定於「加入遊戲」按鈕,用於觸發開啟這個 Dialog。
src\views\the-home.vue
<template>
...
<div class="absolute inset-0 flex flex-col flex-center gap-20">
...
<btn-base
...
@click="joinParty"
>
...
</btn-base>
</div>
</template>
<script setup lang="ts">
...
import DialogJoinParty from '../components/dialog-join-party.vue';
...
async function joinParty() {
$q.dialog({
component: DialogJoinParty,
}).onOk(() => {
$q.notify({
type: 'positive',
message: '加入房間成功'
});
});
}
</script>
...
現在按下加入遊戲後會出現視窗了!✧*。٩(ˊᗜˋ*)و✧*。
現在讓我們用手機開啟看看。
畫面噴出去啦!╭(°A ,°`)╮
別緊張,由於我們的單位都是 rem,所以有一個簡單的處理方法,讓我們到 App.vue 加入以下 CSS。
src\App.vue
...
<style lang="sass">
...
html
font-size: 1.7vmin
@media screen and (orientation: portrait) and (max-width: 360px)
font-size: 6px
@media screen and (orientation: portrait) and (min-width: 1200px)
font-size: 30px
</style>
完成!成功回復世界和平!✧*。٩(ˊᗜˋ*)و✧*。
原理很簡單,就是根據裝置的畫面最小長度尺寸調整 html 的 font-size,由於所有的單位都是 rem,所以會一起跟著 html 的 font-size 調整。
現在讓我們前往伺服器專案,實作加入房間功能吧。
在 room.service 新增 joinRoom method 用來處理玩家加入遊戲功能。
src\room\room.service.ts
...
@Injectable()
export class RoomService {
...
async joinRoom(roomId: string, clientId: string) {
const room = this.roomsMap.get(roomId);
if (!room) {
return Promise.reject(`不存在 ID 為 ${roomId} 的房間`);
}
const isJoined = room.playerIds.includes(clientId);
if (isJoined) {
return room;
}
room.playerIds.push(clientId);
return room;
}
deleteRooms(founderId: string) { ... }
deletePlayer(clientId: string) { ... }
}
接著在 OnEevets 事件中加入 player:join-room 事件,並加入 export 匯出。
types\socket.type.ts
...
export interface OnEvents {
'player:join-room': (data: Room) => void;
}
export interface EmitEvents {
'game-console:room-created': (data: Room) => void;
}
...
Socket.IO 除了透過 emit 發射事件以外,也可以在 on 事件中透過 callback 直接回應(文檔連結),這裡讓我們定義一下回應標準格式。
types\socket.type.ts
...
export interface SocketResponse<T = undefined> {
status: 'err' | 'suc';
message: string;
data?: T;
error?: any;
}
最後回到 room.gateway 中,處理玩家加入事件,並使用 SocketResponse 直接回應加入結果。
src\room\room.gateway.ts
import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import { Room, RoomService } from './room.service';
import { Socket } from 'socket.io';
import { UtilsService } from 'src/utils/utils.service';
import { WsClientService } from 'src/ws-client/ws-client.service';
import { ClientSocket, OnEvents, SocketResponse } from 'types/socket.type';
import { Logger } from '@nestjs/common';
import to from 'await-to-js';
@WebSocketGateway()
export class RoomGateway {
private logger: Logger = new Logger(RoomGateway.name);
...
@SubscribeMessage<keyof OnEvents>('player:join-room')
async handlePlayerJoinRoom(socket: ClientSocket, roomId: string) {
this.logger.log(`socketId : ${socket.id}`);
this.logger.log(`roomId : `, roomId);
if (!this.roomService.hasRoom(roomId)) {
const result: SocketResponse = {
status: 'err',
message: '指定房間不存在',
};
return result;
}
const client = this.wsClientService.getClient({
socketId: socket.id,
});
if (!client) {
const result: SocketResponse = {
status: 'err',
message: 'Socket Client 不存在,請重新連線',
};
return result;
}
const [err, room] = await to(this.roomService.joinRoom(roomId, client.id));
if (err) {
const result: SocketResponse = {
status: 'err',
message: '加入房間發生異常',
error: err,
};
return result;
}
// 加入 socket room
socket.join(roomId);
const result: SocketResponse<Room> = {
status: 'suc',
message: '成功加入房間',
data: room,
};
return result;
}
}
現在讓我們回到網頁專案,在 socket.type 中追加事件與型別定義。
src\types\socket.type.ts
...
interface EmitEvents {
'player:join-room': (roomId: string, callback?: (err: any, res: SocketResponse<Room>) => void) => void;
}
...
export interface SocketResponse<T = undefined> {
status: 'err' | 'suc';
message: string;
data: T;
error: any;
}
接著同 game-console 概念一般,新增 use-client-player 負責處理各類與玩家相關功能。
src\composables\use-client-player.ts
import { computed } from 'vue';
import { useSocketClient } from './use-socket-client';
import { Room } from '../types/socket.type';
export function useClientPlayer() {
const { client, connect } = useSocketClient();
function joinRoom(roomId: string): Promise<Room> {
return new Promise((resolve, reject) => {
// client 尚未連線,先進行連線
if (!client?.value?.connected) {
const client = connect('player');
client.once('connect', () => {
client.removeAllListeners();
emitJoinRoom(client, roomId)
.then(resolve)
.catch(reject)
});
// 發生連線異常
client.once('connect_error', (error) => {
client.removeAllListeners();
reject(error);
});
return;
}
// client 已經連線,發出事件
emitJoinRoom(client.value, roomId)
.then(resolve)
.catch(reject)
});
}
function emitJoinRoom(targetClient: ReturnType<typeof connect>, roomId: string): Promise<Room> {
return new Promise((resolve, reject) => {
targetClient.timeout(3000).emit('player:join-room', roomId, (err, res) => {
if (err) {
return reject(err);
}
if (res.status === 'err') {
return reject(res);
}
resolve(res.data);
});
});
}
return {
joinRoom,
}
}
最後在 dialog-join-party 使用 use-client-player,實際發出加入請求吧。
src\components\dialog-join-party.vue
...
<script lang='ts' setup>
...
import { useClientPlayer } from '../composables/use-client-player';
...
const player = useClientPlayer();
const gameConsoleStore = useGameConsoleStore();
...
async function submit() {
...
const [err, room] = await to(player.joinRoom(targetRoomId.value));
notifyRef();
if (err) {
$q.notify({
type: 'negative',
message: `加入房間失敗 : ${err?.message}`
});
console.error(`加入房間失敗 : `, err);
return;
}
console.log(`[ joinRoom ] room : `, room);
gameConsoleStore.setRoomId(room.id);
onDialogOK();
}
</script>
...
加入成功後會收到
現在讓我們打開兩個不同的瀏覽器,一個先建立派對,另一個則嘗試加入房間。
若打錯房號會出現錯誤通知。
房號正確則會出現成功通知。
並在 console 中印出房間訊息。
恭喜我們成功讓玩家加入房間了!◝(≧∀≦)◟
以上程式碼已同步至 GitLab,大家可以前往下載: