イベントとデリゲートの記事 で、static eventを使えばオブジェクト同士を疎結合にできることを学びました。ところが実際に使い込むと、次の壁が見えてきます。「購読側が PlayerHealth.OnHealthChanged とクラス名をべったり書いている」「シーンを再ロードしたら古い購読が残って二重発火した」——せっかくの疎結合が、まだ半分しか達成できていないのです。
この「次の一歩」が、イベントそのものを ScriptableObjectのアセット にしてしまう イベントチャネル パターンです。この記事では、staticイベントの限界がどこにあるのか、チャネル化すると何が変わるのかを、実装コード付きで解説します。
この記事でわかること
static eventが抱える2つの限界(クラス名依存・シーンまたぎの購読残り)- イベントを「アセット」として扱うイベントチャネルの考え方
- なぜScriptableObjectがイベントの入れ物に最適なのか(SOの特性)
VoidEventChannelSO/ 型付きチャネルの実装と配線方法- コイン取得・ポーズなど、自分のプロジェクトに応用できる設計ケース
- チャネル化すべきでない処理(ダメージなど宛先が明確なケース)の見分け方
staticイベントの2つの限界
まず、staticイベント方式のどこに限界があるのかを整理します。図で見比べてください。

限界1: クラス名への依存が残る
購読側は PlayerHealth.OnHealthChanged += ... と書きます。つまりUIは「体力イベントの発行者はPlayerHealthクラスだ」と知ってしまっています。発行者をボスキャラ用のBossHealthに差し替えたり、テスト用のダミーに入れ替えたりしたくなった瞬間、購読側のコードをすべて書き換えることになります。
限界2: シーンをまたいで購読が残る
staticなイベントはクラスと同じ寿命を持ち、シーンをロードし直しても消えません。購読解除を1箇所でも忘れると、破棄されたオブジェクトへの参照が残り続け、「リトライしたらイベントが2回ずつ発火する」「エラーが出るが原因の場所が分からない」という発見しづらいバグになります。
イベントチャネルとは?
イベントチャネルは、この2つの限界を「イベントをクラスの持ち物ではなく、独立したアセットにする」ことで解決します。
ScriptableObjectでイベント用のアセット(チャネル)を作り、プロジェクトに OnPlayerDied.asset のようなファイルとして置きます。発行者はこのアセットに向かって「発火して」と頼むだけ、購読者はこのアセットに耳を傾けるだけ。お互いの存在を一切知りません。

ラジオにたとえると、staticイベントが「特定の人(クラス)の声を直接聞きに行く」方式なのに対し、チャネルは「周波数(アセット)に合わせておけば、誰が放送していても聞こえる」方式です。放送局を入れ替えても、リスナーは何も変える必要がありません。
なぜScriptableObjectが最適なのか?
「イベントの入れ物」なら普通のC#クラスでも作れそうなのに、なぜScriptableObjectなのか——ここがこのパターンの核心です。鍵は、SOが持つ次の特性にあります。

特性1: シーンではなく「プロジェクト」に存在する
MonoBehaviourはGameObjectに載せてシーンに置くため、シーンが破棄されると一緒に消えます。一方ScriptableObjectは、シーンから独立したプロジェクト内のアセットです。シーンをいくらロード・破棄しても、チャネルという「中継点」は同じ場所に存在し続けます。イベントの受け渡し役として、これ以上ない安定した足場です。
特性2: 全員が「同じ1つ」を参照できる
Inspectorのスロットにアセットを割り当てると、シーンAのオブジェクトも、シーンBのオブジェクトも、メモリ上の同一インスタンスを指します。だからシーンをまたいだ通知が、特別な仕組みなしに成立します。C#の普通のクラスでこれをやろうとすると、結局「共有インスタンスをどう配るか」問題が発生し、staticやシングルトンに逆戻りしてしまいます。
特性3: プレハブから参照できる
これが実務で一番効きます。プレハブは、シーン内のオブジェクトをあらかじめ参照できません(Inspectorで割り当てようとしても入らない)。そのため、プレハブからシーン内のUIに通知したければ、生成後にFind系で探すしかありませんでした。しかしアセットへの参照ならプレハブに焼き込めます。チャネルを経由すれば、「プレハブ → シーンの誰か」への通知が探索ゼロで書けるのです。
補足: ScriptableObjectは本来「データの入れ物」として紹介されることが多い機能です(設定値・アイテムデータなど)。この基本は ScriptableObjectの記事 で解説しています。イベントチャネルは、この「シーンから独立したアセット」という性質をデータではなく通知に応用した使い方です。
実装:チャネルSOと発行・購読
実装は驚くほどシンプルです。まず「引数なし」のイベントチャネルから作ります。
1. チャネル本体 (VoidEventChannelSO.cs)
using UnityEngine;
using UnityEngine.Events;
// 「引数なしイベント」用のチャネルアセット
[CreateAssetMenu(menuName = "Events/Void Event Channel")]
public class VoidEventChannelSO : ScriptableObject
{
// 購読者が += / -= するイベント
public event UnityAction OnEventRaised;
// 発行者が呼ぶメソッド
public void Raise()
{
OnEventRaised?.Invoke();
}
}
作成したら、Projectウィンドウで右クリック →「Events > Void Event Channel」からアセットを作ります(例: OnPlayerDied.asset)。 このアセット1つが「チャンネル1局」 です。
2. 発行する側 (PlayerHealth.cs)
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
// Inspectorでチャネルアセットを割り当てる
[SerializeField] private VoidEventChannelSO onPlayerDied;
private int currentHealth = 100;
public void TakeDamage(int damage)
{
currentHealth -= damage;
if (currentHealth <= 0)
{
// クラス名ではなく「チャネル」に向かって発火する
onPlayerDied.Raise();
}
}
}
3. 購読する側 (GameOverUI.cs)
using UnityEngine;
public class GameOverUI : MonoBehaviour
{
// 同じチャネルア セットをInspectorで割り当てる
[SerializeField] private VoidEventChannelSO onPlayerDied;
private void OnEnable()
{
onPlayerDied.OnEventRaised += ShowGameOverScreen;
}
private void OnDisable()
{
// 鉄則はstaticイベントと同じ:OnDisableで必ず解除
onPlayerDied.OnEventRaised -= ShowGameOverScreen;
}
private void ShowGameOverScreen()
{
// ゲームオーバー画面を表示する処理
}
}
あとは、発行側と購読側の両方のInspectorに同じチャネルアセットをドラッグ&ドロップすれば配線完了です。コード上では、PlayerHealthとGameOverUIはお互いの名前を一度も書いていません。

値を渡したいとき:型付きチャネル
「現在の体力」のような値を一緒に渡したい場合は、型付きのチャネルを作ります。
using UnityEngine;
using UnityEngine.Events;
// int値を1つ渡すイベントチャネル
[CreateAssetMenu(menuName = "Events/Int Event Channel")]
public class IntEventChannelSO : ScriptableObject
{
public event UnityAction<int> OnEventRaised;
public void Raise(int value)
{
OnEventRaised?.Invoke(value);
}
}
float用・string用など、渡したい型ごとにチャネルクラスを用意するのが基本形です。よく使 う型だけ数種類作っておけば、ほとんどの通知はカバーできます。
補足: チャネルSOもアセットとしてシーンをまたいで生き続けるため、購読の解除忘れが危険なのはstaticイベントと同じです。「
OnEnableで+=、OnDisableで-=」のペア書きという イベントとデリゲートの記事 の鉄則は、チャネルでもそのまま適用してください。このペアさえ守れば、シーン破棄時に各購読者がOnDisableで自然に離脱してくれます。
設計ケース集:自分のプロジェクトへの応用
ゲームオーバー通知以外の定番ケースを2つ紹介します。「どんなイベントをチャネル化すると気持ちいいか」の感覚を掴んでください。
ケース1: コイン取得——プレハブからの通知

コインはプレハブで、シーンに何十個も複製されます。各コインがスコアUIを直接参照するのは不可能ですが(前述のとおりプレハブはシーン内を参照できません)、チャネルアセットへの参照ならプレハブに焼き込めます。
using UnityEngine;
public class Coin : MonoBehaviour
{
[SerializeField] private IntEventChannelSO onCoinCollected;
[SerializeField] private int value = 10;
private void OnTriggerEnter(Collider other)
{
if (!other.CompareTag("Player")) return;
// スコアUIもSEも知らない。チャネルに値を流すだけ
onCoinCollected.Raise(value);
Destroy(gameObject);
}
}
購読側はスコアUI(+= AddScore)と効果音(+= PlayCoinSE)。コインを100個置いても、新しい種類のコイン(銀貨・金貨)を増やしても、配線は増えません。同じチャネルにRaiseする限り、システム側は何も変わらないからです。
ケース2: ポーズ——1つの操作に全システムが一斉反応

ポーズは「敵AIを止める」「BGMを絞る」「タイマーを止める」……と、影響範囲がゲーム全体に散らばる機能の代表格です。ポーズボタンが各システムを1つずつ呼び出す設計だと、シス テムが増えるたびにボタン側の修正が必要になります。
boolを渡すチャネル(BoolEventChannelSO)を1本作り、ボタンは onPauseToggled.Raise(true) を呼ぶだけにします。後から「ポーズ中はパーティクルも止めたい」となっても、パーティクル管理側が購読を1行追加するだけ。発行側のボタンは一切触りません。
この2ケースに共通するのは、「発行側が増えても(コイン)、購読側が増えても(ポーズ)、反対側を修正しなくていい」という性質です。自分のプロジェクトで「これを直すとあっちも直す羽目になる」と感じた連携があれば、そこがチャネル化の候補です。
3つのイベント手法の使い分け
ここまでで手札が3つになりました。それぞれの得意分野で使い分けましょう。
| 手法 | 配線場所 | 得意な場面 | 注意点 |
|---|---|---|---|
| C# event(static含む) | コード | クラス内・密接なペアの通知。小規模で完結する連携 | クラス名依存/staticは購読残りに注意 |
| UnityEvent | Inspector | ボタンのOnClickなど、デザイナーがシーン内で繋ぎ替えたい部分 | シーン内の参照に限られる。実行が遅め |
| イベントチャネル(SO) | Inspector(アセット) | シーン・プレハブをまたぐ通知。発行者を差し替えたいシステム間連携 | アセットと配線 の管理が必要 |
判断の目安はこうです。
- まずはC# eventで始める。1つのクラス、または密接な2〜3クラスの通知はこれで十分
- Inspectorでデザイナーが繋ぎ替えたい箇所(ボタン、トリガーゾーンなど)はUnityEvent
- 「このイベント、いろんなシーンのいろんなシステムが聞きたがっているな」 と感じたらチャネル化を検討
注意点:対象が明確な処理はチャネル化しない
イベントチャネルは万能ではありません。むしろ 「宛先がはっきりしている1対1の処理」に使うと、確実に面倒になります。
典型例がダメージ処理です。剣が敵にヒットしたとき、ダメージを与えたい相手は「いま剣が当たったその敵」だと分かりきっています。これをチャネルでやろうとすると、こうなります。
- 「どの敵か」を特定する情報(IDや参照)をイベント引数で運ぶ羽目になる
- すべての敵が同じチャネルを購読し、通知が来るたびに「これは自分宛か?」を判定する
- 「誰がダメージを受けたのか」の流れをコードとエディタで追いにくくなり、デバッグが遠回りになる

こういう場面の正解は、昔ながらの直接呼び出しです。衝突相手から TryGetComponent でコンポーネントを取得し、そのままメソッドを呼びます。
using UnityEngine;
public class Sword : MonoBehaviour
{
[SerializeField] private int damage = 25;
private void OnTriggerEnter(Collider other)
{
// 相手は「いま当たったこの敵」だと分かっている → 直接取得して直接呼ぶ
if (other.TryGetComponent<EnemyHealth>(out var enemy))
{
enemy.TakeDamage(damage);
}
}
}
判断基準はシンプルで、 「宛先が特定できているか」 です。
- 宛先が特定できている(衝突相手・自分自身・Inspectorで参照済みの相手)→
GetComponent系で直接呼び出し - 宛先を知らない・複数いる・今後増減する(UI更新・SE・実績・セーブ)→ イベントチャネル
実戦では「処理は直接、結果の通知はチャネル」という2段構えが定番です。剣→敵のダメージ処理は直接呼び、ダメージを受けた敵側が「HPが変わった」という結果をOnEnemyDamagedチャネルで発行してUIやSEに知らせる——この組み合わせなら両者のいいとこ取りができます。直接呼び出しの基本は GetComponentの記事 で整理できます。
おまけ:先に知っておくと良いこと
イベントチャネルに慣れてきたら、次はこのあたりが視野に入ってきます。
- リスナーをコンポーネント化する別解: チャネルの購読側を 汎用の
GameEventListenerコンポーネント(UnityEventで応答を配線)にする流儀もあります。コードを書かずに購読を増やせる形で、ScriptableObjectの記事 で実装例を紹介しています。 - デバッグ発火ボタン: チャネルSOに「エディタから手動でRaiseするボタン」を生やしておくと、ゲームオーバー画面などを一瞬でテストできます。エディタ拡張の入門は エディタ拡張の記事 が参考になります。
- チャネルの整理術: チャネルが増えたら
Assets/Events/配下にフォルダを切り、OnPlayerDiedOnScoreChangedのように「On+出来事」で命名を統一すると迷子になりません。Unity公式のサンプルプロジェクト(Open Projects: Chop Chop)も、この設計を大規模に採用した実例として一見の価値があります。
まとめ
イベントチャネルは、staticイベントの疎結合を「クラス名からも自由になる」ところまで押し進める設計パターンです。
- staticイベントの限界: 購読側に発行者のクラス名依存が残る/シーンをまたいで購読が残りやすい。
- イベントチャネル: イベントをScriptableObjectアセットにして、発行者と購読者はチャネルだけを共有する。
- SOが最適な理由: シーンではなくプロジェクトに存在し、全員が同じ1つを参照でき、プレハブからも参照できるから。
- 実装は3点セット: チャネルSO(
Raise+event)、発行側([SerializeField]+Raise())、購読側(OnEnable/OnDisableで+=/-=)。 - 値を渡すなら型付きチャネルを型ごとに用意する。
- 応用の勘どころ: 発行側が量産される通知(コイン取得)や、購読側が増え続ける通知(ポーズ)はチャネル化の好例。
- チャネル化しない勇気: 宛先が明確な1対1の処理(衝突相手へのダメージなど)は
TryGetComponentで直接呼ぶ。「処理は直接、結果の通知はチャネル」の2段構えが定番。 - 使い分け: 小さく密な連携はC# event、シーン内のデザイナー配線はUnityEvent、シーン・プレハブをまたぐシステム間連携はチャネル。
「誰が聞いているか知らない」から「誰が叫んでいるかすら知らない」へ。この一歩で、あなたのゲームのシステム同士は、本当の意味で独立して開発・テストできるようになります。