Unityを触り始めたころ、つい1つの巨大なスクリプトの Update に「移動処理」「入力処理」「アニメーション」「エフェクト」…全部まとめて書いてしまうことが多いですよね。
動く床やエレベーターのような「パスに沿って動くオブジェクト」も、ついプレイヤーのスクリプトに混ぜてしまいがちです。

でもそれを続けると、

  • ちょっと挙動を変えたいだけで巨大なスクリプトを開く必要がある
  • 動く床・敵・ギミックなど、似たような移動ロジックがコピペで乱立する
  • レベルデザイナーが「動き方」を調整しづらい(パラメータがバラバラ)

といった問題が出てきます。

そこでこの記事では、「パスに沿って動く」という機能だけに責務を絞ったコンポーネントとして、
PathMover(パス移動)を実装してみます。
動く床・巡回する敵・回転するオブジェクトなど、「親オブジェクトを Path2D に沿って動かす」用途で使い回せるようにしていきましょう。

【Unity】シンプルなパス制御で動く床を量産!「PathMover」コンポーネント

ここでは、指定した2Dパス(複数のポイント)に沿って親オブジェクトを往復させるコンポーネントを作ります。
Transform の位置を直接動かすだけなので、2D/3D どちらでも使えます(名前は Path2D ですが、実体は単なる Transform の配列です)。

前提となる考え方

  • PathMover は「どう移動するか」だけを担当
  • Path2D は「どこを通るか(ウェイポイント)」だけを担当
  • プレイヤーや敵のロジックとは分離して、Prefab 単位で再利用できるようにする

そのために、PathMover は Path2D コンポーネントに依存する構成にします。


フルコード:Path2D & PathMover

まずはコピペで動く完全なコードを載せます。
2つのスクリプトをそれぞれ別ファイルにして使ってください。

Path2D.cs(パス定義コンポーネント)


using System.Collections.Generic;
using UnityEngine;

/// <summary>2D/3D 共通で使えるシンプルなパス定義コンポーネント</summary>
public class Path2D : MonoBehaviour
{
    // パスを構成するポイント(子オブジェクトの Transform を並べる想定)
    [SerializeField] private List<Transform> points = new List<Transform>();

    // シーン上でパスの形を確認するための Gizmos 設定
    [Header("Gizmos 設定")]
    [SerializeField] private Color lineColor = Color.cyan;
    [SerializeField] private Color pointColor = Color.yellow;
    [SerializeField] private float pointRadius = 0.1f;

    /// <summary>パスのポイント数</summary>
    public int PointCount => points.Count;

    /// <summary>index 番目のポイントの位置を取得(範囲外チェック付き)</summary>
    public Vector3 GetPointPosition(int index)
    {
        if (points == null || points.Count == 0)
        {
            Debug.LogWarning($"[Path2D] パスのポイントが設定されていません: {name}");
            return transform.position;
        }

        // 安全のため、範囲外アクセスを防ぐ
        index = Mathf.Clamp(index, 0, points.Count - 1);
        return points[index] != null ? points[index].position : transform.position;
    }

    private void OnDrawGizmos()
    {
        if (points == null || points.Count == 0)
        {
            return;
        }

        Gizmos.color = pointColor;

        // 各ポイントに小さな球を描画
        for (int i = 0; i < points.Count; i++)
        {
            if (points[i] == null) continue;
            Gizmos.DrawSphere(points[i].position, pointRadius);
        }

        // ポイント同士を線で結ぶ
        Gizmos.color = lineColor;
        for (int i = 0; i < points.Count - 1; i++)
        {
            if (points[i] == null || points[i + 1] == null) continue;
            Gizmos.DrawLine(points[i].position, points[i + 1].position);
        }
    }
}

PathMover.cs(パスに沿って親を動かすコンポーネント)


using UnityEngine;

/// <summary>
/// Path2D に沿って親オブジェクトを移動させるコンポーネント。
/// 動く床・巡回する敵・パトロールするカメラなどに利用可能。
/// </summary>
[RequireComponent(typeof(Path2D))]
public class PathMover : MonoBehaviour
{
    [Header("移動設定")]
    [Tooltip("1秒あたりの移動速度(ユニット/秒)")]
    [SerializeField] private float moveSpeed = 2f;

    [Tooltip("パスの最初のポイントから開始するかどうか(false の場合は現在位置から最寄りポイントへ)")]
    [SerializeField] private bool startFromFirstPoint = true;

    [Tooltip("パスの端まで行ったら折り返して往復するかどうか")]
    [SerializeField] private bool loopBackAndForth = true;

    [Tooltip("各ポイントで停止する時間(秒)")]
    [SerializeField] private float waitTimeAtPoint = 0f;

    [Header("補間設定")]
    [Tooltip("true の場合、一定速度で移動(距離ベース)。false の場合、Lerp を Time.deltaTime ベースで使用")]
    [SerializeField] private bool constantSpeed = true;

    // 依存コンポーネント
    private Path2D path;

    // 現在向かっているポイントのインデックス
    private int currentIndex = 0;

    // 次に向かうポイントのインデックス
    private int nextIndex = 1;

    // 移動方向(1: 順方向, -1: 逆方向)
    private int direction = 1;

    // ポイント間の補間パラメータ(0 ~ 1)
    private float t = 0f;

    // ポイント間の距離(一定速度移動に使用)
    private float segmentDistance = 0f;

    // 各ポイントでの待機時間カウンタ
    private float waitTimer = 0f;

    // 移動を一時停止するフラグ(外部から制御用)
    public bool IsPaused { get; set; } = false;

    private void Awake()
    {
        // 必須コンポーネントを取得
        path = GetComponent<Path2D>();
    }

    private void Start()
    {
        InitializePathState();
    }

    /// <summary>
    /// 初期状態の設定(開始ポイントの決定など)
    /// </summary>
    private void InitializePathState()
    {
        int count = path.PointCount;
        if (count < 2)
        {
            Debug.LogWarning($"[PathMover] パスのポイントは2つ以上必要です: {name}");
            enabled = false;
            return;
        }

        if (startFromFirstPoint)
        {
            // パスの最初のポイントから開始
            currentIndex = 0;
            nextIndex = 1;
            direction = 1;
            transform.position = path.GetPointPosition(currentIndex);
        }
        else
        {
            // 現在位置から最も近いポイントを探す
            Vector3 currentPos = transform.position;
            float minDist = float.MaxValue;
            int nearestIndex = 0;

            for (int i = 0; i < count; i++)
            {
                float dist = Vector3.SqrMagnitude(path.GetPointPosition(i) - currentPos);
                if (dist < minDist)
                {
                    minDist = dist;
                    nearestIndex = i;
                }
            }

            currentIndex = nearestIndex;
            // とりあえず次は「次のインデックス」へ向かう(末尾なら前へ)
            nextIndex = (currentIndex + 1 < count) ? currentIndex + 1 : currentIndex - 1;
            direction = nextIndex > currentIndex ? 1 : -1;
        }

        // 最初のセグメントの距離を計算
        segmentDistance = Vector3.Distance(
            path.GetPointPosition(currentIndex),
            path.GetPointPosition(nextIndex)
        );

        t = 0f;
        waitTimer = 0f;
    }

    private void Update()
    {
        if (IsPaused) return;

        int count = path.PointCount;
        if (count < 2) return;

        // ポイントでの待機処理
        if (waitTimer > 0f)
        {
            waitTimer -= Time.deltaTime;
            return;
        }

        Vector3 startPos = path.GetPointPosition(currentIndex);
        Vector3 endPos = path.GetPointPosition(nextIndex);

        if (constantSpeed)
        {
            // 距離ベースで一定速度移動
            if (segmentDistance <= 0f)
            {
                segmentDistance = Vector3.Distance(startPos, endPos);
            }

            if (segmentDistance > 0f)
            {
                float delta = (moveSpeed * Time.deltaTime) / segmentDistance;
                t += delta;
            }
            else
            {
                t = 1f; // 同一点の場合は即到達扱い
            }
        }
        else
        {
            // Time.deltaTime ベースの Lerp(速度は距離に依存)
            t += moveSpeed * Time.deltaTime;
        }

        // t を 0~1 にクランプ
        t = Mathf.Clamp01(t);

        // 実際に位置を補間して移動
        transform.position = Vector3.Lerp(startPos, endPos, t);

        // 次のポイントに到達したらインデックスを更新
        if (Mathf.Approximately(t, 1f))
        {
            OnReachPoint();
        }
    }

    /// <summary>
    /// ポイントに到達したときの処理(インデックス更新・折り返しなど)
    /// </summary>
    private void OnReachPoint()
    {
        int count = path.PointCount;

        // 現在の「次のポイント」が新たな current になる
        currentIndex = nextIndex;

        // 折り返し処理を含めた次のインデックス計算
        if (loopBackAndForth)
        {
            // 端に到達したら方向反転
            if (currentIndex == 0)
            {
                direction = 1;
            }
            else if (currentIndex == count - 1)
            {
                direction = -1;
            }

            nextIndex = currentIndex + direction;
        }
        else
        {
            // 一方向ループ(最後の次は最初に戻る)
            nextIndex = (currentIndex + 1) % count;
        }

        // セグメント情報をリセット
        t = 0f;
        segmentDistance = Vector3.Distance(
            path.GetPointPosition(currentIndex),
            path.GetPointPosition(nextIndex)
        );

        // 各ポイントでの待機時間をセット
        if (waitTimeAtPoint > 0f)
        {
            waitTimer = waitTimeAtPoint;
        }
    }

    /// <summary>
    /// 外部から強制的にパスの先頭に戻すための関数。
    /// 例: リスタート時に呼ぶ。
    /// </summary>
    public void ResetToStart()
    {
        InitializePathState();
    }
}

使い方の手順

ここからは実際に 動く床 を例に、PathMover の使い方を手順で見ていきます。

手順①:Path2D を用意してパスを作る

  1. 空の GameObject を作成し、名前を MovingPlatformPath などにします。
  2. Path2D コンポーネントをアタッチします。
  3. その子オブジェクトとして、通過させたいポイント数だけ Empty を作成します。
    • 例: Point0, Point1, Point2
    • シーンビューでそれぞれの位置を動かしてパスの形を調整します。
  4. Path2Dpoints リストに、作成した子オブジェクトを順番にドラッグ&ドロップします。

これで「どこを通るか」の情報が Path2D としてまとまりました。
Gizmos をオンにすると、シーンビュー上にパスが線で表示されるはずです。

手順②:動かしたいオブジェクト(動く床)を用意する

  1. Cube などで「動く床」の GameObject を作成します(例: MovingPlatform)。
  2. 床のサイズ・見た目はお好みで調整します。
  3. プレイヤーを乗せる場合は、通常通り BoxColliderRigidbody を設定します。
    • 物理挙動を使わず「単に位置を動かしたい」場合は Rigidbody は必須ではありません。

手順③:PathMover をアタッチして Path2D を親にする

  1. MovingPlatformPathMover コンポーネントをアタッチします。
  2. MovingPlatformPath2D を持つオブジェクトの子 にします。
    • Hierarchy 上で MovingPlatformMovingPlatformPath の子にドラッグします。
    • こうすることで、PathMover が GetComponent<Path2D>() で親の Path2D を取得できます。
  3. PathMover のインスペクターでパラメータを設定します。
    • Move Speed: 1~3 くらいから試して調整
    • Start From First Point: パスの始点から動かしたい場合は ON
    • Loop Back And Forth: 端で折り返して往復させたい場合は ON
    • Wait Time At Point: エレベーターのように「端で少し止まる」演出をしたいときに秒数を設定
    • Constant Speed: true にすると、どの区間もほぼ同じ速度で移動

ここまで設定できたら、再生ボタンを押して、床が Path2D のポイントに沿って動くか確認しましょう。

手順④:応用例(敵・カメラ・動くギミック)

  • 巡回する敵
    • 敵キャラの GameObject に PathMover をアタッチ
    • Path2D で「見回りルート」を作成しておく
    • 攻撃や視界判定などは別コンポーネントに分離して、移動だけ PathMover に任せる
  • パトロールするカメラ
    • カメラを複数のポイント間でゆっくり移動させる
    • シネマティックな演出や、タイトル画面の背景カメラなどに便利
  • 動くトラップ・ギミック
    • トゲ付きの床や回転するハンマーなどをパスに沿って動かす
    • レベルデザイナーが Path2D のポイントを動かすだけでトラップの動きを調整できる

メリットと応用

メリット①:移動ロジックの再利用性が高い

「パスに沿って動く」という機能を PathMover に閉じ込めておくことで、

  • 動く床
  • 巡回する敵
  • カメラ
  • 装飾オブジェクト(飛行船、雲、鳥など)

といったさまざまなオブジェクトに、同じコンポーネントをそのまま使い回せます
各オブジェクトごとに「Update に移動処理を書く」必要がなくなり、Godクラス化を防止できます。

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

Path2D は単に Transform のリストなので、

  • シーンビューでポイントをドラッグして形を変えるだけでパスを編集できる
  • 同じ Path2D を複数のオブジェクトが共有して、同じルートを違うタイミングで移動することも可能
  • Gizmos でパスの形が一目でわかる

といった利点があります。
スクリプトに触れずに「ここを通ってほしい」を直感的に編集できるので、レベルデザインの試行錯誤がかなり楽になります。

メリット③:責務が分かれていて保守しやすい

  • Path2D は「どこを通るか」だけ
  • PathMover は「どう動くか」だけ

という分離にしておくと、

  • Path2D に「ランダムなポイントを挿入する」機能を足したい
  • PathMover に「一定確率で止まる」挙動を足したい

といった拡張も、それぞれのクラス内だけを編集すればよくなります。
結果として、プロジェクトが大きくなっても変更の影響範囲を小さく保てます。


改造案:区間ごとに速度を変える

例えば「この区間だけ速く/遅くしたい」といったニーズもよく出てきます。
その場合、PathMover に「区間ごとの速度倍率」を返す関数を追加し、Update で使うようにすれば OK です。


    /// <summary>
    /// 現在のセグメント(currentIndex → nextIndex)に応じて速度倍率を返す。
    /// 区間ごとに移動速度を変えたい場合に利用。
    /// </summary>
    private float GetSpeedMultiplierForCurrentSegment()
    {
        // 例: 0 → 1 の区間だけ速くする
        if (currentIndex == 0 && nextIndex == 1)
        {
            return 2.0f; // 2倍速
        }

        // 例: 1 → 2 の区間だけ遅くする
        if (currentIndex == 1 && nextIndex == 2)
        {
            return 0.5f; // 半分の速度
        }

        // それ以外は通常速度
        return 1.0f;
    }

そして Update() 内の moveSpeed を使っている箇所を、


float speed = moveSpeed * GetSpeedMultiplierForCurrentSegment();

のように差し替えれば、区間ごとに速度を変えられます。
このように、小さな責務のコンポーネントにしておくと、後からの改造もやりやすくなりますね。


PathMover と Path2D を組み合わせることで、「パスに沿って動く」というよくあるギミックを、シンプルなコンポーネントとして再利用できるようになります。
巨大な Update に移動ロジックを書き足すのではなく、こういった小さなコンポーネントを積み重ねていく設計を意識してみてください。