Unityを触り始めると、つい何でもかんでも Update() に書いてしまいがちですよね。「プレイヤーの入力を見る」「UIを更新する」「スコアを管理する」「SEを鳴らす」…と、1つのスクリプトが全部抱え込むと、だんだん何がどこで動いているのか分からなくなってきます。
さらに、UIとプレイヤー、ゲームマネージャーと敵など、「お互いを直接知らなくていい関係」なのに、FindObjectOfType や public 参照でベタベタ結びつけてしまうと、シーン構成を少し変えただけで全部壊れる「依存関係まみれプロジェクト」になりがちです。
そこで今回は、シングルトン経由で関係ないノード同士のシグナルを中継する「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}");
}
}
使い方の手順
-
GlobalEventBus をシーンに配置する
- 空の GameObject を作成し、名前を
GlobalEventBusなどにします。 - 上記コードの
GlobalEventBusコンポーネントをアタッチします。 - シーン内にこのコンポーネントが 1 つだけ存在する状態にしましょう。
- 空の GameObject を作成し、名前を
-
イベント用の型を定義する
- 例: プレイヤーがゴールしたことを通知する
PlayerReachedGoalEventを作る:
[Serializable] public struct PlayerReachedGoalEvent { public string stageName; public PlayerReachedGoalEvent(string stageName) { this.stageName = stageName; } } - 例: プレイヤーがゴールしたことを通知する
-
イベントを「送る」側のコンポーネントを作る
例: プレイヤーがゴール地点のトリガーに入ったらイベントを発行する。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 付き)にアタッチします。
-
イベントを「受け取る」側のコンポーネントを作る
例: ゴール時にクリア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 を動かせるようになります。
- Canvas 配下にクリア演出用の Panel を用意し、
メリットと応用
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 に全部書くスタイルから卒業して、イベント駆動なコンポーネント指向の設計にシフトしていきましょう。
