iT邦幫忙

2021 iThome 鐵人賽

DAY 25
0
Modern Web

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

D24 - 「不斷線的侏儸紀」:天上好多雲、地上一堆仙人掌

本系列文已改編成書「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')
  
  // ...

現在應該會看到有一朵雲在左上角。

Untitled

接著我們希望持續生成雲朵,建立相關程式。

  • 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.vueclouds.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')
  
  // ...

嘗試看看遊戲開始後會不會一直出現雲朵。

D24 - 持續建立雲朵.gif

成功產生雲朵,最後就是雲朵動畫與刪除的部分!

讓雲朵飄起來

雲朵移動動畫使用 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%)

嘗試看看效果。

D24 - 雲朵飄動動畫.gif

不過目前雲朵移出畫面之後沒有刪除,我們加入刪除功能。

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 檢查看看有沒有真的刪除。

D24 - 自動刪除雲朵.gif

成功刪除動畫結束的雲朵!(´,,•ω•,,)

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')
  // ...

如此一來,現在是:

  • 點擊滑鼠左鍵,遊戲開始
  • 點擊滑鼠右鍵,遊戲結束

來實測看看。

D24 - 遊戲結束,雲朵停止.gif

成功了!可以看到不只雲停止,小恐龍也變成癡呆臉、提示文字也顯示遊戲結束。

讓雲朵飆起來

最後就是:

「雲朵」與「仙人掌」會隨著分數的提高而增加速度,但不會超過最大速度。

這裡需要遊戲分數,所以我們透過 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);
    },

    // ...
  },
};

D24 - 分數越高,雲朵速度越快.gif

可以看到今天雲朵沒有極限!─=≡Σ((( つ•̀ω•́)つ

為了讓效果明顯一點所以調了一個誇張一點的數值,我們改為合適一點的數值。

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')
	
  // ...

D24 - 建立仙人掌.gif

仙人掌出現惹。

隨機仙人掌

仙人掌每次都一樣太無趣了,加入不同的仙人掌吧!

  • 引入所有的仙人掌素材。
  • 建立仙人掌時,隨機選擇其中一個。
  • img src 改為動態綁定。

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)'
  )

D24 - 隨機出現不同的仙人掌.gif

可以看到每次出現的仙人掌都不一樣了!

這時候會發現仙人掌很固定都是 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);
    },
    // ...
  },
};

D24 - 仙人掌生成加入時間差.gif
可以發現仙人掌出現間隔出現變化了。

最後有一個小問題,遊戲重新開始時,所有的角色應該要重置,而不是從上次結束的地方開始,如下圖。

D24 - 重新開始遊戲,沒有重置.gif

這裡使用一個簡單暴力的方法:

每次開始遊戲時,都建立新的組件。ᕕ( ゚ ∀。)ᕗ

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 綁定為 cactusescloudskey

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

.game-scene
  .ground
  clouds(
    // ...
    :key='"clouds-" + gameId'
  )
  cactuses(
    // ...
    :key='"cactuses-" + gameId'
  )
  // ...

這時候會發現一個問題,怎麼遊戲開始後,雲和仙人掌都沒有出現了?

D24 - 角色組件 start 沒有觸發.gif

抓蟲蟲時間,透過 Vue DevTools 檢查組件看看。

Untitled

可以發現 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 就會在組件初次建立後執行一次。

嘗試看看按右鍵結束遊戲後,再按左鍵開始遊戲,有沒有成功清空畫面(重建組件)。

D24 - 重啟遊戲後所有組件重建.gif

清場成功!

目前為止我們成功讓所有角色都登場了,最後就是加入完整遊戲邏輯了!✧*。٩(ˊᗜˋ*)و✧*。

總結

  • 完成雲朵組件
  • 完成仙人掌組件

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

GitLab - D24


上一篇
D23 - 「不斷線的侏儸紀」:有一隻小恐龍在跑步(使用 GSAP 動畫)
下一篇
D25 - 「不斷線的侏儸紀」:然後他就死掉了
系列文
你渴望連結嗎?將 Web 與硬體連上線吧!33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言