Unityを触り始めた頃にやりがちなのが、Update() に「移動」「入力」「アニメーション」「当たり判定」「スコア管理」などを全部詰め込んでしまう書き方ですね。最初は動くので気持ちいいのですが、あとから「敵も同じ動きさせたい」「この動きだけ別のオブジェクトに使い回したい」となった瞬間、コードのコピペ地獄が始まります。

とくに「決まった軌道に沿って動かしたい」という要件(例: レール移動する敵、遊園地の乗り物、動く足場など)は、つい1つの巨大スクリプトにロジックを書いてしまいがちです。

そこでこの記事では、「ベジェ曲線に沿ってオブジェクトを滑らかに移動させる」ことだけに責務を絞ったコンポーネント SplineFollower を作っていきます。
小さなコンポーネントに分割しておくことで、「移動はSplineFollower」「攻撃はShooter」「見た目はAnimator」といった感じで、プレハブを組み合わせるだけで複雑な挙動を作れるようになります。

【Unity】ベジェ曲線でレール移動!「SplineFollower」コンポーネント

ここでは「3次ベジェ曲線(4点制御)」を使い、0~1のパラメータに応じて位置と向きを計算しながら移動するコンポーネントを実装します。
・シーン上に置いた4つのTransformを制御点として使用
・時間経過で t を進めるだけで自動的に曲線上を移動
・オプションで「往復」「ループ」「自動再生ON/OFF」などを設定可能

フルコード:SplineFollower.cs


using UnityEngine;

/// <summary>
/// 3次ベジェ曲線に沿ってオブジェクトを移動させるコンポーネント。
/// ・制御点4つ(p0, p1, p2, p3)をシーン上のTransformで指定
/// ・0~1のパラメータ t に応じて位置と向きを算出
/// ・再生モード(1往復 / ループ / 一方向のみ)を選択可能
/// </summary>
[DisallowMultipleComponent]
public class SplineFollower : MonoBehaviour
{
    // 再生モード
    public enum PlayMode
    {
        Once,       // 0→1まで進んだら停止
        Loop,       // 1まで行ったら0に戻る
        PingPong    // 0→1→0→1... と往復
    }

    [Header("ベジェ制御点(4点)")]
    [Tooltip("始点(p0)")]
    [SerializeField] private Transform point0;
    [Tooltip("始点側ハンドル(p1)")]
    [SerializeField] private Transform point1;
    [Tooltip("終点側ハンドル(p2)")]
    [SerializeField] private Transform point2;
    [Tooltip("終点(p3)")]
    [SerializeField] private Transform point3;

    [Header("移動設定")]
    [Tooltip("1秒あたりに進む曲線パラメータ量(0~1)。\n例えば 0.2 なら 5秒で端から端まで移動します。")]
    [SerializeField] private float speed = 0.2f;

    [Tooltip("ゲーム開始時に自動再生するか")]
    [SerializeField] private bool playOnStart = true;

    [Tooltip("再生モード(1回 / ループ / 往復)")]
    [SerializeField] private PlayMode playMode = PlayMode.Loop;

    [Tooltip("進行方向へ自動的に向きを合わせるか")]
    [SerializeField] private bool lookForward = true;

    [Tooltip("進行方向を補間するスムージング係数(0で即座に向き変更)")]
    [SerializeField] private float rotationLerp = 10f;

    [Header("デバッグ")]
    [Tooltip("シーンビューで曲線を可視化するか")]
    [SerializeField] private bool drawGizmos = true;

    [Tooltip("ギズモの分割数(大きいほど滑らか)")]
    [SerializeField] private int gizmoSegmentCount = 20;

    // 現在のパラメータ(0~1)
    [SerializeField, Range(0f, 1f)]
    private float normalizedPosition = 0f;

    // 現在再生中かどうか
    private bool isPlaying = false;

    // PingPong 用:進行方向(+1 or -1)
    private int direction = 1;

    private void Start()
    {
        if (playOnStart)
        {
            Play();
        }
    }

    private void Update()
    {
        if (!isPlaying)
        {
            // 再生していない場合でも、エディタから normalizedPosition をいじれば
            // その位置にスナップさせられるようにしておく
            UpdateTransformByT(normalizedPosition);
            return;
        }

        if (!HasValidPoints())
        {
            // 制御点が足りない場合は何もしない
            return;
        }

        // t を時間で進める
        float delta = speed * Time.deltaTime * direction;
        normalizedPosition += delta;

        // 再生モードに応じて t を補正
        switch (playMode)
        {
            case PlayMode.Once:
                if (normalizedPosition >= 1f)
                {
                    normalizedPosition = 1f;
                    isPlaying = false;
                }
                else if (normalizedPosition <= 0f)
                {
                    normalizedPosition = 0f;
                    isPlaying = false;
                }
                break;

            case PlayMode.Loop:
                // 0~1の範囲にループさせる
                if (normalizedPosition > 1f)
                {
                    normalizedPosition -= 1f;
                }
                else if (normalizedPosition < 0f)
                {
                    normalizedPosition += 1f;
                }
                break;

            case PlayMode.PingPong:
                if (normalizedPosition >= 1f)
                {
                    normalizedPosition = 1f;
                    direction = -1;
                }
                else if (normalizedPosition <= 0f)
                {
                    normalizedPosition = 0f;
                    direction = 1;
                }
                break;
        }

        // 実際のTransformを更新
        UpdateTransformByT(normalizedPosition);
    }

    /// <summary>
    /// 再生開始(または再開)
    /// </summary>
    public void Play()
    {
        isPlaying = true;
    }

    /// <summary>
    /// 一時停止
    /// </summary>
    public void Pause()
    {
        isPlaying = false;
    }

    /// <summary>
    /// 先頭に戻して停止
    /// </summary>
    public void StopAndRewind()
    {
        isPlaying = false;
        direction = 1;
        normalizedPosition = 0f;
        UpdateTransformByT(normalizedPosition);
    }

    /// <summary>
    /// 再生位置を0~1で直接指定する(エディタ拡張などから使える)
    /// </summary>
    public void SetNormalizedPosition(float t)
    {
        normalizedPosition = Mathf.Clamp01(t);
        UpdateTransformByT(normalizedPosition);
    }

    /// <summary>
    /// シーン上に制御点が4つ揃っているかチェック
    /// </summary>
    private bool HasValidPoints()
    {
        return point0 != null && point1 != null && point2 != null && point3 != null;
    }

    /// <summary>
    /// 指定した t(0~1)に応じて、このオブジェクトの位置と向きを更新
    /// </summary>
    private void UpdateTransformByT(float t)
    {
        if (!HasValidPoints())
        {
            return;
        }

        Vector3 position = EvaluatePosition(t);
        transform.position = position;

        if (lookForward)
        {
            // t の少し先の位置をサンプリングして「進行方向」を求める
            float aheadT = Mathf.Clamp01(t + 0.01f * direction);
            Vector3 aheadPosition = EvaluatePosition(aheadT);
            Vector3 forward = (aheadPosition - position).normalized;

            if (forward.sqrMagnitude > 0.0001f)
            {
                Quaternion targetRot = Quaternion.LookRotation(forward, Vector3.up);
                if (rotationLerp > 0f)
                {
                    transform.rotation = Quaternion.Slerp(
                        transform.rotation,
                        targetRot,
                        rotationLerp * Time.deltaTime
                    );
                }
                else
                {
                    transform.rotation = targetRot;
                }
            }
        }
    }

    /// <summary>
    /// 3次ベジェ曲線の位置を計算する
    /// p0, p1, p2, p3 はそれぞれ制御点
    /// </summary>
    private Vector3 EvaluatePosition(float t)
    {
        if (!HasValidPoints())
        {
            return transform.position;
        }

        t = Mathf.Clamp01(t);
        float u = 1f - t;
        float tt = t * t;
        float uu = u * u;
        float uuu = uu * u;
        float ttt = tt * t;

        // 3次ベジェ公式:
        // B(t) = u^3 * p0 + 3u^2t * p1 + 3ut^2 * p2 + t^3 * p3
        Vector3 p0Pos = point0.position;
        Vector3 p1Pos = point1.position;
        Vector3 p2Pos = point2.position;
        Vector3 p3Pos = point3.position;

        Vector3 result =
            uuu * p0Pos +
            3f * uu * t * p1Pos +
            3f * u * tt * p2Pos +
            ttt * p3Pos;

        return result;
    }

    /// <summary>
    /// シーンビュー上にギズモで曲線を描画
    /// </summary>
    private void OnDrawGizmos()
    {
        if (!drawGizmos || !HasValidPoints())
        {
            return;
        }

        if (gizmoSegmentCount < 2)
        {
            gizmoSegmentCount = 2;
        }

        Gizmos.color = Color.cyan;

        Vector3 prev = EvaluatePosition(0f);
        for (int i = 1; i <= gizmoSegmentCount; i++)
        {
            float t = i / (float)gizmoSegmentCount;
            Vector3 current = EvaluatePosition(t);
            Gizmos.DrawLine(prev, current);
            prev = current;
        }

        // 制御点も分かりやすく表示
        Gizmos.color = Color.yellow;
        Gizmos.DrawSphere(point0 != null ? point0.position : Vector3.zero, 0.1f);
        Gizmos.DrawSphere(point1 != null ? point1.position : Vector3.zero, 0.1f);
        Gizmos.DrawSphere(point2 != null ? point2.position : Vector3.zero, 0.1f);
        Gizmos.DrawSphere(point3 != null ? point3.position : Vector3.zero, 0.1f);

        // ハンドル線
        if (point0 != null && point1 != null)
        {
            Gizmos.color = Color.gray;
            Gizmos.DrawLine(point0.position, point1.position);
        }
        if (point2 != null && point3 != null)
        {
            Gizmos.color = Color.gray;
            Gizmos.DrawLine(point2.position, point3.position);
        }
    }
}

使い方の手順

  1. シーンに「レール用オブジェクト」を配置する
    1. 空のGameObjectを4つ作成し、それぞれ名前を
    BezierPoint0, BezierPoint1, BezierPoint2, BezierPoint3 などにしておきます。
    2. これらをシーン上で動かして、始点・ハンドル・終点の位置を大まかに決めます。
      例:遊園地のジェットコースターのレールのようにカーブさせる。
  2. 移動させたいオブジェクトに SplineFollower をアタッチ
    1. プレイヤー、敵、動く床、カメラリグなど、曲線上を動かしたいオブジェクトを選択します。
    2. インスペクターの「Add Component」から SplineFollower を追加します。
    3. Point0~Point3 に、先ほど作成した4つのTransformをドラッグ&ドロップで割り当てます。
  3. 移動パラメータを調整する
    1. Speed:0~1のパラメータを1秒間にどれだけ進めるかを指定します。
      例:0.25 にすると、4秒で始点から終点まで移動します。
    2. Play On Start:ゲーム開始と同時に移動を開始したい場合はON。
    3. Play Mode
      – Once:一度端まで行ったら停止(敵の登場演出など)。
      – Loop:端まで行ったら瞬時に先頭に戻る(回転するギミックなど)。
      – PingPong:往復移動(動く床やパトロール敵など)。
    4. Look Forward:進行方向を向かせたい場合はON。車や列車、カメラリグなどに便利です。
  4. 具体的な使用例
    • 例1:レール移動する敵
      敵キャラのプレハブに SplineFollower を付け、シーンごとに別のベジェ制御点を割り当てます。
      レベルデザイナーは「制御点4つを動かすだけで」敵の軌道を自由に調整できるので、スクリプトを触らなくてもレベルデザインが捗ります。
    • 例2:動く床(エレベーターやベルトコンベア)
      床オブジェクトに SplineFollower を付けて PingPong モードにします。
      曲線にすることで、途中で上下に揺れる床や、円弧を描くエレベーターなども簡単に作れます。
    • 例3:カメラのレールショット
      カメラリグ(カメラを子に持つ空のGameObject)に SplineFollower を付けて、
      カットシーン中だけ Play() を呼び出すことで、映画のドリーショットのようなカメラワークを作れます。

メリットと応用

SplineFollower を使うことで、「曲線移動」という責務を1つのコンポーネントに閉じ込められます。これにはいくつかのメリットがあります。

  • プレハブの再利用性が高い
    敵プレハブに SplineFollower を付けておけば、ステージごとに「制御点のTransformだけ」差し替えることで、まったく違う軌道を簡単に作れます。
    プレハブ自体は共通なので、アニメーションや攻撃ロジックはそのまま使い回せます。
  • レベルデザインが視覚的になる
    ギズモでベジェ曲線がシーンビューに表示されるため、デザイナーは「線を見ながら」軌道を調整できます。
    コードを書き換えなくても、制御点をドラッグするだけで気持ちいい軌道を探れるのが大きな利点です。
  • Godクラスを避けられる
    「敵AIスクリプトの中に軌道計算も全部書く」といった設計だと、すぐに数百行の巨大スクリプトになります。
    移動は SplineFollower、攻撃は別コンポーネント、ステータス管理はまた別、といった分割をしておくと、バグ調査や改造がかなり楽になります。

さらに、少しの改造でいろいろなバリエーションが作れます。例えば「一定時間ごとにランダムな位置にジャンプさせる」ことで、カットシーン中のカメラワークをランダム化したりできます。

以下は、改造案の一例です。
一定間隔でランダムな t にワープする「テレポート移動」を追加するメソッドです(必要に応じて SplineFollower の末尾などに追加してみてください)。


/// <summary>
/// ランダムな位置へテレポートさせる簡単な改造例。
/// カットシーン中にカメラをランダムなレール位置へ飛ばしたいときなどに使えます。
/// </summary>
public void TeleportToRandomPosition()
{
    float randomT = Random.Range(0f, 1f);
    normalizedPosition = randomT;
    UpdateTransformByT(normalizedPosition);
}

このように、まずは「曲線移動」という小さな責務だけに集中したコンポーネントを用意しておくと、あとから「イベントで一時停止」「特定のタイミングで逆再生」などの機能を足していくのも簡単になります。
巨大な1ファイルに機能を詰め込むのではなく、小さなコンポーネントを組み合わせていくスタイルで、Unityプロジェクトを育てていきましょう。