本系列文已改編成書「Arduino 自造趣:結合 JavaScript x Vue x Phaser 輕鬆打造個人遊戲機」,本書改用 Vue3 與 TypeScript 全面重構且加上更詳細的說明,
在此感謝 iT 邦幫忙、博碩文化與編輯小 p 的協助,歡迎大家前往購書,鱈魚在此感謝大家 (。・∀・)。
若想 DIY 卻不知道零件去哪裡買的讀者,可以參考此連結 。( •̀ ω •́ )✧
小恐龍跑啊跑,悠閒地看著天上的雲朵飄過,但是眼前忽然出現了一株仙人掌。
建立 clouds.vue
組件,負責生成雲朵。
src\components\window-app-google-dino\clouds.vue
<template lang="pug">
.clouds
img.cloud(src='@/assets/google-dino/cloud.png')
</template>
<style scoped lang="sass">
@import '@/styles/quasar.variables.sass'
.clouds
position: absolute
top: 0px
left: 0px
width: 100%
height: 100%
.cloud
position: absolute
width: 100px
</style>
<script>
export default {
name: 'Clouds',
components: {},
props: {},
data() {
return {};
},
computed: {},
watch: {},
created() {},
mounted() {},
beforeDestroy() {},
methods: {},
};
</script>
在 game-scene.vue
引入 clouds.vue
。
src\components\window-app-google-dino\game-scene.vue <script>
// ...
import { mapState } from 'vuex';
import Dino from './dino.vue';
import Clouds from './clouds.vue';
export default {
name: 'GameScene',
components: {
dino: Dino,
clouds: Clouds,
},
// ...
};
src\components\window-app-google-dino\game-scene.vue <template lang="pug">
.game-scene(@click='start')
.ground
clouds(ref='clouds')
dino(ref='dino', :game-status='gameStatus')
// ...
現在應該會看到有一朵雲在左上角。
接著我們希望持續生成雲朵,建立相關程式。
props
gameStatus
:目前遊戲狀態data()
clouds
:儲存已建立雲朵timer
:計時器src\components\window-app-google-dino\clouds.vue <script>
/**
* @typedef {Object} Cloud 雲朵
* @property {string} key
* @property {Object} style
*/
import { getRandomString } from '@/script/utils/utils';
import { random } from 'lodash-es';
export default {
name: 'Clouds',
components: {},
props: {
gameStatus: {
type: String,
default: '',
},},
data() {
return {
/** 已建立雲朵
* @type {Cloud[]}
*/
clouds: [],
/** 計時器 */
timer: null,
};
},
// ...
beforeDestroy() {
this.over();
},
methods: {
/** 開始 */
start() {
// 每一秒鐘產生一個雲朵
this.timer = setInterval(() => {
this.addCloud();
}, 1000);
},
/** 結束 */
over() {
clearInterval(this.timer);
},
/** 建立雲朵 */
addCloud() {
/** @type {Cloud} */
const cloud = {
key: getRandomString(),
style: {
top: `${random(10, 50)}%`, // 讓每一朵雲高度都不同
},
};
this.clouds.push(cloud);
},
},
};
.cloud
的部份加入 v-for
。
src\components\window-app-google-dino\clouds.vue <template lang="pug">
.clouds
img.cloud(
src='@/assets/google-dino/cloud.png',
v-for='(cloud, i) in clouds',
:style='cloud.style',
:key='cloud.key'
)
最後則是和小恐龍的部分一樣,使用 watch
監測 gameStatus
,執行對應動作。
src\components\window-app-google-dino\clouds.vue <script>
/**
* @typedef {Object} Cloud 雲朵
* @property {string} key
* @property {Object} style
*/
import utils from '@/script/utils/utils';
import { random } from 'lodash-es';
import { GameStatus } from './game-scene.vue';
export default {
name: 'Clouds',
// ...
watch: {
gameStatus(status) {
if (status === GameStatus.START) {
this.start();
return;
}
if (status === GameStatus.GAME_OVER) {
this.over();
return;
}
},
},
};
game-scene.vue
之 clouds.vue
模板的部份記得加入參數。
src\components\window-app-google-dino\game-scene.vue <template lang="pug">
.game-scene(@click='start')
.ground
clouds(ref='clouds', :game-status='gameStatus')
dino(ref='dino', :game-status='gameStatus')
// ...
嘗試看看遊戲開始後會不會一直出現雲朵。
成功產生雲朵,最後就是雲朵動畫與刪除的部分!
雲朵移動動畫使用 CSS animation 達成
src\components\window-app-google-dino\game-scene.vue <style scoped lang="sass">
@import '@/styles/quasar.variables.sass'
.clouds
position: absolute
top: 0px
left: 0px
width: 100%
height: 100%
.cloud
position: absolute
width: 100px
animation: move 3.2s forwards linear
@keyframes move
0%
right: 0%
transform: translateX(100%)
100%
right: 100%
transform: translateX(0%)
嘗試看看效果。
不過目前雲朵移出畫面之後沒有刪除,我們加入刪除功能。
src\components\window-app-google-dino\clouds.vue <script>
/**
* @typedef {Object} Cloud 雲朵
* @property {string} key
* @property {Object} style
*/
import utils from '@/script/utils/utils';
import { random } from 'lodash-es';
import { GameStatus } from './game-scene.vue';
export default {
name: 'Clouds',
// ...
methods: {
// ...
/** 刪除雲朵
* @param {number} index
*/
deleteCloud(index) {
this.clouds.splice(index, 1);
},
},
};
將 deleteCloud()
綁定 animationend
事件,就可以在移動動畫完成後刪除雲朵。
src\components\window-app-google-dino\clouds.vue <template lang="pug">
.clouds
img.cloud(
src='@/assets/google-dino/cloud.png',
v-for='(cloud, i) in clouds',
:style='cloud.style',
:key='cloud.key',
@animationend='deleteCloud(i)'
)
用 Vue DevTools 檢查看看有沒有真的刪除。
成功刪除動畫結束的雲朵!(´,,•ω•,,)
D21 的遊戲藍圖中有一項遊戲邏輯:
一旦「恐龍」發生碰撞,遊戲狀態變為結束,畫面凍結,結束遊戲。
也就是當遊戲結束時,雲朵應該要停止動作,這點要怎麼實現呢?JS 要怎麼停止 CSS 的 animation
?
CSS 問題用 CSS 解決,這裡使用 animation-play-state
達成效果。
此屬性可以用來控制
animation
播放狀態詳細說明請見連結:MDN:animation-play-state
新增包含暫停屬性的 Class
src\components\window-app-google-dino\clouds.vue <style scoped lang="sass">
// ...
.clouds
// ...
.cloud
// ...
&.pulse
animation-play-state: paused
// ...
接著透過 computed
綁定 Class。
src\components\window-app-google-dino\clouds.vue <script>
// ...
export default {
name: 'Clouds',
components: {},
// ...
computed: {
classes() {
return {
pulse: this.gameStatus === GameStatus.GAME_OVER,
};
},
},
// ...
};
src\components\window-app-google-dino\clouds.vue <template lang="pug">
.clouds
img.cloud(
// ...
:class='classes',
@animationend='deleteCloud(i)'
)
來驗證看看遊戲結束時,雲朵會不會停止動作,回到 game-scene.vue
,讓遊戲狀態能夠變為「遊戲結束」。
將 over()
綁定至滑鼠右鍵 click 事件。
src\components\window-app-google-dino\game-scene.vue <template lang="pug">
.game-scene(@click='start', @click.right='over')
// ...
如此一來,現在是:
來實測看看。
成功了!可以看到不只雲停止,小恐龍也變成癡呆臉、提示文字也顯示遊戲結束。
最後就是:
「雲朵」與「仙人掌」會隨著分數的提高而增加速度,但不會超過最大速度。
這裡需要遊戲分數,所以我們透過 props
傳入。
src\components\window-app-google-dino\game-scene.vue <template lang="pug">
.game-scene(@click='start', @click.right='over')
.ground
clouds(ref='clouds', :game-status='gameStatus', :score='score')
dino(ref='dino', :game-status='gameStatus')
// ...
src\components\window-app-google-dino\clouds.vue <script>
// ...
export default {
name: 'Clouds',
components: {},
props: {
gameStatus: {
type: String,
default: '',
},
score: {
type: Number,
default: 0,
},
},
// ...
};
有分數了,所以具體到底要怎麼讓雲朵加速呢?
老話一句,CSS 問題用 CSS 解決,這裡使用 animation-duration
達成效果。
此屬性可以用來控制
animation
播放長度,時間越短,雲朵移動就越快,詳細說明請見連結:MDN:animation-duration
實作方式為:「生成雲朵的時候依照目前分數,產生動畫時長,並綁定至 style 中」
這裡我們在 utils.js
新增一個用來映射數值用的功能:
src\script\utils\utils.js
/** 根據區間映射數值
* @param {Number} numberIn 待計算數值
* @param {Number} inMin 輸入最小值
* @param {Number} inMax 輸入最大值
* @param {Number} outMin 輸出最小值
* @param {Number} outMax 輸出最大值
*/
export function mapNumber(numberIn, inMin, inMax, outMin, outMax) {
let number = numberIn;
if (numberIn < inMin) {
number = inMin;
}
if (numberIn > inMax) {
number = inMax;
}
const result = (number - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
return result;
}
調整 clouds.vue
中的 addCloud()
內容。
src\components\window-app-google-dino\clouds.vue <script>
// ...
export default {
name: 'Clouds',
// ...
methods: {
// ..
/** 建立雲朵 */
addCloud() {
// 將分數從 0~50 映射至 3.2~0.5 之間的數值
const animationDuration = mapNumber(this.score, 0, 50, 3.2, 0.5);
/** @type {Cloud} */
const cloud = {
key: getRandomString(),
style: {
top: `${random(10, 50)}%`,
animationDuration: `${animationDuration}s`,
},
};
this.clouds.push(cloud);
},
// ...
},
};
可以看到今天雲朵沒有極限!─=≡Σ((( つ•̀ω•́)つ
為了讓效果明顯一點所以調了一個誇張一點的數值,我們改為合適一點的數值。
src\components\window-app-google-dino\clouds.vue <script>
// ...
export default {
name: 'Clouds',
// ...
methods: {
// ..
/** 建立雲朵 */
addCloud() {
const animationDuration = mapNumber(this.score, 0, 1000, 3.2, 2.5);
// ...
},
// ...
},
};
建立 cactuses.vue
組件,負責生成仙人掌,基本概念與雲朵相同,差在微調部分數值。
src\components\window-app-google-dino\cactuses.vue <template lang="pug">
.cactuses
img.cactus(
ref='cactus',
v-for='(cactus, i) in cactuses',
:style='cactus.style',
:key='cactus.key',
:class='classes',
src='@/assets/google-dino/cactus-1.png',
@animationend='deleteCactus(i)'
)
src\components\window-app-google-dino\cactuses.vue <style scoped lang="sass">
@import '@/styles/quasar.variables.sass'
.cactuses
position: absolute
top: 0px
left: 0px
width: 100%
height: 100%
.cactus
position: absolute
height: 70px
bottom: 30px
animation: move 2.5s forwards linear
&.pulse
animation-play-state: paused
@keyframes move
0%
right: 0%
transform: translateX(100%)
100%
right: 100%
transform: translateX(0%)
src\components\window-app-google-dino\cactuses.vue <script>
/**
* @typedef {Object} Cactus
* @property {string} key
* @property {Object} style
*/
import { getRandomString, mapNumber } from '@/script/utils/utils';
import { GameStatus } from './game-scene.vue';
export default {
name: 'Cactuses',
components: {},
props: {
gameStatus: {
type: String,
default: '',
},
score: {
type: Number,
default: 0,
},
},
data() {
return {
/** @type {Cactus[]} */
cactuses: [],
timer: null,
};
},
computed: {
classes() {
return {
pulse: this.gameStatus === GameStatus.GAME_OVER,
};
},
},
watch: {
gameStatus(status) {
if (status === GameStatus.START) {
this.start();
return;
}
if (status === GameStatus.GAME_OVER) {
this.over();
return;
}
},
},
created() {},
mounted() {},
beforeDestroy() {
this.over();
},
methods: {
start() {
this.timer = setInterval(() => {
this.addCactus();
}, 1500);
},
over() {
clearInterval(this.timer);
},
addCactus() {
const animationDuration = mapNumber(this.score, 0, 1000, 2.4, 0.6);
/** @type {Cactus} */
const cactus = {
key: getRandomString(),
style: {
animationDuration: `${animationDuration}s`,
},
};
this.cactuses.push(cactus);
},
deleteCactus(index) {
this.cactuses.splice(index, 1);
},
},
};
在 game-scene.vue
引入 cactuses.vue
。
src\components\window-app-google-dino\game-scene.vue <script>
// ...
import { mapState } from 'vuex';
import Dino from './dino.vue';
import Clouds from './clouds.vue';
import Cactuses from './cactuses.vue';
export default {
name: 'GameScene',
components: {
dino: Dino,
clouds: Clouds,
cactuses: Cactuses,
},
// ...
};
src\components\window-app-google-dino\game-scene.vue <template lang="pug">
.game-scene(@click='start')
.ground
clouds(ref='clouds', :game-status='gameStatus', :score='score')
cactuses(ref='cactuses', :game-status='gameStatus', :score='score')
dino(ref='dino', :game-status='gameStatus')
// ...
仙人掌出現惹。
仙人掌每次都一樣太無趣了,加入不同的仙人掌吧!
src\components\window-app-google-dino\cactuses.vue <script>
/**
* @typedef {Object} Cactus
* @property {string} key
* @property {Object} style
* @property {string} src
*/
import { getRandomString, mapNumber } from '@/script/utils/utils';
import { sample } from 'lodash-es';
import { GameStatus } from './game-scene.vue';
import imgCactus01 from '@/assets/google-dino/cactus-1.png';
import imgCactus02 from '@/assets/google-dino/cactus-2.png';
import imgCactus03 from '@/assets/google-dino/cactus-3.png';
import imgCactus04 from '@/assets/google-dino/cactus-4.png';
export default {
name: 'Cactuses',
// ...
data() {
return {
/** @type {Cactus[]} */
cactuses: [],
timer: null,
cactusTypes: [imgCactus01, imgCactus02, imgCactus03, imgCactus04],
};
},
// ...
methods: {
// ...
addCactus() {
// 隨機選擇仙人掌
const src = sample(this.cactusTypes);
const animationDuration = mapNumber(this.score, 0, 1000, 2.4, 0.6);
/** @type {Cactus} */
const cactus = {
key: getRandomString(),
style: {
animationDuration: `${animationDuration}s`,
},
src,
};
this.cactuses.push(cactus);
},
// ...
},
};
src\components\window-app-google-dino\cactuses.vue <template lang="pug">
.cactuses
img.cactus(
// ...
:src='cactus.src',
@animationend='deleteCactus(i)'
)
可以看到每次出現的仙人掌都不一樣了!
這時候會發現仙人掌很固定都是 1.5 秒出現一個,這樣好像太簡單了,跳躍時間很容易掌握,我們讓每次生成的時候都加入一個隨機的延遲,讓每次每次生成都有時間差,增加一點難度。src\components\window-app-google-dino\cactuses.vue <script>
/**
* @typedef {Object} Cactus
* @property {string} key
* @property {Object} style
* @property {string} src
*/
import { getRandomString, mapNumber, delay } from '@/script/utils/utils';
import { sample, random } from 'lodash-es';
// ...
export default {
name: 'Cactuses',
// ...
methods: {
start() {
this.timer = setInterval(async () => {
await delay(random(1000));
this.addCactus();
}, 1500);
},
// ...
},
};
可以發現仙人掌出現間隔出現變化了。
最後有一個小問題,遊戲重新開始時,所有的角色應該要重置,而不是從上次結束的地方開始,如下圖。
這裡使用一個簡單暴力的方法:
每次開始遊戲時,都建立新的組件。ᕕ( ゚ ∀。)ᕗ
game-scene.vue
新增一個變數,隨機生成字串,用於強制更新組件。
src\components\window-app-google-dino\game-scene.vue <script>
// ...
import { getRandomString } from '@/script/utils/utils';
export default {
name: 'GameScene',
// ...
data() {
return {
gameId: '',
// ...
};
},
// ...
methods: {
start() {
// ...
// 初始化變數
this.gameId = getRandomString();
this.gameStatus = GameStatus.START;
this.score = 0;
this.timeCounter = 0;
// ...
},
// ...
},
};
將 gameId
綁定為 cactuses
與 clouds
的 key
src\components\window-app-google-dino\game-scene.vue <template lang="pug">
.game-scene
.ground
clouds(
// ...
:key='"clouds-" + gameId'
)
cactuses(
// ...
:key='"cactuses-" + gameId'
)
// ...
這時候會發現一個問題,怎麼遊戲開始後,雲和仙人掌都沒有出現了?
抓蟲蟲時間,透過 Vue DevTools 檢查組件看看。
可以發現 timer
都是 null
,表示 start()
沒有被觸發,而觸發 start()
的部份由 watch
負責。
讓我們看看 watch
的程式碼。
src\components\window-app-google-dino\clouds.vue <script>
// ...
export default {
name: 'Clouds',
// ...
watch: {
gameStatus(status) {
if (status === GameStatus.START) {
this.start();
return;
}
if (status === GameStatus.GAME_OVER) {
this.over();
return;
}
},
},
// ...
};
原因是因為組件初始化後 watch
中的 gameStatus
沒有執行,所以這裡我們加入 immediate
改寫為:
src\components\window-app-google-dino\clouds.vue <script>
// ...
export default {
name: 'Clouds',
// ...
watch: {
gameStatus: {
handler(status) {
if (status === GameStatus.START) {
this.start();
return;
}
if (status === GameStatus.GAME_OVER) {
this.over();
return;
}
},
immediate: true,
},
},
// ...
};
cactuses.vue
中的 watch
部分也要記得改。
src\components\window-app-google-dino\cactuses.vue <script>
// ...
export default {
name: 'Cactuses',
// ...
watch: {
gameStatus: {
handler(status) {
if (status === GameStatus.START) {
this.start();
return;
}
if (status === GameStatus.GAME_OVER) {
this.over();
return;
}
},
immediate: true,
},
},
// ...
};
這樣 watch
中的 gameStatus
就會在組件初次建立後執行一次。
嘗試看看按右鍵結束遊戲後,再按左鍵開始遊戲,有沒有成功清空畫面(重建組件)。
清場成功!
目前為止我們成功讓所有角色都登場了,最後就是加入完整遊戲邏輯了!✧*。٩(ˊᗜˋ*)و✧*。
以上程式碼已同步至 GitLab,大家可以前往下載: