iT邦幫忙

2022 iThome 鐵人賽

DAY 11
1
Modern Web

派對動物嗨起來!系列 第 11

D11 - 開房間!開派對!♪( ◜ω◝و(و

  • 分享至 

  • xImage
  •  

本系列文已改編成書「甚麼?網頁也可以做派對遊戲?使用 Vue 和 babylon.js 打造 3D 派對遊戲吧!」

書中不只重構了程式架構、改善了介面設計,還新增了 2 個新遊戲呦!ˋ( ° ▽、° )

新遊戲分別使用了陀螺儀與震動回饋,趕快買書來研究研究吧!ლ(╹∀╹ლ)

在此感謝深智數位的協助,歡迎大家前往購書,鱈魚感謝大家 (。・∀・)。

助教:「所以到底差在哪啊?沒圖沒真相,被你坑了都不知道。(´。_。`)」

鱈魚:「你對我是不是有甚麼很深的偏見啊 (っ °Д °;)っ,來人啊,上連結!」

Yes


連線成功的第一步,就讓我們來建立房間吧!

房間可以用來儲存連線代號和玩家,並與作為遊戲機的網頁端進行資料同步,可以讓遊戲機網頁取得目前玩家數量與其 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;
  }
}
  • 使用 nanoid 提供之 customAlphabet 簡單產生房間 ID。

有房間之後,讓我們接著新增一系列與房間操作有關的 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,處理連線與斷線事件,基本邏輯為:

  • 連線時,若 client type 類型為 game-console,則建立房間。
  • 斷線時,若 client type 類型為 game-console,則自動刪除房間。

一樣載入 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) {...}
}

然後神奇的事情發生了!

Untitled

就是當你輸入 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 了!✧*。٩(ˊᗜˋ*)و✧*。

總結

  • 伺服器完成 room 模組功能
  • 網頁連線至伺服器並取得房間 ID

以上程式碼已同步至 GitLab,大家可以前往下載:

Web

GitLab - D11

Server

GitLab - D11


上一篇
D10 - 讓前後端接上線:使用 Socket.IO 進行連線
下一篇
D12 - 歡迎光臨遊戲大廳:使用 Vue Router 切換頁面
系列文
派對動物嗨起來!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言