iT邦幫忙

2022 iThome 鐵人賽

DAY 15
1

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

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

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

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

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

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

Yes


遊戲大廳基本樣式完成了,現在讓我們開張讓玩家們加入吧!ヽ(✿゚▽゚)ノ

來實作首頁的「加入遊戲」按鈕,預期按下按鈕後會跳出一個視窗,讓玩家輸入房間編號。

利用 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>
...

現在按下加入遊戲後會出現視窗了!✧*。٩(ˊᗜˋ*)و✧*。

Untitled

現在讓我們用手機開啟看看。

271727.jpg

畫面噴出去啦!╭(°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>

完成!成功回復世界和平!✧*。٩(ˊᗜˋ*)و✧*。

271729.jpg

原理很簡單,就是根據裝置的畫面最小長度尺寸調整 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>
...

加入成功後會收到

現在讓我們打開兩個不同的瀏覽器,一個先建立派對,另一個則嘗試加入房間。

若打錯房號會出現錯誤通知。

Untitled

房號正確則會出現成功通知。

Untitled

並在 console 中印出房間訊息。

Untitled

恭喜我們成功讓玩家加入房間了!◝(≧∀≦)◟

總結

  • 網頁完成加入遊戲 Dialog
  • 網頁成功發出加入房間請求
  • 伺服器完成玩家加入功能並回應房間資料

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

Web

GitLab - D15

Server

GitLab - D15


上一篇
D14 - 裝飾大廳細節
下一篇
D16 - 手機變搖桿!
系列文
派對動物嗨起來!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言