本系列文已改編成書「Arduino 自造趣:結合 JavaScript x Vue x Phaser 輕鬆打造個人遊戲機」,本書改用 Vue3 與 TypeScript 全面重構且加上更詳細的說明,
在此感謝 iT 邦幫忙、博碩文化與編輯小 p 的協助,歡迎大家前往購書,鱈魚在此感謝大家 (。・∀・)。
若想 DIY 卻不知道零件去哪裡買的讀者,可以參考此連結 。( •̀ ω •́ )✧
先建立遊戲組件,以便加入後續內容。
src\components\window-app-cat-vs-dog\game-scene.vue <template lang="pug">
.game-scene.w-600px.h-800px(:id='`game-scene-${id}`')
src\components\window-app-cat-vs-dog\game-scene.vue <script>
/**
* @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
*
* @typedef {import('@/types/type').PinInfo} PinInfo
* @typedef {import('@/types/type').PinCapability} PinCapability
*/
import { mapState } from 'vuex';
import Phaser from 'phaser';
export default {
name: 'GameScene',
data() {
return {
/** @type {Phaser.Game} */
game: null,
};
},
mounted() {
this.initGame();
},
methods: {
/** 初始化遊戲 */
initGame() {
/** @type {Phaser.Types.Core.GameConfig} */
const config = {
type: Phaser.WEBGL,
width: 600,
height: 800,
parent: `game-scene-${this.id}`,
scene: [],
backgroundColor: '#FFF',
disableContextMenu: true,
physics: {
default: 'arcade',
arcade: {
// debug: true,
},
},
};
this.game = new Phaser.Game(config);
},
},
};
建立視窗,打開 DevTools,應該會在 Console 中看到以下訊息。
這就表示我們成功載入 Phaser 了!
接下來讓我們加入搖桿並完成設定欄位吧。
先來認識新朋友,相信任何有過遊戲機的玩家應該都有用過。
沒用過搖桿,也有看過搖桿走路。ᕕ( ゚ ∀。)ᕗ
兩個可變電阻剛好相交 90 度角,如此便可以表示一個平面上的運動。
接下來讓我們組一個電路,實際看看搖桿模組的訊號。
組電路之前一樣先來檢查搖桿功能是否正常。
基本上概念等測試「可變電阻」與「按鈕」。
透過先前建立的「數位 I/O 視窗」與「類比輸入視窗」來看看訊號吧!
首先完成電路接線。
接著建立視窗實測看看。
鱈魚:「讓我們回到 D12 與 D15 一樣,開始解析數位訊號與類比訊號吧!」
電子助教:「同一個段子用兩次會沒人要看喔 ...('◉◞⊖◟◉` )」
鱈魚:「窩錯惹,請大家不要離開 ( ´•̥̥̥ ω •̥̥̥` )」
與 D25 時建立按鈕物件一樣,來做一個搖桿物件吧!
建立 joy-stick.js
用於搖桿訊號轉換、抽象化,方便應用。
button.js
,並轉發所有按鈕事件。src\script\electronic-components\joy-stick.js
/**
* @typedef {import('EventEmitter2').Listener} Listener
*
* @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
*
* @typedef {import('@/types/type').PinInfo} PinInfo
* @typedef {import('@/script/firmata/firmata').DigitalResponseMessage} DigitalResponseMessage
* @typedef {import('@/script/firmata/firmata').AnalogResponseMessage} AnalogResponseMessage
* @typedef {import('@/script/electronic-components/Button').ConstructorParams} ButtonParams
*
* @typedef {Object} ConstructorParams
* @property {PortTransceiver} transceiver Port 收發器
* @property {Object} analogPinMap 類比腳位映射表
* @property {AxisOptions} xAxis X 軸設定
* @property {AxisOptions} yAxis Y 軸設定
* @property {Object} btn 按鈕設定
* @property {PinInfo} btn.pin 指定腳位
* @property {number} btn.mode 按鈕腳位模式
*
* @typedef {Object} AxisOptions 軸設定
* @property {PinInfo} pin 指定腳位
* @property {number} [origin] 原點。搖桿無動作時,類比數值基準點。
* @property {number} [threshold] 閥值。origin 正負 threshold 之內的數值,視為沒有動作。
* @property {boolean} [isReverse] 軸反轉
*
*/
import EventEmitter2 from 'eventemitter2';
import { findLast, throttle } from 'lodash-es';
import { PinMode } from '@/script/utils/firmata.utils';
import Button from './button';
const axisOptionsDefault = {
origin: 510,
threshold: 20,
isReverse: false,
};
/**
* 基本搖桿
*/
export default class extends EventEmitter2.EventEmitter2 {
/** 目前軸類比數值 */
axesValue = {
x: 0,
y: 0,
};
/** @type {PortTransceiver} */
portTransceiver = null;
analogPinMap = {};
/** X 軸設定
* @type {AxisOptions}
*/
xAxis = null;
/** Y 軸設定
* @type {AxisOptions}
*/
yAxis = null;
/** 按鈕物件
* @type {Button}
*/
btn = null;
/** 數值回報監聽器
* @type {Listener}
*/
listener = null;
throttle = {
setAxesValue: null,
};
/**
* @param {ConstructorParams} params
*/
constructor(params) {
super();
const {
transceiver = null,
analogPinMap = null,
xAxis, yAxis, btn
} = params;
if (!transceiver) throw new Error(`transceiver 必填`);
if (!analogPinMap) throw new Error(`analogPinMap 必填`);
if (!xAxis?.pin) throw new Error(`xAxis.pin 必填`);
if (!yAxis?.pin) throw new Error(`yAxis.pin 必填`);
if (!btn?.pin) throw new Error(`btn.pin 必填`);
// 儲存變數
this.portTransceiver = transceiver;
this.analogPinMap = analogPinMap;
this.xAxis = {
...axisOptionsDefault, ...xAxis
}
this.yAxis = {
...axisOptionsDefault, ...yAxis
}
/** 初始化按鈕物件 */
this.btn = new Button({
...btn,
transceiver,
});
/** 將所有 btn 事件轉送出去 */
this.btn.onAny((event, value) => this.emit(event, value));
/** 建立 throttle 功能 */
this.throttle = {
setAxesValue: throttle((...params) => {
this.setAxesValue(...params);
}, 100),
}
this.init();
}
/** 初始化
* 進行腳位設定、啟動監聽器
*/
async init() {
const xPinNum = this.xAxis.pin.number;
const yPinNum = this.yAxis.pin.number;
this.portTransceiver.addCmd('setMode', {
pin: xPinNum,
mode: PinMode.ANALOG_INPUT,
});
this.portTransceiver.addCmd('setMode', {
pin: yPinNum,
mode: PinMode.ANALOG_INPUT,
});
this.listener = this.portTransceiver.on('data:analogMessage', (data) => {
this.handleData(data);
}, { objectify: true });
}
/** 銷毀所有物件、監聽器 */
destroy() {
this.btn.destroy();
this.listener.off();
this.removeAllListeners();
}
/** 處理類比訊號數值
* @param {AnalogResponseMessage[]} data
*/
handleData(data) {
const { xAxis, yAxis, analogPinMap } = this;
let x = 0;
let y = 0;
/** 取得 X 軸類比資料 */
const xVal = findLast(data, ({ analogPin }) => {
const mapNum = analogPinMap[xAxis.pin.number];
return mapNum === analogPin
});
if (xVal) {
x = this.calcAxisValue(xVal.value, xAxis)
}
/** 取得 Y 軸類比資料 */
const yVal = findLast(data, ({ analogPin }) => {
const mapNum = analogPinMap[yAxis.pin.number];
return mapNum === analogPin
});
if (yVal) {
y = this.calcAxisValue(yVal.value, yAxis)
}
this.throttle.setAxesValue({
x, y,
});
}
/** 儲存軸向類比數值 */
setAxesValue({ x, y }) {
this.axesValue.x = x;
this.axesValue.y = y;
this.emit('data', this.axesValue);
}
/** 取得軸向類比數值 */
getAxesValue() {
return this.axesValue;
}
/** 取得按鈕數值 */
getBtnValue() {
return this.btn.getValue();
}
/** 將類比數值轉換為搖桿軸資料
* @param {number} value
* @param {AxisOptions} options
*/
calcAxisValue(value, options) {
const { origin, threshold, isReverse } = options;
/**
* 需要設定 threshold,因為實際上搖桿回到中點時,
* 類比數值並非完全靜止,可能會在正負 1 或 2 些微浮動,
* 如果判斷搖桿靜止是用單一數值判斷,容易誤判,
* 所以透過 threshold,以範圍來判斷,就可以解決誤判問題。
*/
const delta = origin - value;
if (Math.abs(delta) < threshold) {
return 0;
}
return isReverse ? delta * -1 : delta;
}
}
了解搖桿後,現在我們知道總共需要設定 3 個腳位,讓我們回到 window-app-cat-vs-dog.vue
完成設定欄位選項。
data()
xPin
、yPin
、btnPin
computed
supportPullupPins()
列舉支援上拉輸入腳位清單supportAnalogInputPins()
列舉支援類比輸入腳位清單isSettingOk()
程式。watch
xPin
、yPin
、btnPin
變化,並呼叫 handlePinSelect()
src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <script>
// ...
import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP, ANALOG_INPUT } = PinMode;
export default {
name: 'WindowAppCatVsDog',
// ...
data() {
return {
xPin: null,
yPin: null,
btnPin: null,
};
},
computed: {
...mapState({
boardPins: (state) => state.board.info.pins,
}),
/** 支援上拉輸入功能腳位 */
supportPullupPins() {
/** @type {PinInfo[]} */
const boardPins = this.boardPins;
return boardPins.filter((pin) =>
pin.capabilities.some((capability) => INPUT_PULLUP === capability.mode)
);
},
/** 支援類比輸入功能腳位 */
supportAnalogInputPins() {
/** @type {PinInfo[]} */
const boardPins = this.boardPins;
return boardPins.filter((pin) =>
pin.capabilities.some((capability) => ANALOG_INPUT === capability.mode)
);
},
/** 設定欄位是否完成 */
isSettingOk() {
return this.xPin && this.yPin && this.btnPin;
},
},
watch: {
xPin(newVal, oldVal) {
this.handlePinSelect(newVal, oldVal);
},
yPin(newVal, oldVal) {
this.handlePinSelect(newVal, oldVal);
},
btnPin(newVal, oldVal) {
this.handlePinSelect(newVal, oldVal);
},
},
// ...
};
src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <template lang="pug">
base-window.window-app-cat-vs-dog(
:pos='pos',
header-icon='r_videogame_asset',
body-class='c-col',
title='貓狗大戰'
)
.h-full.overflow-hidden
game-scene
transition(name='fade-up')
.setting-form(v-if='!isSettingOk')
.form-section
.form-item.mb-20px
q-icon.mr-10px(name='r_gamepad', size='20px')
.text-18px.font-700
| 設定控制器
.form-item
.text-16px.w-200px
| X 軸訊號
base-select-pin.w-full(
v-model='xPin',
:pins='supportAnalogInputPins',
color='light-green-4',
placeholder='點擊選擇',
@err='handleErr'
)
.form-item
.text-16px.w-200px
| Y 軸訊號
base-select-pin.w-full(
v-model='yPin',
:pins='supportAnalogInputPins',
color='light-green-4',
placeholder='點擊選擇',
@err='handleErr'
)
.form-item
.text-16px.w-200px
| 按鈕訊號
base-select-pin.w-full(
v-model='btnPin',
:pins='supportPullupPins',
color='light-green-4',
placeholder='點擊選擇',
@err='handleErr'
)
完成後應該會長這樣。
將著將腳位透過 props
傳入 game-scene.vue
組件吧。
src\components\window-app-cat-vs-dog\game-scene.vue <script>
// ...
export default {
name: 'GameScene',
props: {
xPin: {
type: Object,
default() {
return null;
},
},
yPin: {
type: Object,
default() {
return null;
},
},
btnPin: {
type: Object,
default() {
return null;
},
},
},
// ...
};
window-app-cat-vs-dog.vue
模板中的 game-scene.vue
也要記得加入參數。
src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <template lang="pug">
base-window.window-app-cat-vs-dog(
:pos='pos',
header-icon='r_videogame_asset',
body-class='c-col',
title='貓狗大戰'
)
.h-full.overflow-hidden
game-scene(:x-pin='xPin', :y-pin='yPin', :btn-pin='btnPin')
// ...
接著偵測是否所有的腳位都設定完成,完成後進行搖桿初始化。
data()
新增 joyStick
,儲存搖桿物件。portTransceiver
與 analogPinMap
。mounted()
內的 this.initGame()
,改由其他程式負責呼叫初始化。methods()
新增 initController()
,用於初始化搖桿。created()
使用 $watch
進行所有腳位偵測與初始化。beforeDestroy()
銷毀所有物件。src\components\window-app-cat-vs-dog\game-scene.vue <script>
// ...
import JoyStick from '@/script/electronic-components/joy-stick';
import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP } = PinMode;
export default {
name: 'GameScene',
// ...
data() {
return {
/** @type {Phaser.Game} */
game: null,
/** @type {JoyStick} */
joyStick: null,
};
},
computed: {
...mapState({
/** @type {PortTransceiver} */
portTransceiver: (state) => state.core.transceiver,
analogPinMap: (state) => state.board.info.analogPinMap,
}),
},
// ...
created() {
// 同時偵測所有 Pin
this.$watch(
() => {
return [this.xPin, this.yPin, this.btnPin];
},
([xPin, yPin, btnPin]) => {
// 所有腳位都設定完成才初始化
if (!xPin || !yPin || !btnPin) return;
this.initController();
this.initGame();
}
);
},
mounted() {
// 清空
},
beforeDestroy() {
this.game?.destroy?.();
this.joyStick?.destroy?.();
},
methods: {
// ...
/** 初始化搖桿 */
initController() {
this.joyStick = new JoyStick({
transceiver: this.portTransceiver,
analogPinMap: this.analogPinMap,
xAxis: {
pin: this.xPin,
},
yAxis: {
pin: this.yPin,
},
btn: {
pin: this.btnPin,
mode: INPUT_PULLUP,
},
});
},
},
};
忽然發現建立視窗時有個錯誤。
這是因為忘記使用 inject
將 provide
進來的 id
取出來,就直接使用 id
造成的錯誤。
修正一下這個問題。
src\components\window-app-cat-vs-dog\game-scene.vue <script>
// ...
export default {
name: 'GameScene',
// ...
inject: ['id'],
// ...
};
完成控制器設定!
加入監聽事件看看搖桿物件有沒有正常作用吧。
在 initController()
中加入 onAny()
。
src\components\window-app-cat-vs-dog\game-scene.vue <script>
// ...
import JoyStick from '@/script/electronic-components/joy-stick';
import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP } = PinMode;
export default {
name: 'GameScene',
// ...
methods: {
// ...
/** 初始化搖桿 */
initController() {
this.joyStick = new JoyStick({
// ...
});
this.joyStick.onAny((event, value) => {
if (event === 'data') {
const { x, y } = value;
console.log(`[ joyStick on ${event} ] value : `, x, y);
return;
}
console.log(`[ joyStick on ${event} ] value : `, value);
});
},
},
};
搖桿功能正常!一起玩搖桿吧!✧*。٩(ˊᗜˋ*)و✧*。
以上程式碼已同步至 GitLab,大家可以前往下載: