D25 - 「不断线的侏罗纪」:然後他就死掉了

小恐龙现在就像吃了无敌星星一样,完全无视仙人掌,所以我们来让小恐龙死翘翘吧!

撞上仙人掌

我们来加上「小恐龙」与「仙人掌」的碰撞侦测。

这里我们使用最简单「矩形碰撞」,简单来说就是将所有物体视为一个矩形,判断两个矩形是否有重叠。

对碰撞侦测细节有兴趣的朋友,可以参考以下连结:
矩形的碰撞侦测
“等一下,我碰!”——常见的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.vuetick() 不断检查碰撞。

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();
      }
    },
  },
};

D25 - 小恐龙撞上仙人掌.gif
小恐龙成功撞上仙人掌!

现在游戏逻辑全部完成!只是出现一个神奇的问题。
D25 - 小恐龙浮空.gif

小恐龙学会舞空术啦!╭(°A ,°`)╮

游戏重启後小恐龙没有回归原位,这是因为没有重置 GSAP 动画,导致动画还停留在上次位置。

所以在 dino.vuestart() 中加入重置 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;
      // ...
    },
    // ...
  },
};

D25 - 修正浮空问题.gif

成功废除舞空术 (ง •̀_•́)ง

成功修正小恐龙浮空问题。

我们成功重现恐龙游戏了!那就下一个章节见罗!

所以我说那个按钮呢?

对吼,差点忘了要用实体按钮控制 (゚∀。)。

鳕鱼:「让我们回到 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
  // ...

接着加入以下功能:

  • 透过 computedwatch 配合,侦测 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); // 蹲下
      });
    },
  },
};

D25 - 成功加入按钮控制.gif

成功透过实体按钮控制小恐龙了!来挑战最高分数吧!

至此,我们成功重返侏罗纪并完成任务了,接下来往下一站迈进吧!

大家有兴趣可以自行加上更多进阶功能,例如:最高纪录、加入翼龙、不同高度的仙人掌等等。

总结

  • 成功加入碰撞侦测
  • 成功使用实体按钮控制
  • 完成小恐龙游戏

以上程序码已同步至 GitLab,大家可以前往下载:

GitLab - D25


<<:  JavaScript运算子

>>:  追求JS小姊姊系列 Day24 -- 工具人、姐妹不只身份的差别(下):从记忆体看宣告变数的可变性

第六章 之五

OK,说好本章结要来说一下页面,那就说一下页面能干嘛,首先要提到的是有页面功能又有文章功能,同时都可...

Day 6 Capsule的应用(下)

前言 今天把昨天讲的论文做一个总结,明天就要开始介绍attention了 A2D dataset 这...

成为工具人应有的工具包-03 CredentialsFileView

CredentialsFileView 今天就来认识 CredentialsFileView 这个工...

【Day 06 】- Module 隐藏大法,不可能再被发现了吧 / _ \(基於 VAD 断链的隐藏方法)

Agenda 资安宣言 测试环境与工具 学习目标 技术原理与程序码 References 下期预告 ...

DAY 24- 凭证实察 Certificate

「给你一个凭证,证明你是一个工具人。」 今天是一个户外教学。 我们要来去网站上实际把证书抓出来看看。...