D24 - 「不断线的侏罗纪」:天上好多云、地上一堆仙人掌

小恐龙跑啊跑,悠闲地看着天上的云朵飘过,但是眼前忽然出现了一株仙人掌。

天上好多云

建立云朵组件

建立 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


<<:  Day.29 「Class 语法糖~」 —— ES6 Class 构造函式

>>:  Day22:ws 整合 Vue 渲染聊天讯息

Day-3 用以在上个世代的游戏机取得最佳显示效果的色差端子

在怀旧主机中、色差端子通常是除了 HDMI 以外能用以得到最佳解析度的接头、若主机仍支援这个端子的话...

CSS微动画 - Animation也会影响网页效能?

Q: 终於要讲效能了! A: 以Loading为范例讲黑~ Animation Loading 直...

Day 14. Tutorial: Create a scene flow - 10. Challenge Answer

如果你也有跟着教程做的话,第10节有个练习,可以来跟我交流一下答案,我也不知道我的写法是不是好的,但...

Day6:class函数

半夜睡不着来更新XD Python属於「物件导向程序语言」(Object-oriented prog...

Day-11 Backpropagation 介绍

我们前面提到过深度学习就是模仿神经网路建构一个庞大的训练模型,来达到特徵的选取(调整 weight...