iT邦幫忙

2021 iThome 鐵人賽

DAY 30
1
Modern Web

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

D31 - 「來互相傷害啊!」:無聊我要見到血流成河

本系列文已改編成書「Arduino 自造趣:結合 JavaScript x Vue x Phaser 輕鬆打造個人遊戲機」,本書改用 Vue3 與 TypeScript 全面重構且加上更詳細的說明,

在此感謝 iT 邦幫忙、博碩文化與編輯小 p 的協助,歡迎大家前往購書,鱈魚在此感謝大家 (。・∀・)。

若想 DIY 卻不知道零件去哪裡買的讀者,可以參考此連結 。( •̀ ω •́ )✧


上競技場就是要決鬥阿,不然要幹嘛。

來讓人物發射武器!血流成河吧!

首先來回顧一下 D27 武器規劃。

D26 - 規劃遊戲場景:主場景.png

  • 主角武器
    • 會與敵人發生碰撞
    • 向下飛行、隨機旋轉
    • 最多只能存在 1 個武器,不能連續發射
  • 敵人武器
    • 會與主角發生碰撞
    • 向上飛行、隨機旋轉
    • 最多只能存在 5 個武器,不能連續發射

可以發現同一時間內會出現多個相同的武器,所以這時候要出動 Phaser 的 Group。

Group 可以用來管理重複出現的物體,更容易進行偵測與各類操作,詳細說明可以參考以下連結。

Docs - Phaser.Physics.Arcade.Group

可以發現不管是主角武器還是敵人武器,幾乎所有性質都相同,所以我們可以先建立 group-weapon.js 做為武器容器,建立角色武器時再將對應的武器注入、建立即可。

src\components\window-app-cat-vs-dog\objects\group-weapon.js

import Phaser from 'phaser';

/**
 * @typedef {Object} ConstructorParams
 * @property {Object} classType 注入武器物件
 * @property {string} key
 * @property {number} quantity 物體數量
 */

export default class extends Phaser.Physics.Arcade.Group {
  /**
   * @param {Phaser.Scene} scene 
   * @param {ConstructorParams} params 
   */
  constructor(scene, params) {
    super(scene.physics.world, scene);

    const { classType, key, quantity = 5 } = params;

    this.createMultiple({
      classType,
      frameQuantity: quantity,
      active: false,
      visible: false,
      key,
    });

    this.setDepth(1);

    // 隱藏所有武器
    this.getChildren().forEach((item) => {
      item.setScale(0);
    });
  }

  /** 發射武器
   * @param {number} x 
   * @param {number} y 
   * @param {number} velocity 
   */
  fire(x, y, velocity) {
    const weapon = this.getFirstDead(false);
    if (weapon) {
      weapon.body.enable = true;
      weapon.fire(x, y, velocity);
    }
  }
}

丟你魚骨

首先建立主角的武器。

src\components\window-app-cat-vs-dog\objects\sprite-weapon-cat.js

import Phaser from 'phaser';

/**
 * @typedef {Object} ConstructorParams
 * @property {number} [x]
 * @property {number} [y]
 */

export default class extends Phaser.Physics.Arcade.Sprite {
  /**
   * @param {Phaser.Scene} scene 
   * @param {ConstructorParams} params
   */
  constructor(scene, params) {
    const { x = 0, y = 0 } = params;

    super(scene, x, y, 'cat-weapon');
    this.scene = scene;
  }

  preUpdate(time, delta) {
    super.preUpdate(time, delta);

    /** 檢查武器是否超出世界邊界
     * 透過偵測武器是否與世界有碰撞,取反向邏輯
     * 沒有碰撞,表示物體已經超出邊界
     */
    const outOfBoundary = !Phaser.Geom.Rectangle.Overlaps(
      this.scene.physics.world.bounds,
      this.getBounds(),
    );

    // 隱藏超出邊界武器並關閉活動
    if (outOfBoundary) {
      this.setActive(false)
        .setVisible(false);
    }
  }

  /** 發射武器
   * @param {number} x 
   * @param {number} y 
   * @param {number} velocity 
   */
  fire(x, y, velocity) {
    // 清除所有加速度、速度並設置於指定座標
    this.body.reset(x, y);

    // 角速度
    const angularVelocity = Phaser.Math.Between(-400, 400);

    this.setScale(0.3)
      .setSize(160, 160)
      .setAngularVelocity(angularVelocity)
      .setVelocityY(velocity)
      .setActive(true)
      .setVisible(true);
  }
}

回到 scene-main.js 實際建立主角武器。

src\components\window-app-cat-vs-dog\scenes\scene-main.js

import Phaser from 'phaser';

import SpriteCat from '@/components/window-app-cat-vs-dog/objects/sprite-cat';
import SpriteDog from '@/components/window-app-cat-vs-dog/objects/sprite-dog';

import GroupWeapon from '@/components/window-app-cat-vs-dog/objects/group-weapon';
import SpriteWeaponCat from '@/components/window-app-cat-vs-dog/objects/sprite-weapon-cat';

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  create() {
    // 主角
    const catWeapon = new GroupWeapon(this, {
      classType: SpriteWeaponCat,
      key: 'cat-weapon',
      quantity: 1
    });

    this.cat = new SpriteCat(this);

    // ...
  }
}

建立主角武器後,利用相同概念,將武器注入主角物件中,前往 sprite-cat.js 加入以下內容。

  • 新增 weapon 變數,儲存注入之武器
  • constructorparams 參數加入 weapon
  • joyStick 新增 on('rising') 監聽(按鈕按下事件),用來呼叫 weapon.fire()

src\components\window-app-cat-vs-dog\objects\sprite-cat.js

/**
 * @typedef {import('@/script/electronic-components/joy-stick').default} JoyStick
 * @typedef {import('@/components/window-app-cat-vs-dog/objects/group-weapon')} GroupWeapon
 * 
 * @typedef {Object} CatParams
 * @property {number} [x]
 * @property {number} [y]
 * @property {GroupWeapon} weapon
 */

import Phaser from 'phaser';

/** 最大速度 */
const velocityMax = 300;

export default class extends Phaser.Physics.Arcade.Sprite {
  weapon = null;

  /** 血量 */
  health = 5;

  /**
   * @param {Phaser.Scene} scene 
   * @param {CatParams} params
   */
  constructor(scene, params = {}) {
    const {
      x = 200, y = 200,
      weapon = null,
    } = params;

    if (!weapon) throw new Error('weapon 為必填參數');

    // ...

    this.scene = scene;
    this.weapon = weapon;

    /** @type {JoyStick} */
    const joyStick = scene.game.joyStick;

    joyStick.on('data', ({ x, y }) => {
      // ...
    }).on('rising', () => {
      // 座標設為與主角相同位置
      this.weapon.fire(this.x, this.y, 800);

      // 播放主角發射動畫
      this.play('cat-attack', true);
      this.setVelocity(0, 0);
    });

    // ...
  }

  // ...
}

回到 scene-main.js,將武器武器注入主角中。

src\components\window-app-cat-vs-dog\scenes\scene-main.js

import Phaser from 'phaser';

import SpriteCat from '@/components/window-app-cat-vs-dog/objects/sprite-cat';
import SpriteDog from '@/components/window-app-cat-vs-dog/objects/sprite-dog';

import GroupWeapon from '@/components/window-app-cat-vs-dog/objects/group-weapon';
import SpriteWeaponCat from '@/components/window-app-cat-vs-dog/objects/sprite-weapon-cat';

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  create() {
    // 主角
    const catWeapon = new GroupWeapon(this, {
      classType: SpriteWeaponCat,
      key: 'cat-weapon',
      quantity: 1
    });
    this.cat = new SpriteCat(this, {
      weapon: catWeapon,
    });

    // ...
  }
}

試試看按下搖桿按鈕有沒有成功發射武器吧。

D31 - 主角發射武器.gif

成功發射!但是主角停留在發射動畫,回到 sprite-cat.js 調整一下。

src\components\window-app-cat-vs-dog\objects\sprite-cat.js

// ...

export default class extends Phaser.Physics.Arcade.Sprite {
  weapon = null;

  /** 血量 */
  health = 5;

  /**
   * @param {Phaser.Scene} scene 
   * @param {CatParams} params
   */
  constructor(scene, params = {}) {
    // ...
  }

  preUpdate(time, delta) {
    super.preUpdate(time, delta);

    // 沒有任何動畫播放時,播放 cat-work
    if (!this.anims.isPlaying) {
      this.play('cat-work');
    }
  }

  // ...
}

D31 - 完成主角發射武器動畫.gif

完成主角發射動畫!

吃我骨頭

接著來讓狗狗噴骨頭吧。

首先建立敵人武器 sprite-weapon-dog.js

import Phaser from 'phaser';

export default class extends Phaser.Physics.Arcade.Sprite {
  /**
   * @param {Phaser.Scene} scene 
   * @param {number} x 
   * @param {number} y 
   */
  constructor(scene, x = 0, y = 0) {
    super(scene, x, y, 'dog-weapon');
    this.scene = scene;
  }

  preUpdate(time, delta) {
    super.preUpdate(time, delta);

    const outOfBoundary = !Phaser.Geom.Rectangle.Overlaps(
      this.scene.physics.world.bounds,
      this.getBounds(),
    );

    if (outOfBoundary) {
      this.setActive(false);
      this.setVisible(false);
    }
  }

  /** 發射武器
   * @param {number} x 
   * @param {number} y 
   * @param {number} velocity 
   */
  fire(x, y, velocity) {
    this.body.reset(x, y);

    const angularVelocity = Phaser.Math.Between(-400, 400);

    this.setScale(0.2)
      .setSize(300, 300)
      .setAngularVelocity(angularVelocity)
      .setVelocityY(velocity)
      .setActive(true)
      .setVisible(true);
  }
}

scene-main.js 建立敵人武器並注入敵人物件中。

src\components\window-app-cat-vs-dog\scenes\scene-main.js

// ...

import GroupWeapon from '@/components/window-app-cat-vs-dog/objects/group-weapon';
import SpriteWeaponCat from '@/components/window-app-cat-vs-dog/objects/sprite-weapon-cat';
import SpriteWeaponDog from '@/components/window-app-cat-vs-dog/objects/sprite-weapon-dog';

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  create() {
    // 主角
    // ...

    // 敵人
    const dogWeapon = new GroupWeapon(this, {
      classType: SpriteWeaponDog,
      key: 'dog-weapon',
    });
    this.dog = new SpriteDog(this, {
      weapon: dogWeapon,
      target: this.cat,
    });

    // 加入中央河流
    // ...
  }
}

現在敵人也可以發射武器了,讓我們回到 sprite-dog.js 中,讓狗狗發射骨頭吧!

src\components\window-app-cat-vs-dog\objects\sprite-dog.js

/**
 * @typedef {import('@/components/window-app-cat-vs-dog/objects/group-weapon')} GroupWeapon
 * 
 * @typedef {Object} DogParams
 * @property {number} [x]
 * @property {number} [y]
 * @property {Phaser.Physics.Arcade.Sprite} target
 * @property {GroupWeapon} weapon
 */

// ...

export default class extends Phaser.Physics.Arcade.Sprite {
  /** @type {Phaser.Physics.Arcade.Sprite} */
  target = null;

  /** @type {GroupWeapon} */
  weapon = null;
  health = 10;

  /**
   * @param {Phaser.Scene} scene 
   * @param {DogParams} params
   */
  constructor(scene, params) {
    const {
      x = 500, y = 600,
      weapon = null,
      target = null,
    } = params;

    if (!weapon) throw new Error('weapon 為必填參數');

    // ...

    this.scene = scene;
    this.target = target;
    this.weapon = weapon;

    this.initAutomata();
  }

  preUpdate(time, delta) {
    super.preUpdate(time, delta);

    if (!this.anims.isPlaying) {
      this.play('dog-work');
    }
  }

  // ...

  initAutomata() {
    // 隨機發射
    this.scene.time.addEvent({
      delay: 500,
      callbackScope: this,
      repeat: -1,
      callback: async () => {
        await delay(Phaser.Math.Between(0, 200));
        this.fire();
      },
    });

    // 追貓
    // ...
  }

  fire() {
    this.weapon.fire(this.x, this.y, -500);
    this.play('dog-attack', true);
  }
}

D31 - 敵人發射武器.gif

可以看到狗狗開始很兇殘得丟骨頭了! ⎝(・ω´・⎝)

互相傷害吧!

鱈魚:「再來就是人物與武器的激❤烈❤碰撞了!」

電子助教:「就不能用正常一點的方式描述碰撞偵測嘛 ...(´● ω ●`)」

加入人物扣血與勝敗部分,先將人物的血量顯示出來吧。

src\components\window-app-cat-vs-dog\scenes\scene-main.js

// ...

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  create() {
    // ...

    // 顯示生命值
    this.catHealthText = this.add.text(20, 20, `貓命:${this.cat.health}`, {
      fill: '#000',
      fontSize: 14,
    });

    const sceneHeight = this.game.config.height;
    this.dogHealthText = this.add.text(20, sceneHeight - 20, `狗血:${this.dog.health}`, {
      fill: '#000',
      fontSize: 14,
    }).setOrigin(0, 1);

    // 加入中央河流
    // ...
  }
  update() {
    this.catHealthText.setText(`貓命:${this.cat.health}`);
    this.dogHealthText.setText(`狗血:${this.dog.health}`);
  }
}

Untitled

可以看到畫面上多了貓命與狗血。

最後就是加入人物與武器的碰撞偵測了。

src\components\window-app-cat-vs-dog\scenes\scene-main.js

// ...

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  create() {
    // ...

    // 加入武器與人物碰撞
    this.physics.add.overlap(this.cat, dogWeapon, (cat, weapon) => {
      // 隱藏武器
      weapon.body.enable = false;
      weapon.setActive(false).setVisible(false);

      // 主角扣血
      this.cat.subHealth();
    });

    this.physics.add.overlap(this.dog, catWeapon, (dog, weapon) => {
      // 隱藏武器
      weapon.body.enable = false;
      weapon.setActive(false).setVisible(false);

      // 敵人扣血
      this.dog.subHealth();
    });
  }
  // ...
}

D31 - 人物與武器碰撞.gif

可以看到主角與敵人被擊中時都會播放被擊中動畫,同時減少血量。

但是目前血量歸零後,不會有任何變化,所以要怎麼進到結束場景呢?

很簡單,由於我們已經在 sprite-cat.jssprite-cat.js 中加入「血量歸 0 時,觸發 death 事件的程式」。

所以最後只要監聽人物的 death 事件即可。

src\components\window-app-cat-vs-dog\scenes\scene-main.js

// ...

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  create() {
    // ...

    // 偵測人物事件
    this.dog.once('death', () => {
      this.scene.start('over', 'win');
    });
    this.cat.once('death', () => {
      this.scene.start('over', 'lose');
    });
  }
  // ...
}

貓死表示失敗,狗死表示遊戲獲勝。

將結果透過第二個參數傳輸到下一個場景,就可以在結束場景判斷輸贏了。

結束場景

最後我們將結束場景完成吧。

結束場景的程式非常簡單,就是根據傳來的資料顯示對應的結果。

src\components\window-app-cat-vs-dog\scenes\scene-over.js

import Phaser from 'phaser';

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'over' })
  }
  create(result) {
    const x = this.game.config.width / 2;
    const y = this.game.config.height / 2;

    const text = result === 'win' ? '恭喜獲勝' : '哭哭,被打敗了';
    const texture = result === 'win' ? 'cat-attack' : 'cat-beaten';

    // 主角
    this.cat = this.physics.add.sprite(x, y - 80, texture)
      .setScale(0.5);

    // 提示文字
    this.add.text(x, y + 50, text, {
      fill: '#000',
      fontSize: 30,
    }).setOrigin(0.5);

    this.add.text(x, y + 100, '按下搖桿按鍵重新開始', {
      fill: '#000',
      fontSize: 18,
    }).setOrigin(0.5);

    /** @type {JoyStick} */
    const joyStick = this.game.joyStick

    // 延遲一秒鐘後再偵測搖桿按鈕,防止一進到場景後誤按按鈕馬上觸發
    setTimeout(() => {
      joyStick.once('toggle', () => {
        this.scene.start('main');
      });
    }, 1000);
  }
}

最後讓我們實測看看吧!

獲勝結束畫面

D31 - 獲勝結束畫面.gif

戰敗結束畫面

D31 - 戰敗結束畫面.gif

以上我們完成全部的遊戲功能了,最後讓我們復原為了方便開發調整的內容吧。

  • scene-preload.js 下一個場景改回 welcome
  • game-scene.vue 取消 config.physics.arcade.debug

src\components\window-app-cat-vs-dog\scenes\scene-preload.js

// ...

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'preload' })
  }

  // ...
  create() {
    // ...

    // 前往下一個場景
    this.scene.start('welcome');
  }
}

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

export default {
  name: 'GameScene',
  // ...
  methods: {
    /** 初始化遊戲 */
    initGame() {
      /** @type {Phaser.Types.Core.GameConfig} */
      const config = {
        // ...

        physics: {
          default: 'arcade',
          arcade: {
            // debug: true,
          },
        },
      };

      // ...
    },

    // ...
  },
};

來正式玩一場吧!

D31 - 正式玩一場.gif

少少程式碼就能完成功能的感覺是不是很棒呢

感恩 Phaser!讚歎 Phaser!◝( ゚ ∀。)◟

大家可以挑戰看看更進階的功能,例如:按著開關集氣,可以發射出威力更強的武器、敵人不只會追擊,還會閃躲子彈等等。

總結

  • 建立主角、敵人武器
  • 完成碰撞偵測
  • 完成結束場景
  • 完成「貓狗大戰」視窗

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

GitLab - D31


尾聲

很感謝大家一參與這場為期 31 天的奇幻旅程。

大家的支持是我完賽的原動力,在此感謝大家。

有緣的話,讓我們明年再見囉!✧*。٩(* ˊᗜˋ )ノ٩(ˊᗜˋ*)و✧*。


上一篇
D30 - 「來互相傷害啊!」:貓狗集合!建立 Sprite!
下一篇
D32 - 完賽心得
系列文
你渴望連結嗎?將 Web 與硬體連上線吧!33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言