Unityを触り始めた頃、「とりあえず全部 Update に書いてしまう」ことって多いですよね。
プレイヤーの移動、アニメーション、当たり判定、AI の思考…全部 1 クラスに詰め込んでしまうと、あとから「崖で引き返させたい」「パトロールルートを変えたい」といった要望が出たときに、どこを触ればいいのか分からなくなりがちです。

特に敵AIまわりは、移動処理崖検知プレイヤー検知などがごちゃ混ぜになりやすく、「ちょっとした修正」がバグの温床になりやすい領域ですね。

そこでこの記事では、「崖があったら引き返す」だけに責務を絞ったコンポーネントとして、LedgeDetector を用意します。
敵キャラクターの「移動ロジック」と「崖検知ロジック」を分離しておくことで、コンポーネント単位で差し替え・再利用しやすい設計にしていきましょう。

【Unity】崖でクルッと引き返す敵AI!「LedgeDetector」コンポーネント

LedgeDetector は、親オブジェクトの前方に Raycast を飛ばし、足元に床がなくなったら進行方向を反転させるためのコンポーネントです。
「敵の移動」は別コンポーネントに任せ、このコンポーネントは方向ベクトルの反転だけを担当します。

  • 2D/3D どちらにも使えるように、Transform.forwardレイヤーマスクで柔軟に設計
  • 「どのコンポーネントに進行方向を教えるか?」を ILedgeMovable インターフェースで抽象化
  • 敵の移動側は「方向ベクトルを持っているだけ」でよく、崖検知のことを知らなくてよい

コンポーネント同士を疎結合にしておくと、
「この敵は崖を無視して落ちる」「この敵は崖で引き返す」といったバリエーションを、Inspector 上でコンポーネントの有無を切り替えるだけで作れるようになります。


フルコード:LedgeDetector とシンプルな移動コンポーネント

まずは、崖検知コンポーネント LedgeDetector と、それと連携する簡易移動コンポーネント SimplePatrolMover をまとめて掲載します。
この 2 つを同じ GameObject にアタッチすることで、「崖で引き返すパトロール敵」が完成します。


using UnityEngine;

#region インターフェース定義

/// <summary> 
/// LedgeDetector から「進行方向を反転してほしい」と
/// 要求される側が実装するインターフェース。
/// 
/// - 進行方向の取得 / 設定だけに責務を絞ることで、
///   移動ロジックと崖検知ロジックを疎結合にします。
/// </summary>
public interface ILedgeMovable
{
    /// <summary>現在の移動方向(ベクトル)を返す</summary>
    Vector3 GetMoveDirection();

    /// <summary>新しい移動方向(ベクトル)を設定する</summary>
    void SetMoveDirection(Vector3 direction);
}

#endregion

/// <summary>
/// 親オブジェクトの「前方・足元」に Raycast を飛ばし、
/// 床がなくなったら移動方向を反転させるコンポーネント。
/// 
/// - 敵の移動ロジック自体は持たず、
///   「進行方向をひっくり返す」ことだけを担当します。
/// - ILedgeMovable を実装したコンポーネントと連携します。
/// </summary>
[DisallowMultipleComponent]
public class LedgeDetector : MonoBehaviour
{
    [Header("検知対象と方向設定")]
    [Tooltip("Ray を飛ばす基準位置。未指定ならこのコンポーネントの Transform を使用します。")]
    [SerializeField] private Transform rayOrigin;

    [Tooltip("前方方向の基準。未指定なら親オブジェクトの Transform.forward を使用します。")]
    [SerializeField] private Transform forwardReference;

    [Header("Ray 設定")]
    [Tooltip("足元を検知する Ray の長さ(メートル)。")]
    [SerializeField] private float rayDistance = 1.0f;

    [Tooltip("Ray の角度(度)。0 = 真下, 45 = 斜め前下など。")]
    [Range(0f, 89f)]
    [SerializeField] private float rayDownAngle = 45f;

    [Tooltip("床として判定するレイヤー。")]
    [SerializeField] private LayerMask groundLayer = ~0;

    [Header("検知タイミング")]
    [Tooltip("何フレームごとに崖検知を行うか(1 = 毎フレーム)。")]
    [Min(1)]
    [SerializeField] private int checkIntervalFrames = 1;

    [Tooltip("崖を検知した際に、即座に方向を反転させるか。")]
    [SerializeField] private bool flipImmediately = true;

    [Header("デバッグ表示")]
    [Tooltip("Scene ビューに Ray を描画して可視化します。")]
    [SerializeField] private bool showDebugRay = true;

    // ILedgeMovable を実装した移動コンポーネント
    private ILedgeMovable movable;

    // 内部用: フレームカウンタ
    private int frameCounter;

    private void Awake()
    {
        // Ray の基準位置が指定されていなければ、自分自身を使用
        if (rayOrigin == null)
        {
            rayOrigin = transform;
        }

        // 前方方向の基準が指定されていなければ、親オブジェクトの Transform を使用
        if (forwardReference == null)
        {
            forwardReference = transform;
        }

        // 同じ GameObject から ILedgeMovable を探す
        movable = GetComponent<ILedgeMovable>();
        if (movable == null)
        {
            Debug.LogWarning(
                $"[LedgeDetector] ILedgeMovable を実装したコンポーネントが見つかりません。" +
                $" 進行方向を反転させるには、同じ GameObject に ILedgeMovable 実装を追加してください。",
                this
            );
        }
    }

    private void Update()
    {
        // 指定フレーム間隔ごとに検知処理を行う
        frameCounter++;
        if (frameCounter < checkIntervalFrames)
        {
            return;
        }
        frameCounter = 0;

        CheckLedge();
    }

    /// <summary>
    /// 崖を検知し、必要なら移動方向を反転させる。
    /// </summary>
    private void CheckLedge()
    {
        if (movable == null)
        {
            // 移動対象がいない場合は何もしない
            return;
        }

        // 前方方向ベクトル(Transform.forward)を取得
        Vector3 forward = forwardReference.forward;
        forward.y = 0f; // 水平成分だけを使用(傾いた地形でも安定させるため)
        if (forward.sqrMagnitude < 0.0001f)
        {
            // 前方方向がゼロに近い場合は検知不能
            return;
        }
        forward.Normalize();

        // 「前方+少し上」から「斜め下」方向に Ray を飛ばす
        Vector3 origin = rayOrigin.position;
        Vector3 down = Vector3.down;

        // down を forward の方向に rayDownAngle 度だけ回転させる(斜め前下)
        Quaternion tilt = Quaternion.AngleAxis(rayDownAngle, Vector3.Cross(down, forward));
        Vector3 rayDir = tilt * down;

        Ray ray = new Ray(origin, rayDir);

        bool hasGround = Physics.Raycast(ray, out RaycastHit hit, rayDistance, groundLayer);

        // デバッグ用の Ray 描画
        if (showDebugRay)
        {
            Color color = hasGround ? Color.green : Color.red;
            Debug.DrawRay(origin, rayDir * rayDistance, color);
        }

        // 床がない(= 崖)と判断したら進行方向を反転
        if (!hasGround)
        {
            HandleLedgeDetected();
        }
    }

    /// <summary>
    /// 崖を検知したときの処理。
    /// 現在は「進行方向を反転する」だけ。
    /// </summary>
    private void HandleLedgeDetected()
    {
        if (!flipImmediately || movable == null)
        {
            return;
        }

        // 現在の移動方向を取得
        Vector3 currentDir = movable.GetMoveDirection();

        // 水平方向のみ反転(Y成分はそのままにする)
        Vector3 flat = new Vector3(currentDir.x, 0f, currentDir.z);
        Vector3 flippedFlat = -flat;

        // 元の Y 成分を戻す(重力などを扱う場合のため)
        Vector3 newDir = new Vector3(flippedFlat.x, currentDir.y, flippedFlat.z);

        movable.SetMoveDirection(newDir);
    }

    private void OnDrawGizmosSelected()
    {
        if (!showDebugRay || rayOrigin == null || forwardReference == null)
        {
            return;
        }

        // Scene ビュー上で、選択中のみ Ray のおおよその方向を Gizmo で表示
        Vector3 forward = forwardReference.forward;
        forward.y = 0f;
        if (forward.sqrMagnitude < 0.0001f) return;
        forward.Normalize();

        Vector3 origin = rayOrigin.position;
        Vector3 down = Vector3.down;
        Quaternion tilt = Quaternion.AngleAxis(rayDownAngle, Vector3.Cross(down, forward));
        Vector3 rayDir = tilt * down;

        Gizmos.color = Color.cyan;
        Gizmos.DrawLine(origin, origin + rayDir * rayDistance);
        Gizmos.DrawSphere(origin + rayDir * rayDistance, 0.05f);
    }
}

/// <summary>
/// 非物理ベースのシンプルなパトロール移動コンポーネント。
/// 
/// - Transform を直接動かすだけの軽量な実装です。
/// - ILedgeMovable を実装しており、LedgeDetector と連携可能。
/// - 2D/3D 共通で利用できます(Y は固定で水平方向のみ移動)。
/// </summary>
[DisallowMultipleComponent]
public class SimplePatrolMover : MonoBehaviour, ILedgeMovable
{
    [Header("移動設定")]
    [Tooltip("移動速度(メートル/秒)。")]
    [SerializeField] private float moveSpeed = 2f;

    [Tooltip("初期の移動方向。")]
    [SerializeField] private Vector3 initialDirection = Vector3.right;

    [Tooltip("移動を有効にするかどうか。")]
    [SerializeField] private bool isMoving = true;

    // 内部で保持する現在の移動方向
    private Vector3 moveDirection;

    private void Awake()
    {
        // 初期方向を正規化して保持
        if (initialDirection.sqrMagnitude < 0.0001f)
        {
            // デフォルトで右方向
            initialDirection = Vector3.right;
        }
        moveDirection = initialDirection.normalized;
    }

    private void Update()
    {
        if (!isMoving) return;

        // Y 成分は固定して、水平方向のみ移動
        Vector3 flat = new Vector3(moveDirection.x, 0f, moveDirection.z);
        if (flat.sqrMagnitude < 0.0001f) return;

        flat.Normalize();

        transform.position += flat * (moveSpeed * Time.deltaTime);

        // 向きも移動方向に合わせて回転させたい場合
        if (flat != Vector3.zero)
        {
            transform.rotation = Quaternion.LookRotation(flat, Vector3.up);
        }
    }

    #region ILedgeMovable 実装

    public Vector3 GetMoveDirection()
    {
        return moveDirection;
    }

    public void SetMoveDirection(Vector3 direction)
    {
        // ゼロベクトルは無視
        if (direction.sqrMagnitude < 0.0001f) return;

        moveDirection = direction.normalized;
    }

    #endregion
}

使い方の手順

ここでは、2D 横スクロール風の敵キャラクターを例に、崖で引き返す敵 AI を作る手順を説明します。
(3D でも基本は同じで、床にコライダーを置いておけばOKです)

手順①:シーンに床と敵を用意する

  1. 床(Ground)を作成
    • Hierarchy > 右クリック > 3D Object > Cube(または 2D なら Tilemap など)
    • スケールを伸ばして「足場」を作る
    • 床には Collider(BoxCollider など)を付けておく
    • レイヤーを「Ground」などに設定しておくと管理しやすいです
  2. 敵キャラクターの GameObject を作成
    • Hierarchy > 右クリック > Create Empty > 名前を Enemy に変更
    • SpriteRenderer や Mesh など、見た目を設定
    • 足場の上に配置しておく(崖の手前まで歩ける位置)

手順②:移動コンポーネントをアタッチする

  1. Enemy を選択し、「Add Component」で SimplePatrolMover を追加。
  2. Inspector で以下を設定:
    • Move Speed:2 ~ 3 くらいの値
    • Initial Direction(1, 0, 0)(右方向)や (-1, 0, 0)(左方向)
    • Is Moving:チェックを入れておく
  3. 再生してみて、敵が床の上を一定方向に歩くことを確認します(この時点では崖で落ちます)。

手順③:LedgeDetector をアタッチして設定する

  1. Enemy に「Add Component」で LedgeDetector を追加。
  2. Inspector で以下を設定:
    • Ray Origin
      足元付近に空の子オブジェクト RayOrigin を作り、その Transform を指定すると精度が上がります。
      指定しなければ Enemy 自身の位置から Ray が出ます。
    • Forward Reference
      通常は空欄で OK(Enemy の Transform.forward を使います)。
      2D 横スクロールで +X / -X を「前方」としたい場合、
      敵の向きを +Z ではなく +X に合わせておくか、専用の子オブジェクトを前方基準として指定すると分かりやすいです。
    • Ray Distance
      足元から床までの距離より少し長め(0.5 ~ 1.0 くらい)にします。
    • Ray Down Angle
      45° 前後だと「少し前方の足元」を検知できて使いやすいです。
      真下に飛ばしたい場合は 0° にします。
    • Ground Layer
      床に設定したレイヤー(例: Ground)のみをチェックします。
    • Check Interval Frames
      1 のままで OK(毎フレーム検知)。パフォーマンスが気になるほど敵が多い場合は 2~3 に上げても良いです。
    • Flip Immediately
      とりあえず ON で OK。崖を検知した瞬間に進行方向が反転します。
    • Show Debug Ray
      ON にすると Scene ビューで Ray の状態(緑=床あり、赤=崖)を確認できます。
  3. 再生して、敵が崖の手前でクルッと反転し、落ちずに行ったり来たりすることを確認します。

手順④:他の用途への応用例

  • 敵パトロール用
    今回の例のように、SimplePatrolMover + LedgeDetector を敵プレハブに仕込んでおけば、
    レベルデザイン時には「床にプレハブをポンと置くだけ」で、崖を自動で避ける敵を量産できます。
  • 動く床(落ちないリフト)
    エレベーターや横方向に動く床に SimplePatrolMover を付けて、LedgeDetector で崖検知させれば、
    レールの端で自動的に反転する「往復するリフト」を簡単に作れます。
  • 自律移動する NPC
    村人 NPC などに、プレイヤー検知や会話コンポーネントと並列して LedgeDetector を付けておくと、
    「道の端では引き返す」ような自然な動きを実現できます。

メリットと応用

コンポーネント分割のメリット

  • 責務が明確
    SimplePatrolMover は「移動」だけ、LedgeDetector は「崖検知と方向反転」だけに責務を絞っています。
    バグが出ても、どちらを疑えばいいかが明確ですね。
  • プレハブの再利用性が高い
    「崖で引き返す敵」「崖でも落ちる敵」を作り分けたいときは、
    LedgeDetector コンポーネントの有無を切り替えるだけで済みます。
    同じ移動ロジックを共有しつつ、挙動のバリエーションを増やせます。
  • レベルデザインが楽
    あらかじめ「崖を避ける敵プレハブ」を作っておけば、ステージ上に配置するだけで OK。
    スクリプトを書き換えなくても、Inspector のパラメータ(Ray 距離・角度など)をいじるだけで挙動を微調整できます。
  • テストしやすい
    LedgeDetector 単体で「Ray が正しく床を検知しているか」をデバッグしやすく、
    移動ロジック側のバグと切り分けやすくなります。

改造案:崖検知時に一瞬立ち止まってから反転する

今の LedgeDetector は、崖を検知した瞬間に方向を反転します。
「崖の手前でちょっと立ち止まって、考えてから引き返す」ような演出を入れたい場合は、
崖検知時に 一時停止 → 一定時間後に反転 という処理を追加すると雰囲気が出ます。

例えば、LedgeDetector 内に次のようなコルーチンを追加し、HandleLedgeDetected() から呼び出す形に変えると良いでしょう。


private bool isWaitingOnLedge = false;

private void HandleLedgeDetected()
{
    if (isWaitingOnLedge || movable == null) return;

    // 0.5 秒だけ立ち止まってから反転する
    StartCoroutine(WaitAndFlipDirection(0.5f));
}

private System.Collections.IEnumerator WaitAndFlipDirection(float waitSeconds)
{
    isWaitingOnLedge = true;

    // 現在の移動方向を退避して、いったん停止
    Vector3 originalDir = movable.GetMoveDirection();
    movable.SetMoveDirection(Vector3.zero);

    yield return new WaitForSeconds(waitSeconds);

    // 水平方向のみ反転させて再開
    Vector3 flat = new Vector3(originalDir.x, 0f, originalDir.z);
    Vector3 flippedFlat = -flat;
    Vector3 newDir = new Vector3(flippedFlat.x, originalDir.y, flippedFlat.z);

    movable.SetMoveDirection(newDir);
    isWaitingOnLedge = false;
}

このように、「崖を検知したときの振る舞い」を小さなメソッドに切り出しておけば、
・立ち止まる
・振り向きアニメーションを再生する
・SE を鳴らす
といった演出を、崖検知ロジックを壊さずに追加していけます。

崖検知は多くのアクションゲームで必要になる定番処理なので、
コンポーネントとして切り出しておいて、プロジェクトに「資産」として貯めていきましょう。