Godot 4 で「なめらかに動くオブジェクト」を作ろうとすると、まず候補に上がるのが Path2D / PathFollow2D や、ノードを継承した専用クラスだと思います。
でも実際に触ってみると、こんなモヤモヤが出てきませんか?

  • シーンごとに Path2D を置いて、PathFollow2D を子にして…とノード階層がどんどん深くなる
  • 「敵も」「動く床も」「カメラも」同じように曲線移動させたいのに、毎回スクリプトをコピペ or 継承ツリーが肥大化する
  • 曲線の定義をエディタでちょっと変えたいだけなのに、Path ノードの位置やスケールに引きずられて調整が面倒

そこで今回は、「どのノードにも後付けでアタッチできる」コンポーネントとして、SplineFollower を用意しました。
エディタ上でベジェ曲線(カーブ)を定義しておき、そのカーブに沿って、任意のノードを滑らかに動かすためのコンポーネントです。

Path ノードを前提にしないので、シーンツリーを極力フラットに保ったまま、移動ロジックだけを「合成」できます。
「継承より合成」で、気持ちよく再利用していきましょう。

【Godot 4】ベジェ曲線に吸い付くように動かそう!「SplineFollower」コンポーネント

コンポーネントの概要

  • 役割: 指定した Curve2D に沿って、親ノード(任意の Node2D 系)を移動させる
  • 特徴:
    • 速度ベース / 正規化パラメータベースの両方に対応
    • 往復・ループ移動に対応
    • 向き(rotation)をカーブの接線方向に自動で合わせるオプション
    • エディタ上でカーブリソースを共有すれば、複数オブジェクトが同じ軌道を使える

フルコード(GDScript / Godot 4)


extends Node
class_name SplineFollower
## 任意の Node2D 系ノードを、Curve2D(ベジェ曲線)に沿って移動させるコンポーネント。
## 親ノードの position / rotation を書き換えます。

@export var curve: Curve2D:
	## 移動に使用する 2D カーブ。
	## - インスペクタで新規作成 or 既存の Curve2D リソースを指定してください。
	## - 同じ Curve2D を複数の SplineFollower で共有すると、同じ軌道を使い回せます。
	get:
		return curve
	set(value):
		curve = value
		_update_cached_length()

@export_range(0.0, 1.0, 0.001)
var t: float = 0.0:
	## カーブ上の位置を 0.0〜1.0 の正規化パラメータで表現。
	## play_mode = PARAMETER のとき、この値が直接使われます。
	get:
		return t
	set(value):
		t = clampf(value, 0.0, 1.0)

@export_enum("SPEED", "PARAMETER")
var play_mode: int = 0
## 再生モード
## - SPEED: speed_pixels_per_sec を使って移動距離ベースで進める
## - PARAMETER: normalized_speed を使って t を直接増減させる

@export var active: bool = true:
	## true のときだけ移動処理を行います。
	get:
		return active
	set(value):
		active = value

@export var auto_start: bool = true
## true なら ready 時に自動で active = true にします。

@export var loop: bool = false
## true: カーブの終端まで行ったら 0 に戻る(ループ)
## false: カーブの端まで行ったら停止 or ping_pong による折り返し

@export var ping_pong: bool = false
## true: 0〜1〜0〜1… と往復します(loop より優先)
## false: 端で止まる or loop でループ

@export var speed_pixels_per_sec: float = 100.0
## play_mode = SPEED のときに使う「ピクセル毎秒」の速度。
## カーブの実際の長さに応じて t が計算されます。

@export_range(0.0, 5.0, 0.001)
var normalized_speed: float = 0.2
## play_mode = PARAMETER のときに使う「t の毎秒増加量」。
## 1.0 で 1 秒間にカーブを 1 周分進むイメージ。

@export var orient_to_curve: bool = true
## true: カーブの接線方向に親ノードの rotation を合わせる
## false: rotation は変更しない

@export var rotation_offset_deg: float = 0.0
## orient_to_curve が true のとき、接線方向に対して何度回転させるか。
## スプライトの向きが「右向き」前提か「上向き」前提かなどで調整に使います。

@export var use_global_position: bool = false
## true: 親ノードの global_position を操作
## false: 親ノードの position(ローカル)を操作

@export var debug_draw_in_editor: bool = true
## true: エディタ上でカーブを簡易表示します(親が Node2D のときのみ)。

var _direction: float = 1.0
## 現在の進行方向。1.0 = 正方向, -1.0 = 逆方向

var _cached_length: float = 0.0
## カーブの長さキャッシュ(SPEED モード用)

var _parent_2d: Node2D = null


func _ready() -> void:
	_parent_2d = get_parent() as Node2D
	if _parent_2d == null:
		push_warning("SplineFollower: 親ノードが Node2D ではありません。位置更新は行われません。")
	
	_update_cached_length()
	
	if auto_start:
		active = true
	
	# 初期位置に反映
	_apply_position_and_rotation()


func _process(delta: float) -> void:
	if not active:
		return
	
	if curve == null or curve.get_point_count() < 2:
		# カーブが定義されていない場合は何もしない
		return
	
	# t を更新
	match play_mode:
		0: # SPEED
			_update_t_by_speed(delta)
		1: # PARAMETER
			_update_t_by_parameter(delta)
	
	# 親ノードに反映
	_apply_position_and_rotation()


func _update_t_by_speed(delta: float) -> void:
	if _cached_length <= 0.0:
		_update_cached_length()
		if _cached_length <= 0.0:
			return
	
	var distance := speed_pixels_per_sec * delta * _direction
	var dt := distance / _cached_length
	t += dt
	_handle_t_bounds()


func _update_t_by_parameter(delta: float) -> void:
	var dt := normalized_speed * delta * _direction
	t += dt
	_handle_t_bounds()


func _handle_t_bounds() -> void:
	# 0〜1 の範囲を超えたときの処理(loop / ping_pong / 停止)
	if ping_pong:
		if t > 1.0:
			t = 1.0 - (t - 1.0)  # 超過分を折り返す
			_direction = -1.0
		elif t < 0.0:
			t = -t
			_direction = 1.0
	else:
		if loop:
			# 0〜1 にループさせる
			t = fposmod(t, 1.0)
		else:
			# 端で止める
			if t > 1.0:
				t = 1.0
				active = false
			elif t < 0.0:
				t = 0.0
				active = false


func _apply_position_and_rotation() -> void:
	if _parent_2d == null:
		return
	if curve == null or curve.get_point_count() < 2:
		return
	
	# t を 0〜1 にクランプしてから使用
	var tt := clampf(t, 0.0, 1.0)
	var pos: Vector2 = curve.sample_baked(tt)
	
	if use_global_position:
		_parent_2d.global_position = pos
	else:
		_parent_2d.position = pos
	
	if orient_to_curve:
		# 接線方向を求めるため、少し先の点との差分を使う
		var epsilon := 0.001
		var tt2 := clampf(tt + epsilon * _direction, 0.0, 1.0)
		var pos2: Vector2 = curve.sample_baked(tt2)
		var tangent: Vector2 = pos2 - pos
		
		if tangent.length() > 0.0001:
			var angle := tangent.angle()
			angle += deg_to_rad(rotation_offset_deg)
			_parent_2d.rotation = angle


func _update_cached_length() -> void:
	if curve == null:
		_cached_length = 0.0
		return
	
	# baked カーブの長さを取得
	_cached_length = curve.get_baked_length()


## --- 公開 API(スクリプトから制御したいとき用) --- ##

func restart(from_start: bool = true) -> void:
	## 移動をリスタートする。
	## from_start = true なら t=0 から、false なら t=1 から。
	t = 0.0 if from_start else 1.0
	_direction = 1.0 if from_start else -1.0
	active = true
	_apply_position_and_rotation()


func set_direction_forward() -> void:
	## 進行方向を正方向(0 → 1)に設定。
	_direction = 1.0


func set_direction_backward() -> void:
	## 進行方向を逆方向(1 → 0)に設定。
	_direction = -1.0


func pause() -> void:
	## 一時停止。
	active = false


func resume() -> void:
	## 再開。
	active = true


func is_finished() -> bool:
	## loop=false, ping_pong=false のとき、端まで到達して停止したかどうか。
	return not active and (t <= 0.0 or t >= 1.0)


## --- エディタ用の簡易デバッグ描画 --- ##

func _draw() -> void:
	if not Engine.is_editor_hint():
		return
	if not debug_draw_in_editor:
		return
	if curve == null or curve.get_point_count() < 2:
		return
	
	# 親が Node2D でない場合はローカル座標で描画
	var color := Color.CYAN
	var points := curve.get_baked_points()
	for i in points.size() - 1:
		draw_line(points[i], points[i + 1], color, 2.0)


func _notification(what: int) -> void:
	if what == NOTIFICATION_TRANSFORM_CHANGED:
		# 親の Transform 変更時に再描画(エディタ用)
		if Engine.is_editor_hint():
			queue_redraw()

使い方の手順

手順①: スクリプトをプロジェクトに追加

  1. res://components/ など、好きな場所に SplineFollower.gd を作成します。
  2. 上記コードをコピペして保存します。
  3. Godot が自動的に class_name SplineFollower を認識するので、インスペクタから直接アタッチできるようになります。

手順②: カーブリソース(Curve2D)を用意

  1. 任意のノード(例: Node2D)を選択し、インスペクタの curve プロパティで「新規 Curve2D」を作成します。
  2. Curve2D リソースをダブルクリックして、エディタで制御点を編集します。
    • 始点・終点・中間点を追加して、ベジェ曲線を描く
    • 敵の巡回ルート、動く床の軌道、カメラのレールなどを自由にデザイン
  3. この Curve2D はリソースなので、複数の SplineFollower から共有できます。

手順③: 実際の使用例(プレイヤー、敵、動く床)

例1: レールに沿って移動する敵
Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── SplineFollower (Node)
  1. Enemy シーンを開き、子ノードとして Node を追加し、スクリプトに SplineFollower をアタッチします。
  2. curve プロパティに「新規 Curve2D」を作成し、敵の巡回ルートを描きます。
  3. パラメータ例:
    • play_mode = SPEED
    • speed_pixels_per_sec = 80
    • loop = true(永遠に巡回)
    • orient_to_curve = true(進行方向を向かせる)
  4. ゲームを再生すると、敵がカーブに沿ってぐるっと巡回します。
例2: なめらかに往復する動く床
MovingPlatform (StaticBody2D or CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── SplineFollower (Node)
  1. MovingPlatform シーンを作り、子ノードに Node を追加して SplineFollower をアタッチ。
  2. Curve2D で「U 字」や「S 字」の軌道を描きます。
  3. パラメータ例:
    • play_mode = SPEED
    • speed_pixels_per_sec = 50
    • loop = false
    • ping_pong = true(端で折り返して往復)
    • orient_to_curve = false(床なので回転させない)
  4. これで、プレイヤーを乗せられる「なめらか往復床」の完成です。
例3: レールカメラ(カットシーンやステージイントロなど)
CameraRig (Node2D)
 ├── Camera2D
 └── SplineFollower (Node)
  1. CameraRigCamera2D を子として置き、その親の CameraRig にスクリプトをアタッチしても OK ですし、上記のように別ノードに SplineFollower を付けても構いません。
  2. Curve2D でカメラのレールを描き、auto_start = false にしておきます。
  3. カットシーン開始時にスクリプトから:

    var follower := $CameraRig/SplineFollower
    follower.restart(true)

  4. このようにして、イベント時だけレールカメラを動かすことができます。

手順④: カーブの共有でシーンをスッキリさせる

例えば「同じ軌道を異なる速度で回る敵」を複数置きたい場合、Curve2D をリソースとして共有すると便利です。

Level (Node2D)
 ├── EnemyA (CharacterBody2D)
 │    └── SplineFollower (curve = res://curves/enemy_route.tres)
 ├── EnemyB (CharacterBody2D)
 │    └── SplineFollower (curve = res://curves/enemy_route.tres)
 └── EnemyC (CharacterBody2D)
      └── SplineFollower (curve = res://curves/enemy_route.tres)
  • 3 体とも同じ enemy_route.tres を参照
  • それぞれ speed_pixels_per_secnormalized_speed を変えるだけで、「速い敵」「遅い敵」を簡単に配置
  • 軌道を変えたくなったら、Curve2D リソースを 1 箇所編集するだけで、すべての敵のルートが更新されます

メリットと応用

メリット1: シーン構造がフラットで見通しが良い

Godot 標準の Path2D / PathFollow2D をフル活用すると、どうしても:

Path2D
 └── PathFollow2D
      └── Enemy (CharacterBody2D)
           ├── Sprite2D
           └── CollisionShape2D

のように「動かしたいノードが PathFollow2D の子になる」構造になりがちです。
これ自体が悪いわけではありませんが、

  • 既存のプレイヤー / 敵シーンを「Path 用」に作り直すのが面倒
  • 階層が深くなって、デバッグ時に追いかけづらい

といったデメリットがあります。

SplineFollower コンポーネント方式なら、

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── SplineFollower (Node)

と、「元のシーン構造」はほぼそのままに、移動機能だけを後付けできます。
これはまさに「継承より合成」の恩恵ですね。

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

「曲線に沿って動く」というロジックは、プレイヤーにも敵にもカメラにも動く床にも使える、かなり汎用的な振る舞いです。

  • クラス継承ベースだと、「曲線移動プレイヤー」「曲線移動敵」「曲線移動カメラ」…と増殖しがち
  • コンポーネントなら、1 つの SplineFollower をどこにでもアタッチすれば良いだけ

結果として、

  • コードの重複が減る
  • 「曲線移動の仕様変更」を 1 箇所で済ませられる
  • テストもしやすい(SplineFollower 単体をテストすれば OK)

と、メンテナンス性がかなり上がります。

メリット3: レベルデザインとの相性が良い

Curve2D はエディタ上でビジュアルに編集できるので、レベルデザイナーや自分自身が「目で見て」軌道を調整できます。
さらに、このコンポーネントは Curve2D リソースの共有を前提にしているので、

  • 「このステージの敵は全部このレール上を動く」といったコンセプトを簡単に実現
  • ステージの雰囲気に合わせて、レール形状だけ差し替える

といったレベルデザインがやりやすくなります。

改造案: イベントコールバックを追加する

例えば「カーブの終端に着いたときに何かイベントを発火したい」ことがありますよね。
そんなときは、シグナルを 1 本追加してあげると便利です。


signal reached_end(direction: int)
## direction: 1 のとき 0→1 側の終端, -1 のとき 1→0 側の終端

func _handle_t_bounds() -> void:
	if ping_pong:
		if t > 1.0:
			t = 1.0 - (t - 1.0)
			_direction = -1.0
			emit_signal("reached_end", 1)
		elif t < 0.0:
			t = -t
			_direction = 1.0
			emit_signal("reached_end", -1)
	else:
		if loop:
			if t > 1.0:
				t = fposmod(t, 1.0)
				emit_signal("reached_end", 1)
			elif t < 0.0:
				t = fposmod(t, 1.0)
				emit_signal("reached_end", -1)
		else:
			if t > 1.0:
				t = 1.0
				active = false
				emit_signal("reached_end", 1)
			elif t < 0.0:
				t = 0.0
				active = false
				emit_signal("reached_end", -1)

これで、reached_end に接続して「次のフェーズに進む」「敵を消す」「SE を鳴らす」など、イベント駆動の演出がやりやすくなります。

こんなふうに、まずはシンプルな SplineFollower をベースにして、
自分のプロジェクトに合わせて少しずつ「合成可能なコンポーネント群」を育てていくと、Godot 開発がかなり快適になりますよ。