Unityを触り始めた頃にありがちなのが、プレイヤーの移動も、敵AIも、カメラ制御も、全部ひとつの Update() に書いてしまうパターンです。最初は動くので「これでいいじゃん」と思いがちですが、少し規模が大きくなると問題が一気に噴き出します。

  • 敵の巡回ルートを変えたいだけなのに、巨大なスクリプトを開いてスクロール地獄
  • 別の敵にも同じ挙動をさせたいのに、コピペだらけでバグ修正が地獄
  • レベルデザイナー(=シーンを組む人)が、コードを触らないとルート変更できない

こういった「Godクラス問題」を避けるためには、役割ごとに小さなコンポーネントを分けるのが大事ですね。今回はその一例として、2Dの巡回ルートを担当するだけのコンポーネントを用意して、敵AIの「動き」の部分だけをきれいに切り出してみましょう。

この記事では、Path2Dに設定されたポイントを順番に移動し、端まで来たら折り返すAIを実装するための「PatrolPath」コンポーネントを紹介します。

【Unity】折り返しパトロールを超シンプルに!「PatrolPath」コンポーネント

以下のコンポーネントは、

  • シーン上に並べた複数のポイントを順番に移動
  • 最後のポイントまで到達したら、逆順に折り返し
  • 2D用に Rigidbody2D を使った物理フレンドリーな実装

という役割だけに責任を絞った、シンプルな巡回AIです。

フルコード


using UnityEngine;

/// <summary>
/// 2D用の巡回ルートコンポーネント。
/// Path2D(Transform配列)に設定されたポイントを順番に移動し、
/// 端まで到達したら折り返して逆順に移動します。
/// 
/// 責務は「与えられたポイント列に沿って移動させること」だけに限定しています。
/// 攻撃処理やアニメーション制御は、別コンポーネントに分けることを推奨します。
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class PatrolPath : MonoBehaviour
{
    [Header("移動設定")]
    [SerializeField] private float moveSpeed = 2.0f;              // 巡回速度
    [SerializeField] private float reachThreshold = 0.05f;        // 目標ポイントに到達したとみなす距離

    [Header("巡回ポイント設定")]
    [Tooltip("巡回するポイント(順番に辿ります)。シーン上の空オブジェクトを並べて指定すると管理しやすいです。")]
    [SerializeField] private Transform[] pathPoints;

    [Header("初期設定")]
    [Tooltip("開始時に最も近いポイントからスタートするか(true推奨)")]
    [SerializeField] private bool startFromClosestPoint = true;

    // 内部状態管理用
    private Rigidbody2D rb;
    private int currentIndex = 0;      // 現在向かっているポイントのインデックス
    private int direction = 1;         // 1: 順方向, -1: 逆方向

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();

        // Rigidbody2Dの推奨設定(パトロール専用のため)
        // ここでデフォルト値を上書きしておくと、プレハブ化したときに一貫性が保てます。
        rb.gravityScale = 0f;          // 2Dパトロールなので重力は使わない前提
        rb.freezeRotation = true;      // 回転はさせない(横向きのまま移動したいケースが多いため)

        // パスが設定されていない場合は警告を出して無効化
        if (pathPoints == null || pathPoints.Length < 2)
        {
            Debug.LogWarning(
                $"[PatrolPath] パスが正しく設定されていません: {name}. " +
                "少なくとも2つ以上のポイントを設定してください。",
                this);

            enabled = false;
            return;
        }

        // 最も近いポイントからスタートするオプション
        if (startFromClosestPoint)
        {
            currentIndex = FindClosestPointIndex(transform.position);
        }
        else
        {
            currentIndex = 0;
        }
    }

    private void FixedUpdate()
    {
        // パスが無効な場合は何もしない
        if (pathPoints == null || pathPoints.Length == 0) return;

        // 現在のターゲットポイント
        Transform targetPoint = pathPoints[currentIndex];
        if (targetPoint == null)
        {
            Debug.LogWarning($"[PatrolPath] pathPoints[{currentIndex}] が null です: {name}", this);
            return;
        }

        // 現在位置とターゲット位置
        Vector2 currentPosition = rb.position;
        Vector2 targetPosition = targetPoint.position;

        // ターゲットまでの方向ベクトル(正規化)
        Vector2 toTarget = targetPosition - currentPosition;
        float distance = toTarget.magnitude;

        // すでに到達しているか判定
        if (distance <= reachThreshold)
        {
            // 次のポイントへ進む
            AdvanceToNextPoint();
            return; // このフレームでは速度を0にして終了(次フレームから新しいターゲットへ移動)
        }

        Vector2 directionNormalized = toTarget / distance;

        // 移動速度ベクトルを計算
        Vector2 velocity = directionNormalized * moveSpeed;

        // Rigidbody2D の velocity を直接設定して移動
        rb.velocity = velocity;

        // 左右反転(スプライトを左右向きに切り替えたい場合)
        // x方向の速度で向きを判定し、localScale.x の符号を変える
        if (Mathf.Abs(velocity.x) > 0.01f)
        {
            Vector3 localScale = transform.localScale;
            localScale.x = Mathf.Sign(velocity.x) * Mathf.Abs(localScale.x);
            transform.localScale = localScale;
        }
    }

    /// <summary>
    /// 次のポイントに進む(端に到達したら折り返し)。
    /// </summary>
    private void AdvanceToNextPoint()
    {
        // 一旦停止
        rb.velocity = Vector2.zero;

        // 次のインデックスを計算
        int nextIndex = currentIndex + direction;

        // 端に到達したか?
        if (nextIndex < 0 || nextIndex >= pathPoints.Length)
        {
            // 方向を反転
            direction *= -1;

            // 反転後の方向でもう一度計算
            nextIndex = currentIndex + direction;

            // 念のため範囲チェック(ポイントが1つだけなどの異常系)
            nextIndex = Mathf.Clamp(nextIndex, 0, pathPoints.Length - 1);
        }

        currentIndex = nextIndex;
    }

    /// <summary>
    /// 指定位置から最も近いポイントのインデックスを返します。
    /// </summary>
    private int FindClosestPointIndex(Vector2 fromPosition)
    {
        int closestIndex = 0;
        float closestSqrDist = float.MaxValue;

        for (int i = 0; i < pathPoints.Length; i++)
        {
            Transform point = pathPoints[i];
            if (point == null) continue;

            float sqrDist = ((Vector2)point.position - fromPosition).sqrMagnitude;
            if (sqrDist < closestSqrDist)
            {
                closestSqrDist = sqrDist;
                closestIndex = i;
            }
        }

        return closestIndex;
    }

    // --- エディタ上での補助(ギズモ描画)---

    private void OnDrawGizmos()
    {
        if (pathPoints == null || pathPoints.Length == 0) return;

        Gizmos.color = Color.cyan;

        // ポイント同士を線でつなぐ
        for (int i = 0; i < pathPoints.Length - 1; i++)
        {
            if (pathPoints[i] == null || pathPoints[i + 1] == null) continue;

            Gizmos.DrawLine(pathPoints[i].position, pathPoints[i + 1].position);
        }

        // 各ポイントに球を描画
        for (int i = 0; i < pathPoints.Length; i++)
        {
            if (pathPoints[i] == null) continue;

            Gizmos.DrawSphere(pathPoints[i].position, 0.07f);
        }
    }
}

使い方の手順

ここでは、2Dアクションゲームの「巡回する敵」を例に手順を説明します。

  1. シーンに敵キャラクターを配置する
    • Hierarchy で Right Click > Create Empty から空オブジェクトを作成し、名前を Enemy_Patrol などに変更。
    • スプライト(SpriteRenderer)や Collider2D を追加して、見た目と当たり判定を設定。
    • Rigidbody2D を追加(PatrolPath が自動で付けてくれますが、先に付けておいてもOK)。
    • 上記の PatrolPath.cs をプロジェクトに作成し、Enemy_Patrol にアタッチ。
  2. 巡回ポイント用の空オブジェクトを作る
    • Hierarchy で Right Click > Create Empty を2つ以上作成し、Point_A, Point_B, Point_C… のようにリネーム。
    • Sceneビューでドラッグして、敵に歩かせたい位置にそれぞれ移動。
    • 管理しやすいように、Enemy_Patrol の子オブジェクトにしておくと見通しが良くなります(必須ではありません)。
  3. PatrolPath にポイントを登録する
    • Enemy_Patrol を選択し、Inspector で PatrolPath コンポーネントを確認。
    • Path PointspathPoints)配列のサイズを、作成したポイント数に合わせて変更。
    • 各要素に Point_A, Point_B, Point_C… をドラッグ&ドロップで割り当て。
    • Move Speed で巡回速度、Reach Threshold で「到達したとみなす距離」を調整。
  4. 動作確認&調整
    • Playボタンを押すと、敵が Point_A → Point_B → Point_C → Point_B → Point_A → … のように折り返しながら移動します。
    • ポイントの位置を動かすだけで、巡回ルートを直感的に編集可能です。
    • 同じ PatrolPath コンポーネントを別の敵プレハブにもアタッチすれば、ルートだけ変えて簡単にバリエーションを作れます。

応用例としては、

  • 動く床(Moving Platform)に PatrolPath を付けて、プレイヤーを運ぶリフトにする
  • 見張りロボットの巡回ルートとして使い、別コンポーネントで視界チェックや攻撃を追加する
  • コインやアイテムがふわふわ往復する演出に使う

といった形で、「一定ルートを行ったり来たりするもの」全般に流用できます。

メリットと応用

PatrolPath を使うメリットは、単に「楽に動かせる」だけではありません。

  • プレハブ管理がシンプルになる
    敵プレハブには「見た目」「当たり判定」「巡回AI」「攻撃AI」など、小さなコンポーネントを積み上げるだけでOKになります。
    巡回ロジックを巨大なAIスクリプトに埋め込まず、「動きだけ」を独立させているので、他の敵にも簡単に再利用できます。
  • レベルデザインがコードレスで完結する
    巡回ルートの変更は、Sceneビューでポイントの位置を動かすだけです。
    新しいルートを追加したいときも、空オブジェクトをポンポン置いて、pathPoints に登録するだけで完了します。
  • 責務が明確でテストしやすい
    このコンポーネントの責務は「ポイント列に沿って移動すること」だけなので、バグの原因を追いやすく、単体テストもしやすくなります。
    攻撃やアニメーションは別コンポーネントに分けておけば、「止まっているときだけ攻撃する」「歩いているときだけ歩きモーション」などの制御も組み合わせやすくなります。

さらに、簡単な改造で表現の幅を広げることもできます。例えば、「端まで行ったら少し待ってから折り返す」ようにしたい場合は、こんなメソッドを追加して AdvanceToNextPoint() の代わりに呼び出すとよいでしょう。


/// <summary>
/// 端に到達したときに少し待ってから折り返す例。
/// コルーチンを使って待機時間を挟みます。
/// </summary>
private System.Collections.IEnumerator AdvanceToNextPointWithDelay(float waitSeconds)
{
    // いったん停止
    rb.velocity = Vector2.zero;

    // 端にいるかどうかをチェック
    bool isAtEdge = (currentIndex == 0 && direction < 0) ||
                    (currentIndex == pathPoints.Length - 1 && direction > 0);

    if (isAtEdge && waitSeconds > 0f)
    {
        // 待機
        yield return new WaitForSeconds(waitSeconds);
    }

    // 通常のAdvanceToNextPointロジックをそのまま呼ぶ
    AdvanceToNextPoint();
}

このように、小さく責務がはっきりしたコンポーネントにしておくと、あとからの改造や差し替えがとても楽になります。
「とりあえず全部 Update() に書く」から一歩進んで、こうしたパトロール専用コンポーネントを積み重ねていくスタイルに慣れていきましょう。