Unityを触り始めた頃は、つい「とりあえず全部Updateに書けば動くし楽!」となりがちですよね。
プレイヤーの移動、敵AI、UI更新、当たり判定の処理などを1つの巨大スクリプトに押し込んでしまうと、次のような問題が出てきます。

  • ちょっと仕様を変えたいだけなのに、どこを触ればいいか分からない
  • 敵の挙動を増やすたびに、既存コードを壊してしまう
  • Prefab単位での再利用が難しく、コピペ地獄になる

敵AI周りで特にありがちなのが、「敵がダメージを受けたら仲間を呼ぶ」「一定距離にプレイヤーが入ったらリンクして一斉に襲ってくる」といった「アグロ(敵対)共有」の処理を、1つの巨大なEnemyControllerにベタ書きしてしまうパターンです。

この記事では、その「仲間呼び」ロジックを AggroLink という専用コンポーネントに切り出して、
「ダメージを受けたら、周囲の同じグループの敵を一斉にアクティブ化する」
という処理を、シンプルかつ再利用しやすい形で実装していきます。

ここでは次のような前提の「小さな責務」に分けた構成をとります。

  • AggroLink
    ダメージを受けたときに、周囲の仲間を「アクティブ状態」にするコンポーネント
  • EnemyAggroState
    敵が「待機中か、プレイヤー追跡中か」の状態を持つ小さなコンポーネント
  • EnemyHealth
    敵のHP管理とダメージ受付を行い、「ダメージを受けた」イベントを発火するコンポーネント

すべてこの記事内で完結しているので、コピペしてそのまま動かせます。



using System;
using UnityEngine;

namespace Sample.Enemy
{
    /// <summary>
    /// 敵のアグロ(プレイヤー追跡状態)を管理するシンプルなコンポーネント。
    /// ここでは「アクティブかどうか」だけを持たせています。
    /// 実際の移動AIは別コンポーネントに分けることを想定しています。
    /// </summary>
    public class EnemyAggroState : MonoBehaviour
    {
        [SerializeField]
        private bool isAggro;   // 現在アグロ状態かどうか

        /// <summary>
        /// 外部からアグロ状態を読み取るためのプロパティ
        /// </summary>
        public bool IsAggro => isAggro;

        /// <summary>
        /// アグロ状態を設定する。
        /// ここでアグロ開始時の演出などを追加してもよいです。
        /// </summary>
        public void SetAggro(bool value)
        {
            if (isAggro == value) return;

            isAggro = value;

            // デバッグ用ログ(必要なければ削除してOK)
            if (isAggro)
            {
                Debug.Log($"[{name}] Aggro ON", this);
            }
            else
            {
                Debug.Log($"[{name}] Aggro OFF", this);
            }
        }

        /// <summary>
        /// 外部から「アグロ開始」を明示的に呼び出したいとき用のショートカット。
        /// </summary>
        public void ActivateAggro()
        {
            SetAggro(true);
        }
    }

    /// <summary>
    /// 敵のHPとダメージ受付を管理するコンポーネント。
    /// ダメージを受けたタイミングをイベントで通知します。
    /// AggroLink はこのイベントをフックして仲間呼びを行います。
    /// </summary>
    [RequireComponent(typeof(Collider))]
    public class EnemyHealth : MonoBehaviour
    {
        [SerializeField]
        private int maxHealth = 100;

        [SerializeField]
        private int currentHealth;

        /// <summary>
        /// ダメージを受けたときに発火するイベント。
        /// (被ダメージ量, ダメージの発生源) を通知します。
        /// </summary>
        public event Action<int, GameObject> OnDamaged;

        /// <summary>
        /// HPが0以下になったときに発火するイベント。
        /// </summary>
        public event Action OnDied;

        private void Awake()
        {
            // ゲーム開始時に現在HPを最大値で初期化
            currentHealth = maxHealth;
        }

        /// <summary>
        /// 外部からダメージを与えるときに呼ぶ関数。
        /// 弾やプレイヤー攻撃スクリプトから呼び出す想定です。
        /// </summary>
        /// <param name="damage">与えるダメージ量</param>
        /// <param name="attacker">攻撃者(プレイヤーなど)のGameObject</param>
        public void ApplyDamage(int damage, GameObject attacker = null)
        {
            if (currentHealth <= 0) return;

            currentHealth -= damage;
            if (currentHealth < 0)
            {
                currentHealth = 0;
            }

            // ダメージイベントを発火
            OnDamaged?.Invoke(damage, attacker);

            // 死亡判定
            if (currentHealth == 0)
            {
                OnDied?.Invoke();
                HandleDeath();
            }
        }

        /// <summary>
        /// デモ用の簡易死亡処理。
        /// 実際のゲームではアニメーション再生やドロップ処理などに差し替えてください。
        /// </summary>
        private void HandleDeath()
        {
            Debug.Log($"[{name}] Died", this);
            // とりあえず消すだけ
            Destroy(gameObject);
        }
    }

    /// <summary>
    /// ダメージを受けたときに、周囲の同じグループの敵をアクティブ(アグロ状態)にするコンポーネント。
    /// 
    /// - EnemyHealth の OnDamaged イベントを購読して発火トリガーとします。
    /// - 同じ groupId を持つ EnemyAggroState を、一定半径内から探して一斉にアクティブ化します。
    /// 
    /// 「仲間呼び」ロジックだけをこのクラスに閉じ込めることで、
    /// 他のAIや移動処理と疎結合に保てます。
    /// </summary>
    [RequireComponent(typeof(EnemyHealth))]
    [DisallowMultipleComponent]
    public class AggroLink : MonoBehaviour
    {
        [Header("グループ設定")]
        [SerializeField]
        [Tooltip("同じグループIDを持つ敵同士が仲間呼びの対象になります。")]
        private string groupId = "DefaultGroup";

        [Header("仲間呼びの範囲")]
        [SerializeField]
        [Tooltip("仲間呼びを行う半径(ワールド座標)。")]
        private float linkRadius = 10f;

        [SerializeField]
        [Tooltip("仲間呼びの中心を、敵の現在位置ではなく任意のTransformにしたい場合に指定します。")]
        private Transform customCenter;

        [Header("自分もアグロ化するか")]
        [SerializeField]
        [Tooltip("ダメージを受けた本人もアグロ状態にするかどうか。")]
        private bool includeSelf = true;

        [Header("一度だけ仲間呼びするか")]
        [SerializeField]
        [Tooltip("true の場合、一度仲間呼びしたらこのコンポーネントは無効化されます。")]
        private bool triggerOnlyOnce = true;

        [Header("デバッグ表示")]
        [SerializeField]
        [Tooltip("Sceneビューに仲間呼びの範囲をワイヤーフレームで表示します。")]
        private bool drawGizmos = true;

        [SerializeField]
        [Tooltip("Gizmosで描画する色。")]
        private Color gizmoColor = new Color(1f, 0.5f, 0f, 0.3f);

        // キャッシュ
        private EnemyHealth _health;
        private EnemyAggroState _myAggroState;
        private bool _hasTriggered;  // すでに仲間呼びを発動したかどうか

        private void Awake()
        {
            _health = GetComponent<EnemyHealth>();

            // 自分自身もアグロ対象にする場合に限り、AggroState を探しておく
            if (includeSelf)
            {
                _myAggroState = GetComponent<EnemyAggroState>();
            }
        }

        private void OnEnable()
        {
            // ダメージイベントを購読
            if (_health != null)
            {
                _health.OnDamaged += HandleDamaged;
            }
        }

        private void OnDisable()
        {
            // イベント購読解除(メモリリークや多重登録を防ぐ)
            if (_health != null)
            {
                _health.OnDamaged -= HandleDamaged;
            }
        }

        /// <summary>
        /// ダメージを受けたときに呼ばれるコールバック。
        /// ここから仲間呼びを行います。
        /// </summary>
        private void HandleDamaged(int damage, GameObject attacker)
        {
            // すでに一度トリガー済みで、triggerOnlyOnce が有効なら何もしない
            if (triggerOnlyOnce && _hasTriggered)
            {
                return;
            }

            _hasTriggered = true;

            // 自分もアグロ状態にする設定なら、ここでONにする
            if (includeSelf && _myAggroState != null)
            {
                _myAggroState.ActivateAggro();
            }

            // 実際の仲間呼び処理
            ActivateNearbyAllies();

            // 一度だけトリガーする設定なら、自身を無効化して二度と発動しないようにする
            if (triggerOnlyOnce)
            {
                enabled = false;
            }
        }

        /// <summary>
        /// 周囲の同じグループの敵を検索し、アグロ状態に切り替える。
        /// </summary>
        private void ActivateNearbyAllies()
        {
            // 仲間呼びの中心位置を決定(customCenter が指定されていればそちらを優先)
            Vector3 center = customCenter != null ? customCenter.position : transform.position;

            // Physics.OverlapSphere で一定半径内のコライダーを取得
            Collider[] hits = Physics.OverlapSphere(center, linkRadius);

            int activatedCount = 0;

            foreach (var hit in hits)
            {
                // 自分自身のコライダーを拾うこともあるので、GameObjectレベルで比較して除外
                if (hit.attachedRigidbody != null && hit.attachedRigidbody.gameObject == gameObject)
                {
                    continue;
                }
                if (hit.gameObject == gameObject) continue;

                // 同じグループIDを持つ AggroLink を探す
                AggroLink otherLink = hit.GetComponentInParent<AggroLink>();
                if (otherLink == null) continue;

                // グループIDが一致しないならスキップ
                if (!string.Equals(otherLink.groupId, groupId, StringComparison.Ordinal))
                {
                    continue;
                }

                // アグロ状態コンポーネントを探す
                EnemyAggroState aggroState = hit.GetComponentInParent<EnemyAggroState>();
                if (aggroState == null) continue;

                // すでにアグロ状態ならスキップ(無駄な再設定を避ける)
                if (aggroState.IsAggro) continue;

                // アグロON!
                aggroState.ActivateAggro();
                activatedCount++;
            }

            Debug.Log($"[{name}] AggroLink activated {activatedCount} allies in group '{groupId}'", this);
        }

        /// <summary>
        /// Sceneビューに仲間呼びの範囲を表示するためのGizmos描画。
        /// デバッグ時に範囲を調整しやすくなります。
        /// </summary>
        private void OnDrawGizmosSelected()
        {
            if (!drawGizmos) return;

            Gizmos.color = gizmoColor;
            Vector3 center = customCenter != null ? customCenter.position : transform.position;
            Gizmos.DrawWireSphere(center, linkRadius);
        }
    }
}

使い方の手順

ここでは「プレイヤーに殴られた敵が、周囲の敵を巻き込んで一斉に追跡モードになる」ケースを例にします。

  1. 敵Prefabに必要なコンポーネントを付ける
    1つの敵Prefabに対して、少なくとも次の3つをアタッチします。
    • Collider(BoxCollider / CapsuleCollider など、Is Trigger でも可
    • EnemyHealth(HP管理)
    • EnemyAggroState(アグロ状態管理)
    • AggroLink(仲間呼び)

    敵の見た目用に MeshRendererAnimator を付けていても問題ありません。
    プレイヤーの攻撃スクリプトからは EnemyHealth.ApplyDamage() を呼ぶだけでOKです。

  2. AggroLink のパラメータを設定する
    敵Prefabを選択し、Inspector で AggroLink の項目を調整します。
    • Group Id(groupId)
      例:RoomA, GoblinCamp など。
      同じIDを持つ敵同士が仲間呼びの対象になります。
      部屋ごとにグループを分ければ、「この部屋の敵だけ連動して襲ってくる」といった調整ができます。
    • Link Radius(linkRadius)
      仲間呼びの半径。例えば 8 ~ 15 くらいにしておくと、
      1体を殴ると近くの敵だけ反応する、という自然な挙動になります。
    • Custom Center(customCenter)
      通常は空欄でOKです(敵の位置が中心になります)。
      「部屋の中央から半径を測りたい」など特別なケースで、Emptyオブジェクトを指定します。
    • Include Self(includeSelf)
      ONにすると、ダメージを受けた本人もアグロ状態になります(普通はON推奨)。
      「本人は隠れていて、仲間だけ襲ってくる」など変わったギミックを作りたい場合はOFFにしてもOKです。
    • Trigger Only Once(triggerOnlyOnce)
      ONにすると、1回仲間呼びしたらそれ以降は発動しません。
      「1回バレたら全員でずっと追いかけてくる」タイプの敵に向いています。
      OFFにすると、殴られるたびに周囲の敵を再チェックします。
  3. プレイヤーの攻撃からダメージを与える
    例えば近接攻撃の当たり判定で、次のようなスクリプトをプレイヤー側に付けておきます。
    
    using UnityEngine;
    using Sample.Enemy; // EnemyHealth / AggroLink の名前空間
    
    public class SimpleMeleeAttack : MonoBehaviour
    {
        [SerializeField] private int damage = 10;
        [SerializeField] private float attackRange = 2f;
        [SerializeField] private LayerMask enemyLayer;
    
        public void PerformAttack()
        {
            // プレイヤーの前方にRayを飛ばして、敵に当たったらダメージを与える簡易攻撃例
            Vector3 origin = transform.position + Vector3.up * 1f;
            Vector3 direction = transform.forward;
    
            if (Physics.Raycast(origin, direction, out RaycastHit hit, attackRange, enemyLayer))
            {
                var health = hit.collider.GetComponentInParent<EnemyHealth>();
                if (health != null)
                {
                    health.ApplyDamage(damage, gameObject);
                }
            }
        }
    }
    

    この ApplyDamage が呼ばれたタイミングで EnemyHealth.OnDamaged が発火し、
    それを AggroLink が受け取って仲間呼びを行います。

  4. 敵のアグロ状態をAIや移動に反映する
    EnemyAggroStateIsAggro を、移動AIコンポーネントから参照します。
    例えば「プレイヤーを追いかけるだけの超簡易AI」はこんな感じです。
    
    using UnityEngine;
    using Sample.Enemy;
    
    [RequireComponent(typeof(EnemyAggroState))]
    public class SimpleChaseAI : MonoBehaviour
    {
        [SerializeField] private Transform target;   // 追いかける対象(プレイヤー)
        [SerializeField] private float moveSpeed = 3f;
    
        private EnemyAggroState _aggroState;
    
        private void Awake()
        {
            _aggroState = GetComponent<EnemyAggroState>();
        }
    
        private void Update()
        {
            if (!_aggroState.IsAggro) return;
            if (target == null) return;
    
            Vector3 direction = (target.position - transform.position);
            direction.y = 0f; // 水平移動に限定
    
            if (direction.sqrMagnitude < 0.01f) return;
    
            direction.Normalize();
            transform.position += direction * (moveSpeed * Time.deltaTime);
            transform.rotation = Quaternion.LookRotation(direction);
        }
    }
    

    これで、1体が殴られる → AggroLink が発動 → 周囲の敵の IsAggro が true になる → 全員が追跡開始 という流れが実現できます。


メリットと応用

AggroLink コンポーネントを導入することで、次のようなメリットがあります。

  • 敵AIスクリプトの責務を分離できる
    ダメージ処理(EnemyHealth)、アグロ状態(EnemyAggroState)、仲間呼び(AggroLink)、移動AI(SimpleChaseAI)を分けておくことで、
    「仲間呼びの仕様だけを変えたい」「アグロ状態のON/OFFだけを別のイベントに繋ぎたい」といった変更がやりやすくなります。
  • Prefab単位での再利用がしやすい
    同じ敵Prefabを別のステージに配置するときでも、
    groupIdlinkRadius を変えるだけで、
    「この部屋では4体同時に襲ってくる」「この通路では1体ずつしか反応しない」など、レベルデザインに応じた調整が簡単です。
  • レベルデザインの試行錯誤が早くなる
    Sceneビューで Gizmos によって仲間呼び範囲が可視化されるため、
    「プレイヤーがここを通ると、この敵とこの敵がリンクしてくるな」というのが一目で分かります。
    デザイナーやレベル設計担当が、コードを触らずにパラメータだけで調整できるのも大きな利点ですね。

改造案:視線が通っている仲間だけを呼ぶ

もう一歩踏み込んで、「壁の向こうにいる敵はリンクしない」ようにしたい場合は、
ActivateNearbyAllies() の中で Raycast を使って「視線が遮られていないか」を判定するとよいです。

例えば次のようなヘルパー関数を AggroLink に追加して、
仲間呼びの前にチェックを挟む形にできます。


/// <summary>
/// center から target への視線が壁などで遮られていないかを判定する。
/// 壁用の LayerMask を用意しておくと、より正確に判定できます。
/// </summary>
private bool HasLineOfSight(Vector3 center, Transform target, LayerMask obstacleMask)
{
    Vector3 origin = center + Vector3.up * 1f;          // 少し持ち上げて地面との干渉を避ける
    Vector3 dest = target.position + Vector3.up * 1f;
    Vector3 dir = dest - origin;
    float distance = dir.magnitude;

    if (distance <= 0.01f) return true;

    dir /= distance;

    // 障害物に当たったら視線が通っていないとみなす
    if (Physics.Raycast(origin, dir, distance, obstacleMask))
    {
        return false;
    }

    return true;
}

この関数を使って、「視線が通っている仲間だけ ActivateAggro() を呼ぶ」といったロジックにすれば、
よりステルス寄りのゲームデザインにも対応できます。

このように、仲間呼びのロジックを AggroLink という1つの責務に閉じ込めておくことで、
ゲームの仕様に合わせた拡張・改造がとてもやりやすくなります。
巨大なEnemyクラスに全部詰め込むのではなく、小さなコンポーネントに分割していく方針で設計していきましょう。