iT邦幫忙

2021 iThome 鐵人賽

DAY 26
0
Modern Web

你渴望連結嗎?將 Web 與硬體連上線吧!系列 第 26

D25 - 「不斷線的侏儸紀」:然後他就死掉了

本系列文已改編成書「Arduino 自造趣:結合 JavaScript x Vue x Phaser 輕鬆打造個人遊戲機」,本書改用 Vue3 與 TypeScript 全面重構且加上更詳細的說明,

在此感謝 iT 邦幫忙、博碩文化與編輯小 p 的協助,歡迎大家前往購書,鱈魚在此感謝大家 (。・∀・)。

若想 DIY 卻不知道零件去哪裡買的讀者,可以參考此連結 。( •̀ ω •́ )✧


小恐龍現在就像吃了無敵星星一樣,完全無視仙人掌,所以我們來讓小恐龍死翹翹吧!

撞上仙人掌

我們來加上「小恐龍」與「仙人掌」的碰撞偵測。

這裡我們使用最簡單「矩形碰撞」,簡單來說就是將所有物體視為一個矩形,判斷兩個矩形是否有重疊。

對碰撞偵測細節有興趣的朋友,可以參考以下連結:
矩形的碰撞偵測
“等一下,我碰!”——常见的2D碰撞检测

game-scene.vue 新增碰撞偵測用的 method

src\components\window-app-google-dino\game-scene.vue <script>

// ...

export default {
  name: 'GameScene',
  // ...
  methods: {
    // ...

    /** 檢查兩個 DOM 是否重疊
     * @param {HTMLElement} dom01
     * @param {HTMLElement} dom02
     * @param {number} tolerance 容差值
     */
    isOverlap(dom01, dom02, tolerance = 20) {
      const dom01Rect = dom01.getBoundingClientRect();
      const dom02Rect = dom02.getBoundingClientRect();

      const dom01Left = dom01Rect.left + tolerance;
      const dom01Top = dom01Rect.top + tolerance;
      const dom01Right = dom01Left + dom01Rect.width - tolerance;
      const dom01Bottom = dom01Top + dom01Rect.height - tolerance;

      const dom02Left = dom02Rect.left + tolerance;
      const dom02Top = dom02Rect.top + tolerance;
      const dom02Right = dom02Left + dom02Rect.width - tolerance;
      const dom02Bottom = dom02Top + dom02Rect.height - tolerance;

      // 檢查重疊
      if (
        dom02Bottom > dom01Top &&
        dom02Right > dom01Left &&
        dom02Left < dom01Right &&
        dom02Top < dom01Bottom
      ) {
        return true;
      }

      return false;
    },
  },
};

接著就是將小恐龍和仙人掌的 DOM 取出來用了。

仙人掌 methods 新增 getDoms()

src\components\window-app-google-dino\cactuses.vue <script>

// ...

export default {
  name: 'Cactuses',
  // ...
  methods: {
    // ...

    /** 取出所有仙人掌 DOM */
    getDoms() {
      return this.$refs?.cactus ?? [];
    },
  },
};

模板中記得加入 ref

src\components\window-app-google-dino\cactuses.vue <template lang="pug">

.cactuses
  img.cactus(
    ref='cactus',
    // ...
  )

接著是小恐龍的部分。

src\components\window-app-google-dino\dino.vue <script>

// ...

export default {
  name: 'Dino',
  // ...
  methods: {
    // ...

    /** 取出 DOM */
    getDom() {
      return this.$refs?.dino;
    },
  },
};

src\components\window-app-google-dino\dino.vue <template lang="pug">

.dino(
  // ...
)
  img(ref='dino', :src='imgSrc')

最後在 game-scene.vuetick() 不斷檢查碰撞。

src\components\window-app-google-dino\game-scene.vue <script>

// ...

export default {
  name: 'GameScene',
  // ...
  methods: {
    // ...

    tick() {
      this.timeCounter++;

      // score 每 0.1 秒增加一次
      if (this.timeCounter % 10 === 0) {
        this.score++;
      }

      // 碰撞偵測
      /** @type {HTMLElement[]} */
      const cactuses = this.$refs.cactuses.getDoms();
      /** @type {HTMLElement} */
      const dino = this.$refs.dino.getDom();

      // 檢查是否有任一個仙人掌與小恐龍重疊
      const isBump = cactuses.some((cactus) => this.isOverlap(dino, cactus));
      if (isBump) {
        this.over();
      }
    },
  },
};

D25 - 小恐龍撞上仙人掌.gif
小恐龍成功撞上仙人掌!

現在遊戲邏輯全部完成!只是出現一個神奇的問題。
D25 - 小恐龍浮空.gif

小恐龍學會舞空術啦!╭(°A ,°`)╮

遊戲重啟後小恐龍沒有回歸原位,這是因為沒有重置 GSAP 動畫,導致動畫還停留在上次位置。

所以在 dino.vuestart() 中加入重置 GSAP 的部分。

src\components\window-app-google-dino\dino.vue <script>

// ...

export default {
  name: 'Dino',
  // ...
  methods: {
    /** 開始 */
    start() {
      // 將動畫進度設在起點
      this.gsapAni.jump?.progress(0);

      this.status.jumping = false;
      // ...
    },
    // ...
  },
};

D25 - 修正浮空問題.gif

成功廢除舞空術 (ง •̀_•́)ง

成功修正小恐龍浮空問題。

我們成功重現恐龍遊戲了!那就下一個章節見囉!

所以我說那個按鈕呢?

對吼,差點忘了要用實體按鈕控制 (゚∀。)。

鱈魚:「讓我們回到 game-scene.vue 準備像 D12 一樣,開始解析數位訊號吧!」

電子助教:「蛤,一樣的事情要這樣一直重複做喔 ...('◉◞⊖◟◉` )」

鱈魚:「也是捏,這樣違反 DRY 原則」

電子助教:「乾燥原則?」

鱈魚:「是 Don't repeat yourself 原則」

詳細說明可以參考連結:DRY 原則

所以我們將數位訊號處理的過程封裝一下吧!

建立按鈕物件

建立 button.js 用於將數位輸入訊號轉換成按鈕行為,方便應用。

  • 給定腳位、模式、收發器,自動處理 Port 轉換、數位訊號監聽等等邏輯。

  • 主動通知按鈕「按下」、「放開」等等事件。

  • 將上拉輸入反轉,變為較為直覺的訊號呈現。

    上拉輸入按下按鈕為 0,放開為 1。

    一般符合直覺的訊號應該是按下為 1,放開為 0。

程式內容基本上同 D12 數位控制組件過程相同,差在多了 prcoessEvent() 處理事件。

src\script\electronic-components\button.js

/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 * @typedef {import('eventemitter2').Listener} Listener
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/script/firmata/firmata').DigitalResponseMessage} DigitalResponseMessage
 * 
 * @typedef {Object} ConstructorParams
 * @property {PinInfo} pin 
 * @property {number} mode 腳位模式
 * @property {PortTransceiver} transceiver
 * @property {Number} [debounceMillisecond] 去彈跳時長
 */

import EventEmitter2 from 'eventemitter2';
import { findLast, debounce } from 'lodash-es';
import { delay, getBitWithNumber } from '@/script/utils/utils';

import { PinMode } from '@/script/utils/firmata.utils';
const { DIGITAL_INPUT, INPUT_PULLUP } = PinMode;

/** 
 * 基本按鈕
 * 支援數位輸入、上拉輸入
 */
export default class extends EventEmitter2.EventEmitter2 {
  /** 指定腳位 
   * @type {PinInfo}
   */
  pin = null;

  /** 腳位 Port 號 */
  portNum = 0;

  /** 腳位模式 */
  mode = 0;

  /** COM Port 收發器 
   * @type {PortTransceiver}
   */
  portTransceiver = null;

  /** 訊號回報監聽器 
   * @type {Listener}
   */
  listener = null;

  /** 目前數位訊號 */
  value = false;

  /**
   * @param {ConstructorParams} params
   */
  constructor(params) {
    super();

    const {
      pin = null,
      transceiver = null,
      mode = DIGITAL_INPUT,
      debounceMillisecond = 10,
    } = params;

    if (!pin) throw new Error(`pin 必填`);
    if (!transceiver) throw new Error(`transceiver 必填`);
    if (![DIGITAL_INPUT, INPUT_PULLUP].includes(mode)) {
      throw new Error(`不支援指定的腳位模式 : ${mode}`);
    }

    this.pin = pin;
    this.portNum = (pin.number / 8) | 0;
    this.mode = mode;
    this.portTransceiver = transceiver;

    this.options = {
      debounceMillisecond
    }
    this.debounce = {
      prcoessEvent: debounce((...params) => {
        this.prcoessEvent(...params)
      }, debounceMillisecond),
    }

    this.init();
  }

  async init() {
    this.portTransceiver.addCmd('setMode', {
      pin: this.pin.number,
      mode: this.mode,
    });

    // 延遲一下再監聽數值,忽略 setMode 初始化的數值變化
    await delay(500);

    this.listener = this.portTransceiver.on(
      'data:digitalMessage',
      (data) => {
        this.handleData(data);
      },
      { objectify: true }
    );
  }
  destroy() {
    // 銷毀所有監聽器,以免 Memory Leak
    this.listener?.off?.();
    this.removeAllListeners();
  }

  /** 處理數值
   * @param {DigitalResponseMessage[]} data
   */
  handleData(data) {
    const target = findLast(data, ({ port }) => this.portNum === port);
    if (!target) return;

    const { value } = target;

    /** @type {PinInfo} */
    const pin = this.pin;
    const bitIndex = pin.number % 8;

    const pinValue = getBitWithNumber(value, bitIndex);
    this.debounce.prcoessEvent(pinValue);
  }

  /** 依照數位訊號判斷按鈕事件
   * - rising:上緣,表示放開按鈕
   * - falling:下緣,表示按下按鈕
   * - toggle:訊號切換,放開、按下都觸發
   * 
   * 參考資料:
   * [訊號邊緣](https://zh.wikipedia.org/wiki/%E4%BF%A1%E5%8F%B7%E8%BE%B9%E7%BC%98)
   * 
   * @param {boolean} value 
   */
  prcoessEvent(value) {
    let correctionValue = value;

    // 若為上拉輸入,則自動反向
    if (this.mode === INPUT_PULLUP) {
      correctionValue = !correctionValue;
    }

    if (this.value === correctionValue) return;

    if (correctionValue) {
      this.emit('rising');
    }
    if (!correctionValue) {
      this.emit('falling');
    }

    this.emit('toggle', correctionValue);
    this.value = correctionValue;
  }

  getValue() {
    if (this.mode === INPUT_PULLUP) {
      return !this.value;
    }

    return this.value;
  }
}

大家還可以思考看看怎麼加入按住指定時間、雙擊等等事件喔!

引入按鈕

接著把測試用的 click() 事件都刪除。

不想刪除,想留著玩也是可以 ヾ(◍'౪`◍)ノ゙

src\components\window-app-google-dino\dino.vue <template lang="pug">

.dino(:style='style')
  // ...

src\components\window-app-google-dino\game-scene.vue <template lang="pug">

.game-scene
  // ...

接著加入以下功能:

  • 透過 computedwatch 配合,偵測 props 兩個輸入腳位是否都選擇完成。
  • 根據 props 選擇腳位初始化 button 物件
  • 透過 button 物件控制角色。
  • beforeDestroy() 中銷毀所有物件與監聽器。
// ...

import Button from '@/script/electronic-components/button';
// ...

export default {
  name: 'GameScene',
  // ...
  data() {
    return {
      gameId: '',

      gameStatus: GameStatus.STANDBY,
      timer: null,
      timeCounter: 0,

      score: 0,

      /** @type {Button} */
      jumpButton: null,
      /** @type {Button} */
      squatButton: null,
    };
  },
  computed: {
    // ...

    // 回傳兩個腳位
    pins() {
      return [this.jumpPin, this.squatPin];
    },
  },
  watch: {
    // 偵測腳位設定
    pins([jumpPin, squatPin]) {
      // 所有腳位都設定完成才初始化
      if (!jumpPin || !squatPin) return;

      this.initBtns();
    },
  },
  created() {},
  mounted() {},
  beforeDestroy() {
    // 清空事件
    this.over();

    this.jumpButton?.destroy?.();
    this.squatButton?.destroy?.();
  },
  methods: {
    // ...

    /** 初始化按鈕物件 */
    initBtns() {
      const transceiver = this.portTransceiver;
      const mode = INPUT_PULLUP;

      this.jumpButton = new Button({
        pin: this.jumpPin,
        mode,
        transceiver,
      }).on('rising', () => {
        this.start();
        this.$refs.dino.jump(); // 跳躍
      });

      this.squatButton = new Button({
        pin: this.squatPin,
        mode,
        transceiver,
      }).on('toggle', (value) => {
        this.start();
        this.$refs.dino.setSquat(value); // 蹲下
      });
    },
  },
};

D25 - 成功加入按鈕控制.gif

成功透過實體按鈕控制小恐龍了!來挑戰最高分數吧!

至此,我們成功重返侏儸紀並完成任務了,接下來往下一站邁進吧!

大家有興趣可以自行加上更多進階功能,例如:最高紀錄、加入翼龍、不同高度的仙人掌等等。

總結

  • 成功加入碰撞偵測
  • 成功使用實體按鈕控制
  • 完成小恐龍遊戲

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

GitLab - D25


上一篇
D24 - 「不斷線的侏儸紀」:天上好多雲、地上一堆仙人掌
下一篇
D26 - 「來互相傷害啊!」:站在 Phaser 的肩膀上
系列文
你渴望連結嗎?將 Web 與硬體連上線吧!33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言