本系列文已改編成書「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.vue
的 tick()
不斷檢查碰撞。
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();
}
},
},
};
小恐龍成功撞上仙人掌!
現在遊戲邏輯全部完成!只是出現一個神奇的問題。
小恐龍學會舞空術啦!╭(°A ,°`)╮
遊戲重啟後小恐龍沒有回歸原位,這是因為沒有重置 GSAP 動畫,導致動畫還停留在上次位置。
所以在 dino.vue
的 start()
中加入重置 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;
// ...
},
// ...
},
};
成功廢除舞空術 (ง •̀_•́)ง
成功修正小恐龍浮空問題。
我們成功重現恐龍遊戲了!那就下一個章節見囉!
對吼,差點忘了要用實體按鈕控制 (゚∀。)。
鱈魚:「讓我們回到 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
// ...
接著加入以下功能:
computed
與 watch
配合,偵測 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); // 蹲下
});
},
},
};
成功透過實體按鈕控制小恐龍了!來挑戰最高分數吧!
至此,我們成功重返侏儸紀並完成任務了,接下來往下一站邁進吧!
大家有興趣可以自行加上更多進階功能,例如:最高紀錄、加入翼龍、不同高度的仙人掌等等。
以上程式碼已同步至 GitLab,大家可以前往下載: