Unityの初心者~中級者のあいだで一番よくあるパターンが、敵AIやプレイヤー制御を「とりあえず全部Updateに書く」やり方ですよね。
最初は動くので満足できますが、だんだんと

  • 「プレイヤーを追う処理」
  • 「見失ったときの処理」
  • 「攻撃する処理」
  • 「アニメーション切り替え」

…といったロジックが全部1つのスクリプトに積み上がっていき、気づいたらGodクラス化してしまいます。

そこでこの記事では、「見失った位置まで移動して、周囲をキョロキョロ見回す」という1つの責務だけに絞ったコンポーネント
「InvestigatePos(不審点調査)」を用意して、敵AIの「捜索ステート」をコンポーネントとして切り出す方法を紹介します。

追跡や攻撃などの他の処理は別コンポーネントに分け、このコンポーネントは
「不審な位置を調査する」ことだけに集中させる構成を目指します。


【Unity】見失った場所を調査せよ!「InvestigatePos」コンポーネント

このコンポーネントは、ざっくり言うと次のような動きをします。

  1. 外部から「調査したい位置(最後にプレイヤーを見た位置など)」をセットされる
  2. その位置まで移動する
  3. 到着したら、一定時間「首を振るように」周囲をキョロキョロ見回す
  4. 調査が終わったら、コールバックで「調査完了」を通知する

敵AIのステートマシンやビヘイビアツリーなどから
「見失ったからInvestigatePosを有効にする」といった使い方を想定しています。


フルコード:InvestigatePos.cs


using UnityEngine;
using System;
using System.Collections;

namespace Samples.AI
{
    /// <summary>
    /// 不審点(最後にプレイヤーを見た位置など)まで移動し、
    /// 周囲をキョロキョロ見回すためのコンポーネント。
    /// 
    /// ・「どこを調査するか」を外部から指定する
    /// ・移動と首振り(回転)アニメーションをここに閉じ込める
    /// ・調査完了をイベントで通知する
    /// 
    /// という単一責務を担当させています。
    /// </summary>
    [RequireComponent(typeof(CharacterController))]
    public class InvestigatePos : MonoBehaviour
    {
        [Header("移動設定")]
        [SerializeField]
        private float moveSpeed = 3.0f;    // 調査ポイントまで歩く速度

        [SerializeField]
        private float arriveDistance = 0.2f;  // この距離以内に入ったら「到着」とみなす

        [Header("首振り(見回し)設定")]
        [SerializeField]
        private float lookAroundDuration = 3.0f; // 見回す合計時間(秒)

        [SerializeField]
        private float lookAngle = 45.0f;        // 左右に振る最大角度(度)

        [SerializeField]
        private float lookSpeed = 2.0f;         // 首振りの速さ(大きいほど速く往復)

        [Header("デバッグ表示")]
        [SerializeField]
        private Color gizmoColor = Color.yellow;

        [SerializeField]
        private float gizmoRadius = 0.25f;

        // 内部状態
        private CharacterController characterController;

        // 調査対象のワールド座標
        private Vector3 targetPosition;
        private bool hasTarget = false;

        // 調査中フラグ
        private bool isInvestigating = false;

        // 見回し中のコルーチン参照
        private Coroutine lookAroundCoroutine;

        // 調査完了を外部に通知するイベント
        public event Action OnInvestigationCompleted;

        // もともとの向き(見回し後に戻す用)
        private Quaternion initialRotation;

        private void Awake()
        {
            characterController = GetComponent<CharacterController>();
        }

        private void Update()
        {
            if (!isInvestigating || !hasTarget)
            {
                return;
            }

            // まだ目的地に到着していなければ、そこまで移動する
            MoveTowardsTarget();

            // 目的地に到着したら見回し開始(1回だけ)
            if (HasArrivedToTarget())
            {
                // もう見回しを始めているなら何もしない
                if (lookAroundCoroutine == null)
                {
                    // 移動は止めるため、ターゲットフラグを一旦オフにする
                    hasTarget = false;
                    lookAroundCoroutine = StartCoroutine(LookAroundRoutine());
                }
            }
        }

        /// <summary>
        /// 外部から「この位置を調査して」と指示するためのメソッド。
        /// 敵AIの別コンポーネントから呼び出す想定です。
        /// </summary>
        /// <param name="worldPos">調査したいワールド座標</param>
        public void StartInvestigation(Vector3 worldPos)
        {
            targetPosition = worldPos;
            hasTarget = true;
            isInvestigating = true;

            // 途中で呼ばれた場合に備えて、見回しコルーチンを止めておく
            if (lookAroundCoroutine != null)
            {
                StopCoroutine(lookAroundCoroutine);
                lookAroundCoroutine = null;
            }
        }

        /// <summary>
        /// 調査を強制的に中断したい場合に呼ぶ。
        /// 例:プレイヤーを再び視認したときなど。
        /// </summary>
        public void CancelInvestigation()
        {
            isInvestigating = false;
            hasTarget = false;

            if (lookAroundCoroutine != null)
            {
                StopCoroutine(lookAroundCoroutine);
                lookAroundCoroutine = null;
            }
        }

        /// <summary>
        /// 現在調査中かどうかを返す。
        /// </summary>
        public bool IsInvestigating => isInvestigating;

        /// <summary>
        /// ターゲット位置へ移動する処理。
        /// CharacterController を使って地面に沿って移動します。
        /// </summary>
        private void MoveTowardsTarget()
        {
            // 水平方向のみで距離を計算したい場合は y を揃える
            Vector3 current = transform.position;
            Vector3 flatTarget = new Vector3(targetPosition.x, current.y, targetPosition.z);

            Vector3 dir = (flatTarget - current);
            float distance = dir.magnitude;

            if (distance <= 0.0001f)
            {
                return;
            }

            dir.Normalize();

            // 移動量(重力はここでは扱わない。別コンポーネントで管理してもOK)
            Vector3 move = dir * moveSpeed * Time.deltaTime;
            characterController.Move(move);

            // 移動方向を向く(自然な見た目用)
            if (dir.sqrMagnitude > 0.0001f)
            {
                Quaternion lookRot = Quaternion.LookRotation(dir, Vector3.up);
                transform.rotation = Quaternion.Slerp(transform.rotation, lookRot, 10f * Time.deltaTime);
            }
        }

        /// <summary>
        /// 目的地に到着したかどうか。
        /// </summary>
        private bool HasArrivedToTarget()
        {
            Vector3 current = transform.position;
            Vector3 flatTarget = new Vector3(targetPosition.x, current.y, targetPosition.z);

            float distance = Vector3.Distance(current, flatTarget);
            return distance <= arriveDistance;
        }

        /// <summary>
        /// 目的地に到着したあと、一定時間キョロキョロ見回すコルーチン。
        /// </summary>
        private IEnumerator LookAroundRoutine()
        {
            isInvestigating = true; // 念のため

            float elapsed = 0f;
            initialRotation = transform.rotation;

            // 首振りの原点となるY軸回転角
            float baseY = initialRotation.eulerAngles.y;

            while (elapsed < lookAroundDuration)
            {
                elapsed += Time.deltaTime;

                // 0~1の時間正規化
                float t = elapsed / lookAroundDuration;

                // -1~+1 を往復するような値を作る(Mathf.Sinを利用)
                float wave = Mathf.Sin(Time.time * lookSpeed);

                // wave=-1~+1 を lookAngle にスケールして左右に振る
                float offsetY = wave * lookAngle;

                // 新しいY角度
                float newY = baseY + offsetY;

                Vector3 euler = initialRotation.eulerAngles;
                euler.y = newY;
                transform.rotation = Quaternion.Euler(euler);

                yield return null;
            }

            // 最後に元の向きに戻す
            transform.rotation = initialRotation;

            isInvestigating = false;
            lookAroundCoroutine = null;

            // 調査完了を通知
            OnInvestigationCompleted?.Invoke();
        }

        /// <summary>
        /// Sceneビューで調査ポイントを分かりやすくするためのGizmo描画。
        /// </summary>
        private void OnDrawGizmosSelected()
        {
            if (!hasTarget)
            {
                return;
            }

            Gizmos.color = gizmoColor;
            Gizmos.DrawWireSphere(targetPosition, gizmoRadius);
        }
    }
}

使い方の手順

ここでは「敵キャラクターがプレイヤーを見失ったとき、その最後に見た位置まで行ってキョロキョロする」ケースを例にします。

手順①:敵プレハブにコンポーネントを追加

  1. 敵キャラクターのプレハブ(またはシーン上のGameObject)を選択
  2. CharacterController コンポーネントをアタッチ(まだ付いていなければ)
  3. InvestigatePos.cs を同じオブジェクトにアタッチ
  4. インスペクターで
    • Move Speed(移動速度)
    • Arrive Distance(到着判定距離)
    • Look Around Duration(見回し時間)
    • Look Angle(左右に振る角度)

    を好みに合わせて調整

手順②:プレイヤーを見失った位置を渡すスクリプトを用意

敵の視界判定などを行っている別コンポーネントから、StartInvestigation を呼び出します。
ここでは簡単な例として、「スペースキーを押したら、プレイヤーの現在位置を不審点として調査させる」サンプルを示します。


using UnityEngine;

namespace Samples.AI
{
    /// <summary>
    /// デモ用:スペースキーを押した位置を「不審点」として
    /// InvestigatePos に渡すサンプル。
    /// 実際のゲームでは「プレイヤーを見失った瞬間の位置」を渡すと良いです。
    /// </summary>
    public class InvestigateDemoController : MonoBehaviour
    {
        [SerializeField]
        private InvestigatePos investigatePos;

        [SerializeField]
        private Transform playerTransform;

        private void Reset()
        {
            // 同じオブジェクトに InvestigatePos がある場合、自動取得
            if (investigatePos == null)
            {
                investigatePos = GetComponent<InvestigatePos>();
            }
        }

        private void Update()
        {
            // 非推奨の旧Inputですが、サンプル簡略化のため使用
            // 実プロジェクトでは新Input Systemに置き換えてください。
            if (Input.GetKeyDown(KeyCode.Space))
            {
                if (playerTransform != null && investigatePos != null)
                {
                    Vector3 lastSeenPos = playerTransform.position;
                    investigatePos.StartInvestigation(lastSeenPos);
                    Debug.Log($"Investigate start at {lastSeenPos}");
                }
            }
        }
    }
}

このサンプルでは、敵がプレイヤーを見失ったかどうかなどの本格的な判定は行っていませんが、
「外部から座標を渡して、InvestigatePosに任せる」という使い方のイメージはつかめるはずです。

手順③:調査完了を受け取って次の行動につなげる

InvestigatePos は、見回しが終わったタイミングで OnInvestigationCompleted イベントを発火します。
これを利用して、「巡回ルートに戻る」「別のステートに遷移する」といった処理を組み合わせましょう。


using UnityEngine;

namespace Samples.AI
{
    /// <summary>
    /// 調査完了を受け取って、次の行動に切り替える簡単なサンプル。
    /// 実際にはステートマシンやアニメーション切り替えにつなげると良いです。
    /// </summary>
    public class InvestigateListener : MonoBehaviour
    {
        [SerializeField]
        private InvestigatePos investigatePos;

        private void Reset()
        {
            if (investigatePos == null)
            {
                investigatePos = GetComponent<InvestigatePos>();
            }
        }

        private void OnEnable()
        {
            if (investigatePos != null)
            {
                investigatePos.OnInvestigationCompleted += HandleInvestigationCompleted;
            }
        }

        private void OnDisable()
        {
            if (investigatePos != null)
            {
                investigatePos.OnInvestigationCompleted -= HandleInvestigationCompleted;
            }
        }

        private void HandleInvestigationCompleted()
        {
            Debug.Log("調査完了!次の行動に移ります。");
            // TODO:
            // ここで「巡回ステートに戻す」「警戒度を下げる」などを実装
        }
    }
}

手順④:応用例

  • 敵AI:プレイヤーを視界から見失った瞬間、その位置を StartInvestigation で渡す
  • 警備ドローン:センサーが反応した位置に飛んでいき、一定時間その場をスキャンする演出
  • 動く監視カメラ:プレイヤーを見失った向きの先をしばらく探索する、など

これらはすべて、「不審点調査のロジックは InvestigatePos に任せる」という構成で、他のロジックときれいに分離できます。


メリットと応用

メリット①:AIロジックの責務分離がしやすい

追跡・攻撃・巡回・調査…といったAIの各フェーズを、
それぞれ別コンポーネントとして分けやすくなります。

  • ChasePlayer:プレイヤーを追いかけるだけ
  • PatrolRoute:巡回ポイントを回るだけ
  • InvestigatePos:不審点を調査するだけ

といった具合に分解しておけば、
「この敵は巡回+調査だけ」「この敵は追跡+攻撃+調査」など、プレハブ単位で行動パターンを組み合わせることが簡単になります。

メリット②:レベルデザインが楽になる

InvestigatePos は「座標さえ渡せば動く」ように作ってあるので、
レベルデザイナーは

  • トリガーゾーンに入ったらその位置を調査させる
  • 爆発音の発生位置を調査させる
  • プレイヤーの残した足跡の最後の位置を調査させる

といったギミックを、座標を1つ渡すだけで簡単に組み込めます。
スクリプトの中身をいじらず、コンポーネントの組み合わせとパラメータ調整だけでバリエーションを出せるのが大きな利点です。

メリット③:アニメーションやサウンドの差し替えも容易

見回し中のアニメーションやサウンドを追加したくなった場合も、
InvestigatePos の内部、もしくは連携する別コンポーネントで完結させられます。

例えば、見回し開始時に「うーん?」というボイスを鳴らすコンポーネントを追加するだけで、
他のAIロジックには一切手を触れずに演出を強化できます。


改造案:見回し中にランダムな高さも少し変える

首を左右に振るだけでなく、少しだけ上下にも揺らして「覗き込んでいる」感じを出したい場合の改造例です。
下記のようなメソッドを追加し、LookAroundRoutine 内で呼び出すようにすればOKです。


/// <summary>
/// Y軸回転に加えて、少しだけ上下にも揺らす見回し処理の一例。
/// lookAroundRoutine から呼ぶことを想定。
/// </summary>
private void ApplyFancyLook(float baseY, float elapsed)
{
    // 左右の首振り(既存と同じロジック)
    float wave = Mathf.Sin(Time.time * lookSpeed);
    float offsetY = wave * lookAngle;

    // 上下方向の小さな揺れ(覗き込むような動き)
    float verticalWave = Mathf.Sin(Time.time * (lookSpeed * 1.5f));
    float offsetX = verticalWave * 5.0f; // 上下5度だけ揺らす

    Vector3 euler = initialRotation.eulerAngles;
    euler.y = baseY + offsetY;
    euler.x = euler.x + offsetX; // もとのXに対して加算
    transform.rotation = Quaternion.Euler(euler);
}

このように、小さな改造を別メソッドとして切り出しておくと、
「派手な見回し」「控えめな見回し」などをメソッド単位で差し替えられるようになり、
さらにコンポーネント指向らしい設計に近づけます。