Godotで敵や動く足場の「巡回」を作るとき、ありがちな実装はこんな感じですよね。

  • 敵プレイヤーシーンを作る
  • その中に Path2D / PathFollow2D を直で置く
  • 敵スクリプトの中に「巡回ロジック」をベタ書き

最初はそれでも動くのですが、

  • 別の敵にも同じ巡回ロジックをコピペしたくなる
  • 「この敵は速さだけ変えたい」「この動く床は往復させたい」などの差分が増える
  • シーン階層が深くなって「どのノードが動きを制御してるのか」分かりにくくなる

…といった「Godotあるある」にハマりがちです。

そこで今回は、Path2D に沿って往復巡回する動きを、どんなノードにも後付けできるコンポーネントとして切り出してみましょう。
その名も 「PatrolPath」コンポーネント です。


【Godot 4】Path2Dでサクッと往復巡回AI!「PatrolPath」コンポーネント

このコンポーネントのゴールはシンプルです。

  • 動かしたいノード(敵、動く床、NPCなど)に PatrolPath をアタッチ
  • 外部の Path2D を指定
  • speed などを調整するだけで、Path2D 上を端から端まで往復移動してくれる

移動ロジックは全部コンポーネント側に閉じ込めるので、
プレイヤー/敵/NPC のスクリプトは「戦闘」「アニメーション」「UI連携」など、別の責務に集中できます。まさに「継承より合成」ですね。


フルコード:PatrolPath.gd


extends Node
class_name PatrolPath
"""
Path2D 上を往復しながら巡回するコンポーネント。

・任意のノードにアタッチして使う(敵、動く床、NPCなど)
・外部の Path2D を指定すると、そのカーブに沿って移動する
・端まで行くと自動で折り返す(ping-pong)
・2D/3Dどちらでも「位置ベクトルをセットできるノード」なら利用可能
"""

@export var path_2d: Path2D:
	## 巡回に使う Path2D。
	## シーン内の任意の Path2D をドラッグ&ドロップで指定してください。
	set(value):
		path_2d = value
		_update_curve_cache()

@export_range(0.0, 2000.0, 1.0)
var speed: float = 100.0
## 巡回速度(ピクセル/秒)。
## 正の値であれば自動で前進・後退を切り替えます。

@export_range(0.01, 10.0, 0.01)
var start_ratio: float = 0.0:
	## 巡回開始位置(0.0 = 始点, 1.0 = 終点)
	## エディタ上でプレビューしたいときにも便利です。
	set(value):
		start_ratio = clampf(value, 0.0, 1.0)
		_current_ratio = start_ratio
		_apply_position_from_ratio()

@export var auto_start: bool = true
## true の場合、_ready() で自動的に巡回を開始します。

@export var orient_to_path: bool = false
## true にすると、移動方向にノードを回転させます。
## 2D の敵や乗り物などで向きを合わせたいときに便利です。

@export_range(0.0, 1.0, 0.01)
var smoothing: float = 0.1
## 進行度の補間係数。0 でガチガチ、1 で瞬間移動。
## 通常は 0.05〜0.2 程度がおすすめです。

# 内部状態
var _current_ratio: float = 0.0   # 0.0〜1.0 の範囲で現在位置を表す
var _direction: int = 1           # 1: 始点→終点, -1: 終点→始点
var _curve: Curve2D = null
var _curve_length: float = 0.0
var _is_playing: bool = false

func _ready() -> void:
	_update_curve_cache()
	_current_ratio = start_ratio
	_apply_position_from_ratio()

	if auto_start:
		play()

func _physics_process(delta: float) -> void:
	if not _is_playing:
		return
	if _curve == null or _curve_length <= 0.0:
		return

	# ratio 空間でどれだけ進むかを計算
	var distance = speed * delta * float(_direction)
	var delta_ratio = 0.0
	if _curve_length > 0.0:
		delta_ratio = distance / _curve_length

	var target_ratio = _current_ratio + delta_ratio

	# 端に到達したら折り返し
	if target_ratio > 1.0:
		target_ratio = 1.0 - (target_ratio - 1.0)  # 反射
		_direction = -1
	elif target_ratio < 0.0:
		target_ratio = -target_ratio               # 反射
		_direction = 1

	# スムージング付きで ratio を更新
	_current_ratio = lerpf(_current_ratio, target_ratio, smoothing)

	_apply_position_from_ratio()

func _update_curve_cache() -> void:
	# Path2D が設定されていれば Curve2D を取得して長さを計算
	if path_2d and path_2d.curve:
		_curve = path_2d.curve
		_curve_length = _curve.get_baked_length()
	else:
		_curve = null
		_curve_length = 0.0

func _apply_position_from_ratio() -> void:
	if _curve == null:
		return

	# ratio (0〜1) を距離に変換
	var distance = _curve_length * clampf(_current_ratio, 0.0, 1.0)
	var pos: Vector2 = _curve.sample_baked(distance)

	# このコンポーネントがアタッチされているノードを移動
	if owner:
		# 2D の場合: position / global_position を使う
		# 3D の場合: translation / global_position などに変えるなど、必要に応じて改造してください。
		if owner is Node2D:
			owner.global_position = pos
			if orient_to_path:
				# 進行方向ベクトルを取得して回転を設定
				var ahead_distance = clamp(distance + 5.0 * _direction, 0.0, _curve_length)
				var ahead_pos: Vector2 = _curve.sample_baked(ahead_distance)
				var dir: Vector2 = (ahead_pos - pos).normalized()
				if dir.length() > 0.001:
					owner.rotation = dir.angle()
		else:
			# Node2D 以外の場合は、必要に応じてここを書き換えてください。
			# とりあえず position プロパティが存在すればそこに代入してみる。
			if "position" in owner:
				owner.position = pos

# --- 公開API ---------------------------------------------------------

func play() -> void:
	"""巡回を開始(再開)します。"""
	_is_playing = true

func stop() -> void:
	"""巡回を停止します。"""
	_is_playing = false

func set_ratio(ratio: float) -> void:
	"""
	任意の位置(0.0〜1.0)にワープさせたいときに使います。
	例えば、敵の初期配置をパスの途中から始めたい場合など。
	"""
	_current_ratio = clampf(ratio, 0.0, 1.0)
	_apply_position_from_ratio()

func is_playing() -> bool:
	return _is_playing

func set_forward() -> void:
	"""進行方向を始点→終点に固定します。"""
	_direction = 1

func set_backward() -> void:
	"""進行方向を終点→始点に固定します。"""
	_direction = -1

func flip_direction() -> void:
	"""現在の進行方向を反転します。"""
	_direction *= -1

使い方の手順

ここでは 2D プロジェクトを前提に、敵キャラ動く床 の2パターンを例に手順を見ていきます。

手順①:Path2D で巡回ルートを作る

  1. 新しいシーン、または既存のレベルシーンを開きます。
  2. Path2D ノードを追加し、名前を EnemyPath などに変更します。
  3. EnemyPath を選択し、「カーブ」ツールでポイントを打って巡回ルートを作ります。
    端から端まで直線でも、曲線でも OK です。
LevelRoot (Node2D)
 ├── EnemyPath (Path2D)
 └── ...

手順②:動かしたいノードに PatrolPath をアタッチ

例として、敵キャラシーンを作ってみます。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PatrolPath (Node)
  1. Enemy シーンを開きます。
  2. 子ノードとして Node を追加し、名前を PatrolPath に変更します。
  3. そのノードに、先ほどの PatrolPath.gd をアタッチします。

これで「Enemy は PatrolPath コンポーネントを持つ」構成になりました。
敵の本体スクリプト(攻撃AIなど)は Enemy.gd に、巡回ロジックは PatrolPath.gd に分離されます。

手順③:Path2D を紐づけてパラメータを調整

  1. レベルシーンに Enemy シーンをインスタンス化します。
  2. レベルシーン上で Enemy/PatrolPath ノードを選択します。
  3. インスペクタから以下を設定します。
    • path_2d: シーン内の EnemyPath をドラッグ&ドロップ
    • speed: 100〜300 ぐらいで調整
    • start_ratio: 0.0(始点)か 0.5(中間スタート)など
    • auto_start: true(ゲーム開始と同時に動かしたい場合)
    • orient_to_path: 敵の向きを進行方向に合わせたいなら true

レベルシーンの構成イメージはこんな感じです。

LevelRoot (Node2D)
 ├── EnemyPath (Path2D)
 ├── Enemy (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── PatrolPath (Node)  <-- ここで EnemyPath を参照
 └── ...

再生すると、Enemy が EnemyPath 上を端から端まで移動し、端に着いたら折り返して往復してくれます。

手順④:動く床にもそのまま流用してみる

同じコンポーネントを「動く床」にも使ってみましょう。

MovingPlatform (StaticBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PatrolPath (Node)
  1. MovingPlatform シーンを作り、StaticBody2D などで床を作ります。
  2. 子ノードとして Node を追加し、PatrolPath.gd をアタッチ。
  3. レベルシーンにインスタンス化し、別の Path2D(例: PlatformPath)を作って指定。
LevelRoot (Node2D)
 ├── EnemyPath (Path2D)
 ├── PlatformPath (Path2D)
 ├── Enemy (CharacterBody2D)
 │    └── PatrolPath (Node)  --> path_2d = EnemyPath
 └── MovingPlatform (StaticBody2D)
      └── PatrolPath (Node)  --> path_2d = PlatformPath

同じ PatrolPath コンポーネントを、敵と床で「使い回し」できているのがポイントです。
階層を深くしたり、継承ツリーを増やさなくても、「動き」という機能だけを合成できます。


メリットと応用

メリット1: シーン構造がシンプルになる
巡回ロジックを敵や床のスクリプトにベタ書きすると、「移動」「攻撃」「アニメーション」が全部1ファイルに詰め込まれがちです。
PatrolPath をコンポーネントとして切り出すことで、

  • Enemy.gd … 攻撃AI・アニメーション制御
  • PatrolPath.gd … パスに沿った移動だけ

と責務が分かれて、後から読むときもかなり楽になります。

メリット2: どんなノードにも後付けできる
「このオブジェクトも巡回させたい」と思ったら、シーンに PatrolPath ノードを足して Path2D を指定するだけです。
基底クラスを PatrollingEnemy みたいに増やす必要もありませんし、継承ツリーが肥大化しません。

メリット3: レベルデザイン時にパスだけいじればOK
動きは Path2D に完全に依存しているので、レベルデザイナーは Path2D のカーブをいじるだけで巡回ルートを変更できます。
スクリプトを触らずに「ここをもっと遠回りさせたい」「ここで一回止めたい(※改造で対応)」などの調整がしやすくなります。

メリット4: 2D/3D 両対応に拡張しやすい
現在の実装は Node2D を想定していますが、owner の型チェック部分を少し改造すれば、Node3D にも流用できます。
「移動ロジック」はコンポーネント側に閉じ込めてあるので、拡張ポイントが明確です。


改造案:パスの特定ポイントで一時停止する

例えば、「端に着いたら1秒止まってから折り返したい」「中間地点で0.5秒止まりたい」といった動きを追加したくなることがあります。
そんなときは、_physics_process に「待ち時間ロジック」を挟むのが手っ取り早いです。

一例として、端に着いたときに一定時間だけ停止する関数を追加してみましょう。


@export_range(0.0, 5.0, 0.1)
var edge_pause_time: float = 0.0  # 端に着いたときの停止時間(秒)

var _pause_timer: float = 0.0

func _physics_process(delta: float) -> void:
	if not _is_playing:
		return
	if _curve == null or _curve_length <= 0.0:
		return

	# 端での待機中はタイマーを減らしていくだけ
	if _pause_timer > 0.0:
		_pause_timer -= delta
		return

	var distance = speed * delta * float(_direction)
	var delta_ratio = 0.0
	if _curve_length > 0.0:
		delta_ratio = distance / _curve_length

	var target_ratio = _current_ratio + delta_ratio

	# 端に到達したら折り返し + 一時停止
	var reached_edge := false
	if target_ratio > 1.0:
		target_ratio = 1.0
		reached_edge = true
		_direction = -1
	elif target_ratio < 0.0:
		target_ratio = 0.0
		reached_edge = true
		_direction = 1

	_current_ratio = lerpf(_current_ratio, target_ratio, smoothing)
	_apply_position_from_ratio()

	if reached_edge and edge_pause_time > 0.0:
		_pause_timer = edge_pause_time

このように、コンポーネントとして分離しておくと、「一時停止」「イベント発火」「プレイヤーが近づいたら動き出す」などの拡張も、
他のゲームロジックを汚さずにサクッと追加しやすくなります。

ぜひ、自分のプロジェクト用に PatrolPath をベースにカスタマイズしてみてください。
継承ツリーに悩まされず、「動きはコンポーネントに任せる」スタイルに慣れていくと、Godot の開発体験がかなり快適になりますよ。