本系列文已改編成書「甚麼?網頁也可以做派對遊戲?使用 Vue 和 babylon.js 打造 3D 派對遊戲吧!」
書中不只重構了程式架構、改善了介面設計,還新增了 2 個新遊戲呦!ˋ( ° ▽、° )
新遊戲分別使用了陀螺儀與震動回饋,趕快買書來研究研究吧!ლ(╹∀╹ლ)
在此感謝深智數位的協助,歡迎大家前往購書,鱈魚感謝大家 (。・∀・)。
助教:「所以到底差在哪啊?沒圖沒真相,被你坑了都不知道。(´。_。`)」
鱈魚:「你對我是不是有甚麼很深的偏見啊 (っ °Д °;)っ,來人啊,上連結!」
現在終於跳轉至玩家大廳搖桿畫面了,現在終於可以讓我們開始完成搖桿的內容了。
預期樣式如下圖。
基本組成為:
可以發現方向鍵實際上也由四個按鈕組成,所以第一步讓我們新增按鈕組件吧。
定義參數與事件。
src\components\gamepad-btn.vue
...
<script setup lang="ts">
interface Props {
/** 尺寸 */
size?: string;
/** 按鈕內 icon 名稱 */
icon?: string;
/** 按鈕底色 */
color?: string;
/** 按鈕觸發底色 */
activeColor?: string,
}
const props = withDefaults(defineProps<Props>(), {
size: '2rem',
icon: undefined,
color: 'grey-10',
activeColor: 'grey-3',
});
const emit = defineEmits<{
(e: 'click'): void;
(e: 'trigger', status: boolean): void;
}>();
</script>
...
新增狀態變數、狀態數值與事件。
...
<script setup lang="ts">
...
const status = ref(false);
const color = computed(() =>
status.value ? props.activeColor : props.color
);
function onClick() {
emit('click');
}
function onUp(e: TouchEvent | MouseEvent) {
e.preventDefault();
status.value = false;
emit('trigger', false);
onClick();
}
function onDown(e: TouchEvent | MouseEvent) {
e.preventDefault();
status.value = true;
emit('trigger', true);
}
</script>
...
最後把資料與事件綁定於 template 吧。
<template>
<q-btn
round
unelevated
:size="props.size"
:icon="props.icon"
:color="color"
@mouseup="onUp"
@mousedown="onDown"
@touchend="onUp"
@touchstart="onDown"
@contextmenu="(e) => e.preventDefault()"
>
<slot />
</q-btn>
</template>
...
這裡使用 slot 保留彈性,並綁定 @contextmenu 以防觸控長按時,意外開啟右鍵選單。
這樣按鈕就完成了!讓我們實際擺到畫面中看看吧。( ´ ▽ ` )ノ
src\views\player-gamepad-lobby.vue
<template>
<div class="w-full h-full flex text-white select-none">
<gamepad-btn
class="absolute bottom-10 right-20"
size="6rem"
icon="done"
/>
</div>
</template>
<script setup lang="ts">
import GamepadBtn from '../components/gamepad-btn.vue';
...
</script>
按鈕出現了。(´,,•ω•,,)
讓我們稱勝追擊,繼續完成方向鍵吧。
新增組件、預期參數與事件。
src\components\gamepad-d-pad.vue
...
<script setup lang="ts">
import { ref } from 'vue';
type KeyNames = 'up' | 'left' | 'right' | 'down';
interface Props {
/** 尺寸,直徑 */
size?: string;
btnSize?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: '34rem',
btnSize: '3rem'
});
const emit = defineEmits<{
(e: 'click', keyName: KeyNames): void;
(e: 'trigger', data: { keyName: KeyNames, status: boolean }): void;
}>();
</script>
...
預期引用剛剛建立的 gamepad-btn 建立按紐,設計 function 接收按鈕觸發事件。
...
<script setup lang="ts">
...
function handleBtnTrigger(keyName: KeyNames, status: boolean) {
if (!status) {
emit('click', keyName);
}
emit('trigger', {
keyName, status
});
}
</script>
...
template 加入 gamepad-btn 與 CSS。
<template>
<div class="d-pad rounded-full bg-grey-10">
<gamepad-btn
class="btn up"
color="grey-9"
icon="arrow_drop_up"
size="3rem"
@trigger="(status) => handleBtnTrigger('up', status)"
/>
<gamepad-btn
class="btn left"
color="grey-9"
icon="arrow_left"
size="3rem"
@trigger="(status) => handleBtnTrigger('left', status)"
/>
<gamepad-btn
class="btn right"
color="grey-9"
icon="arrow_right"
size="3rem"
@trigger="(status) => handleBtnTrigger('right', status)"
/>
<gamepad-btn
class="btn down"
color="grey-9"
icon="arrow_drop_down"
size="3rem"
@trigger="(status) => handleBtnTrigger('down', status)"
/>
</div>
</template>
<script setup lang="ts">
...
import GamepadBtn from './gamepad-btn.vue';
...
</script>
<style scoped lang="sass">
.d-pad
width: v-bind('props.size')
height: v-bind('props.size')
.btn
position: absolute
&.up
left: 50%
top: 0%
transform: translate(-50%, 20%)
&.left
left: 0%
top: 50%
transform: translate(20%, -50%)
&.right
right: 0%
top: 50%
transform: translate(-20%, -50%)
&.down
left: 50%
bottom: 0%
transform: translate(-50%, -20%)
</style>
最後把方向鍵放到畫面中吧。ヽ(●`∀´●)ノ
src\views\player-gamepad-lobby.vue
<template>
<div
...
@touchmove="(e)=>e.preventDefault()"
>
<gamepad-d-pad class="absolute bottom-5 left-8" />
<gamepad-btn
class="absolute bottom-10 right-20"
size="6rem"
icon="done"
/>
</div>
</template>
<script setup lang="ts">
import GamepadBtn from '../components/gamepad-btn.vue';
import GamepadDPad from '../components/gamepad-d-pad.vue';
...
</script>
追加一個取消 touchmove 事件,避免觸控時不小心拖動畫面。
再來讓我們加上「提示玩家將手機打橫的提示」。
<template>
<div ... >
...
<q-dialog
v-model="isPortrait"
persistent
>
<q-card class="p-8">
<q-card-section class="flex flex-col items-center gap-6">
<q-spinner-box
color="primary"
size="10rem"
/>
<div class="text-4xl">
請將手機轉為橫向
</div>
<div class="text-base">
轉為橫向後,此視窗會自動關閉
</div>
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
...
import { useScreenOrientation } from '@vueuse/core';
...
const { orientation } = useScreenOrientation();
// 轉向
const isPortrait = computed(() => orientation.value?.includes('portrait'));
...
</script>
現在只要直握手機,都會出現以下畫面 ◝( •ω• )◟
最後讓我們加上顯示玩家代號的部分吧!
玩家使用到的功能基本上都集中在 use-client-player 中,讓我們在此新增 codeName,表示玩家代號。
src\composables\use-client-player.ts
...
import { UpdateGameConsoleState, useGameConsoleStore } from '../stores/game-console.store';
import { useMainStore } from '../stores/main.store';
export function useClientPlayer() {
...
const gameConsoleStore = useGameConsoleStore();
const mainStore = useMainStore();
...
const codeName = computed(() => {
const index = gameConsoleStore.players.findIndex((player) =>
player.clientId === mainStore.clientId
);
if (index < 0) {
return 'unknown ';
}
return `${index + 1}P`;
});
return {
...
codeName,
}
}
回到搖桿組件引入 codeName。
src\views\player-gamepad-lobby.vue
<template>
<div
class="w-full h-full flex text-white select-none"
@touchmove="(e)=>e.preventDefault()"
>
<gamepad-d-pad class="absolute bottom-5 left-8" />
<gamepad-btn
class="absolute bottom-10 right-20"
size="6rem"
icon="done"
/>
<q-dialog
v-model="isPortrait"
persistent
>
<q-card class="p-8">
<q-card-section class="flex flex-col items-center gap-6">
<q-spinner-box
color="primary"
size="10rem"
/>
<div class="text-4xl">
請將手機轉為橫向
</div>
<div class="text-base">
轉為橫向後,此視窗會自動關閉
</div>
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
...
import { getPlayerColor } from '../common/utils';
...
import { useClientPlayer } from '../composables/use-client-player';
...
const player = useClientPlayer();
// 玩家資訊
const codeName = computed(() => player.codeName.value);
const playerColorName = computed(() => getPlayerColor({
codeName: codeName.value
}));
const codeNameClass = computed(() => `bg-${playerColorName.value}`);
...
</script>
最後完成 template 內容。
<template>
<div .. >
...
<div
class="code-name"
:class="codeNameClass"
>
{{ codeName }}
</div>
<q-dialog ... >
...
</q-dialog>
</div>
</template>
<script setup lang="ts">
...
</script>
<style scoped lang="sass">
.code-name
position: absolute
top: 0
left: 50%
transform: translateX(-50%)
width: 20rem
height: 20rem
display: flex
justify-content: center
padding: 0.1rem
clip-path: circle(50% at 50% 0)
font-size: 4rem
text-shadow: 0px 0px 2px rgba(#000, 0.6)
</style>
完成大廳搖桿畫面!✧*。٩(ˊᗜˋ*)و✧*。
現在讓我們把搖桿資料給伺服器吧!( •̀ ω •́ )✧
老樣子需要先新增 socket 事件定義。
src\types\socket.type.ts
...
interface OnEvents {
...
'player:gamepad-data': (data: GamepadData) => void;
}
interface EmitEvents {
...
'player:gamepad-data': (data: GamepadData) => void;
...
}
...
現在讓我們設計一下 GamepadData 的資料定義,新增 player.type。
src\types\player.type.ts
/** 按鍵類型 */
export enum KeyName {
UP = 'up',
LEFT = 'left',
RIGHT = 'right',
DOWN = 'down',
CONFIRM = 'confirm',
}
/** 數位訊號
*
* 只有開和關兩種狀態
*/
export interface DigitalData {
name: `${KeyName}`;
value: boolean;
}
/** 類比訊號
*
* 連續數字組成的訊號,例如:類比搖桿、姿態感測器訊號等等
*/
export interface AnalogData {
name: `${KeyName}`;
value: number;
}
export type SingleData = DigitalData | AnalogData;
export interface GamepadData {
playerId: string;
keys: SingleData[];
}
回到 socket.type 引入型別定義。
src\types\socket.type.ts
import { GamepadData } from './player.type';
...
interface OnEvents {
...
'player:gamepad-data': (data: GamepadData) => void;
}
interface EmitEvents {
...
'player:gamepad-data': (data: GamepadData) => void;
...
}
...
最後分別是:
分別在 use-client-player 與 use-client-game-console 先增對應功能。
use-client-player 新增 emitGamepadData 發射控制訊號。
src\composables\use-client-player.ts
...
import { SingleData } from '../types/player.type';
export function useClientPlayer() {
...
async function emitGamepadData(data: SingleData[]) {
if (!client?.value?.connected) {
return Promise.reject('client 尚未連線');
}
client.value.emit('player:gamepad-data', {
playerId: mainStore.clientId,
keys: data,
})
}
return {
...
emitGamepadData,
}
}
use-client-game-console 新增 hook,監聽控制訊號。
src\composables\use-client-game-console.ts
...
import { GamepadData } from '../types/player.type';
...
export function useClientGameConsole() {
...
const gamepadDataHook = createEventHook<GamepadData>();
client?.value?.on('player:gamepad-data', gamepadDataHook.trigger);
onBeforeUnmount(() => {
client?.value?.removeListener('player:gamepad-data', gamepadDataHook.trigger);
});
return {
...
/** 搖桿控制訊號事件 */
onGamepadData: gamepadDataHook.on,
}
}
以上我們準備好大廳搖桿發送訊號與遊戲機接收控制訊號的功能了!✧*。٩(ˊᗜˋ*)و✧*。
以上程式碼已同步至 GitLab,大家可以前往下載: