敵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 ノードを追加
Enemy (CharacterBody2D)シーンを開く- 子ノードとして
Node2Dを追加し、スクリプトにLedgeDetector.gdをアタッチする- または、
+ ノード追加→LedgeDetectorで直接追加
- または、
ray_lengthやforward_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
のように使えば、「崖検知は共通だが、どう振る舞うかは個別の敵ごとに変える」というコンポーネント指向らしい設計になります。
こんな感じで、崖検知をひとつのコンポーネントに閉じ込めておくと、
ゲーム全体のノード構造とスクリプト構造がかなりスッキリします。
ぜひ自分のプロジェクト用にカスタマイズしてみてください。
