敵AIを作っていると、「足場の端でちゃんと止まってほしい」って場面、多いですよね。
Godot標準だと、以下のような実装をしがちです。

  • 敵ごとに RayCast2D ノードを生やして、向きに合わせて位置を変える
  • 敵スクリプトの中に「崖チェック」のロジックをベタ書きする
  • 敵の種類が増えるたびに、似たような処理をコピペ&修正

結果として…

  • ノード階層が深くなる(RayCast2D を向きごとに2本置いたり)
  • 「崖の検知ロジック」が敵スクリプトにベッタリ貼り付いて再利用しづらい
  • 敵AIを増やすたびに、崖検知のバグを量産しがち

そこで今回は、「継承より合成」の思想で、
どんな敵にもポン付けできる LedgeDetector コンポーネントを作りましょう。
敵のスクリプトは「崖を検知したら向きを変える」という結果だけを受け取ればOK、という構成にします。

【Godot 4】崖でUターンする敵AIをコンポーネント化!「LedgeDetector」コンポーネント

このコンポーネントは、親ノードの前方に向けて下向きの Ray を飛ばし、足場がなくなったらシグナルで通知します。
敵側は「今どっち向きに歩いてるか」さえ教えてあげれば、あとは勝手に崖を検知してくれます。

フルコード:LedgeDetector.gd


extends Node2D
class_name LedgeDetector
## 親の前方にレイを飛ばして「崖」を検知するコンポーネント。
## 主に敵AI用。親の移動方向に応じて自動でレイの位置を更新し、
## 足場が途切れたら `ledge_detected` シグナルで通知します。

## 崖を検知したときに発火するシグナル
## 引数:
## - direction: Vector2 ... 崖を検知したときの「移動方向」
signal ledge_detected(direction: Vector2)

## === 設定パラメータ ===

@export var enabled: bool = true:
	set(value):
		enabled = value
		set_process(value)

## レイを飛ばす距離(ワールド座標系)。足場の高さに合わせて調整しましょう。
@export var ray_length: float = 32.0

## 親からどれだけ前方にオフセットしてレイを飛ばすか。
## 例: 16 にすると、親の中心から16px前に出た位置から真下にレイを飛ばします。
@export var forward_offset: float = 16.0

## レイの原点をどれだけ下にずらすか。
## 敵の足元より少し上から飛ばしたい場合に使います。
@export var vertical_offset: float = 0.0

## 崖とみなすまでの待ち時間(秒)。
## 0 にすると「そのフレームで即判定」。
@export var ledge_detect_delay: float = 0.05

## どのコリジョンレイヤーを「床」とみなすか。
## 敵が立つ床のレイヤーを指定しましょう。
@export_flags_2d_physics var floor_collision_mask: int = 1

## デバッグ用にレイを画面に描画するかどうか。
@export var debug_draw: bool = false:
	set(value):
		debug_draw = value
		queue_redraw()

## 親の「移動方向」をここに渡してください。
## 例: 左に歩いているなら Vector2.LEFT、右なら Vector2.RIGHT。
## AI側のスクリプトから毎フレーム更新する想定です。
var move_direction: Vector2 = Vector2.RIGHT

## 内部用タイマー
var _no_floor_time: float = 0.0
var _had_floor_last_frame: bool = true


func _ready() -> void:
	## 親ノードが2D系であることをざっくりチェック(必須ではないが安全のため)
	if not owner or not (owner is Node2D):
		push_warning("LedgeDetector: 親が Node2D 系ではありません。位置計算が正しく動かない可能性があります。")
	set_process(enabled)


func _process(delta: float) -> void:
	if not enabled:
		return

	if move_direction == Vector2.ZERO:
		## 移動していない場合は崖チェック不要とみなす
		_no_floor_time = 0.0
		_had_floor_last_frame = true
		return

	var parent := owner as Node2D
	if parent == null:
		return

	## 親のグローバル位置
	var origin: Vector2 = parent.global_position

	## 親の向きに応じて「前方」オフセットを計算
	var forward: Vector2 = move_direction.normalized() * forward_offset

	## Ray の開始位置(原点)
	var ray_start: Vector2 = origin + forward + Vector2(0, vertical_offset)
	## Ray の終了位置(真下方向に ray_length 分)
	var ray_end: Vector2 = ray_start + Vector2.DOWN * ray_length

	## Physics2DDirectSpaceState を使って Raycast
	var space_state := get_world_2d().direct_space_state
	var query := PhysicsRayQueryParameters2D.create(ray_start, ray_end)
	query.collision_mask = floor_collision_mask
	var result := space_state.intersect_ray(query)

	var has_floor := result.size() > 0

	if has_floor:
		## 足場があるならタイマーをリセット
		_no_floor_time = 0.0
		_had_floor_last_frame = true
	else:
		if _had_floor_last_frame:
			## 直前まで床があったのに、今フレームなくなった場合
			_no_floor_time = 0.0
			_had_floor_last_frame = false
		else:
			_no_floor_time += delta

		## 一定時間足場がなければ「崖」と判定してシグナルを送る
		if _no_floor_time >= ledge_detect_delay:
			_no_floor_time = 0.0
			emit_signal("ledge_detected", move_direction.normalized())

	## デバッグ描画の更新
	if debug_draw:
		queue_redraw()


func _draw() -> void:
	if not debug_draw:
		return

	var parent := owner as Node2D
	if parent == null:
		return

	var origin: Vector2 = parent.global_position
	var forward: Vector2 = (move_direction if move_direction != Vector2.ZERO else Vector2.RIGHT).normalized() * forward_offset
	var ray_start: Vector2 = origin + forward + Vector2(0, vertical_offset)
	var ray_end: Vector2 = ray_start + Vector2.DOWN * ray_length

	## ローカル座標に変換して描画する
	var local_start := to_local(ray_start)
	var local_end := to_local(ray_end)

	## 緑色のラインで Ray を可視化
	draw_line(local_start, local_end, Color(0, 1, 0), 2.0)
	## Ray の先端に小さな丸
	draw_circle(local_end, 3.0, Color(1, 0, 0))

使い方の手順

ここでは、左右に歩き回る敵が、崖に来たらUターンする例で解説します。

シーン構成例

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── LedgeDetector (Node2D)  ← このコンポーネントをアタッチ

手順①:LedgeDetector.gd を用意する

上の LedgeDetector.gd を新規スクリプトとして保存します。
class_name LedgeDetector が付いているので、エディタの「ノード追加」からそのまま検索して追加できます。

手順②:敵シーンに LedgeDetector ノードを追加

  1. Enemy (CharacterBody2D) シーンを開く
  2. 子ノードとして Node2D を追加し、スクリプトに LedgeDetector.gd をアタッチする
    • または、+ ノード追加LedgeDetector で直接追加
  3. ray_lengthforward_offset をインスペクタから調整
    • ray_length は「足場の高さ + 余裕分」くらい
    • forward_offset は「敵の半径」くらい(スプライトの半分)
    • floor_collision_mask を「床のレイヤー」に合わせる
    • 動作確認時は debug_draw を ON にすると便利

手順③:敵AIから move_direction を渡す

敵本体のスクリプト側で、「今どっちに歩いているか」を LedgeDetector に教えてあげます。


extends CharacterBody2D

@export var move_speed: float = 60.0

var _move_dir: Vector2 = Vector2.RIGHT
var _ledge_detector: LedgeDetector


func _ready() -> void:
	## 同じシーン内の LedgeDetector を取得
	_ledge_detector = $LedgeDetector
	## 崖を検知したときのコールバックを接続
	_ledge_detector.ledge_detected.connect(_on_ledge_detected)


func _physics_process(delta: float) -> void:
	## 今の移動方向を LedgeDetector に伝える
	_ledge_detector.move_direction = _move_dir

	## 単純な左右移動
	velocity.x = _move_dir.x * move_speed
	move_and_slide()


func _on_ledge_detected(direction: Vector2) -> void:
	## 崖を検知したら、進行方向を反転させる
	_move_dir = -direction

	## 見た目の向きも変える(任意)
	if _move_dir.x < 0.0:
		$Sprite2D.flip_h = true
	else:
		$Sprite2D.flip_h = false

ポイントは、

  • _ledge_detector.move_direction = _move_dir を毎フレーム更新
  • ledge_detected シグナルを受けて 敵側で向きを変えるだけ にしていること

崖ロジックはコンポーネントに集約されているので、
別の敵にも LedgeDetector を付けて同じようにシグナルをつなげば、すぐに再利用できます。

手順④:他の用途の例

  • 動く床(Moving Platform)

    • 床自体に LedgeDetector を付けて、端まで行ったら反転させる


    MovingPlatform (CharacterBody2D)
    ├── Sprite2D
    ├── CollisionShape2D
    └── LedgeDetector (Node2D)

  • パトロールするロボット
    • ロボットが崖に来たらUターンしつつ、アニメーションも切り替えるなど

メリットと応用

LedgeDetector をコンポーネントとして切り出すことで、次のようなメリットがあります。

  • 敵スクリプトが「移動ロジック」に集中できる
    • 崖検知は LedgeDetector に丸投げ
    • 敵側は「崖が来たら向きを変える」という意思決定だけを書く
  • ノード階層がスッキリ
    • 左右別々の RayCast2D を置く必要がない
    • 見た目のノード(Sprite, Animation)と、ロジックのノード(LedgeDetector)がきれいに分離
  • 再利用性が高い
    • 「崖でUターンする何か」なら、敵でも床でもギミックでも同じコンポーネントを流用可能
    • 崖検知のパラメータだけ個別に調整できる
  • テストしやすい
    • シンプルなテストシーンに LedgeDetector だけ置いて挙動を確認できる
    • バグ修正も1か所で済む

「継承ベースで BaseEnemy に崖ロジックを詰め込む」よりも、
Enemy はただの移動・攻撃ロジック」「崖は LedgeDetector に外出し」という構成の方が、将来的な拡張に強いですね。

改造案:片側だけ崖チェックを無効にする

例えば「左側は崖でも落ちてOK、右側だけUターンしたい」みたいなケースもあります。
そんなときは、以下のようなヘルパー関数を LedgeDetector に追加して、
シグナルを受ける側でフィルタリングしてもいいですね。


## 崖検知の結果を「この方向だけ有効」にしたい場合のヘルパー
func is_direction_allowed(dir: Vector2, allow_left: bool = true, allow_right: bool = true) -> bool:
	if dir.x < 0.0 and not allow_left:
		return false
	if dir.x > 0.0 and not allow_right:
		return false
	return true

敵側では、


func _on_ledge_detected(direction: Vector2) -> void:
	## 右側の崖だけで反転したい場合
	if not _ledge_detector.is_direction_allowed(direction, allow_left = true, allow_right = true):
		return
	_move_dir = -direction

のように使えば、「崖検知は共通だが、どう振る舞うかは個別の敵ごとに変える」というコンポーネント指向らしい設計になります。

こんな感じで、崖検知をひとつのコンポーネントに閉じ込めておくと、
ゲーム全体のノード構造とスクリプト構造がかなりスッキリします。
ぜひ自分のプロジェクト用にカスタマイズしてみてください。