iT邦幫忙

2022 iThome 鐵人賽

DAY 17
0
Modern Web

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

D17 - 所以我說那個搖桿呢?

  • 分享至 

  • xImage
  •  

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

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

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

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

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

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

Yes


現在終於跳轉至玩家大廳搖桿畫面了,現在終於可以讓我們開始完成搖桿的內容了。

預期樣式如下圖。

D17 - 大廳搖桿草稿 (1).png

基本組成為:

  • 左邊為方向鍵
  • 右邊為確認按鈕
  • 上方是玩家代號與顏色

可以發現方向鍵實際上也由四個按鈕組成,所以第一步讓我們新增按鈕組件吧。

定義參數與事件。

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>

按鈕出現了。(´,,•ω•,,)

Untitled

讓我們稱勝追擊,繼續完成方向鍵吧。

新增組件、預期參數與事件。

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>

現在只要直握手機,都會出現以下畫面 ◝( •ω• )◟

Untitled

最後讓我們加上顯示玩家代號的部分吧!

玩家使用到的功能基本上都集中在 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>

完成大廳搖桿畫面!✧*。٩(ˊᗜˋ*)و✧*。

Untitled

傳輸搖桿資料

現在讓我們把搖桿資料給伺服器吧!( •̀ ω •́ )✧

老樣子需要先新增 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,大家可以前往下載:

GitLab - D17


上一篇
D16 - 手機變搖桿!
下一篇
D18 - 玩家一起粗乃玩!
系列文
派對動物嗨起來!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言