Godot 4 で「敵パトロール」や「動く足場」「レール移動するカメラ」みたいな処理を書くとき、Path2D と PathFollow2D を組み合わせるのが定番ですよね。でも素直に実装しようとすると、
- 毎回
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 を用意する
- 2D シーンを開く
Path2Dノードを追加し、名前をPath_MovingPlatformなどにする- インスペクタの Curve を編集して、移動させたい経路を描く(ポイントを追加して曲線を作る)
Path_MovingPlatform (Path2D)
手順②:動かしたいオブジェクトを作る
例:動く足場(Moving Platform)
Node2Dを作成してMovingPlatformと命名- 子として
Sprite2DとCollisionShape2Dを追加 - この
MovingPlatformシーンを、先ほどのPath_MovingPlatformの子としてインスタンス化
シーン構成図はこんな感じです:
Path_MovingPlatform (Path2D)
└── MovingPlatform (Node2D)
├── Sprite2D
├── CollisionShape2D
└── PathFollower (Node)
ポイントは、PathFollower を「動かしたいノードの子」に付けることです。
このコンポーネントは owner(通常は親ノード)を動かす設計になっているので、
- 動かしたいノード = MovingPlatform (Node2D)
- その子に PathFollower (Node)
という構造にします。
手順③:PathFollower を設定する
PathFollower.gdをプロジェクトに保存(例:res://components/PathFollower.gd)- シーンツリーで
PathFollowerノードを選択し、スクリプトにPathFollower.gdを割り当てる - インスペクタで以下を設定:
Parent Path2D Path:..(親が Path2D なら未設定でも自動検出されます)Speed: 100 ~ 200 くらい(好みで)Loop: false(往復させたい場合)Pause At Ends: 0.5 ~ 1.0 秒くらいにすると「端で一瞬止まる」動きになりますUse Path Rotation: 足場なら false、敵やカメラなら true でも OKFlip On Reverse: 横向きスプライトの敵などで左右反転させたいときに true
ここまで設定すれば、シーンを再生したときに MovingPlatform が Path_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 をベースに、自分のプロジェクト用の「移動系コンポーネントライブラリ」を育てていくと、後々かなり開発が楽になりますね。ぜひプロジェクトに組み込んでみてください。
