本系列文已改編成書「甚麼?網頁也可以做派對遊戲?使用 Vue 和 babylon.js 打造 3D 派對遊戲吧!」
書中不只重構了程式架構、改善了介面設計,還新增了 2 個新遊戲呦!ˋ( ° ▽、° )
新遊戲分別使用了陀螺儀與震動回饋,趕快買書來研究研究吧!ლ(╹∀╹ლ)
在此感謝深智數位的協助,歡迎大家前往購書,鱈魚感謝大家 (。・∀・)。
助教:「所以到底差在哪啊?沒圖沒真相,被你坑了都不知道。(´。_。`)」
鱈魚:「你對我是不是有甚麼很深的偏見啊 (っ °Д °;)っ,來人啊,上連結!」
連線成功的第一步,就讓我們來建立房間吧!
房間可以用來儲存連線代號和玩家,並與作為遊戲機的網頁端進行資料同步,可以讓遊戲機網頁取得目前玩家數量與其 ID。
首先到伺服器專案,讓我們建立 room 模組,輸入以下命令:
nest g resource room
後續選擇與 ws-client 模組相同,選擇「WebSockets」後輸入「n」。
首先在 room.module 中加入 exports。
src\room\room.module.ts
import { Module } from '@nestjs/common';
import { RoomService } from './room.service';
import { RoomGateway } from './room.gateway';
@Module({
providers: [RoomGateway, RoomService],
exports: [RoomService],
})
export class RoomModule {
//
}
接著來完成 service 功能,與 ws-client 概念相同,先來定義資料型別。
src\room\room.service.ts
import { Injectable } from '@nestjs/common';
import { ClientId } from 'src/ws-client/ws-client.service';
/** 房間 ID,6 位數字組成 */
export type RoomId = string;
export interface Room {
/** 房間 ID,6 位數字組成 */
id: RoomId;
founderId: ClientId;
playerIds: ClientId[];
}
@Injectable()
export class RoomService { }
加入 logger 與 roomsMap。
...
@Injectable()
export class RoomService {
private logger: Logger = new Logger(RoomService.name);
roomsMap = new Map<RoomId, Room>();
}
加入新增房間 method
import { customAlphabet } from 'nanoid';
const createRoomId = customAlphabet('1234567890', 6);
...
export class RoomService {
...
addRoom(clientId: string) {
let roomId = createRoomId();
while (this.roomsMap.has(roomId)) {
roomId = createRoomId();
}
const newRoom = {
id: roomId,
founderId: clientId,
playerIds: [],
};
this.roomsMap.set(roomId, newRoom);
this.logger.log(`created room : `, newRoom);
return newRoom;
}
}
有房間之後,讓我們接著新增一系列與房間操作有關的 method。
...
export class RoomService {
...
addRoom(clientId: string) { ... }
getRoom(params: GetRoomParams) {
const result = [...this.roomsMap.values()].find((room) => {
if ('founderId' in params) {
return room.founderId === params.founderId;
}
return room.playerIds.includes(params.playerId);
});
return result;
}
hasRoom(roomId: string) {
return this.roomsMap.has(roomId);
}
deleteRooms(founderId: string) {
const rooms = [...this.roomsMap.values()].filter(
(room) => room.founderId === founderId,
);
rooms.forEach(({ id }) => {
this.roomsMap.delete(id);
});
}
deletePlayer(clientId: string) {
this.roomsMap.forEach((room, key) => {
const index = room.playerIds.indexOf(clientId);
if (index < 0) return;
room.playerIds.splice(index, 1);
this.roomsMap.set(key, room);
});
}
}
最後讓我們前往 room.gateway,處理連線與斷線事件,基本邏輯為:
一樣載入 UtilsService 後,新增 handleConnection、handleDisconnect 之 method。
src\room\room.gateway.ts
import { WebSocketGateway } from '@nestjs/websockets';
import { RoomService } from './room.service';
import { Socket } from 'socket.io';
import { UtilsService } from 'src/utils/utils.service';
@WebSocketGateway()
export class RoomGateway {
constructor(
private readonly roomService: RoomService,
private readonly utilsService: UtilsService,
) {
//
}
handleConnection(socket: Socket) {
}
handleDisconnect(socket: Socket) {
}
}
實作連線邏輯。
...
export class RoomGateway {
...
handleConnection(socket: Socket) {
const queryData = socket.handshake.query as unknown;
if (!this.utilsService.isSocketQueryData(queryData)) return;
const { clientId, type } = queryData;
if (type !== 'game-console') return;
const room = this.roomService.addRoom(clientId);
/** 加入 Socket.IO 提供的 room 功能,
* 這樣可以簡單輕鬆的對所有成員廣播資料
*
* https://socket.io/docs/v4/rooms/#default-room
*/
socket.join(room.id);
/** 發送房間建立成功事件 */
socket.emit('game-console:room-created', room);
}
handleDisconnect(socket: Socket) {
}
}
最後完成斷線邏輯,由於我們需要從 socketId 取得 client,這裡需要用到 wsClientService 功能。
需要先在 room.module 引入 ws-client.module 才行。
src\room\room.module.ts
import { Module } from '@nestjs/common';
import { RoomService } from './room.service';
import { RoomGateway } from './room.gateway';
import { WsClientModule } from 'src/ws-client/ws-client.module';
@Module({
imports: [WsClientModule],
providers: [RoomGateway, RoomService],
exports: [RoomService],
})
export class RoomModule {
//
}
然後在 room.gateway 之 constructor 引用 WsClientService
src\room\room.gateway.ts
...
import { WsClientService } from 'src/ws-client/ws-client.service';
@WebSocketGateway()
export class RoomGateway {
constructor(
private readonly roomService: RoomService,
private readonly utilsService: UtilsService,
private readonly wsClientService: WsClientService,
) {
//
}
...
}
接著完成 handleDisconnect 內容。
...
export class RoomGateway {
...
handleDisconnect(socket: Socket) {
const client = this.wsClientService.getClient({
socketId: socket.id,
});
if (!client) return;
if (client.type === 'game-console') {
this.roomService.deleteRooms(client.id);
return;
}
if (client.type === 'player') {
this.roomService.deletePlayer(client.id);
return;
}
}
}
完成!但是這裡其實有個地方不太好,聰明的讀者們有發現是哪裡嗎?(^∀^●)
答案是 handleConnection 中的 socket.emit('game-console:room-created’)
部分!
為甚麼說這樣不好呢?原因是因為這個 emit 事件的名稱沒有被列舉,這樣會很難保證未來在功能增減的過程中保證事件名稱統一,所以讓我們改進這個部分吧!
根據 Socket.IO 文檔描述,我們可以自行列舉所有的 on 或 emit 事件,所以讓我們新增 socket.type 檔案。
types\socket.type.ts
import { Socket } from 'socket.io';
import { Room } from 'src/room/room.service';
interface OnEvents {
'': () => void;
}
interface EmitEvents {
'game-console:room-created': (data: Room) => void;
}
export type ClientSocket = Socket<OnEvents, EmitEvents>;
回到 room.gateway 替換一下型別定義。
src\room\room.gateway.ts
...
import { ClientSocket } from 'types/socket.type';
...
export class RoomGateway {
...
handleConnection(socket: ClientSocket) {...}
handleDisconnect(socket: ClientSocket) {...}
}
然後神奇的事情發生了!
就是當你輸入 emit 內容時,編譯器就會自動提示有那些事件可以輸入了!
改進大成功!ヽ(●`∀´●)ノ
現在讓我們切回網頁專案,來實際接收一下房間資訊吧。
把伺服器定義事件的 socket.type 複製過來,然後交換一下 OnEvents 與 EmitEvents 內容並補充內容。
src\types\socket.type.ts
import { Socket } from 'socket.io-client';
export interface Room {
/** 房間 ID,6 位數字組成 */
id: string;
founderId: string;
playerIds: string[];
}
interface OnEvents {
'game-console:room-created': (data: Room) => void;
}
interface EmitEvents {
'': () => void;
}
export type ClientSocket = Socket<OnEvents, EmitEvents>;
接著把 main.store 的 Socket 型別換掉,這樣網頁端也有 socket 事件提示了。
src\stores\main.store.ts
...
import { ClientSocket } from '../types/socket.type';
interface State {
...
/** Socket.io Client 物件 */
client?: ClientSocket,
...
}
export const useMainStore = defineStore('main', {
...
actions: {
...
setClient(client: ClientSocket, type: `${ClientType}`) {
this.$patch({
client,
type
});
}
}
})
最後建立 use-client-game-console 功能,用來封裝和 game-console 之伺服器事件功能。
src\composables\use-client-game-console.ts
import { useSocketClient } from './use-socket-client';
export function useClientGameConsole() {
const { connect, close } = useSocketClient();
async function startParty() {
close();
// 開始連線
const client = connect('game-console');
return new Promise<string>((resolve, reject) => {
// 5 秒後超時
const timer = setTimeout(() => {
close();
client.removeAllListeners();
reject('連線 timeout');
}, 3000);
// 發生連線異常
client.once('connect_error', (error) => {
client.removeAllListeners();
reject(error);
});
// 房間建立成功
client.once('game-console:room-created', async ({ id }) => {
client.removeAllListeners();
clearTimeout(timer);
resolve(id);
});
});
}
return {
/** 開始派對
*
* 建立連線,並回傳房間 ID
*/
startParty,
}
}
讓我們回到 the-home,使用 use-client-game-console 功能開啟並取得房間 ID 吧!( ´ ▽ ` )ノ
如果連線發生錯誤,希望直接使用 Quasar 提供之 notify 提示使用者,根據文檔描述,需要先在 main.ts 中安裝。
src\main.ts
...
import { Quasar, Notify } from 'quasar'
...
createApp(App)
.use(Quasar, {
plugins: {
Notify,
},
lang: quasarLang,
})
...
如此便可以使用 Quasar 的 notify 功能了。
接著讓我們修改一下 the-home 中的 startParty() 內容。
src\views\the-home.vue
...
<script setup lang="ts">
...
import { useClientGameConsole } from '../composables/use-client-game-console';
import { useQuasar } from 'quasar';
const loading = useLoading();
const router = useRouter();
const gameConsole = useClientGameConsole();
const $q = useQuasar();
async function startParty() {
await loading.show();
const [err, roomId] = await to(gameConsole.startParty());
if (err) {
console.error(`[ startParty ] err : `, err);
$q.notify({
type: 'negative',
message: '建立派對失敗,請吸嗨後再度嘗試'
});
return;
}
console.log(`roomId : `, roomId);
router.push({
name: RouteName.GAME_CONSOLE
});
}
</script>
...
現在按下「建立派對」,應該會在讀取畫面消失後,於 DevTool 的 console 看到以下訊息。
roomId : 518728
我們成功建立房間並取得房間 ID 了!✧*。٩(ˊᗜˋ*)و✧*。
以上程式碼已同步至 GitLab,大家可以前往下載: