iT邦幫忙

2021 iThome 鐵人賽

DAY 30
0
Modern Web

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

D30 - 「來互相傷害啊!」:貓狗集合!

有場景了,來讓人物登場吧!(≧∀≦)

首先將場景載入遊戲中。

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

/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/types/type').PinCapability} PinCapability
 */

import { mapState } from 'vuex';
import Phaser from 'phaser';

import ScenePreload from './scenes/scene-preload';
import SceneWelcome from './scenes/scene-welcome';
import SceneMain from './scenes/scene-main';
import SceneOver from './scenes/scene-over';

// ...

export default {
  name: 'GameScene',
  // ...
  methods: {
    /** 初始化遊戲 */
    initGame() {
      /** @type {Phaser.Types.Core.GameConfig} */
      const config = {
        type: Phaser.WEBGL,
        width: 600,
        height: 800,
        parent: `game-scene-${this.id}`,
        scene: [ScenePreload, SceneWelcome, SceneMain, SceneOver],
        backgroundColor: '#FFF',
        disableContextMenu: true,

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

      this.game = new Phaser.Game(config);
    },

    // ...
  },
};

接著刪除 initController() 中測試用的 onAny 監聽程式,並將 joyStick 物件掛載至 game 物件中,讓遊戲中也能取用。

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

// ...

export default {
  name: 'GameScene',
  // ...
  methods: {
    /** 初始化遊戲 */
    initGame() {
      /** @type {Phaser.Types.Core.GameConfig} */
      const config = {
        type: Phaser.WEBGL,
        width: 600,
        height: 800,
        parent: `game-scene-${this.id}`,
        scene: [ScenePreload, SceneWelcome, SceneMain, SceneOver],
        backgroundColor: '#FFF',
        disableContextMenu: true,

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

      this.game = new Phaser.Game(config);
      this.game.joyStick = this.joyStick;
    },

    /** 初始化搖桿 */
    initController() {
      this.joyStick = new JoyStick({
        // ...
      });
    },
  },
};

歡迎場景

讓我們前往 scene-welcome.js 場景,首先讓主角出現在場地上。

import Phaser from 'phaser';

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

    this.cat = this.physics.add
      .sprite(x, y - 80, 'cat-work')
      .setScale(0.5)
      .setCollideWorldBounds(true);
  }
  update() {
  }
}
  • xy 是場景尺寸,用來將主角定位至場地中央。
  • setScale() 可以用來控制人物尺寸,以免原本素材尺寸太小或太大。
  • setCollideWorldBounds() 設定人物會與世界邊界發生碰撞,這樣人物就不會衝出世界外。

D30 - 主角登場.gif

主角登場!

讓我們加點動畫吧!

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

// ...

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

    this.cat = this.physics.add
      .sprite(x, y - 80, 'cat-work')
      .setScale(0.5)
      .setCollideWorldBounds(true)
      .anims.play('cat-work');
  }
}

一行完成!

D30 - 主角一般動作.gif

是不是和之前小恐龍章節自幹動畫相比,相當的簡單愜意呢。

感恩 Phaser!讚歎 Phaser!ᕕ( ゚ ∀。)ᕗ

接著加點提示文字吧。

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

// ...

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

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

Untitled

歡迎場景完成!只用了不到 20 行程式就完成的感覺真好。◝(≧∀≦)◟

所以說搖桿呢?

又差點忘了搖桿 (´,,•ω•,,)

把搖桿取到的類比搖桿數值,設為人物的速度,就可以讓人物移動了!

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

/**
 * @typedef {import('@/script/electronic-components/joy-stick').default} JoyStick
 */

import Phaser from 'phaser';

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

    /** @type {JoyStick} */
    const joyStick = this.game.joyStick
    joyStick.on('data', ({ x, y }) => {
      this.cat.setVelocity(x, y);
    });
  }
}

D30 - 使用搖桿控制主角.gif

最後就是按下按鈕後,進入下一個場景。

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

/**
 * @typedef {import('@/script/electronic-components/joy-stick').default} JoyStick
 */

import Phaser from 'phaser';

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

    /** @type {JoyStick} */
    const joyStick = this.game.joyStick
    joyStick.on('data', ({ x, y }) => {
      this.cat.setVelocity(x, y);
    }).once('toggle', () => {
      this.scene.start('main');
    });

    /** 監聽 destroy 事件,清除所有搖桿監聽器
     * 以免人物被銷毀後,搖桿還持續呼叫 setVelocity,導致錯誤
     */
    this.cat.once('destroy', () => {
      joyStick.removeAllListeners();
    });
  }
}

D30 - 從歡迎場景進入主場景.gif

可以看到按下搖桿按鈕後,主角和文字都不見了。

不是壞掉了,而是我們進入下一個場景了。

主場景

正式進入互相傷害場景!

為了方便測試,進行以下調整:

  • scene-preload.js 將下一個進入的場景從 welcome 改為 main。
  • game-scene.vue 遊戲設定之 physics.arcade.debug 設為 true,如此會顯示所有物體的碰撞邊界與速度向量等等。

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

// ...

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

  preload() {
    // ...

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

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

      // ...
    },

    // ...
  },
};

接著在 scene-main.js 加入河流。

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

import Phaser from 'phaser';

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  preload() {
  }
  create() {
    // 加入中央河流
    this.platforms = this.physics.add.staticGroup();
    this.platforms.create(300, 400, 'river').setScale(0.17).refreshBody();
  }
}
  • staticGroup() 表示建立靜態物體群組,用於存放靜態物體。靜態物體為不受重力影響、沒有速度的物體,常用於地板、牆壁等等用途。
  • refreshBody() 用於讓物體根據縮放尺寸調整碰撞箱尺寸,從以下比較圖即可知道為甚麼。

未使用 refreshBody()

Untitled

加入 refreshBody()

Untitled

可以注意到使用 refreshBody() 後,河流的碰撞箱尺寸才是正確的尺寸。

加入主角

接下來準備加入人物吧,讓我們複習一下 D27 中主角的設計。

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

  • 主角
    • 透過搖桿控制人物移動,按下按鈕發射武器
    • 按下按鈕發射武器,並播放發射動畫
    • 血量顯示在左上角,預設 5 點
    • 被敵人武器擊中時,播放被擊中動畫並減少生命值
    • 生命值歸零時,觸發死亡事件

由於主場景中的主角有多個程式邏輯,直接將程式寫在場景中會讓程式難以維護,所以將主角獨立一個 class 吧!

新增 src\components\window-app-cat-vs-dog\objects 目錄,用來存放各種人物 class。

新增主角檔案並加入基本內容。

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

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

import Phaser from 'phaser';

export default class extends Phaser.Physics.Arcade.Sprite {
  /** 血量 */
  health = 5;

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

    super(scene, x, y, 'cat-work');

    // 將人物加入至場景並加入物理系統
    scene.add.existing(this);
    scene.physics.add.existing(this);

    // 設定人物縮放、碰撞箱尺寸、碰撞反彈、世界邊界碰撞
    this.setScale(0.3)
      .setSize(220, 210)
      .setBounce(0.2)
      .setCollideWorldBounds(true);

    // 播放動畫
    this.play('cat-work');

    this.scene = scene;
  }

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

回到 scene-main.js 引入主角並建立物件。

import Phaser from 'phaser';

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

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  preload() {
  }
  create() {
    // 建立主角
    this.cat = new SpriteCat(this);

    // 加入中央河流
    this.platforms = this.physics.add.staticGroup();
    this.platforms.create(300, 400, 'river').setScale(0.17).refreshBody();

  }
}

試試看主角有沒有成功登場。

D30 - 建立主角物件.gif

成功!貓貓動起來了!

接著加入搖桿控制人物速度的部分,透過轉為單位向量的方式限制人物速度。

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

/**
 * @typedef {import('@/script/electronic-components/joy-stick').default} JoyStick
 * 
 * @typedef {Object} CatParams
 * @property {number} [x]
 * @property {number} [y]
 */

import Phaser from 'phaser';

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

export default class extends Phaser.Physics.Arcade.Sprite {
  // ...
  constructor(scene, params = {}) {
    // ...

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

    joyStick.on('data', ({ x, y }) => {
      // 將 x、y 數值組合為向量並轉為單位向量。
      const velocityVector = new Phaser.Math.Vector2(x, y);
      velocityVector.normalize();

      // 將單位向量 x、y 分量分別乘上最大速度
      const { x: vx, y: vy } = velocityVector;
      this.setVelocity(vx * velocityMax, vy * velocityMax);
    });

    this.once('destroy', () => {
      joyStick.removeAllListeners();
    });
  }

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

D30 - 主場景主角移動.gif

主角可以移動了,但是發現一個問題,主角竟然可以穿過河流。

這是因為沒有加上碰撞,回到 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';

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

    // 加入河流與人物碰撞
    this.physics.add.collider([this.cat, this.platforms]);
  }
}

一行完成!

D30 - 主角與河流碰撞.gif

可以看到主角現在沒辦法輕功水上飄了。

最後在主角 class 中加入生命值相關的 method

// ...

export default class extends Phaser.Physics.Arcade.Sprite {
  // ...

  /** 取得生命值 */
  getHealth() {
    return this.health;
  }
  /** 扣血 */
  subHealth(val = 1) {
    this.health = Phaser.Math.MinSub(this.health, val, 0);
    this.play('cat-beaten', true);

    if (this.health === 0) {
      this.emit('death');
    }
  }
}

Phaser.Math.MinSub() 可以指定減法結果最小值,可以省去自己判斷是否減過頭的工作。

加入敵人

回顧一下設計。

  • 上下隨機移動,左右則追著主角移動
  • 隨機發射武器並播放發射動畫
  • 血量顯示在左上角,預設 10 點
  • 被主角武器擊中時,播放被擊中動畫並減少生命值
  • 生命值歸零時,觸發死亡事件

基本概念與主角完全相同,差別在輸入參數多一個 target,用來表示要追擊的目標。

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

/**
 * @typedef {Object} DogParams
 * @property {number} [x]
 * @property {number} [y]
 * @property {Phaser.Physics.Arcade.Sprite} target
 */

import Phaser from 'phaser';

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

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

    super(scene, x, y, 'dog-work');
    scene.add.existing(this);
    scene.physics.add.existing(this);

    this.setScale(0.2)
      .setSize(340, 420)
      .setCollideWorldBounds(true);
    this.play('dog-work');

    this.scene = scene;
    this.target = target;
  }

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

  /** 取得生命值 */
  getHealth() {
    return this.health;
  }
  /** 扣血 */
  subHealth(val = 1) {
    this.health = Phaser.Math.MinSub(this.health, val, 0);
    this.play('dog-beaten', true);

    if (this.health === 0) {
      this.emit('death');
    }
  }
}

加入計時器讓敵人動起來。

// ...

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

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

    this.initAutomata();
  }

  // ...

  initAutomata() {
    // 追貓
    this.scene.time.addEvent({
      delay: 500,
      callbackScope: this,
      repeat: -1,
      callback: async () => {
        const vx = (this.target.x - this.x) * 1.5;
        const vy = Phaser.Math.Between(-400, 400);

        this.setVelocity(vx, vy);
      },
    });
  }
}
  • X 方向速度為與目標之差值,這樣就會讓敵人持續往目標的方向前進。
  • Y 方向則隨機運動。

大家也可以自行設計更強大的敵人 AI 喔

現在回到 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';

export default class extends Phaser.Scene {
  constructor() {
    super({ key: 'main' })
  }
  create() {
    this.cat = new SpriteCat(this);

    this.dog = new SpriteDog(this, {
      target: this.cat,
    });

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

    // 加入河流與人物碰撞
    this.physics.add.collider([this.cat, this.dog, this.platforms]);
  }
}

D30 - 敵人登場.gif

可以看到敵人成功登場,而且會持續追著主角移動了。

Phaser 處理完多種繁瑣細節,我們只要專注於遊戲邏輯即可,感覺是不是很棒啊

感恩 Phaser!讚歎 Phaser!ᕕ( ゚ ∀。)ᕗ

電子助教:「這是甚麼奇怪的宗教嗎?...(́⊙◞౪◟⊙‵)」


就算砍了很多內容結果還是不小心寫超過 30 篇了 Σ(ˊДˋ;)
下次會好好調整內容,還請大家繼續看下去

總結

  • 建立主角物件
  • 建立敵人物件

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

GitLab - D30


上一篇
D29 - 「來互相傷害啊!」:天時地利
下一篇
D31 - 「來互相傷害啊!」:無聊我要見到血流成河
系列文
你渴望連結嗎?將 Web 與硬體連上線吧!33

尚未有邦友留言

立即登入留言