本系列文已改編成書「甚麼?網頁也可以做派對遊戲?使用 Vue 和 babylon.js 打造 3D 派對遊戲吧!」
書中不只重構了程式架構、改善了介面設計,還新增了 2 個新遊戲呦!ˋ( ° ▽、° )
新遊戲分別使用了陀螺儀與震動回饋,趕快買書來研究研究吧!ლ(╹∀╹ლ)
在此感謝深智數位的協助,歡迎大家前往購書,鱈魚感謝大家 (。・∀・)。
助教:「所以到底差在哪啊?沒圖沒真相,被你坑了都不知道。(´。_。`)」
鱈魚:「你對我是不是有甚麼很深的偏見啊 (っ °Д °;)っ,來人啊,上連結!」
現在我們可以讓玩家加入房間,也可以傳輸玩家的搖桿資料了,現在我們把遊戲大廳內的玩家頭像對應實際上的玩家吧!( •̀ ω •́ )✧
必須在玩家數量發生變更時,發送玩家數量更新事件,前往伺服器專案,追加「玩家加入房間」與「玩家斷線」對應功能。
首先新增事件定義。
types\socket.type.ts
...
export interface EmitEvents {
...
'game-console:player-update': (data: Player[]) => void;
}
...
並在 room.service 新增 emitPlayerUpdate 功能,用於發送發送指定房間之玩家數量。
...
@Injectable()
export class RoomService {
...
/** 發送指定房間之玩家數量*/
async emitPlayerUpdate(
founderId: string,
server: Server<OnEvents, EmitEvents>,
) {
const room = this.getRoom({
founderId,
});
if (!room) return;
// 取得 game-console Client 資料
const founderClient = this.wsClientService.getClient({
clientId: founderId,
});
if (!founderClient) return;
const players = room.playerIds.map((playerId) => ({
clientId: playerId,
}));
// 對特定 socket 發送資料
const gameConsoleSocket = server.sockets.sockets.get(
founderClient.socketId,
);
gameConsoleSocket?.emit('game-console:player-update', players);
}
}
並在 room.gateway 處理事件。
src\room\room.gateway.ts
...
@WebSocketGateway()
export class RoomGateway {
...
@WebSocketServer()
private server!: Server<OnEvents, EmitEvents>;
...
handleDisconnect(socket: ClientSocket) {
...
if (client.type === 'player') {
// 取得此玩家所處房間
const room = this.roomService.getRoom({
playerId: client.id,
});
// 刪除玩家
this.roomService.deletePlayer(client.id);
// 若房間存在則發送玩家資料更新
if (room) {
this.roomService.emitPlayerUpdate(room.founderId, this.server);
}
return;
}
}
@SubscribeMessage<keyof OnEvents>('player:join-room')
async handlePlayerJoinRoom(socket: ClientSocket, roomId: string) {
...
// 發送玩家資料更新
this.roomService.emitPlayerUpdate(room.founderId, this.server);
const result: SocketResponse<Room> = {
status: 'suc',
message: '成功加入房間',
data: room,
};
return result;
}
}
現在伺服器會在房間人數發生變更時,自動推送訊息至遊戲機網頁。
差點忘記還需要加入轉送搖桿資料發送的功能惹。(´,,•ω•,,)
將網頁專案的 player.type 檔案移過來伺服器專案。
types\player.type.ts
import { Socket } from 'socket.io';
import { Room } from 'src/room/room.service';
import {
GameConsoleState,
Player,
UpdateGameConsoleState,
} from 'src/game-console/game-console.type';
import { GamepadData } from './player.type';
export interface OnEvents {
'player:join-room': (data: Room) => void;
'player:request-game-console-state': () => void;
'player:gamepad-data': (data: GamepadData) => void;
'game-console:state-update': (data: UpdateGameConsoleState) => void;
}
export interface EmitEvents {
'game-console:room-created': (data: Room) => void;
'game-console:state-update': (data: GameConsoleState) => void;
'game-console:player-update': (data: Player[]) => void;
'player:gamepad-data': (data: GamepadData) => void;
}
export type ClientSocket = Socket<OnEvents, EmitEvents>;
export interface SocketResponse<T = undefined> {
status: 'err' | 'suc';
message: string;
data?: T;
error?: any;
}
並在 socket.type 新增事件。
types\socket.type.ts
...
export interface OnEvents {
...
'player:gamepad-data': (data: GamepadData) => void;
...
}
export interface EmitEvents {
...
'player:gamepad-data': (data: GamepadData) => void;
}
...
最後在 game-console.gateway 處理事件。
src\game-console\game-console.gateway.ts
...
@WebSocketGateway()
export class GameConsoleGateway {
...
@SubscribeMessage<keyof OnEvents>('player:gamepad-data')
async handlePlayerGamepadData(socket: ClientSocket, data: GamepadData) {
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 founderClient = this.wsClientService.getClient({
clientId: room.founderId,
});
if (!founderClient) {
const result: SocketResponse = {
status: 'err',
message: '此 socket 不存在 client',
};
return result;
}
const targetSocket = this.server.sockets.sockets.get(
founderClient.socketId,
);
if (!targetSocket) {
const result: SocketResponse = {
status: 'err',
message: '不存在 room founder 對應之 Client',
};
return result;
}
targetSocket.emit('player:gamepad-data', data);
const result: SocketResponse = {
status: 'suc',
message: '傳輸搖桿資料成功',
};
return result;
}
}
現在伺服器具備了「主動通知玩家人數變化」與「轉傳搖桿控制訊號」的功能了!
讓我們回到網頁專案,並在 use-client-game-console 加入玩家數量變化事件的 hook 吧!( •̀ ω •́ )✧
src\composables\use-client-game-console.ts
...
export function useClientGameConsole() {
...
const playerUpdateHook = createEventHook<Player[]>();
client?.value?.on('game-console:player-update', playerUpdateHook.trigger);
onBeforeUnmount(() => {
client?.value?.removeListener('game-console:player-update', playerUpdateHook.trigger);
});
return {
...
/** 玩家人數發生變更,例如玩家加入或斷線等等 */
onPlayerUpdate: playerUpdateHook.on,
...
}
}
接著在 game-console 中使用 hook,更新玩家清單狀態。
src\views\game-console.vue
...
<script setup lang="ts">
...
function init() {
...
gameConsole.onPlayerUpdate((players) => {
gameConsoleStore.updateState({
players,
});
});
// 跳轉至遊戲大通
...
}
init();
</script>
這樣遊戲機網頁就可以在玩家加入或斷線時,變更玩家數量了。
現在讓我們調整 game-console-lobby 中的 player-avatar 內容。
我們讓 player-avatar 改成由 v-for 動態產生,新增 playersInfo 負責產生玩家資料。
src\views\game-console-lobby.vue
<template>
...
<div class="absolute inset-0 flex">
<div class="flex w-full h-full">
<!-- 選單 -->
<div class="w-1/3 flex flex-col p-12">
...
<!-- 玩家清單 -->
<transition-group
name="list"
tag="div"
class="flex justify-center items-center gap-4 h-32"
>
<player-avatar
v-for="player in playersInfo"
:key="player.id"
:player-id="player.id"
:code-name="player.codeName"
/>
</transition-group>
</div>
...
</div>
</div>
</template>
<script setup lang="ts">
...
const gameConsoleStore = useGameConsoleStore();
const playersInfo = computed(() => {
const result = gameConsoleStore.players.map((player, i) => ({
id: player.clientId,
codeName: `${i + 1}P`,
}));
return result;
});
...
</script>
...
並新增 transition-group 用的動畫 class
src\style\animate.sass
...
.list-move, .list-enter-active, .list-leave-active
transition: all 0.5s ease
.list-enter-from, .list-leave-to
opacity: 0
.list-leave-active
position: absolute
現在讓我們試試看加入遊戲並讓搖桿網頁斷線看看。
玩家出現了!✧*。٩(ˊᗜˋ*)و✧*。
現在讓搖桿發出訊號並於遊戲大廳監聽搖桿資料看看。
首先是搖桿發出訊號,概念很簡單,就是接收控制組件 @trigger 事件即可。
src\views\player-gamepad-lobby.vue
<template>
<div .. >
<gamepad-d-pad
...
@trigger="({ keyName, status }) => handleBtnTrigger(keyName, status)"
/>
<gamepad-btn
...
@trigger="(status) => handleBtnTrigger('confirm', status)"
/>
...
</div>
</template>
<script setup lang="ts">
import { KeyName } from '../types/player.type';
...
function handleBtnTrigger(keyName: `${KeyName}`, status: boolean) {
console.log(`[ handleBtnTrigger ] : `, { keyName, status });
player.emitGamepadData([{
name: keyName,
value: status,
}]);
}
...
</script>
...
最後在 game-console-lobby 中接收訊號吧。
src\views\game-console-lobby.vue
...
<script setup lang="ts">
...
function init() {
...
gameConsole.onGamepadData((data) => {
console.log(`[ onGamepadData ] data : `, data);
});
}
init();
</script>
...
現在來按按看搖桿上的按鍵。
可以看到遊戲機網頁成功接收到玩家搖桿網頁的控制訊號了!♪( ◜ω◝و(و
以上程式碼已同步至 GitLab,大家可以前往下載: