Unityを触り始めた頃、「敵のAIも当たり判定もアニメーションも、とりあえず全部Updateに書いちゃえ!」となりがちですよね。最初は動きますが、時間が経つと

  • 1つのスクリプトが数百行〜千行になり、どこを直せばいいか分からない
  • 似たような敵をもう1体作りたいだけなのに、巨大なクラスをコピペしてバグが量産される
  • 「待ち伏せする敵」「巡回する敵」「追いかける敵」が全部if文だらけで混在する

といった地獄にハマりやすいです。

そこで今回は、「待ち伏せしていて、プレイヤーが近づくと正体を現して攻撃する」という振る舞いだけを切り出したコンポーネント Ambusher を作ってみましょう。
「見た目の切り替え」「プレイヤーとの距離判定」「攻撃タイミング」を1つの責務としてまとめた、小さめのコンポーネントにしておくことで、他の敵AIと気持ちよく組み合わせやすくなります。

【Unity】近づくと正体を現す待ち伏せAI!「Ambusher」コンポーネント

フルコード


using UnityEngine;

/// <summary>
/// プレイヤーが一定距離まで近づくと、擬態状態から正体を現し、
/// 一定間隔で攻撃アニメーション(またはイベント)を発生させるコンポーネント。
/// 
/// ・見た目の切り替え(擬態 <-> 本体)
/// ・プレイヤーとの距離判定
/// ・攻撃タイミング管理
/// 
/// だけに責務を絞った「待ち伏せ」専用コンポーネントです。
/// </summary>
[DisallowMultipleComponent]
public class Ambusher : MonoBehaviour
{
    // --- 参照系 ---

    [Header("ターゲット設定")]
    [Tooltip("待ち伏せを解除する対象(通常はプレイヤー)。未指定ならTag \"Player\" を自動検索します。")]
    [SerializeField] private Transform target;

    [Header("見た目の切り替え")]
    [Tooltip("擬態状態のときに表示するオブジェクト(岩モデルなど)。")]
    [SerializeField] private GameObject disguisedVisual;
    [Tooltip("正体を現したときに表示するオブジェクト(本体モデルなど)。")]
    [SerializeField] private GameObject revealedVisual;

    [Header("待ち伏せパラメータ")]
    [Tooltip("この距離以内にターゲットが入ると、擬態を解除して正体を現します。")]
    [SerializeField] private float revealDistance = 5f;

    [Tooltip("正体を現してから、最初の攻撃を行うまでの待ち時間。")]
    [SerializeField] private float firstAttackDelay = 0.5f;

    [Tooltip("2回目以降の攻撃間隔(秒)。")]
    [SerializeField] private float attackInterval = 2f;

    [Header("攻撃パラメータ")]
    [Tooltip("攻撃が届く最大距離。ターゲットがこれより遠い場合は攻撃しません。")]
    [SerializeField] private float attackRange = 2f;

    [Tooltip("攻撃が命中したときに与えるダメージ。実際のダメージ適用はOnAttackメソッド内で行います。")]
    [SerializeField] private int attackDamage = 10;

    [Header("デバッグ")]
    [Tooltip("シーンビューに感知距離と攻撃距離のGizmosを表示します。")]
    [SerializeField] private bool drawGizmos = true;


    // --- 内部状態 ---

    // 現在、擬態状態かどうか
    private bool isDisguised = true;

    // 待ち伏せが解除されている(=一度でも正体を現した)かどうか
    private bool hasRevealed = false;

    // 次に攻撃できる時刻(Time.time と比較)
    private float nextAttackTime = Mathf.Infinity;


    private void Awake()
    {
        // targetが未設定なら、Tag "Player" を自動で探す簡易フォールバック
        if (target == null)
        {
            GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
            if (playerObj != null)
            {
                target = playerObj.transform;
            }
        }

        // 初期状態では擬態を有効化しておく
        SetDisguisedState(true);
    }

    private void Update()
    {
        // ターゲットが存在しない場合は何もしない
        if (target == null) return;

        // すでに正体を現しているかどうかで処理を分岐
        if (!hasRevealed)
        {
            CheckRevealCondition();
        }
        else
        {
            HandleAttack();
        }
    }

    /// <summary>
    /// ターゲットとの距離をチェックし、一定距離以内なら擬態を解除します。
    /// </summary>
    private void CheckRevealCondition()
    {
        float distance = Vector3.Distance(transform.position, target.position);

        // ターゲットがrevealDistance以内に入ったら正体を現す
        if (distance <= revealDistance)
        {
            Reveal();
        }
    }

    /// <summary>
    /// 擬態状態から正体を現す。
    /// 見た目の切り替えと、攻撃タイマーの初期化を行います。
    /// </summary>
    private void Reveal()
    {
        if (hasRevealed) return; // 二重呼び出し防止

        hasRevealed = true;
        SetDisguisedState(false);

        // 最初の攻撃タイミングをセット
        nextAttackTime = Time.time + firstAttackDelay;

        // ここでアニメーションやSEを鳴らすのもOK
        // 例: Animatorを使う場合
        // animator.SetTrigger("Reveal");
    }

    /// <summary>
    /// 擬態状態のON/OFFを切り替えるヘルパー。
    /// 擬態用モデルと本体モデルの有効/無効をここで一括管理します。
    /// </summary>
    /// <param name="disguised">trueなら擬態状態、falseなら正体を現した状態</param>
    private void SetDisguisedState(bool disguised)
    {
        isDisguised = disguised;

        if (disguisedVisual != null)
        {
            disguisedVisual.SetActive(disguised);
        }

        if (revealedVisual != null)
        {
            revealedVisual.SetActive(!disguised);
        }
    }

    /// <summary>
    /// 正体を現した後の攻撃ロジック。
    /// 一定間隔でターゲットとの距離をチェックし、攻撃可能なら攻撃処理を呼び出します。
    /// </summary>
    private void HandleAttack()
    {
        // まだ攻撃可能な時間になっていなければ何もしない
        if (Time.time < nextAttackTime) return;

        // ターゲットとの距離チェック
        float distance = Vector3.Distance(transform.position, target.position);
        if (distance <= attackRange)
        {
            // 攻撃実行
            OnAttack();
        }

        // 次の攻撃タイミングを更新
        nextAttackTime = Time.time + attackInterval;
    }

    /// <summary>
    /// 実際の攻撃処理。
    /// ここでは「ターゲット側にIDamageableインターフェースがあればダメージを与える」
    /// というシンプルな処理にしてあります。
    /// 
    /// プロジェクトに合わせて、アニメーション再生や弾の生成などを追加してください。
    /// </summary>
    private void OnAttack()
    {
        // ここで攻撃アニメーションを再生してもOK
        // 例: animator.SetTrigger("Attack");

        // シンプルなダメージ適用例
        // プレイヤー側に「IDamageable」インターフェースを実装したコンポーネントがあればダメージを与える
        IDamageable damageable = target.GetComponent<IDamageable>();
        if (damageable != null)
        {
            damageable.TakeDamage(attackDamage);
        }

        // もしIDamageableがなければ、ここでDebugログを出しておくとデバッグしやすいです
        // Debug.Log($"{name} attacked {target.name} for {attackDamage} damage.");
    }

    /// <summary>
    /// エディタ上で感知距離・攻撃距離を可視化するためのGizmos描画。
    /// レベルデザイン時に非常に便利です。
    /// </summary>
    private void OnDrawGizmosSelected()
    {
        if (!drawGizmos) return;

        // 感知距離(revealDistance)を黄色で表示
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(transform.position, revealDistance);

        // 攻撃距離(attackRange)を赤で表示
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(transform.position, attackRange);
    }
}

/// <summary>
/// ダメージを受け取れる対象が実装すべきインターフェース。
/// Ambusherはこのインターフェースだけを知っていればよいので、
/// プレイヤーや敵の実装詳細から疎結合にできます。
/// </summary>
public interface IDamageable
{
    void TakeDamage(int damage);
}

/// <summary>
/// 簡易的なダメージ受け取りサンプル。
/// プレイヤーや敵にアタッチして動作確認に使えます。
/// 実プロジェクトでは、既存のHP管理コンポーネントにIDamageableを実装する形がオススメです。
/// </summary>
public class SimpleHealth : MonoBehaviour, IDamageable
{
    [Tooltip("最大HP。0以下になるとDestroyします。")]
    [SerializeField] private int maxHealth = 50;

    [Tooltip("現在のHP(デバッグ用にインスペクター表示)。")]
    [SerializeField] private int currentHealth;

    private void Awake()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(int damage)
    {
        currentHealth -= damage;
        Debug.Log($"{name} took {damage} damage. HP: {currentHealth}");

        if (currentHealth <= 0)
        {
            Die();
        }
    }

    private void Die()
    {
        Debug.Log($"{name} died.");
        Destroy(gameObject);
    }
}

使い方の手順

  1. 待ち伏せ用プレハブを作る
    • 空のGameObjectを作成し、名前を Ambusher_StoneEnemy などにします。
    • その子オブジェクトとして
      • 岩に擬態している見た目用オブジェクト(例: StoneVisual
      • 正体を現したときの本体モデル(例: MonsterVisual

      を用意します。

    • 岩モデルとモンスターのモデルには、それぞれMeshRendererやSkinnedMeshRendererなど通常の見た目用コンポーネントを付けておきます。
  2. Ambusherコンポーネントをアタッチする
    • 親オブジェクト(Ambusher_StoneEnemy)に Ambusher コンポーネントを追加します。
    • インスペクターで
      • Disguised Visual に岩モデルのGameObject(StoneVisual
      • Revealed Visual にモンスターのGameObject(MonsterVisual

      をドラッグ&ドロップで設定します。

    • Target は空でOKです。Tagが Player のオブジェクトを自動で探してくれます。
      もしプレイヤーオブジェクトのTagが違う場合は、プレイヤーのTransformを直接セットしましょう。
  3. プレイヤー側にダメージ受け取り処理を付ける
    • プレイヤーのGameObjectに、サンプルとして SimpleHealth コンポーネントをアタッチします。
    • これでプレイヤーは IDamageable を実装した扱いになり、Ambusherの攻撃が当たるとHPが減ります。
    • すでに独自のHP管理スクリプトがある場合は、そのスクリプトに IDamageable を実装し、TakeDamage 内でHPを減らす処理を書きましょう。
  4. パラメータを調整してテストする
    例えばプレイヤーキャラがいるシーンで:
    • Ambusherプレハブをプレイヤーの通り道に配置します(岩っぽい場所に置くと雰囲気が出ます)。
    • Reveal Distance を 5〜8 くらいに設定すると、「近づいたらガバッと動き出す」感が出ます。
    • Attack Range は 1.5〜2.5 くらいにして、近接攻撃っぽくします。
    • First Attack Delay を 0.5〜1.0 にすると、「飛び出してから一瞬ためて攻撃する」演出にできます。
    • シーンビューでAmbusherを選択すると、感知距離と攻撃距離がGizmosとして表示されるので、レベルデザイン時にとても便利です。

例としては、こんな使い方がしやすいです。

  • プレイヤーの進行方向の岩場に配置して、「この岩、なんか怪しいな…」と思ったら案の定モンスターだった、という演出。
  • ダンジョンの曲がり角の手前に置いておき、角を曲がった瞬間に飛び出してくる待ち伏せ敵。
  • 動く床やエレベーターの上に配置し、プレイヤーが乗って一定距離に近づいたら動き出すトラップ的な使い方。

メリットと応用

Ambusherコンポーネントを使うことで、

  • 「待ち伏せする」という振る舞いだけを1つのコンポーネントに閉じ込められるので、
    • 移動ロジック(パトロールや追尾)は別コンポーネント
    • 攻撃の種類(近接・飛び道具・範囲攻撃)はさらに別コンポーネント

    といった分割がしやすくなります。

  • プレハブとして一度作っておけば、シーン上にポンポン置くだけで「待ち伏せポイント」が量産できます。
  • Gizmosで感知距離・攻撃距離が見えるので、レベルデザイナーが「ここに敵を置いたらどこで反応するか」を視覚的に調整しやすいです。
  • ダメージ処理は IDamageable インターフェースに依存しているだけなので、プレイヤー実装を差し替えてもAmbusher側は一切変更不要です。

このように、待ち伏せという1つの責務に絞ったコンポーネントにしておくと、プレハブの組み合わせでゲームのバリエーションを増やしやすくなります。

改造案:一度だけ攻撃して消える「使い捨てトラップ」にする

例えば「1回だけ飛び出して攻撃したら消える岩トラップ」にしたい場合は、OnAttackの最後にこんな処理を追加できます。


    /// <summary>
    /// 1回攻撃したら自壊するバリエーション例。
    /// 通常のOnAttackの末尾に呼び出すだけでもOKです。
    /// </summary>
    private void SelfDestructAfterAttack()
    {
        // ここでエフェクトや破壊アニメーションを再生してもよい
        // 例: animator.SetTrigger("Die");

        // 一定時間後に自分自身を削除(0なら即時)
        Destroy(gameObject, 0.5f);
    }

実際には、OnAttack() の末尾で SelfDestructAfterAttack(); を呼ぶだけで、「一撃必殺の待ち伏せ岩」になります。
このように、小さな関数単位で改造ポイントを追加していくと、Godクラス化を防ぎつつ、多彩な敵バリエーションを作りやすくなりますね。