Unityを触り始めると、つい何でもかんでも Update() に書いてしまいがちですよね。「プレイヤーの入力を見る」「UIを更新する」「スコアを管理する」「SEを鳴らす」…と、1つのスクリプトが全部抱え込むと、だんだん何がどこで動いているのか分からなくなってきます。

さらに、UIとプレイヤー、ゲームマネージャーと敵など、「お互いを直接知らなくていい関係」なのに、FindObjectOfTypepublic 参照でベタベタ結びつけてしまうと、シーン構成を少し変えただけで全部壊れる「依存関係まみれプロジェクト」になりがちです。

そこで今回は、シングルトン経由で関係ないノード同士のシグナルを中継する「GlobalEventBus」コンポーネントを作って、コンポーネント同士をゆるくつなぐ仕組みを用意してみましょう。

【Unity】シグナルでゆるくつなぐ!「GlobalEventBus」コンポーネント

フルコード(GlobalEventBus.cs)


using System;
using System.Collections.Generic;
using UnityEngine;

/// <summary> 
/// シングルトンなイベントバス。
/// - 任意の型のイベントを登録/解除/発行できる
/// - UIやプレイヤーなど、互いを直接参照しないでやり取りできる
/// - ScriptableObject ではなく、シーン上の GameObject にアタッチして使う想定
/// </summary>
public class GlobalEventBus : MonoBehaviour
{
    // --- シングルトン実装 ---------------------------------------------

    /// <summary>
    /// グローバルなインスタンスへのアクセス。
    /// シーン内に 1 つだけ存在することを想定。
    /// </summary>
    public static GlobalEventBus Instance { get; private set; }

    /// <summary>
    /// アプリケーション終了時に破棄されたかどうか。
    /// 終了中にイベントを飛ばしてエラーになるのを防ぐ。
    /// </summary>
    private static bool _isShuttingDown = false;

    private void Awake()
    {
        // すでにインスタンスが存在している場合は自分を破棄
        if (Instance != null && Instance != this)
        {
            Debug.LogWarning(
                $"[GlobalEventBus] 既にインスタンスが存在するため、このコンポーネントを削除します。 " +
                $"存在しているオブジェクト: {Instance.gameObject.name}, 競合オブジェクト: {gameObject.name}");
            Destroy(gameObject);
            return;
        }

        Instance = this;
        _isShuttingDown = false;

        // シーンが切り替わっても残したい場合はコメントアウトを外す
        // DontDestroyOnLoad(gameObject);
    }

    private void OnApplicationQuit()
    {
        _isShuttingDown = true;
    }

    private void OnDestroy()
    {
        // アプリケーション終了時は Instance をクリア
        if (Instance == this)
        {
            Instance = null;
        }
    }

    // --- イベントバスの中身 -------------------------------------------

    /// <summary>
    /// イベントの型ごとに、デリゲート(コールバック)を管理する辞書。
    /// key: イベント型 (typeof(SomeEvent))
    /// value: そのイベントを受け取るコールバック(object で保持)
    /// </summary>
    private readonly Dictionary<Type, Delegate> _eventTable = new();

    /// <summary>
    /// 型 T のイベントにリスナーを追加する。
    /// </summary>
    /// <typeparam name="TEvent">イベントの型(クラスでも struct でもOK)</typeparam>
    /// <param name="listener">イベントを受け取るコールバック</param>
    public static void Subscribe<TEvent>(Action<TEvent> listener)
    {
        // シングルトンが存在しない場合は警告して終了
        if (!IsValidInstance())
        {
            Debug.LogWarning("[GlobalEventBus] Subscribe が呼ばれましたが、インスタンスが存在しません。");
            return;
        }

        Instance.InternalSubscribe(listener);
    }

    /// <summary>
    /// 型 T のイベントからリスナーを削除する。
    /// </summary>
    public static void Unsubscribe<TEvent>(Action<TEvent> listener)
    {
        if (!IsValidInstance())
        {
            // 終了時など、特に警告を出さなくてもよいケースが多いので黙って終了
            return;
        }

        Instance.InternalUnsubscribe(listener);
    }

    /// <summary>
    /// 型 T のイベントを発行(ブロードキャスト)する。
    /// </summary>
    /// <param name="eventData">イベントの中身(データ)</param>
    public static void Publish<TEvent>(TEvent eventData)
    {
        if (!IsValidInstance())
        {
            // アプリ終了時やインスタンス未配置時は無視
            return;
        }

        Instance.InternalPublish(eventData);
    }

    /// <summary>
    /// シングルトンインスタンスが有効かどうかを判定するヘルパー。
    /// </summary>
    private static bool IsValidInstance()
    {
        return !_isShuttingDown && Instance != null;
    }

    // --- 内部実装(インスタンスメソッド) -------------------------------

    /// <summary>
    /// 実際の Subscribe 処理(インスタンス側)。
    /// </summary>
    private void InternalSubscribe<TEvent>(Action<TEvent> listener)
    {
        var eventType = typeof(TEvent);

        if (_eventTable.TryGetValue(eventType, out var existingDelegate))
        {
            // すでに登録されているデリゲートに追加
            _eventTable[eventType] = Delegate.Combine(existingDelegate, listener);
        }
        else
        {
            // 初めてのイベント型なら、そのまま登録
            _eventTable[eventType] = listener;
        }
    }

    /// <summary>
    /// 実際の Unsubscribe 処理(インスタンス側)。
    /// </summary>
    private void InternalUnsubscribe<TEvent>(Action<TEvent> listener)
    {
        var eventType = typeof(TEvent);

        if (!_eventTable.TryGetValue(eventType, out var existingDelegate))
        {
            // そもそも登録されていない
            return;
        }

        var currentDelegate = Delegate.Remove(existingDelegate, listener);

        if (currentDelegate == null)
        {
            // 誰もいなくなったら辞書から削除
            _eventTable.Remove(eventType);
        }
        else
        {
            _eventTable[eventType] = currentDelegate;
        }
    }

    /// <summary>
    /// 実際の Publish 処理(インスタンス側)。
    /// </summary>
    private void InternalPublish<TEvent>(TEvent eventData)
    {
        var eventType = typeof(TEvent);

        if (!_eventTable.TryGetValue(eventType, out var existingDelegate))
        {
            // リスナーがいなければ何もしない
            return;
        }

        // 型安全にキャストして呼び出す
        if (existingDelegate is Action<TEvent> callback)
        {
            try
            {
                callback.Invoke(eventData);
            }
            catch (Exception ex)
            {
                Debug.LogError($"[GlobalEventBus] イベント処理中に例外が発生しました: {ex}");
            }
        }
        else
        {
            Debug.LogError(
                $"[GlobalEventBus] イベント型 {eventType.Name} のデリゲート型が不正です。登録と解除の型が一致しているか確認してください。");
        }
    }
}

// ------------------------------------------------------------
// 以下は使用例としてのイベント定義とサンプルコンポーネント
// ------------------------------------------------------------

/// <summary>
/// プレイヤーがダメージを受けたことを通知するイベント。
/// UI 側で HP を減らしたり、エフェクトを再生したりするのに使える。
/// </summary>
[Serializable]
public struct PlayerDamagedEvent
{
    public int currentHp;
    public int maxHp;
    public Vector3 hitPosition;

    public PlayerDamagedEvent(int currentHp, int maxHp, Vector3 hitPosition)
    {
        this.currentHp = currentHp;
        this.maxHp = maxHp;
        this.hitPosition = hitPosition;
    }
}

/// <summary>
/// プレイヤーがコインを取得したことを通知するイベント。
/// </summary>
[Serializable]
public struct CoinCollectedEvent
{
    public int totalCoins;

    public CoinCollectedEvent(int totalCoins)
    {
        this.totalCoins = totalCoins;
    }
}

/// <summary>
/// プレイヤー側からイベントを発行するサンプル。
/// 実際のプロジェクトでは、ダメージ処理やアイテム取得処理から Publish を呼ぶ。
/// </summary>
public class SamplePlayerEventSender : MonoBehaviour
{
    [Header("ダメージテスト用パラメータ")]
    [SerializeField] private int _maxHp = 100;
    [SerializeField] private int _currentHp = 100;

    [Header("コインテスト用パラメータ")]
    [SerializeField] private int _totalCoins = 0;

    private void Update()
    {
        // キー入力でテスト用のイベントを飛ばす例
        // 本番ではダメージを受けたタイミングやコイン取得時に呼びましょう。

        // D キーでダメージイベントを発行
        if (Input.GetKeyDown(KeyCode.D))
        {
            TakeDamage(10);
        }

        // C キーでコイン取得イベントを発行
        if (Input.GetKeyDown(KeyCode.C))
        {
            AddCoin(1);
        }
    }

    private void TakeDamage(int damage)
    {
        _currentHp = Mathf.Max(0, _currentHp - damage);

        // イベント構造体を作成して Publish
        var damageEvent = new PlayerDamagedEvent(
            currentHp: _currentHp,
            maxHp: _maxHp,
            hitPosition: transform.position
        );

        GlobalEventBus.Publish(damageEvent);
    }

    private void AddCoin(int amount)
    {
        _totalCoins += amount;

        var coinEvent = new CoinCollectedEvent(_totalCoins);
        GlobalEventBus.Publish(coinEvent);
    }
}

/// <summary>
/// UI 側で HP 表示を更新するサンプル。
/// Player オブジェクトを直接参照せず、イベントだけを受け取る。
/// </summary>
public class SampleHpUiListener : MonoBehaviour
{
    [Header("デバッグ用に現在の HP をインスペクターで確認する")]
    [SerializeField] private int _currentHp;
    [SerializeField] private int _maxHp;

    private void OnEnable()
    {
        // イベントの購読開始
        GlobalEventBus.Subscribe<PlayerDamagedEvent>(OnPlayerDamaged);
    }

    private void OnDisable()
    {
        // イベントの購読解除
        GlobalEventBus.Unsubscribe<PlayerDamagedEvent>(OnPlayerDamaged);
    }

    private void OnPlayerDamaged(PlayerDamagedEvent damageEvent)
    {
        _currentHp = damageEvent.currentHp;
        _maxHp = damageEvent.maxHp;

        // 実際には TextMeshPro や Image.fillAmount を更新する処理を書く
        Debug.Log($"[SampleHpUiListener] HP 更新: {_currentHp} / {_maxHp}");
    }
}

/// <summary>
/// スコア管理やコイン数表示など、UI 側でコイン取得イベントを受け取る例。
/// </summary>
public class SampleCoinUiListener : MonoBehaviour
{
    [SerializeField] private int _totalCoins;

    private void OnEnable()
    {
        GlobalEventBus.Subscribe<CoinCollectedEvent>(OnCoinCollected);
    }

    private void OnDisable()
    {
        GlobalEventBus.Unsubscribe<CoinCollectedEvent>(OnCoinCollected);
    }

    private void OnCoinCollected(CoinCollectedEvent coinEvent)
    {
        _totalCoins = coinEvent.totalCoins;
        Debug.Log($"[SampleCoinUiListener] コイン枚数: {_totalCoins}");
    }
}

使い方の手順

  1. GlobalEventBus をシーンに配置する
    • 空の GameObject を作成し、名前を GlobalEventBus などにします。
    • 上記コードの GlobalEventBus コンポーネントをアタッチします。
    • シーン内にこのコンポーネントが 1 つだけ存在する状態にしましょう。
  2. イベント用の型を定義する
    • 例: プレイヤーがゴールしたことを通知する PlayerReachedGoalEvent を作る:
    
    [Serializable]
    public struct PlayerReachedGoalEvent
    {
        public string stageName;
    
        public PlayerReachedGoalEvent(string stageName)
        {
            this.stageName = stageName;
        }
    }
        
  3. イベントを「送る」側のコンポーネントを作る
    例: プレイヤーがゴール地点のトリガーに入ったらイベントを発行する。
    
    using UnityEngine;
    
    [RequireComponent(typeof(Collider))]
    public class PlayerGoalTrigger : MonoBehaviour
    {
        [SerializeField] private string _stageName = "Stage1";
    
        private void OnTriggerEnter(Collider other)
        {
            // ここではタグ "Player" をゴール判定対象にする例
            if (!other.CompareTag("Player")) return;
    
            var goalEvent = new PlayerReachedGoalEvent(_stageName);
            GlobalEventBus.Publish(goalEvent);
    
            Debug.Log("[PlayerGoalTrigger] ゴールイベントを発行しました。");
        }
    }
        

    このコンポーネントをゴール地点のオブジェクト(IsTrigger をオンにした Collider 付き)にアタッチします。

  4. イベントを「受け取る」側のコンポーネントを作る
    例: ゴール時にクリアUIを表示するコンポーネントを UI キャンバス側にアタッチ。
    
    using UnityEngine;
    
    public class GoalUiListener : MonoBehaviour
    {
        [SerializeField] private GameObject _clearPanel;
    
        private void OnEnable()
        {
            // ゴールイベントを購読開始
            GlobalEventBus.Subscribe<PlayerReachedGoalEvent>(OnPlayerReachedGoal);
        }
    
        private void OnDisable()
        {
            // ゴールイベントを購読解除
            GlobalEventBus.Unsubscribe<PlayerReachedGoalEvent>(OnPlayerReachedGoal);
        }
    
        private void OnPlayerReachedGoal(PlayerReachedGoalEvent goalEvent)
        {
            if (_clearPanel != null)
            {
                _clearPanel.SetActive(true);
            }
    
            Debug.Log($"[GoalUiListener] ステージクリア: {goalEvent.stageName}");
        }
    }
        
    • Canvas 配下にクリア演出用の Panel を用意し、_clearPanel にドラッグ&ドロップします。
    • プレイヤーやゴールオブジェクトを一切参照せず、イベントだけで UI を動かせるようになります。

メリットと応用

GlobalEventBus を導入すると、以下のようなメリットがあります。

  • コンポーネント同士の依存がゆるくなる
    UI は「プレイヤーのスクリプト」を知らなくてよく、「プレイヤーがダメージを受けた」「コインを取った」という事実だけを知れば十分になります。
    これにより、プレイヤーの実装を差し替えても UI 側のコードはそのままで済みます。
  • プレハブの再利用性が上がる
    例えば「HPバー UI プレハブ」は、PlayerDamagedEvent さえ飛んでくればどのシーンでも動作します。
    「このシーンではプレイヤーオブジェクトの参照を差し替えて…」といった面倒なセットアップが減り、イベントを購読するだけのプレハブとして量産しやすくなります。
  • レベルデザインが楽になる
    スイッチを押したらドアが開く、敵を全部倒したら次のエリアが解放される、などのギミックを、
    「スイッチが押されたイベント」「敵が全滅したイベント」として組み立てれば、
    シーンビュー上で「どのオブジェクトがどれに紐づいているか」をいちいちドラッグしてつなぐ必要が減ります。
  • 巨大な GameManager スクリプトを避けられる
    何でもかんでも GameManager に書くのではなく、
    「イベントを受け取ってやることだけを担当する小さなコンポーネント」をたくさん作るスタイルに移行しやすくなります。

応用としては、以下のような拡張も考えられます。

  • イベントに優先度を付けて、特定のリスナーを先に実行する
  • エディタ拡張で「現在登録されているイベントとリスナー一覧」をデバッグ表示する
  • ネットワーク越しに一部のイベントだけ同期する

最後に、「一時的にイベントをミュートする」改造案の例を載せておきます。
ゲーム一時停止中は UI 更新イベントだけ止める、などの用途に使えます。


public partial class GlobalEventBus : MonoBehaviour
{
    // 特定のイベント型をミュートするためのセット
    private readonly HashSet<Type> _mutedEventTypes = new();

    /// <summary>
    /// 指定したイベント型をミュート(Publish されても無視)する。
    /// </summary>
    public static void MuteEventType<TEvent>()
    {
        if (!IsValidInstance()) return;
        Instance._mutedEventTypes.Add(typeof(TEvent));
    }

    /// <summary>
    /// 指定したイベント型のミュートを解除する。
    /// </summary>
    public static void UnmuteEventType<TEvent>()
    {
        if (!IsValidInstance()) return;
        Instance._mutedEventTypes.Remove(typeof(TEvent));
    }

    // InternalPublish を少し改造して、ミュートチェックを追加
    private void InternalPublish<TEvent>(TEvent eventData)
    {
        var eventType = typeof(TEvent);

        // ミュートされているイベント型なら何もしない
        if (_mutedEventTypes.Contains(eventType))
        {
            return;
        }

        if (!_eventTable.TryGetValue(eventType, out var existingDelegate))
        {
            return;
        }

        if (existingDelegate is Action<TEvent> callback)
        {
            callback.Invoke(eventData);
        }
    }
}

このように、GlobalEventBus をベースに少しずつ機能を足していくと、「小さな責務のコンポーネント」がイベントでつながる設計を実現しやすくなります。
Update に全部書くスタイルから卒業して、イベント駆動なコンポーネント指向の設計にシフトしていきましょう。