iT邦幫忙

2022 iThome 鐵人賽

DAY 16
0
Modern Web

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

D16 - 手機變搖桿!

  • 分享至 

  • xImage
  •  

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

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

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

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

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

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

Yes


玩家加入房間成功後,應該要讓玩家畫面跳轉至搖桿畫面,現在來讓我們打造玩家的搖桿吧!( ´ ▽ ` )ノ

預計不同的遊戲狀態會有不同的專用搖桿,首先來建立最外層的搖桿容器頁面。

src\views\player-gamepad.vue

<template>
  <div class="w-full h-full bg-black">
    <router-view />
  </div>
</template>

<script setup lang="ts">
import { useLoading } from '../composables/use-loading';

const loading = useLoading();

function init() {
  loading.hide();
}
init();
</script>

並加到 Router 中。

src\router\router.ts

...
export enum RouteName {
  ...
  PLAYER_GAMEPAD = 'player-gamepad',
}

const routes: Array<RouteRecordRaw> = [
  ...
  {
    path: `/player-gamepad`,
    name: RouteName.PLAYER_GAMEPAD,
    component: () => import('../views/player-gamepad.vue'),
    children: []
  },

  {
    path: '/:pathMatch(.*)*',
    redirect: '/'
  },
]
...

現在讓我們在 the-home 加入 Router,於加入房間後跳轉頁面吧。♪( ◜ω◝و(و

...
<script setup lang="ts">
...
async function joinParty() {
  $q.dialog({
    component: DialogJoinParty,
  }).onOk(async () => {
    $q.notify({
      type: 'positive',
      message: '加入房間成功'
    });

    await loading.show();

    router.push({
      name: RouteName.PLAYER_GAMEPAD
    });
  });
}
</script>
...

現在加入房間後,會在讀取畫面結束時跳到一片黑暗之中了!( •̀ ω •́ )(?

Untitled

接著新增 player-gamepad-lobby 組件,用於提供大廳專用搖桿按鍵。

src\views\player-gamepad-lobby.vue

<template>
  <div class="w-full h-full flex text-white select-none">
  </div>
</template>

<script setup lang="ts">
import { useLoading } from '../composables/use-loading';

const loading = useLoading();

function init() {
  loading.hide();
}
init();
</script>

隱藏 loading 的工作改成交給 player-gamepad-lobby,所以我們把 player-gamepad 中的部份刪除。( ‧ω‧)ノ╰(‧ω‧ )

src\views\player-gamepad.vue

<template>
  <div class="w-full h-full bg-black">
    <router-view />
  </div>
</template>

<script setup lang="ts">
function init() { }
init();
</script>

現在讓我們把 player-gamepad-lobby 加到 router 中吧。

src\router\router.ts

...
export enum RouteName {
  ...
  PLAYER_GAMEPAD_LOBBY = 'player-gamepad-lobby',
}

const routes: Array<RouteRecordRaw> = [
  ...
  {
    path: `/player-gamepad`,
    ...
    children: [
      {
        path: `lobby`,
        name: RouteName.PLAYER_GAMEPAD_LOBBY,
        component: () => import('../views/player-gamepad-lobby.vue')
      },
    ]
  },
  ...
]
...

在 player-gamepad 加入跳轉至 player-gamepad-lobby 的邏輯。

src\views\player-gamepad.vue

...
<script setup lang="ts">
import { RouteName } from '../router/router';

import { useRouter } from 'vue-router';
import { useGameConsoleStore } from '../stores/game-console.store';

const gameConsoleStore = useGameConsoleStore();
const router = useRouter();

function init() {
  if (!gameConsoleStore.roomId) {
    router.push({
      name: RouteName.HOME
    });
    return;
  }

  if (gameConsoleStore.status === 'lobby') {
    router.push({
      name: RouteName.PLAYER_GAMEPAD_LOBBY
    });
  }
}
init();
</script>

現在若嘗試加入遊戲會發現畫面卡在 loading,沒辦法跳到搖桿畫面。

原因很簡單,搖桿畫面依照遊戲機狀態進行跳轉,但是我們都還沒實作同步遊戲機狀態功能,所以現在來讓我們完成最重要的部分:「同步遊戲機狀態」。

首先在 socket.type 新增事件定義。

src\types\socket.type.ts

import { Socket } from 'socket.io-client';
import { UpdateGameConsoleState } from '../stores/game-console.store';
...
interface OnEvents {
  ...
  'game-console:state-update': (data: Required<UpdateGameConsoleState>) => void;
}

interface EmitEvents {
  ...
  'game-console:state-update': (data: UpdateGameConsoleState) => void;
}
...

接著在 use-client-game-console 新增設定遊戲狀態的 function。

src\composables\use-client-game-console.ts

...
import { GameConsoleStatus, GameName, UpdateGameConsoleState, useGameConsoleStore } from '../stores/game-console.store';
...
export function useClientGameConsole() {
  const { client, connect, close } = useSocketClient();
  const gameConsoleStore = useGameConsoleStore();

  function setStatus(status: `${GameConsoleStatus}`) {
    gameConsoleStore.updateState({
      status
    });

    if (!client?.value?.connected) {
      return Promise.reject('client 尚未連線');
    }

    client.value.emit('game-console:state-update', {
      status
    });
  }
  function setGameName(gameName: `${GameName}`) {
    gameConsoleStore.updateState({
      gameName
    });

    if (!client?.value?.connected) {
      return Promise.reject('client 尚未連線');
    }

    client.value.emit('game-console:state-update', {
      gameName
    });
  }
  ...
  return {
    ...
    /** 設定遊戲狀態,會自動同步至房間內所有玩家 */
    setStatus,
    /** 設定遊戲名稱,會自動同步至房間內所有玩家 */
    setGameName,
  }
}

最後在 game-console-lobby 中使用 useClientGameConsole。

src\views\game-console-lobby.vue

<script setup lang="ts">
...
import { useClientGameConsole } from '../composables/use-client-game-console';

const loading = useLoading();
const gameConsole = useClientGameConsole();

function init() {
  gameConsole.setStatus('lobby');
  loading.hide();
}
init();
</script>

以上 game-console 設定狀態的部分基本上 OK 了,接下來我們還需要:

  • 伺服器對同一個房間內的所有玩家廣播 game-console 狀態更新事件
  • 玩家搖桿要監聽狀態變更事件,並要有主動發出請求狀態的能力。

首先讓我們移駕到伺服器專案吧!(≧∇≦)ノ

第一步是讓我們新增 game-console 模組,調整 CLI 自動生成內容並引入重要模組。

src\game-console\game-console.module.ts

import { Module } from '@nestjs/common';
import { GameConsoleService } from './game-console.service';
import { GameConsoleGateway } from './game-console.gateway';
import { WsClientModule } from 'src/ws-client/ws-client.module';
import { RoomModule } from 'src/room/room.module';

@Module({
  imports: [WsClientModule, RoomModule],
  providers: [GameConsoleGateway, GameConsoleService],
  exports: [GameConsoleService],
})
export class GameConsoleModule {
  //
}

src\game-console\game-console.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { RoomService } from 'src/room/room.service';
import { WsClientService } from 'src/ws-client/ws-client.service';

@Injectable()
export class GameConsoleService {
  private logger: Logger = new Logger(GameConsoleService.name);

  constructor(
    private readonly roomService: RoomService,
    private readonly wsClientService: WsClientService,
  ) {
    //
  }
}

src\game-console\game-console.gateway.ts

import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import { GameConsoleService } from './game-console.service';
import { UtilsService } from 'src/utils/utils.service';
import { WsClientService } from 'src/ws-client/ws-client.service';
import { Logger } from '@nestjs/common';

@WebSocketGateway()
export class GameConsoleGateway {
  private logger: Logger = new Logger(GameConsoleGateway.name);

  constructor(
    private readonly gameConsoleService: GameConsoleService,
    private readonly utilsService: UtilsService,
    private readonly wsClientService: WsClientService,
  ) {
    //
  }
}

手動新增 game-console.type 定義資料型別。

src\game-console\game-console.type.ts

export enum GameConsoleStatus {
  /** 首頁 */
  HOME = 'home',
  /** 大廳等待中 */
  LOBBY = 'lobby',
  /** 遊戲中 */
  PLAYING = 'playing',
}

export enum GameName { }

export interface Player {
  clientId: string;
}

export interface GameConsoleState {
  status: `${GameConsoleStatus}`;
  gameName?: `${GameName}`;
  players: Player[];
}

export type UpdateGameConsoleState = Partial<GameConsoleState>;

接著新增 socket 事件定義!◝( •ω• )◟

types\socket.type

...
import { UpdateGameConsoleState } from 'src/game-console/game-console.type';

export interface OnEvents {
  ...
  'game-console:state-update': (data: UpdateGameConsoleState) => void;
}

export interface EmitEvents {
  ...
  'game-console:state-update': (data: GameConsoleState) => void;
}
...

現在讓我們實作 game-console 模組邏輯。

首先 game-console.service 負責儲存或提供 game-console 狀態

src\game-console\game-console.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { cloneDeep } from 'lodash';
import { RoomService } from 'src/room/room.service';
import { WsClientService } from 'src/ws-client/ws-client.service';
import { GameConsoleState, Player } from './game-console.type';

/** GameConsoleService 儲存之狀態不需包含 players
 * 因為 players 數值由 roomService 提供,所以這裡忽略
 */
type GameConsoleData = Omit<GameConsoleState, 'players'>;

const defaultState: GameConsoleData = {
  status: 'home',
  gameName: undefined,
};

@Injectable()
export class GameConsoleService {
  private logger: Logger = new Logger(GameConsoleService.name);
  /** key 為 founder 之 clientId */
  private readonly gameConsolesMap = new Map<string, GameConsoleData>();

  constructor(
    private readonly roomService: RoomService,
    private readonly wsClientService: WsClientService,
  ) {
    //
  }

  setState(founderId: string, state: Partial<GameConsoleData>) {
    const oriState = this.gameConsolesMap.get(founderId);

    let newState: GameConsoleData;
    if (oriState) {
      newState = {
        ...oriState,
        ...state,
      };
    } else {
      newState = {
        ...cloneDeep(defaultState),
        ...state,
      };
    }

    this.gameConsolesMap.set(founderId, newState);
  }

  getState(founderId: string) {
    const data = this.gameConsolesMap.get(founderId);

    // 取得房間
    const room = this.roomService.getRoom({
      founderId,
    });
    if (!room) {
      return undefined;
    }

    // 加入玩家
    const players: Player[] = room.playerIds.map((playerId) => ({
      clientId: playerId,
    }));

    const state: GameConsoleState = {
      ...data,
      status: data?.status ?? 'home',
      players,
    };

    return state;
  }
}

game-console.gateway 接收狀態更新事件。

src\game-console\game-console.gateway.ts

...
import { ClientSocket, OnEvents } from 'types/socket.type';
import { UpdateGameConsoleState } from './game-console.type';

@WebSocketGateway()
export class GameConsoleGateway {
  ...
  @SubscribeMessage<keyof OnEvents>('game-console:state-update')
  async handleGameConsoleStateUpdate(
    socket: ClientSocket,
    state: UpdateGameConsoleState,
  ) {
    const client = this.wsClientService.getClient({
      socketId: socket.id,
    });
    if (!client) return;

    const { status, gameName } = state;

    this.gameConsoleService.setState(client.id, { status, gameName });
  }
}

現在我們已經可以從遊戲機網頁發送狀態更新至伺服器,並在伺服器儲存遊戲機網頁狀態。

還差狀態更新時,伺服器廣播至房間內所有玩家功能。

新增廣播用 method。

src\game-console\game-console.service.ts

...
import { Server } from 'socket.io';
import { EmitEvents, OnEvents } from 'types/socket.type';
...
@Injectable()
export class GameConsoleService {
  ...
  async broadcastState(
    founderId: string,
    server: Server<OnEvents, EmitEvents>,
  ) {
    const room = this.roomService.getRoom({
      founderId,
    });

    if (!room) {
      this.logger.warn(`此 founderId 未建立任何房間 : ${founderId}`);
      return;
    }

    const state = this.getState(founderId);
    if (!state) {
      this.logger.warn(`此 founderId 不存在 state : ${founderId}`);
      return;
    }

    const sockets = await server.in(room.id).fetchSockets();
    sockets.forEach((socketItem) => {
      socketItem.emit('game-console:state-update', state);
    });
  }
}

最後在 game-console.gateway 呼叫廣播功能,由於需要直接呼叫 socket server,所以同時新增 server 成員。

src\game-console\game-console.gateway.ts

import {
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Server } from 'socket.io';
...
import { ClientSocket, EmitEvents, OnEvents } from 'types/socket.type';
...
@WebSocketGateway()
export class GameConsoleGateway {
  ...
  @WebSocketServer()
  private server!: Server<OnEvents, EmitEvents>;
  ...
  @SubscribeMessage<keyof OnEvents>('game-console:state-update')
  async handleGameConsoleStateUpdate( ... ) {
    ...

    // 廣播狀態
    this.gameConsoleService.broadcastState(client.id, this.server);
  }
}

以上我們完成「伺服器對同一個房間內的所有玩家廣播 game-console 狀態更新事件」,現在還差「玩家搖桿要監聽狀態變更事件,並要有主動發出請求狀態的能力」。

讓我們繼續努力!ヽ(●`∀´●)ノ,讓我們回到網頁專案。

在 use-client-player 中提供 game-console 狀態更新觸發的 hook 吧!

src\composables\use-client-player.ts

import { computed, onBeforeUnmount } from 'vue';

...
import { createEventHook } from '@vueuse/core';
import { UpdateGameConsoleState } from '../stores/game-console.store';

export function useClientPlayer() {
  ...
  const stateUpdateHook = createEventHook<UpdateGameConsoleState>();
  client?.value?.on('game-console:state-update', stateUpdateHook.trigger);
  onBeforeUnmount(() => {
    client?.value?.removeListener('game-console:state-update', stateUpdateHook.trigger);
  });

  return {
    joinRoom,

    onGameConsoleStateUpdate: stateUpdateHook.on,
  }
}

在 player-gamepad 中使用此 hook,持續接收 game-console 狀態更新。

src\views\player-gamepad.vue

...
<script setup lang="ts">
...
const gameConsoleStore = useGameConsoleStore();
const router = useRouter();
const player = useClientPlayer();

function init() {
  if (!gameConsoleStore.roomId) {
    router.push({
      name: RouteName.HOME
    });
    return;
  }

  player.onGameConsoleStateUpdate((state) => {
    const { status } = state;

    console.log(`[ onGameConsoleStateUpdate ] state : `, state);
    gameConsoleStore.updateState(state);

    if (status === 'home') {
      router.push({
        name: RouteName.HOME
      });
    }
    if (status === 'lobby') {
      router.push({
        name: RouteName.PLAYER_GAMEPAD_LOBBY
      });
    }
  });
}
init();
</script>

如此便可以在遊戲機網頁狀態更新時,玩家的網頁也會自動跳轉了!

讀者:「終於好了嗎?快睡著惹…(›´ω`‹ )」

鱈魚:「但是現在玩家加入遊戲後還是一樣會卡在 loading 畫面呦。ᕕ( ゚ ∀。)ᕗ」

讀者:「所以是怎樣啦!⎝(・ω´・⎝)」

別氣別氣,這是因為玩家加入遊戲,根本就不會觸發遊戲機的狀態更新,所以還差最後一步,「玩家網頁可以請求取得遊戲機狀態」的功能。

一樣讓我們在 socket.type 新增事件。

src\types\socket.type.ts

...
interface EmitEvents {
  'player:join-room': (roomId: string, callback?: (err: any, res: SocketResponse<Room>) => void) => void;
  'player:request-game-console-state': () => void;
  ...
}
...

接著在 use-client-player 中提供請求遊戲機狀態的 function。

src\composables\use-client-player.ts

...
export function useClientPlayer() {
  ...
  async function requestGameConsoleState() {
    if (!client?.value?.connected) {
      return Promise.reject('client 尚未連線');
    }
    client.value.emit('player:request-game-console-state');
  }

  return {
    ...
    requestGameConsoleState,
  }
}

最後在 player-gamepad 呼叫。

src\views\player-gamepad.vue

...
<script setup lang="ts">
...
function init() {
  ...
  player.onGameConsoleStateUpdate((state) => { ... });

  player.requestGameConsoleState();
}
init();
</script>

現在我們只要在伺服器回應玩家網頁的請求就完成了!( ´ ▽ ` )ノ

讓我們回到伺服器專案。

第一步就是先新增事件定義。

types\socket.type.ts

...
export interface OnEvents {
  'player:join-room': (data: Room) => void;
  'player:request-game-console-state': () => void;
  ...
}
...

並在 game-console.gateway 處理事件。

src\game-console\game-console.gateway.ts

...
@WebSocketGateway()
export class GameConsoleGateway {
  ...
  @SubscribeMessage<keyof OnEvents>('player:request-game-console-state')
  async handleRequestState(socket: ClientSocket) {
    const client = this.wsClientService.getClient({
      socketId: socket.id,
    });
    if (!client) {
      const result: SocketResponse = {
        status: 'err',
        message: '此 socket 不存在 client',
      };
      return result;
    }

    const room = this.roomService.getRoom({
      playerId: client.id,
    });
    if (!room) {
      const result: SocketResponse = {
        status: 'err',
        message: 'client 未加入任何房間',
      };
      return result;
    }

    const state = this.gameConsoleService.getState(room.founderId);
    if (!state) {
      const result: SocketResponse = {
        status: 'err',
        message: '此房間之 game-console 不存在 state',
      };
      return result;
    }

    socket.emit('game-console:state-update', state);

    const result: SocketResponse<GameConsoleState> = {
      status: 'suc',
      message: '取得 state 成功',
      data: state,
    };
    return result;
  }
}

鱈魚:「現在是真的完成了!✧*。٩(ˊᗜˋ*)و✧*。」

讀者:「真的嗎?(́◉◞౪◟◉‵)」

鱈魚:「真的真的,有影片有真相 ( •̀ ω •́ )✧」

ezgif-2-f9e5cf8433.gif

同時也會在 console 中看到事件觸發的訊息。

Untitled

讀者:「所以…我說那個搖桿呢?(´・ω・`)」

鱈魚:「啊…(́⊙◞౪◟⊙‵)」

總結

  • 網頁完成跳轉至玩家大廳搖桿頁面功能
  • 網頁完成「遊戲機發送狀態變更事件」與「玩家監聽並請求遊戲機狀態事件」
  • 伺服器完成 game-console 模組功能,可以儲存並廣播遊戲機狀態

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

Web

GitLab - D16

Server

GitLab - D16


上一篇
D15 - 加入派對
下一篇
D17 - 所以我說那個搖桿呢?
系列文
派對動物嗨起來!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言