Unityでゲームを作り始めると、つい何でもかんでも Update() に書いてしまいがちですよね。プレイヤーの入力、敵のAI、スコア管理、演出トリガー……全部ひとつのスクリプトに押し込んでしまうと、次のような問題が出てきます。

  • 処理の流れが追いづらく、バグの原因箇所を特定しにくい
  • 別のシーンや別プロジェクトで再利用しづらい
  • 敵やギミックの数が増えると、条件分岐の嵐でGodクラス化する

とくに「特定のイベントが起きたら、敵グループ全員に知らせたい」といった処理を、Update() に書き連ねてしまうと、

  • FindObjectsOfType<Enemy>() を毎フレーム呼んでしまう
  • 敵側の実装にべったり依存してしまう(コンポーネント指向から遠ざかる)

といった非効率&非モジュールな状態になりがちです。

そこでこの記事では、「イベント発生時に、Enemyグループなどの全オブジェクトへ通知を送る」役割だけに責務を絞ったコンポーネント 「GroupSignaler(グループ通知)」 を作ってみましょう。
シーン上に1つ置いておき、任意のタイミングで「合図」を発信すると、同じグループに属しているオブジェクトが一斉に反応してくれる仕組みです。

【Unity】一斉合図でスッキリ実装!「GroupSignaler」コンポーネント

ここでは以下のような設計方針にします。

  • 「グループ」を 文字列ID で表現(例: "Enemy""MovingPlatform"
  • 通知を受け取りたい側は IGroupSignalListener インターフェースを実装
  • 通知は 1フレーム内でまとめて 対象を検索し、OnGroupSignalReceived を呼び出す
  • 発信トリガーは
    • インスペクターからボタン押し(テスト用)
    • 任意のスクリプトから GroupSignaler.Signal("Enemy") のように呼び出し

コンポーネント指向的には、

  • GroupSignaler:通知を「送る」だけを担当
  • EnemyGroupListener(例):通知を「受けてどうするか」を担当

というふうに責務を分けるのがポイントです。

フルコード:GroupSignaler & サンプルリスナー


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

#region Group Signal Core

/// <summary>グループ通知を受け取りたいコンポーネントが実装するインターフェース</summary>
public interface IGroupSignalListener
{
    /// <summary>
    /// グループ通知を受け取ったときに呼ばれるコールバック
    /// </summary>
    /// <param name="groupId">発信元が指定したグループID</param>
    /// <param name="payload">任意の追加情報(必要なければ null)</param>
    void OnGroupSignalReceived(string groupId, object payload);
}

/// <summary>
/// 特定のイベント時に、指定グループのオブジェクトへ通知を送るコンポーネント。
/// シーンに1つ以上置いて使う想定。
/// </summary>
public class GroupSignaler : MonoBehaviour
{
    /// <summary>
    /// インスペクターからテスト送信するときに使うグループID。
    /// (例)"Enemy", "MovingPlatform" など
    /// </summary>
    [SerializeField]
    private string testGroupId = "Enemy";

    /// <summary>
    /// インスペクターからテスト送信するときに使うメッセージ。
    /// 実際のゲームでは payload に任意オブジェクトを渡す方が柔軟ですが、
    /// デバッグしやすいように string も用意しておきます。
    /// </summary>
    [SerializeField]
    private string testMessage = "TestSignal";

    /// <summary>
    /// グループIDごとにキャッシュされたリスナー一覧。
    /// 毎フレーム Find するのを避けるため、必要になったときにだけ更新します。
    /// </summary>
    private readonly Dictionary<string, List<IGroupSignalListener>> _listenerCache
        = new Dictionary<string, List<IGroupSignalListener>>();

    /// <summary>
    /// キャッシュの有効期限(秒)。0以下なら毎回再スキャン。
    /// </summary>
    [SerializeField]
    private float cacheLifetime = 1.0f;

    /// <summary>
    /// グループIDごとのキャッシュ作成時間。
    /// </summary>
    private readonly Dictionary<string, float> _cacheTime
        = new Dictionary<string, float>();

    /// <summary>
    /// シングルトン的に簡単にアクセスしたい場合用(任意)。
    /// シーンに1つだけ置く前提なら使ってもOKです。
    /// </summary>
    public static GroupSignaler Instance { get; private set; }

    private void Awake()
    {
        // シンプルなシングルトン実装(複数あってもエラーにはしない)
        if (Instance == null)
        {
            Instance = this;
        }
        else if (Instance != this)
        {
            // 2つ目以降は警告を出すだけ
            Debug.LogWarning(
                $"[GroupSignaler] 複数のインスタンスが存在します。Instance は最初に生成されたものを指します。",
                this
            );
        }
    }

    /// <summary>
    /// インスペクターからテスト送信用のボタンを表示するための属性。
    /// Unity6 では標準でボタン属性はありませんが、
    /// コンテキストメニューを使うことで右クリックメニューから実行できます。
    /// </summary>
    [ContextMenu("Test Signal")]
    private void ContextMenuTestSignal()
    {
        Debug.Log($"[GroupSignaler] コンテキストメニューからテスト送信: {testGroupId}, message={testMessage}");
        Signal(testGroupId, testMessage);
    }

    /// <summary>
    /// 指定したグループIDに通知を送ります。
    /// </summary>
    /// <param name="groupId">通知先のグループID</param>
    /// <param name="payload">任意の追加情報。必要なければ null</param>
    public void Signal(string groupId, object payload = null)
    {
        if (string.IsNullOrEmpty(groupId))
        {
            Debug.LogWarning("[GroupSignaler] groupId が空です。Signal は実行されません。", this);
            return;
        }

        // 対象グループのリスナー一覧を取得(必要に応じてスキャン)
        var listeners = GetListenersForGroup(groupId);

        if (listeners.Count == 0)
        {
            // リスナーがいない場合はデバッグログだけ出して終了
            Debug.Log($"[GroupSignaler] グループ \"{groupId}\" に登録されたリスナーが見つかりませんでした。", this);
            return;
        }

        // コールバックを順番に呼び出す
        for (int i = 0; i < listeners.Count; i++)
        {
            var listener = listeners[i];
            if (listener == null)
            {
                continue;
            }

            try
            {
                listener.OnGroupSignalReceived(groupId, payload);
            }
            catch (Exception ex)
            {
                Debug.LogError($"[GroupSignaler] リスナーの OnGroupSignalReceived 実行中に例外が発生しました: {ex}", this as Object);
            }
        }
    }

    /// <summary>
    /// 指定グループIDのリスナー一覧を取得します。
    /// 必要であればシーン内から再スキャンします。
    /// </summary>
    private List<IGroupSignalListener> GetListenersForGroup(string groupId)
    {
        // キャッシュが有効であればそれを返す
        if (_listenerCache.TryGetValue(groupId, out var cachedList) &&
            _cacheTime.TryGetValue(groupId, out var time) &&
            cacheLifetime > 0f &&
            Time.time - time < cacheLifetime)
        {
            return cachedList;
        }

        // 有効なキャッシュがないので、シーン内から再スキャン
        var result = new List<IGroupSignalListener>();

        // すべての MonoBehaviour を走査し、IGroupSignalListener を実装しているものを集める
        var behaviours = FindObjectsOfType<MonoBehaviour>(includeInactive: false);
        foreach (var mb in behaviours)
        {
            if (mb is IGroupSignalListener listener)
            {
                // さらに、GroupIdFilter を持っている場合は一致するものだけ追加
                var filter = mb.GetComponent<GroupIdFilter>();
                if (filter != null)
                {
                    if (filter.IsMatch(groupId))
                    {
                        result.Add(listener);
                    }
                }
                else
                {
                    // Filter が無い場合は「全グループの通知を受ける」ものとして扱う
                    result.Add(listener);
                }
            }
        }

        // キャッシュを更新
        _listenerCache[groupId] = result;
        _cacheTime[groupId] = Time.time;

        return result;
    }

    /// <summary>
    /// キャッシュを明示的にクリアしたいときに呼ぶヘルパー。
    /// 例:敵を大量に生成・破棄したあとなど。
    /// </summary>
    public void ClearCache()
    {
        _listenerCache.Clear();
        _cacheTime.Clear();
    }
}

/// <summary>
/// オブジェクトが所属するグループIDを指定するためのコンポーネント。
/// IGroupSignalListener と同じ GameObject に付けて使います。
/// </summary>
public class GroupIdFilter : MonoBehaviour
{
    [SerializeField]
    private string groupId = "Enemy";

    /// <summary>
    /// 現在設定されているグループIDを公開(読み取り専用)
/// </summary>
    public string GroupId => groupId;

    /// <summary>
    /// 指定されたグループIDと一致するかどうかを判定します。
    /// 大文字・小文字は区別しません。
    /// </summary>
    public bool IsMatch(string targetGroupId)
    {
        if (string.IsNullOrEmpty(targetGroupId))
        {
            return false;
        }

        return string.Equals(groupId, targetGroupId, StringComparison.OrdinalIgnoreCase);
    }
}

#endregion

#region Sample Listener Implementations

/// <summary>
/// 敵キャラクターの例:
/// GroupSignaler から "Enemy" グループの通知を受け取ったときに
/// 一斉にプレイヤーを追いかけ始める、などの挙動を実装できます。
/// </summary>
[RequireComponent(typeof(GroupIdFilter))]
public class EnemyGroupListener : MonoBehaviour, IGroupSignalListener
{
    [SerializeField]
    private string listenGroupId = "Enemy";

    [SerializeField]
    private float chaseSpeed = 3.0f;

    [SerializeField]
    private Transform target; // 追いかける対象(例:プレイヤー)

    private bool _isChasing;

    private void Reset()
    {
        // Reset はコンポーネント追加時に呼ばれるので、GroupIdFilter の初期値を揃えておく
        var filter = GetComponent<GroupIdFilter>();
        if (filter != null)
        {
            // インスペクター上で同じ値になるようにしておくと分かりやすい
#if UNITY_EDITOR
            UnityEditor.Undo.RecordObject(filter, "Set Default GroupId");
#endif
            typeof(GroupIdFilter)
                .GetField("groupId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
                ?.SetValue(filter, listenGroupId);
        }
    }

    private void Update()
    {
        if (_isChasing && target != null)
        {
            // プレイヤー方向へ移動する簡単なサンプル
            Vector3 dir = (target.position - transform.position).normalized;
            transform.position += dir * chaseSpeed * Time.deltaTime;
        }
    }

    public void OnGroupSignalReceived(string groupId, object payload)
    {
        // groupId が自分の listenGroupId と一致する場合だけ反応
        if (!string.Equals(groupId, listenGroupId, StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        // payload に特定の文字列が入っているときだけ反応する、なども可能
        if (payload is string message)
        {
            Debug.Log($"[EnemyGroupListener] グループ \"{groupId}\" からメッセージ \"{message}\" を受信しました。", this);

            if (message == "StartChase")
            {
                StartChase();
            }
            else if (message == "StopChase")
            {
                StopChase();
            }
        }
        else
        {
            // payload を使わない単純な「合図」として扱う
            Debug.Log($"[EnemyGroupListener] グループ \"{groupId}\" から通知を受信しました。payload なし", this);
            StartChase();
        }
    }

    private void StartChase()
    {
        _isChasing = true;
    }

    private void StopChase()
    {
        _isChasing = false;
    }
}

/// <summary>
/// 動く床の例:
/// "MovingPlatform" グループの通知を受けたら一斉に動き出す、など。
/// </summary>
[RequireComponent(typeof(GroupIdFilter))]
public class MovingPlatformListener : MonoBehaviour, IGroupSignalListener
{
    [SerializeField]
    private string listenGroupId = "MovingPlatform";

    [SerializeField]
    private Vector3 moveOffset = new Vector3(0, 2, 0);

    [SerializeField]
    private float moveDuration = 1.0f;

    private Vector3 _startPos;
    private Vector3 _targetPos;
    private float _moveTimer;
    private bool _isMoving;

    private void Start()
    {
        _startPos = transform.position;
        _targetPos = _startPos + moveOffset;
    }

    private void Update()
    {
        if (!_isMoving)
        {
            return;
        }

        _moveTimer += Time.deltaTime;
        float t = Mathf.Clamp01(_moveTimer / moveDuration);
        transform.position = Vector3.Lerp(_startPos, _targetPos, t);

        if (t >= 1.0f)
        {
            _isMoving = false;
        }
    }

    public void OnGroupSignalReceived(string groupId, object payload)
    {
        if (!string.Equals(groupId, listenGroupId, StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        Debug.Log($"[MovingPlatformListener] グループ \"{groupId}\" から通知を受信。動き始めます。", this);

        _isMoving = true;
        _moveTimer = 0f;
    }
}

#endregion

使い方の手順

  1. シーンに GroupSignaler を配置する
    • 空の GameObject を作成し、名前を GroupSignaler にします。
    • 上記コードの GroupSignaler コンポーネントをアタッチします。
    • テスト用に testGroupId"Enemy"testMessage"StartChase" などに設定しておきましょう。
  2. 敵オブジェクトにリスナーを付ける(Enemyグループの例)
    • 敵プレハブ(またはシーン上の敵オブジェクト)を選択します。
    • GroupIdFilter コンポーネントを追加し、GroupId"Enemy" に設定します。
    • EnemyGroupListener コンポーネントを追加します。
    • EnemyGroupListenerListen Group Id"Enemy" にします。
    • Target にプレイヤーの Transform をドラッグ&ドロップで設定します。

    これで、「Enemy グループに属する敵」が、通知を受け取れる状態になります。

  3. 動く床にリスナーを付ける(MovingPlatformグループの例)
    • 動く床用のプレハブ(またはシーン上の床オブジェクト)を選択します。
    • GroupIdFilter コンポーネントを追加し、GroupId"MovingPlatform" に設定します。
    • MovingPlatformListener コンポーネントを追加し、Listen Group Id"MovingPlatform" に設定します。
    • Move OffsetMove Duration を好みの値に調整します。

    これで、「MovingPlatform グループの通知で一斉に動き出す床」が作れます。

  4. イベントから GroupSignaler を呼び出す
    具体的なトリガー例をいくつか挙げます。
    • プレイヤーがスイッチを踏んだときに敵を一斉起動
      
      using UnityEngine;
      
      public class SwitchTrigger : MonoBehaviour
      {
          private void OnTriggerEnter(Collider other)
          {
              if (!other.CompareTag("Player"))
              {
                  return;
              }
      
              // シーン上の GroupSignaler にアクセスして Enemy グループへ通知
              if (GroupSignaler.Instance != null)
              {
                  GroupSignaler.Instance.Signal("Enemy", "StartChase");
              }
      
              // ついでに動く床も起動したい場合
              if (GroupSignaler.Instance != null)
              {
                  GroupSignaler.Instance.Signal("MovingPlatform", null);
              }
          }
      }
      
    • テスト用にインスペクターから通知を送る
      • シーン上の GroupSignaler オブジェクトを選択します。
      • インスペクター右上の「⋮」メニュー(またはコンポーネント右クリック)から Test Signal を実行します。
      • testGroupIdtestMessage に応じて、該当グループのオブジェクトが反応するはずです。

メリットと応用

メリット1:プレハブが完全に自律的になる
敵プレハブ側は「IGroupSignalListener を実装して、GroupIdFilter で自分のグループを名乗る」だけです。
「どのスイッチが自分を起動するか」「どのステージで使われるか」といった情報は一切持ちません。

レベルデザイナーは、

  • シーンに GroupSignaler を1つ置く
  • 好きな場所に敵や動く床のプレハブをポンポン配置する
  • スイッチやイベントトリガーから Signal("Enemy")Signal("MovingPlatform") を呼ぶ

だけで、複雑な連携演出を組み立てることができます。プレハブ同士が直接参照し合わないので、シーン間のコピペや再利用もとても楽になります。

メリット2:Update の責務が分離されて見通しが良くなる
「イベント発生の検知(スイッチやトリガー)」と「通知の一斉送信(GroupSignaler)」と「通知を受けて動くロジック(EnemyGroupListener など)」が完全に分離されるので、
1つの Update() に全部入り、という状態を避けられます。

メリット3:グループの概念を流用しやすい
グループIDはただの文字列なので、

  • "BossPhase1" / "BossPhase2" でボスの行動パターンを切り替える
  • "PuzzleRoomA" のギミック群を一斉にリセットする
  • "UI_FadeGroup" の Canvas や Image に一斉にフェード指示を出す

といった応用も簡単です。

改造案:一度きりの通知にしたい場合

例えば「この通知は一度だけ有効で、その後は無視したい」というケースもあります。
そんなときは、リスナー側に「一度だけ反応する」ヘルパーを追加してもよいですね。


private bool _hasReacted;

public void OnGroupSignalReceived(string groupId, object payload)
{
    if (_hasReacted)
    {
        // すでに一度反応していれば無視
        return;
    }

    if (!string.Equals(groupId, listenGroupId, StringComparison.OrdinalIgnoreCase))
    {
        return;
    }

    _hasReacted = true;

    // ここに一度きりの処理を書く
    StartChase();
}

このように、「通知を送るコンポーネント(GroupSignaler)」と「通知をどう扱うか(リスナー側)」をきれいに分離しておくと、後から仕様変更や演出追加があっても、個々のコンポーネントを小さく差し替えるだけで対応できるようになります。
Godクラス化を避けつつ、シーン全体の連携をスマートに組み立てていきましょう。