【Unity】ScriptableObjectイベントチャネル設計入門 - staticイベントの次の一歩

作成: 2026-07-05最終更新: 2026-07-12

staticイベントによる疎結合には「クラス名への依存」「シーンをまたぐ購読残り」という限界があります。イベントをScriptableObjectアセットとして扱う「イベントチャネル」パターンで、さらに柔軟で安全なイベント設計を実現する方法を解説します。

イベントとデリゲートの記事 で、static eventを使えばオブジェクト同士を疎結合にできることを学びました。ところが実際に使い込むと、次の壁が見えてきます。「購読側が PlayerHealth.OnHealthChangedクラス名をべったり書いている」「シーンを再ロードしたら古い購読が残って二重発火した」——せっかくの疎結合が、まだ半分しか達成できていないのです。

この「次の一歩」が、イベントそのものを ScriptableObjectのアセット にしてしまう イベントチャネル パターンです。この記事では、staticイベントの限界がどこにあるのか、チャネル化すると何が変わるのかを、実装コード付きで解説します。

イベントチャネルのイメージ。中継アンテナ(チャネル)が発信者の電波を受けて、複数の受信者へ届けている

この記事でわかること

  • static eventが抱える2つの限界(クラス名依存・シーンまたぎの購読残り)
  • イベントを「アセット」として扱うイベントチャネルの考え方
  • なぜScriptableObjectがイベントの入れ物に最適なのか(SOの特性)
  • VoidEventChannelSO / 型付きチャネルの実装と配線方法
  • コイン取得・ポーズなど、自分のプロジェクトに応用できる設計ケース
  • チャネル化すべきでない処理(ダメージなど宛先が明確なケース)の見分け方

Sponsored

staticイベントの2つの限界

まず、staticイベント方式のどこに限界があるのかを整理します。図で見比べてください。

staticイベントとイベントチャネルの比較図。staticイベントは購読者が発行者クラスを名指しで参照するが、チャネル方式は発行者も購読者もチャネルアセットだけを見る

限界1: クラス名への依存が残る

購読側は PlayerHealth.OnHealthChanged += ... と書きます。つまりUIは「体力イベントの発行者はPlayerHealthクラスだ」と知ってしまっています。発行者をボスキャラ用のBossHealthに差し替えたり、テスト用のダミーに入れ替えたりしたくなった瞬間、購読側のコードをすべて書き換えることになります。

限界2: シーンをまたいで購読が残る

staticなイベントはクラスと同じ寿命を持ち、シーンをロードし直しても消えません。購読解除を1箇所でも忘れると、破棄されたオブジェクトへの参照が残り続け、「リトライしたらイベントが2回ずつ発火する」「エラーが出るが原因の場所が分からない」という発見しづらいバグになります。

イベントチャネルとは?

イベントチャネルは、この2つの限界を「イベントをクラスの持ち物ではなく、独立したアセットにする」ことで解決します。

ScriptableObjectでイベント用のアセット(チャネル)を作り、プロジェクトに OnPlayerDied.asset のようなファイルとして置きます。発行者はこのアセットに向かって「発火して」と頼むだけ、購読者はこのアセットに耳を傾けるだけ。お互いの存在を一切知りません

イベントチャネルの仕組みの図。発行者がチャネルアセットのRaiseを呼ぶと、チャネルを購読しているUI・オーディオ・実績システムに通知が届く

ラジオにたとえると、staticイベントが「特定の人(クラス)の声を直接聞きに行く」方式なのに対し、チャネルは「周波数(アセット)に合わせておけば、誰が放送していても聞こえる」方式です。放送局を入れ替えても、リスナーは何も変える必要がありません。

なぜScriptableObjectが最適なのか?

「イベントの入れ物」なら普通のC#クラスでも作れそうなのに、なぜScriptableObjectなのか——ここがこのパターンの核心です。鍵は、SOが持つ次の特性にあります。

ScriptableObjectの特性の図。チャネルアセットはシーンの外(プロジェクト側)に存在し、シーンAからもシーンBからもプレハブからも、同じ1つのアセットを参照できる

特性1: シーンではなく「プロジェクト」に存在する

MonoBehaviourはGameObjectに載せてシーンに置くため、シーンが破棄されると一緒に消えます。一方ScriptableObjectは、シーンから独立したプロジェクト内のアセットです。シーンをいくらロード・破棄しても、チャネルという「中継点」は同じ場所に存在し続けます。イベントの受け渡し役として、これ以上ない安定した足場です。

特性2: 全員が「同じ1つ」を参照できる

Inspectorのスロットにアセットを割り当てると、シーンAのオブジェクトも、シーンBのオブジェクトも、メモリ上の同一インスタンスを指します。だからシーンをまたいだ通知が、特別な仕組みなしに成立します。C#の普通のクラスでこれをやろうとすると、結局「共有インスタンスをどう配るか」問題が発生し、staticやシングルトンに逆戻りしてしまいます。

特性3: プレハブから参照できる

これが実務で一番効きます。プレハブは、シーン内のオブジェクトをあらかじめ参照できません(Inspectorで割り当てようとしても入らない)。そのため、プレハブからシーン内のUIに通知したければ、生成後にFind系で探すしかありませんでした。しかしアセットへの参照ならプレハブに焼き込めます。チャネルを経由すれば、「プレハブ → シーンの誰か」への通知が探索ゼロで書けるのです。

補足: ScriptableObjectは本来「データの入れ物」として紹介されることが多い機能です(設定値・アイテムデータなど)。この基本は ScriptableObjectの記事 で解説しています。イベントチャネルは、この「シーンから独立したアセット」という性質をデータではなく通知に応用した使い方です。

Sponsored

実装:チャネル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に同じチャネルアセットをドラッグ&ドロップすれば配線完了です。コード上では、PlayerHealthGameOverUIはお互いの名前を一度も書いていません。

Inspectorでの配線の図。発行者と購読者それぞれのコンポーネントのスロットに、同じOnPlayerDiedチャネルアセットをドラッグして割り当てる

値を渡したいとき:型付きチャネル

「現在の体力」のような値を一緒に渡したい場合は、型付きのチャネルを作ります。

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で自然に離脱してくれます。

Sponsored

設計ケース集:自分のプロジェクトへの応用

ゲームオーバー通知以外の定番ケースを2つ紹介します。「どんなイベントをチャネル化すると気持ちいいか」の感覚を掴んでください。

ケース1: コイン取得——プレハブからの通知

コイン取得のイベントチャネル設計図。プレハブから複製された各コインが、取得時にOnCoinCollectedチャネルへRaiseし、スコアUIと効果音が反応する。コインを何個複製しても配線は同じ

コインはプレハブで、シーンに何十個も複製されます。各コインがスコア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つの操作に全システムが一斉反応

ポーズのイベントチャネル設計図。ポーズボタンがOnPauseToggledチャネルへRaiseすると、敵AIの停止・BGMの音量ダウン・タイマーの一時停止が一斉に起こる

ポーズは「敵AIを止める」「BGMを絞る」「タイマーを止める」……と、影響範囲がゲーム全体に散らばる機能の代表格です。ポーズボタンが各システムを1つずつ呼び出す設計だと、システムが増えるたびにボタン側の修正が必要になります。

boolを渡すチャネル(BoolEventChannelSO)を1本作り、ボタンは onPauseToggled.Raise(true) を呼ぶだけにします。後から「ポーズ中はパーティクルも止めたい」となっても、パーティクル管理側が購読を1行追加するだけ。発行側のボタンは一切触りません。

この2ケースに共通するのは、「発行側が増えても(コイン)、購読側が増えても(ポーズ)、反対側を修正しなくていい」という性質です。自分のプロジェクトで「これを直すとあっちも直す羽目になる」と感じた連携があれば、そこがチャネル化の候補です。

3つのイベント手法の使い分け

ここまでで手札が3つになりました。それぞれの得意分野で使い分けましょう。

手法配線場所得意な場面注意点
C# event(static含む)コードクラス内・密接なペアの通知。小規模で完結する連携クラス名依存/staticは購読残りに注意
UnityEventInspectorボタンのOnClickなど、デザイナーがシーン内で繋ぎ替えたい部分シーン内の参照に限られる。実行が遅め
イベントチャネル(SO)Inspector(アセット)シーン・プレハブをまたぐ通知。発行者を差し替えたいシステム間連携アセットと配線の管理が必要

判断の目安はこうです。

  • まずはC# eventで始める。1つのクラス、または密接な2〜3クラスの通知はこれで十分
  • Inspectorでデザイナーが繋ぎ替えたい箇所(ボタン、トリガーゾーンなど)はUnityEvent
  • 「このイベント、いろんなシーンのいろんなシステムが聞きたがっているな」 と感じたらチャネル化を検討

注意点:対象が明確な処理はチャネル化しない

イベントチャネルは万能ではありません。むしろ 「宛先がはっきりしている1対1の処理」に使うと、確実に面倒になります

典型例がダメージ処理です。剣が敵にヒットしたとき、ダメージを与えたい相手は「いま剣が当たったその敵」だと分かりきっています。これをチャネルでやろうとすると、こうなります。

  • 「どの敵か」を特定する情報(IDや参照)をイベント引数で運ぶ羽目になる
  • すべての敵が同じチャネルを購読し、通知が来るたびに「これは自分宛か?」を判定する
  • 「誰がダメージを受けたのか」の流れをコードとエディタで追いにくくなり、デバッグが遠回りになる
チャネル経由と直接呼び出しの比較図。剣が当たった敵が分かっているのにチャネルを経由すると、敵全員に通知が届いて各自が自分宛か判定する遠回りになる。相手が分かっているなら、当たった敵のTakeDamageを直接呼ぶ

こういう場面の正解は、昔ながらの直接呼び出しです。衝突相手から 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/ 配下にフォルダを切り、OnPlayerDied OnScoreChanged のように「On+出来事」で命名を統一すると迷子になりません。Unity公式のサンプルプロジェクト(Open Projects: Chop Chop)も、この設計を大規模に採用した実例として一見の価値があります。

まとめ

イベントチャネルは、staticイベントの疎結合を「クラス名からも自由になる」ところまで押し進める設計パターンです。

  • staticイベントの限界: 購読側に発行者のクラス名依存が残る/シーンをまたいで購読が残りやすい。
  • イベントチャネル: イベントをScriptableObjectアセットにして、発行者と購読者はチャネルだけを共有する。
  • SOが最適な理由: シーンではなくプロジェクトに存在し、全員が同じ1つを参照でき、プレハブからも参照できるから。
  • 実装は3点セット: チャネルSO(Raiseevent)、発行側([SerializeField]Raise())、購読側(OnEnable/OnDisable+=/-=)。
  • 値を渡すなら型付きチャネルを型ごとに用意する。
  • 応用の勘どころ: 発行側が量産される通知(コイン取得)や、購読側が増え続ける通知(ポーズ)はチャネル化の好例。
  • チャネル化しない勇気: 宛先が明確な1対1の処理(衝突相手へのダメージなど)はTryGetComponentで直接呼ぶ。「処理は直接、結果の通知はチャネル」の2段構えが定番。
  • 使い分け: 小さく密な連携はC# event、シーン内のデザイナー配線はUnityEvent、シーン・プレハブをまたぐシステム間連携はチャネル。

「誰が聞いているか知らない」から「誰が叫んでいるかすら知らない」へ。この一歩で、あなたのゲームのシステム同士は、本当の意味で独立して開発・テストできるようになります。