プレイヤーがダメージを受けたら、体力バーを更新して、悲鳴を再生して、カメラを揺らして、ゲームオーバー判定もして——気づけばプレイヤーのスクリプトが UIManager も AudioManager も CameraManager も全部知っている。「1つ直すと全部壊れる」コードのできあがりです。
この「全員が全員を知っている」状態(密結合)を断ち切るのが、C#の デリゲート (delegate) と イベント (event) を使った イベント駆動設計 です。プレイヤーは「ダメージを受けた!」と叫ぶだけ。誰がそれを聞いているかは知らなくていい——この記事では、その仕組みと実装方法を解説します。
この記事でわかること
- 直接参照だらけの「密結合」が抱える3つの問題
- デリゲート=「メソッドへの参照」、イベント=「安全なラッパー」という関係
OnEnableで購読・OnDisableで解除、という鉄則の理由- 体力システムを題材にした疎結合な 実装例
なぜ直接参照だとダメなのか?
最も素朴な実装は、プレイヤーのスクリプトがUIManager、AudioManager、CameraManager、GameManagerなどをすべて直接参照し、それぞれのメソッドを呼び出す方法です。しかしこのアプローチ(密結合)には、多くの問題があります。
- 依存関係の複雑化: プレイヤースクリプトが、本来知る必要のないUIやオーディオといった他の多くのシステムの存在を前提にしてしまいます。
- 拡張性の低下: 新しく「実績システムに通知する」という処理を追加したい場合、プレイヤースクリプトを修正する必要があります。
- 再利用性の低下: このプレイヤーの仕組みを 敵キャラクターに流用しようとしても、UIやカメラなど、敵には不要な依存関係が絡んできて簡単には再利用できません。
イベント駆動設計の基本的な考え方は、「何か(イベント)が起きたことを宣言する側」と、「そのイベントが起きたら何か(処理)をしたい側」を完全に分離することです。

デリゲート (Delegate) とは?
デリゲートは、一言で言えば「メソッドへの参照を保持できる型」です。デリゲートを使うと、メソッドをあたかも変数であるかのように、他のメソッドに渡したり、クラスのフィールドとして保持したりできます。
// まず、デリゲートの「型」を定義します
// これは「戻り値が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}"); }
}
補足: 実際の開発では、
delegateを毎回自分で定義する代わりに、.NET標準の汎用デリゲート型Action(戻り値なし)やFunc(戻り値あり)を使うのが主流です。Action<int>は「int引数1つ・戻り値なしのメソッド」を表します。
イベント (Event) とは?
デリゲートは強力ですが、publicなデリゲート変数は、クラスの外部から自由に呼び出したり(myDelegate(5))、登録されたメソッドをまるごと消したり(myDelegate = null)できてしまい、カプセル化が不十分です。
イベント (event) は、このデリゲートをラップし、クラスの外部からはメソッドの登録(+=)と解除(-=)しかできないように制限する仕組みです。イベントの発行(呼び出し)は、そのイベントを宣言したクラスの内部からしか行えません。これにより、安全なイベント通知パターンを実装できます。
Unityでの実践例:疎結合な体力 システム
プレイヤーの体力システムをイベント駆動で実装してみましょう。コードの前に、まず全体像を図で掴んでください。

プレイヤーはOnHealthChangedというイベントを発行する(叫ぶ)だけ。それを購読しているUI・オーディオ・カメラが、通知を受けてそれぞれ自分の仕事をします。この形を、これからコードにしていきます。
1. イベントを発行する側 (PlayerHealth.cs)
PlayerHealthクラスは、ダメージを受けたことと、体力がゼロになったことをイベントとして通知します。このクラスは、UIやオーディオの存在を一切知りません。
using System;
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
// イベントの定義
// Action<T>は、.NETに標準で用意されている便利なデリゲート型
// instanceイベント: 「このプレイヤーの」体力変化を通知する
public event Action<int, int> OnHealthChanged; // 引数: 現在の体力, 最大体力
// staticイベント: 「ゲーム全体の合図」に限定して使う
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() は、購読者が1人もいない場合のNullReferenceExceptionを防ぐ安全な呼び出し方
OnHealthChanged?.Invoke(currentHealth, maxHealth);
if (currentHealth <= 0)
{
OnPlayerDied?.Invoke();
}
}
}
2. イベントを購読する側 (UIManager.cs, AudioManager.cs)
UIManagerとAudioManagerは、PlayerHealthのイベントを購読し、通知が来たらそれぞれの処理を実行します。
// UIManager.cs
using UnityEngine;
using TMPro; // TextMeshProを使うために必要
public class UIManager : MonoBehaviour
{
[SerializeField] private PlayerHealth player; // 監視する相手への参照はこの1本だけ
public TextMeshProUGUI healthText;
private void OnEnable() // オブジェクトが有効になった時
{
// playerのイベントを購読
player.OnHealthChanged += UpdateHealthUI;
}
private void OnDisable() // オブジェクトが無効になった時
{
// 必ず購読を解除する(破棄済みオブジェクトへの参照が残るのを防ぐ)
player.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()
{
// 死亡サウンドを再生する処理
}
}
2種類のイベントを使い分けている点に注目してください。体力の変化は「 どの個体の話か 」が重要なので instanceイベント にし、購読者は相手への参照を1本だけ持ちます。一方OnPlayerDiedのような「 ゲーム全体への合図 」は送信元の個体を区別する必要がないため、staticイベントとしてクラス名から直接購読できるようにしています。この使い分けの意味は、次の実践で敵を3体に増やすとはっきり分かります。
鉄則:OnEnableで購読、OnDisableで解除
購読(+=)したまま解除(-=)を忘れると、オブジェクトが破棄されてもイベント側に参照が残り続けます。staticイベントの場合はシーンを切り替えても残るため、破棄済みオブジェクトのメソッドが呼ばれてエラーになる、GCに回収されずメモリリークするといった、発見しづらいバグの温床になります。

「OnEnableで+=したら、必ずOnDisableで-=する」——ペアで書く習慣をつけましょう。
実践:敵が3体いても通知が混ざらない体力バー
instanceイベントの真価は、 同じクラスのオブジェクトが複数いるとき に出ます。敵が3体並ぶARPG、ユニットが何十体もいるRTS、頭上にHPバーが浮かぶMMO風の画面——「敵Bを攻撃したのに、なぜか全員のバーが減る」を防げるかどうかは、イベントの持ち方で決まります。
組み立て手順: 敵プレハブにEnemyHealthと、頭上のSlider+EnemyHealthBarを持たせて、シーンに3体(A・B・C)並べます。
// ファイル名: EnemyHealth.cs — 各敵にアタッチ
using System;
using UnityEngine;
public class EnemyHealth : MonoBehaviour
{
// instanceイベント: 「この敵の」体力変化だけを通知する
public event Action<int, int> OnHealthChanged;
public int maxHealth = 30;
private int currentHealth;
void Awake() { currentHealth = maxHealth; }
public void TakeDamage(int damage)
{
currentHealth = Mathf.Max(currentHealth - damage, 0);
OnHealthChanged?.Invoke(currentHealth, maxHealth);
}
}
// ファイル名: EnemyHealthBar.cs — 各敵の頭上HPバーにアタッチ
using UnityEngine;
using UnityEngine.UI;
public class EnemyHealthBar : MonoBehaviour
{
[SerializeField] private EnemyHealth owner; // 自分の主。この1体だけを見る
[SerializeField] private Slider slider;
void OnEnable() { owner.OnHealthChanged += UpdateBar; }
void OnDisable() { owner.OnHealthChanged -= UpdateBar; }
void UpdateBar(int current, int max)
{
slider.value = (float)current / max;
}
}
再生して真ん中の敵Bだけを攻撃してみてください。 Bのバーだけが減り、AとCは微動だにしません。ownerという参照1本で、通知の宛先が自動的に正しくなる——これがinstance イベントの効果です。
もしOnHealthChangedをstaticイベントにすると、敵Bを攻撃した瞬間に 3本のバーが全部反応します。staticイベントはクラスに1つしかないので、「誰の体力の話か」を区別できないのです。区別しようとすると引数に送信元を足して購読側でフィルタする羽目になり、本末転倒です。判断基準はシンプルに——
- 送信元の個体が重要(被弾・HP・選択状態)→ instanceイベント
- ゲーム全体への合図(Game Over・ポーズ・ステージクリア)→ staticイベント。シーンをまたぐ規模なら イベントチャネル へ

わざと壊して、直す
イベントまわりの定番事故を、この3体で安全に再現してみましょう。
- 解除忘れで二重反応:
EnemyHealthBarのOnDisableの-=をコメントアウトし、バーのGameObjectを非表示→再表示してから攻撃すると、UpdateBarに仕込んだDebug.Logが 1回の被弾で2回 出ます。開閉のたびに購読が積み上がるからです。-=を戻せば何度開閉しても1回に戻ります - ラムダ式は別のラムダで解除できない:
owner.OnHealthChanged += (c, m) => slider.value = (float)c / m;と購読し、同じ見た目の-=を書いても 解除されません。ラムダは書くたびに別物だからです。解除が必要な購読は、メソッド名(または変数に保存したラムダ)で行います - 破棄済みの購読者が残る: バーだけを
Destroyして攻撃すると、イベントに残った参照経由で破棄済みUIのUpdateBarが呼ばれ、MissingReferenceExceptionが出ます。「 購読者は破棄される前に必ず解除する 」はinstance/staticどちらでも鉄則です
仕上げの確認はシンプルで、「その通知は誰に届くべきか」と画面が一致しているかどうかです。敵Bを殴ってBのバーだけが減り、Game Overの合図だけはUI・音・カメラへ一斉に届く——instanceとstaticをこの感覚で選べるようになっていれば、イベント設計はもう自分の道具です。
おまけ:先に知っておくと良いこと
イベント駆動に慣れてきたら、次はこのあたりが視野に入ってきます。
- UnityEventとの使い分け: Inspectorから購読を設定できる
UnityEvent(ボタンのOnClickと同じ仕組み)もあります。デザイナーが繋ぎ替えたい部分はUnityEvent、コード内の通知はC# event、が目安です 。 - staticイベントのリセット問題:
staticイベントはシーンをまたいで生き残るため、シーン再ロード時に古い購読が残ることがあります。規模が大きくなったら、ScriptableObjectをイベントの入れ物にする イベントチャネル パターンが有力です。イベントチャネル設計の記事 で本記事の続きとして解説しています。 - 探し回る前にイベントを:
GetComponentやFindFirstObjectByTypeで他のオブジェクトを探し回る設計は密結合のもとです。参照取得の基本は GetComponentの記事 で整理できます。
まとめ
イベント駆動設計は、Unityでクリーンでスケーラブルなコードを書くための基本かつ強力なテクニックです。
- 密結合な直接参照は、コードの修正や再利用を困難にする。
- デリゲートは「メソッドへの参照」、イベントは「安全なデリゲートのラッパー」。
- イベントの発行側は「起きたこと」を宣言するだけ。購読側の存在を知る必要はない。
- イベントの購読側は、興味のあるイベントを購読(
+=)し、OnDisableで必ず解除(-=)する。 - このパターンにより、各システムは独立して機能し、疎結合で拡張性の高い設計が実現できる。
他のオブジェクトを探し回って直接呼び出す前に、「これはイベントで通知できないか?」と考えてみましょう。その習慣が、あなたのコードをよりプロフェッショナルなレ ベルに引き上げてくれるはずです。