Unityを触り始めた頃、「とりあえず Update() に全部書いて動けばOK!」となりがちですよね。
プレイヤーの入力処理、カメラ追従、敵AI、移動ロジック……全部1つのスクリプトに押し込んでしまうと、数日後には「どこを触れば壊れないのか分からない巨大スクリプト」が完成してしまいます。

とくに「経路移動(決められた道を往復する敵や動く床)」は、Update() にベタ書きされやすい典型パターンです。

  • スピード計算
  • 補間(Lerp)処理
  • 端に着いたら折り返すロジック
  • プレイヤーが乗っているときの挙動

こういった処理が1つのクラスに詰め込まれると、再利用が難しく、プレハブごとに微妙に違うコピペコードが量産されてしまいます。

そこでこの記事では、「経路の定義」と「経路に沿って移動する処理」をきれいに分離するためのコンポーネントとして、PathFollower(経路移動) を紹介します。
親オブジェクトに配置した Path2D を参照し、その経路に沿ってオブジェクトを往復移動させるコンポーネントです。

【Unity】往復する動く床や敵を量産!「PathFollower」コンポーネント

ここでは以下の2コンポーネントをセットで使います。

  • Path2D … 経路(ウェイポイント)を定義するだけのコンポーネント
  • PathFollower … Path2D に沿ってオブジェクトを往復移動させるコンポーネント

責務を分けることで、「経路はレベルデザイナーが Scene 上で編集」「移動ロジックはプログラマが PathFollower に閉じ込める」 という役割分担がしやすくなります。


Path2D(経路定義コンポーネント)のコード


using System.Collections.Generic;
using UnityEngine;

/// <summary>2D/3D 共通で使える、シンプルな経路定義コンポーネント</summary>
public class Path2D : MonoBehaviour
{
    // 経路を構成するポイントたち
    // 子オブジェクトとして配置してもいいし、シーン上の別オブジェクトを参照してもOK
    [SerializeField]
    private List<Transform> waypoints = new List<Transform>();

    /// <summary>経路上のポイント数</summary>
    public int Count => waypoints.Count;

    /// <summary>インデックスでポイントの位置を取得(範囲外ならクランプ)</summary>
    public Vector3 GetPoint(int index)
    {
        if (waypoints.Count == 0)
        {
            return transform.position;
        }

        index = Mathf.Clamp(index, 0, waypoints.Count - 1);
        return waypoints[index].position;
    }

    /// <summary>インデックスで Transform 自体を取得(必要なら)</summary>
    public Transform GetWaypoint(int index)
    {
        if (waypoints.Count == 0)
        {
            return transform;
        }

        index = Mathf.Clamp(index, 0, waypoints.Count - 1);
        return waypoints[index];
    }

    /// <summary>エディタ上で経路を可視化するためのギズモ描画</summary>
    private void OnDrawGizmos()
    {
        Gizmos.color = Color.cyan;

        // ポイントが 2 個以上あるときだけ線を引く
        for (int i = 0; i < waypoints.Count; i++)
        {
            if (waypoints[i] == null) continue;

            // ポイントの位置に小さな球を描画
            Gizmos.DrawSphere(waypoints[i].position, 0.1f);

            // 次のポイントとの間に線を引く
            if (i < waypoints.Count - 1 && waypoints[i + 1] != null)
            {
                Gizmos.DrawLine(waypoints[i].position, waypoints[i + 1].position);
            }
        }
    }
}

PathFollower(経路移動コンポーネント)のコード


using UnityEngine;

/// <summary>
/// 親などに配置された Path2D に沿って、オブジェクトを往復移動させるコンポーネント。
/// 経路の定義は Path2D に任せ、こちらは「経路に沿ってどう動くか」だけを担当します。
/// </summary>
[RequireComponent(typeof(Transform))]
public class PathFollower : MonoBehaviour
{
    [Header("参照設定")]

    [Tooltip("移動に使用する Path2D。未指定の場合は親から自動取得を試みます。")]
    [SerializeField]
    private Path2D path;

    [Header("移動パラメータ")]

    [Tooltip("経路に沿って進む速さ(単位: ユニット/秒)。")]
    [SerializeField]
    private float moveSpeed = 2f;

    [Tooltip("ポイント間を滑らかに補間するか(true: 線形補間, false: ポイント間を瞬間移動)。")]
    [SerializeField]
    private bool smoothMovement = true;

    [Tooltip("経路の最初のポイントにスナップしてから動き始めるか。")]
    [SerializeField]
    private bool snapToFirstPointOnStart = true;

    [Tooltip("開始時に移動を有効にするか。false の場合、外部から EnableMovement() を呼ぶまで停止します。")]
    [SerializeField]
    private bool playOnStart = true;

    [Header("デバッグ")]

    [Tooltip("現在のターゲットインデックスをインスペクタ上で確認するための表示用変数。")]
    [SerializeField, ReadOnlyInInspector]
    private int currentIndex = 0;

    [Tooltip("現在の経路進行方向(true: 正方向, false: 逆方向)。")]
    [SerializeField, ReadOnlyInInspector]
    private bool forward = true;

    // 実際の移動ON/OFFフラグ
    private bool isMoving;

    // 現在のセグメント内での補間係数(0~1)
    private float segmentT = 0f;

    private void Reset()
    {
        // コンポーネントが追加されたときに、親から Path2D を探して自動設定しておく
        if (path == null)
        {
            path = GetComponentInParent<Path2D>();
        }
    }

    private void Awake()
    {
        // Awake 時点でも一応自動取得を試みる(親に Path2D を置いているケースをサポート)
        if (path == null)
        {
            path = GetComponentInParent<Path2D>();
        }
    }

    private void Start()
    {
        // 経路が無い場合は警告を出して無効化
        if (path == null || path.Count <= 1)
        {
            Debug.LogWarning($"[PathFollower] 有効な Path2D が見つからないか、ポイント数が 2 未満です。オブジェクト名: {gameObject.name}");
            enabled = false;
            return;
        }

        // 初期位置を経路の最初のポイントに合わせる
        if (snapToFirstPointOnStart)
        {
            transform.position = path.GetPoint(0);
        }

        // 開始時に移動するかどうか
        isMoving = playOnStart;

        // 初期状態
        currentIndex = 0;
        forward = true;
        segmentT = 0f;
    }

    private void Update()
    {
        if (!isMoving || path == null || path.Count <= 1)
        {
            return;
        }

        // 経路に沿って移動処理を行う
        MoveAlongPath();
    }

    /// <summary>
    /// 経路に沿って往復移動させるメインロジック。
    /// </summary>
    private void MoveAlongPath()
    {
        // 現在のポイントと次のポイントのインデックスを決定
        int fromIndex = currentIndex;
        int toIndex = GetNextIndex(fromIndex, forward);

        Vector3 from = path.GetPoint(fromIndex);
        Vector3 to = path.GetPoint(toIndex);

        // ポイント間の距離
        float distance = Vector3.Distance(from, to);

        if (distance <= Mathf.Epsilon)
        {
            // 距離がほぼ 0 の場合は、次のポイントに進めてしまう
            AdvanceIndex();
            return;
        }

        // 今フレームで進める距離を、セグメント長で割って「進行度(0~1)」に変換
        float deltaT = (moveSpeed * Time.deltaTime) / distance;

        // セグメント内の進行度を更新
        segmentT += deltaT;

        if (smoothMovement)
        {
            // 0~1 の間で線形補間して滑らかに移動
            transform.position = Vector3.Lerp(from, to, segmentT);
        }
        else
        {
            // 補間しない場合は、一定距離ごとにポイントへ「カクカク」移動させたいケースを想定
            // segmentT が 1 を超えたら次のポイントへスナップするシンプルな実装
            if (segmentT >= 1f)
            {
                transform.position = to;
            }
        }

        // セグメントを走破したら次のポイントへ
        if (segmentT >= 1f)
        {
            AdvanceIndex();
        }
    }

    /// <summary>
    /// 現在のインデックスと進行方向から、次に向かうインデックスを取得します。
    /// </summary>
    private int GetNextIndex(int index, bool forwardDirection)
    {
        if (forwardDirection)
        {
            return Mathf.Min(index + 1, path.Count - 1);
        }
        else
        {
            return Mathf.Max(index - 1, 0);
        }
    }

    /// <summary>
    /// セグメントを走破したときに、次のインデックスと進行方向を更新する処理。
    /// </summary>
    private void AdvanceIndex()
    {
        if (forward)
        {
            if (currentIndex < path.Count - 1)
            {
                // まだ終点に到達していなければ、次のポイントへ
                currentIndex++;
            }
            else
            {
                // 終点に到達したので折り返し
                forward = false;
                currentIndex = Mathf.Max(path.Count - 2, 0);
            }
        }
        else
        {
            if (currentIndex > 0)
            {
                // まだ始点に到達していなければ、前のポイントへ
                currentIndex--;
            }
            else
            {
                // 始点に到達したので折り返し
                forward = true;
                currentIndex = Mathf.Min(1, path.Count - 1);
            }
        }

        // 新しいセグメントを 0 からスタート
        segmentT = 0f;
    }

    #region 公開API(外部から制御したいとき用)

    /// <summary>
    /// 移動を有効化します。
    /// </summary>
    public void EnableMovement()
    {
        isMoving = true;
    }

    /// <summary>
    /// 移動を停止します。
    /// </summary>
    public void DisableMovement()
    {
        isMoving = false;
    }

    /// <summary>
    /// 経路の最初のポイントにワープさせ、進行状態もリセットします。
    /// </summary>
    public void ResetToStart()
    {
        if (path == null || path.Count == 0) return;

        currentIndex = 0;
        forward = true;
        segmentT = 0f;
        transform.position = path.GetPoint(0);
    }

    /// <summary>
    /// 移動速度を動的に変更します。
    /// </summary>
    public void SetSpeed(float newSpeed)
    {
        moveSpeed = Mathf.Max(0f, newSpeed);
    }

    #endregion
}

/// <summary>
/// インスペクタ上でフィールドを読み取り専用に見せるための簡易属性。
/// (実行時に値は変化しますが、インスペクタからは編集できなくなります)
/// </summary>
public class ReadOnlyInInspectorAttribute : PropertyAttribute { }

#if UNITY_EDITOR
using UnityEditor;

/// <summary>ReadOnlyInInspector 用のカスタムプロパティドロワー</summary>
[CustomPropertyDrawer(typeof(ReadOnlyInInspectorAttribute))]
public class ReadOnlyInInspectorDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        GUI.enabled = false;          // 編集不可にする
        EditorGUI.PropertyField(position, property, label, true);
        GUI.enabled = true;           // 元に戻す
    }
}
#endif

使い方の手順

ここからは、実際に「動く床」や「パトロールする敵」に使う具体的な手順を見ていきましょう。

手順①:経路用の親オブジェクトを作る(Path2D)

  1. Hierarchy で Create > Empty を選び、オブジェクト名を MovingPlatformPath などにします。
  2. このオブジェクトに Path2D コンポーネントを追加します。
  3. 経路用のポイントを作成します:
    • MovingPlatformPath の子として空の子オブジェクトを2つ作成(例:PointA, PointB)。
    • それぞれをシーンビューで好きな位置に配置します(動く床の端の位置など)。
    • Path2Dwaypoints リストに、この2つの子オブジェクトをドラッグ&ドロップで登録します。

これで「A 点 ⇔ B 点」を往復するための経路が定義できました。
3点以上追加すれば「A → B → C → B → A…」のような往復経路にもできます。

手順②:動かしたいオブジェクトに PathFollower を付ける

例として「動く床」を作ってみます。

  1. 床用の Cube を作成し、名前を MovingPlatform にします。
  2. MovingPlatformPathFollower コンポーネントを追加します。
  3. PathFollowerpath フィールドに、さきほど作成した MovingPlatformPath をドラッグ&ドロップします。
    もし MovingPlatformMovingPlatformPath の子オブジェクトにしている場合は、自動で親から Path2D を拾ってくれます。
  4. moveSpeed を 2~3 あたりに設定し、smoothMovement を ON にしておくと自然な往復運動になります。

手順③:敵キャラクターにパトロールさせる

同じやり方で「敵が一定ルートを往復パトロールする」動きも簡単に作れます。

  1. 敵キャラのプレハブ(例:Enemy)をシーンに配置します。
  2. 敵の移動ルート用に EnemyPath という Empty を作り、Path2D を追加します。
  3. EnemyPath の子として、Point1, Point2, Point3 を作り、パトロールさせたい位置に配置します。
  4. EnemyPathFollower を追加し、pathEnemyPath を指定します。
  5. playOnStart を ON にしておけば、ゲーム開始と同時にパトロールを開始します。
    プレイヤーが近づいたときだけ動かしたい場合は playOnStart を OFF にし、トリガーに入ったタイミングで
    EnableMovement() を呼ぶようなスクリプトを別途用意するとよいですね。

手順④:レベルデザインで経路を調整する

経路のポイントはすべて Transform なので、シーンビューでつまんで動かすだけでパスの形が変わります。

  • 動く床の端の位置を少しずらしたい
  • 敵のパトロールルートを曲げたい
  • 途中で一時停止させたいポイントを追加したい

こういった調整を コードを一切触らずに 行えるのが、Path2D + PathFollower という分割の大きなメリットです。


メリットと応用

このコンポーネント構成のメリットを整理してみます。

  • 責務が分離されている
    Path2D は「経路データ」、PathFollower は「経路に沿った移動ロジック」と役割がはっきり分かれているので、コードの見通しが良くなります。
  • プレハブの再利用性が高い
    敵や動く床のプレハブには PathFollower だけを持たせ、シーン側で Path2D を差し替えるだけで「同じ動きロジックで違うルート」を簡単に作れます。
  • レベルデザインがしやすい
    経路の編集は Transform を動かすだけなので、ノンプログラマでも直感的に調整できます。ギズモ表示のおかげでシーンビュー上でも経路が視覚的に把握できます。
  • Godクラス化を防げる
    「プレイヤー」「敵AI」「パス移動」などを1つの巨大スクリプトに詰め込まず、小さなコンポーネントに分けて組み合わせるスタイルを自然に促せます。

改造案:ポイントごとに一時停止させる

「端まで行ったら 1 秒止まってから折り返す」など、ポイントごとに待機時間を入れたいケースはよくあります。
簡単な例として、特定のポイントに到達したときに少し待つ処理を追加する改造案を示します。


/// <summary>指定したインデックスのポイントに到達したら一定時間停止する例</summary>
private System.Collections.IEnumerator PauseAtPointCoroutine(int pauseIndex, float pauseDuration)
{
    while (true)
    {
        // 経路が無効なら待機
        if (path == null || path.Count <= 1)
        {
            yield return null;
            continue;
        }

        // 指定ポイントに到達したら停止
        if (currentIndex == pauseIndex && segmentT == 0f)
        {
            bool wasMoving = isMoving;
            isMoving = false; // 一時停止
            yield return new WaitForSeconds(pauseDuration);
            isMoving = wasMoving; // 元の状態に戻す
        }

        yield return null;
    }
}

このコルーチンを Start()StartCoroutine(PauseAtPointCoroutine(0, 1.0f)); のように起動すれば、
「インデックス 0 のポイントに到達するたびに 1 秒間停止する」動きが簡単に追加できます。

このように、PathFollower 自体は「シンプルな往復移動」に責務を絞りつつ、
必要な挙動は小さなメソッドや別コンポーネントとして増やしていくと、プロジェクト全体がかなりスッキリしてきます。