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
使い方の手順
-
シーンに GroupSignaler を配置する
- 空の GameObject を作成し、名前を
GroupSignalerにします。 - 上記コードの
GroupSignalerコンポーネントをアタッチします。 - テスト用に
testGroupIdを"Enemy"、testMessageを"StartChase"などに設定しておきましょう。
- 空の GameObject を作成し、名前を
-
敵オブジェクトにリスナーを付ける(Enemyグループの例)
- 敵プレハブ(またはシーン上の敵オブジェクト)を選択します。
GroupIdFilterコンポーネントを追加し、GroupIdを"Enemy"に設定します。EnemyGroupListenerコンポーネントを追加します。EnemyGroupListenerのListen Group Idも"Enemy"にします。Targetにプレイヤーの Transform をドラッグ&ドロップで設定します。
これで、「Enemy グループに属する敵」が、通知を受け取れる状態になります。
-
動く床にリスナーを付ける(MovingPlatformグループの例)
- 動く床用のプレハブ(またはシーン上の床オブジェクト)を選択します。
GroupIdFilterコンポーネントを追加し、GroupIdを"MovingPlatform"に設定します。MovingPlatformListenerコンポーネントを追加し、Listen Group Idも"MovingPlatform"に設定します。Move OffsetやMove Durationを好みの値に調整します。
これで、「MovingPlatform グループの通知で一斉に動き出す床」が作れます。
-
イベントから 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を実行します。 testGroupIdとtestMessageに応じて、該当グループのオブジェクトが反応するはずです。
- シーン上の
-
プレイヤーがスイッチを踏んだときに敵を一斉起動
メリットと応用
メリット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クラス化を避けつつ、シーン全体の連携をスマートに組み立てていきましょう。
