Godot 4 で「敵パトロール」や「動く足場」「レール移動するカメラ」みたいな処理を書くとき、Path2DPathFollow2D を組み合わせるのが定番ですよね。でも素直に実装しようとすると、

  • 毎回 PathFollow2D ノードを用意して、その子に Sprite や Collision をぶら下げる
  • 「この敵はパス移動しない」パターンが出てくると、シーン構造がバラバラになる
  • 「往復」「ループ」「停止時間」などの細かい挙動を毎回スクリプトで書き直す

…と、どうしても「ノード階層が深くなる+スクリプトのコピペ地獄」になりがちです。

そこで今回は、どんなノードにも後付けで「パスに沿って往復移動する」機能を足せる、コンポーネント指向な PathFollower コンポーネントを作ってみましょう。
親に Path2D を置いておけば、このコンポーネントをアタッチしたノードが自動的にその経路に沿って動いてくれる、という設計です。

【Godot 4】レール移動をコンポーネント化!「PathFollower」コンポーネント

今回のコンポーネントの特徴:

  • 親の Path2D を指定して、その経路に沿って移動
  • 「往復」「ループ」が選べる
  • 経路の開始位置・移動速度・開始ディレイ・端での待機時間をエクスポート変数で調整
  • どの 2D ノードにもアタッチできる(Node2D 派生ならOK)

つまり、「継承して専用の敵クラスを増やす」のではなく、「動かしたいノードに PathFollower を 1 個足すだけ」でパス移動を実現するアプローチですね。


フルコード:PathFollower.gd


extends Node
class_name PathFollower
## 親の Path2D に沿って、アタッチ先の Node2D を往復/ループ移動させるコンポーネント
##
## 使い方:
## - 任意の Node2D (例: Player, Enemy, MovingPlatform) の子としてこのコンポーネントを追加
## - parent_path2d_path で、同じシーン内の Path2D の NodePath を指定
## - 後はシーンを再生すると、自動で経路に沿って移動します

@export_node_path("Path2D") var parent_path2d_path: NodePath
## 経路として使う Path2D へのパス。
## 通常は、このコンポーネントを付けたオブジェクトの親、もしくは上位階層にある Path2D を指定します。

@export var speed: float = 150.0
## 経路に沿って移動する速度(ピクセル/秒)。
## 0 以下にすると移動しません。

@export_range(0.0, 1.0, 0.01) var start_progress: float = 0.0
## 経路上の開始位置(0.0 ~ 1.0)。
## 0.0 で経路の始点、1.0 で経路の終点付近からスタートします。

@export var loop: bool = false
## true: 経路の端まで行くと始点にワープしてループ
## false: 端まで行くと折り返して往復移動します。

@export var pause_at_ends: float = 0.0
## 経路の端(0.0 or 1.0)に到達したときに停止する時間(秒)。
## 往復パターンのときに特に有効です。

@export var start_delay: float = 0.0
## シーン開始から移動を開始するまでのディレイ時間(秒)。
## 0 なら即時スタート。

@export var use_path_rotation: bool = false
## true にすると、Path2D に沿った向きに回転させます。
## 敵の向きや動く足場の向きを経路に合わせたいときに便利です。

@export var flip_on_reverse: bool = false
## 方向が反転したときに X スケールを反転させるオプション。
## 横向きスプライトの左右反転などに使えます。

var _path2d: Path2D
var _curve: Curve2D
var _target_node: Node2D
var _progress: float = 0.0      # 0.0 ~ 1.0 の範囲で経路上の位置を表す
var _direction: float = 1.0     # 1: 正方向, -1: 逆方向
var _pause_timer: float = 0.0
var _start_delay_timer: float = 0.0

func _ready() -> void:
    # このコンポーネントがぶら下がっている親ノードをターゲットとする
    _target_node = owner as Node2D
    if _target_node == null:
        push_warning("PathFollower: owner が Node2D ではありません。このコンポーネントは 2D ノード専用です。")
        set_process(false)
        return

    # Path2D を取得
    if parent_path2d_path.is_empty():
        # パスが指定されていない場合、親階層から最初に見つかった Path2D を自動検出
        _path2d = _find_parent_path2d()
    else:
        _path2d = get_node_or_null(parent_path2d_path) as Path2D

    if _path2d == null:
        push_warning("PathFollower: Path2D が見つかりません。parent_path2d_path を正しく設定してください。")
        set_process(false)
        return

    _curve = _path2d.curve
    if _curve == null or _curve.point_count == 0:
        push_warning("PathFollower: Path2D に Curve2D が設定されていないか、ポイントがありません。")
        set_process(false)
        return

    # 初期位置を設定
    _progress = clamp(start_progress, 0.0, 1.0)
    _update_target_transform()

    _direction = 1.0
    _pause_timer = 0.0
    _start_delay_timer = start_delay

    set_process(true)


func _process(delta: float) -> void:
    if speed <= 0.0:
        return

    # 開始ディレイ中
    if _start_delay_timer > 0.0:
        _start_delay_timer -= delta
        return

    # 端での一時停止中
    if _pause_timer > 0.0:
        _pause_timer -= delta
        return

    # 経路上の進捗を更新
    var curve_length := _curve.get_baked_length()
    if curve_length <= 0.0:
        return

    # 速度(ピクセル/秒)を 0.0~1.0 の進捗に変換
    var delta_progress := (speed * delta) / curve_length
    _progress += delta_progress * _direction

    # 端に到達したときの処理
    if loop:
        # ループモード:0.0~1.0 を循環させる
        _progress = fposmod(_progress, 1.0)
    else:
        # 往復モード
        if _progress >= 1.0:
            _progress = 1.0
            _direction = -1.0
            _on_reached_end()
        elif _progress <= 0.0:
            _progress = 0.0
            _direction = 1.0
            _on_reached_end()

    _update_target_transform()


func _on_reached_end() -> void:
    # 端に到達したときに一時停止と向き反転を処理
    if pause_at_ends > 0.0:
        _pause_timer = pause_at_ends

    if flip_on_reverse and _target_node:
        var scale := _target_node.scale
        scale.x = -scale.x
        _target_node.scale = scale


func _update_target_transform() -> void:
    # progress (0.0~1.0) から距離(0.0~curve_length)に変換して位置を取得
    var curve_length := _curve.get_baked_length()
    if curve_length <= 0.0:
        return

    var distance := curve_length * clamp(_progress, 0.0, 1.0)
    var position_on_path: Vector2 = _curve.sample_baked(distance)

    # Path2D のローカル座標系をワールド座標に変換
    var global_pos := _path2d.to_global(position_on_path)
    _target_node.global_position = global_pos

    if use_path_rotation:
        # 経路の接線方向から回転を求める
        var offset := 2.0
        var pos_a := _curve.sample_baked(clamp(distance, 0.0, curve_length))
        var pos_b := _curve.sample_baked(clamp(distance + offset, 0.0, curve_length))
        var tangent: Vector2 = pos_b - pos_a
        if tangent.length() > 0.001:
            var angle := tangent.angle()
            _target_node.global_rotation = angle


func _find_parent_path2d() -> Path2D:
    # 親階層を遡って最初に見つかった Path2D を返す
    var current: Node = _target_node.get_parent()
    while current:
        if current is Path2D:
            return current as Path2D
        current = current.get_parent()
    return null

使い方の手順

ここでは「動く足場」「パトロールする敵」「レール移動カメラ」の3パターンを例に、基本的な使い方を見ていきます。

手順①:Path2D を用意する

  1. 2D シーンを開く
  2. Path2D ノードを追加し、名前を Path_MovingPlatform などにする
  3. インスペクタの Curve を編集して、移動させたい経路を描く(ポイントを追加して曲線を作る)
Path_MovingPlatform (Path2D)

手順②:動かしたいオブジェクトを作る

例:動く足場(Moving Platform)

  1. Node2D を作成して MovingPlatform と命名
  2. 子として Sprite2DCollisionShape2D を追加
  3. この MovingPlatform シーンを、先ほどの Path_MovingPlatform の子としてインスタンス化

シーン構成図はこんな感じです:

Path_MovingPlatform (Path2D)
 └── MovingPlatform (Node2D)
      ├── Sprite2D
      ├── CollisionShape2D
      └── PathFollower (Node)

ポイントは、PathFollower を「動かしたいノードの子」に付けることです。
このコンポーネントは owner(通常は親ノード)を動かす設計になっているので、

  • 動かしたいノード = MovingPlatform (Node2D)
  • その子に PathFollower (Node)

という構造にします。

手順③:PathFollower を設定する

  1. PathFollower.gd をプロジェクトに保存(例:res://components/PathFollower.gd
  2. シーンツリーで PathFollower ノードを選択し、スクリプトに PathFollower.gd を割り当てる
  3. インスペクタで以下を設定:
    • Parent Path2D Path: ..(親が Path2D なら未設定でも自動検出されます)
    • Speed: 100 ~ 200 くらい(好みで)
    • Loop: false(往復させたい場合)
    • Pause At Ends: 0.5 ~ 1.0 秒くらいにすると「端で一瞬止まる」動きになります
    • Use Path Rotation: 足場なら false、敵やカメラなら true でも OK
    • Flip On Reverse: 横向きスプライトの敵などで左右反転させたいときに true

ここまで設定すれば、シーンを再生したときに MovingPlatformPath_MovingPlatform に沿って往復移動するはずです。

手順④:他の用途への使い回し

同じコンポーネントを、そのまま別のノードにも付けるだけで再利用できます。

例1:パトロールする敵
Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PathFollower (Node)
  • 敵の親階層に Path2D を置き、パトロール経路を描く
  • 敵の子に PathFollower を付ける
  • loop = false, flip_on_reverse = true にすると、「端でくるっと向きを変える敵」になります
例2:レール移動カメラ
Path_CameraRail (Path2D)
 └── Camera2D
      └── PathFollower (Node)
  • Camera2D に PathFollower を付けるだけで、レールカメラが完成
  • use_path_rotation = true にすると、カメラも経路の向きに合わせて回転します

メリットと応用

この PathFollower コンポーネントを使うと、かなりシーン構造とスクリプト管理がスッキリします。

  • ノード階層が浅く保てる
    通常は Path2D -> PathFollow2D -> Enemy のような入れ子構造になりがちですが、
    このコンポーネントでは Enemy 自体を動かすので、
    Path_EnemyPatrol (Path2D)
    └── Enemy (CharacterBody2D)
    └── PathFollower

    というシンプルな構造にできます。


  • 「パス移動するかどうか」を後から決められる
    同じ Enemy シーンに対して、必要なときだけ PathFollower を子として足せば OK。
    「パス移動する敵」「しない敵」で別シーンを作る必要がありません。
  • 挙動のカスタマイズが楽
    スクリプトを継承して増やすのではなく、@export パラメータでスピードやループ方法を変えるだけ。
    レベルデザイナーがインスペクタから値をいじるだけで調整できます。
  • コードの再利用性が高い
    「動く床」「敵」「ギミック」「カメラ」など、2D で動かしたいものは全部同じコンポーネントで済みます。

まさに「継承より合成」のお手本パターンですね。

改造案:シグナルで「端に到達した」を通知する

例えば、「パスの端に到達したときにアニメーションを変える」「SE を鳴らす」といった処理を、外部スクリプトから簡単にフックしたい場合は、シグナルを追加すると便利です。


signal reached_end(direction: float)
## 経路の端に到達したときに発火するシグナル。
## direction: 到達後の進行方向(1.0 or -1.0)

func _on_reached_end() -> void:
    if pause_at_ends > 0.0:
        _pause_timer = pause_at_ends

    if flip_on_reverse and _target_node:
        var scale := _target_node.scale
        scale.x = -scale.x
        _target_node.scale = scale

    emit_signal("reached_end", _direction)

こうしておけば、例えば Enemy 側のスクリプトで:


func _ready() -> void:
    var follower := $PathFollower
    follower.reached_end.connect(_on_reached_end)

func _on_reached_end(direction: float) -> void:
    # 端に着いたので、アニメーションを切り替えるなど
    $AnimationPlayer.play("turn")

といった感じで、きれいに責務を分離しながらも、柔軟な挙動を実現できます。

この PathFollower をベースに、自分のプロジェクト用の「移動系コンポーネントライブラリ」を育てていくと、後々かなり開発が楽になりますね。ぜひプロジェクトに組み込んでみてください。