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()
使い方の手順
手順①: スクリプトをプロジェクトに追加
res://components/など、好きな場所にSplineFollower.gdを作成します。- 上記コードをコピペして保存します。
- Godot が自動的に
class_name SplineFollowerを認識するので、インスペクタから直接アタッチできるようになります。
手順②: カーブリソース(Curve2D)を用意
- 任意のノード(例:
Node2D)を選択し、インスペクタのcurveプロパティで「新規 Curve2D」を作成します。 - Curve2D リソースをダブルクリックして、エディタで制御点を編集します。
- 始点・終点・中間点を追加して、ベジェ曲線を描く
- 敵の巡回ルート、動く床の軌道、カメラのレールなどを自由にデザイン
- この Curve2D はリソースなので、複数の SplineFollower から共有できます。
手順③: 実際の使用例(プレイヤー、敵、動く床)
例1: レールに沿って移動する敵
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── SplineFollower (Node)
Enemyシーンを開き、子ノードとしてNodeを追加し、スクリプトにSplineFollowerをアタッチします。curveプロパティに「新規 Curve2D」を作成し、敵の巡回ルートを描きます。- パラメータ例:
play_mode = SPEEDspeed_pixels_per_sec = 80loop = true(永遠に巡回)orient_to_curve = true(進行方向を向かせる)
- ゲームを再生すると、敵がカーブに沿ってぐるっと巡回します。
例2: なめらかに往復する動く床
MovingPlatform (StaticBody2D or CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── SplineFollower (Node)
MovingPlatformシーンを作り、子ノードにNodeを追加してSplineFollowerをアタッチ。- Curve2D で「U 字」や「S 字」の軌道を描きます。
- パラメータ例:
play_mode = SPEEDspeed_pixels_per_sec = 50loop = falseping_pong = true(端で折り返して往復)orient_to_curve = false(床なので回転させない)
- これで、プレイヤーを乗せられる「なめらか往復床」の完成です。
例3: レールカメラ(カットシーンやステージイントロなど)
CameraRig (Node2D) ├── Camera2D └── SplineFollower (Node)
CameraRigにCamera2Dを子として置き、その親のCameraRigにスクリプトをアタッチしても OK ですし、上記のように別ノードに SplineFollower を付けても構いません。- Curve2D でカメラのレールを描き、
auto_start = falseにしておきます。 - カットシーン開始時にスクリプトから:
var follower := $CameraRig/SplineFollower
follower.restart(true) - このようにして、イベント時だけレールカメラを動かすことができます。
手順④: カーブの共有でシーンをスッキリさせる
例えば「同じ軌道を異なる速度で回る敵」を複数置きたい場合、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_secやnormalized_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 開発がかなり快適になりますよ。
