概要
ゲーム開発が進むにつれて、オブジェクト間の連携はどんどん複雑になります。例えば、「プレイヤーがダメージを受けたら、UIの体力バーを更新し、オーディオマネージャーに悲鳴を再生させ、カメラにシェイクエフェクトをかけ、ゲームマネージャーにプレイヤーの死亡を通知する」といった処理を考えてみましょう。
最も素朴な実装は、プレイヤーのスクリプトがUIManager、AudioManager、CameraManager、GameManagerなどをすべて 直接参照し、それぞれのメソッドを呼び出す方法です。しかしこのアプローチ(密結合)には、多くの問題があります。
- 依存関係の複雑化: プレイヤースクリプトが、本来知る必要のないUIやオーディオといった他の多くのシステムの存在を前提にしてしまいます。
- 拡張性の低下: 新しく「実績システムに通知する」という処理を追加したい場合、プレイヤースクリプトを修正する必要があります。
- 再利用性の低下: このプレイヤーの仕組みを、敵キャラクターに流用しようとしても、UIやカメラなど、敵には不要な依存関係が絡んできてしまい、簡単には再利用できません。
これらの問題を解決し、クリーンで拡張性の高いコードを書くための強力な仕組みが、C#のデリゲート (delegate) とイベント (event) を使ったイベント駆動設計です。
イベント駆動設計の基本的な考え方は、「何か(イベント)が起きたことを宣言する側」と、「そのイベントが起きたら何か(処理)をしたい側」を完全に分離することです。プレイヤーは「ダメージを受けた!」と叫ぶだけで、誰がそれを聞いているかを知る必要はありません。UIやオーディオは、その叫び声を聞きつけて、各自が自分の仕事をするだけです。
デリゲート (Delegate) とは?
デリゲートは、一言で言えば「メソッドへの参照を保持できる型」です。C/C++の関数ポインタに似ていますが、より安全でオブジェクト指向的な仕組みです。デリゲートを使うと、メソッドをあたかも変数であるかのように、他のメソッドに渡したり、クラスのフィールドとして保持したりできます。
// まず、デリゲートの「型」を定義します。
// これは「戻り値がvoidで、int型の引数を一つ取るメソッド」の型定義です。
public delegate void MyDelegate(int number);
public class DelegateExample
{
public void Run()
{
// デリゲート型の変数に、メソッドを代入する
MyDelegate myDelegate = PrintNumber;
myDelegate += PrintDoubleNumber; // マルチキャストデリゲート:複数のメソッドを登録できる
// デリゲートを呼び出す(登録されたメソッドがすべて呼ばれる)
myDelegate(5);
// 出力:
// Number: 5
// Double Number: 10
}
void PrintNumber(int num) { Debug.Log($"Number: {num}"); }
void PrintDoubleNumber(int num) { Debug.Log($"Double Number: {num * 2}"); }
}
イベント (Event) とは?
デリゲートは強力ですが、publicなデリゲート変数は、クラスの外部から自由に呼び出したり(myDelegate(5))、登録されたメソッドをクリアしたり(myDelegate = null)できてしまい、カプセル化が不十分です。
イベント (event) は、このデリゲートをラップし、クラスの外部からはメソッドの登録(+=)と解除(-=)しかできないように制限する仕組みです。イベントの発行(呼び出し)は、そのイベントを宣言したクラスの内部からしか行えません 。これにより、安全なイベント通知パターンを実装できます。
Unityでの実践例:疎結合な体力システム
プレイヤーの体力システムをイベント駆動で実装してみましょう。
1. イベントを発行する側 (PlayerHealth.cs)
PlayerHealthクラスは、ダメージを受けたことと、体力がゼロになったことをイベントとして通知します。このクラスは、UIやオーディオの存在を一切知りません。
using System;
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
// イベントの定義
// Action<T>は、Unity/.NETに標準で用意されている便利なデリゲート型
public static event Action<int, int> OnHealthChanged; // 引数: 現在の体力, 最大体力
public static event Action OnPlayerDied;
public int maxHealth = 100;
private int currentHealth;
private void Start()
{
currentHealth = maxHealth;
}
public void TakeDamage(int damage)
{
currentHealth -= damage;
if (currentHealth < 0) currentHealth = 0;
// イベントを発行(購読者がいれば通知される)
// ?.Invoke() は、購読者がいない場合にNullReferenceExceptionを防ぐ安全な呼び出し方
OnHealthChanged?.Invoke(currentHealth, maxHealth);
if (currentHealth <= 0)
{
OnPlayerDied?.Invoke();
}
}
}
2. イベントを購読する側 (UIManager.cs, AudioManager.cs)
UIManagerとAudioManagerは、PlayerHealthのイベントを購読し、通知が来たらそれぞれの処理を実行します。
// UIManager.cs
using UnityEngine;
using UnityEngine.UI; // Textを使うために必要
public class UIManager : MonoBehaviour
{
public Text healthText;
private void OnEnable() // オブジェクトが有効になった時
{
// PlayerHealthのイベントを購読
PlayerHealth.OnHealthChanged += UpdateHealthUI;
}
private void OnDisable() // オブジェクトが無効になった時
{
// 必ず購読を解除する(メモリリークを防ぐため)
PlayerHealth.OnHealthChanged -= UpdateHealthUI;
}
private void UpdateHealthUI(int current, int max)
{
healthText.text = $"HP: {current} / {max}";
}
}
// AudioManager.cs
public class AudioManager : MonoBehaviour
{
private void OnEnable()
{
PlayerHealth.OnPlayerDied += PlayDeathSound;
}
private void OnDisable()
{
PlayerHealth.OnPlayerDied -= PlayDeathSound;
}
private void PlayDeathSound()
{
// 死亡サウンドを再生する処理
}
}
staticイベントを使うことで、インスタンスへの参照なしにクラス名で直接イベントを購読できるため、シングルトンを使わなくてもマネージャークラス同士が簡単に連携できます。
まとめ
イベント駆動設計は、Unityでクリーンでスケーラブルなコードを書くための基本かつ強力なテクニックです。
- 密結合な直接参照は、コードの修正や再利用を困難にする。
- デリゲートは「メソッドへの参照」、イベントは「安全なデリゲートのラッパー」。
- イベントの発行側は「起きたこと」を宣言するだけ。購読側の存在を知る必要はない。
- イベントの購読側は、興味のあるイベントを購読(
+=)し、不要になったら必ず解除(-=)する。 - このパターンにより、各システムは独立して機能し、疎結合で拡張性の高い設計が実現できる。
GetComponentやFindObjectOfTypeで他のオブジェクトを探し回る前に、「これはイベントで通知できないか?」と考えてみましょう。その習慣が、あなたのコードをよりプロフェッショナルなレベルに引き上げてくれるはずです。