9. イベントドリブンECS

はじめに

EnemySpawnSystemで敵を生成しますが、そのときにCollisionSystemで衝突設定をする必要があります。

現在は、EnemySpawnSystemでcreateEnemy関数を呼び、createEnemyからCollisionSystemのregisterEnemyを呼び出していますが、 System間の依存が増えてしまうので、あまりきれいな処理ではありません。

そこでイベントを使って、敵を生成したときにイベントを発生(Emit)し、CollisionSystemでそのイベントを購読(Subscribe)して衝突設定を行うという方法に変えることにしました。

イベントに向いているもの

  • 敵の生成
  • 弾の生成
  • プレイヤー被弾
  • 敵の破壊
  • レベルアップ
  • アイテム取得
  • ゲームオーバー

といった「たまに発生する瞬間」をイベントで配信するのが良い。

毎フレーム発生するようなイベントはパフォーマンス低下につながるので避ける。

実装方法

Phaser標準のEventEmitterを使います。
https://docs.phaser.io/phaser/concepts/events

例として、HealthSystemで敵の破壊イベント(EnemyDestroyed)を発生し、ScoreSystemで受け取る場合の処理です。

// HealthSystem.ts
if (health.hp <= 0) {
  this.scene.events.emit("EnemyDestroyed", { entityId });
}

// ScoreSystem.ts
this.scene.events.on("EnemyDestroyed", (data) => {
  const enemyId = data.entityId;
  // enemyIdの敵が倒されたので、それに応じたスコアを計算
});

このとき、Payload(data引数)はできるだけシンプルにします。 EntityId(number)のみにしておいて、必要なデータはComponentから取得し、IDなど小さなデータのみをイベントで配信するようにします。

購読解除

Scene終了時に removeAllListenersしてメモリリークを防止します。

// GameScene.ts
this.events.once("shutdown", () => this.events.removeAllListeners());

グローバルイベント

イベントはシーンごとに独立しているため、UISceneからはGameSceneのイベントを購読できません。 シーンをまたいで使うには、グローバルなイベントを使う必要があります。

// GameScene.ts
this.game.events.emit("HPChanged", {hp});

// UIScene.ts
this.game.events.on("HPChanged", onHPChanged);

メリット

イベントにより、どこで関数の呼び出しを行うか、という悩みが減り、拡張が楽になります。 例えば、敵が破壊されたときに効果音を出すなら、SoundSystemでEnemyDestroyedイベントを購読して、効果音を鳴らせばいいだけです。

まとめ

イベントを使って設計を見直しました。