横スクロールのアクションゲームを作っていると、敵や味方の AI に「足元に穴があったらジャンプしてくれ!」という挙動をさせたくなることが多いですよね。
Godot 4 で素直に実装しようとすると、
- プレイヤーや敵クラスを
extends CharacterBody2Dして、そこに AI ロジックを書き足す - さらにサブクラスを増やして「ジャンプする敵」「ジャンプしない敵」などを作る
- 結果として、継承チェーンが長くなり、どのクラスに何が書いてあるのか分かりにくくなる
…という「継承地獄」に入りがちです。
しかも、プレイヤーと敵で「穴を検知してジャンプするロジック」はほぼ同じなのに、コピペで二重管理するのはもったいないですよね。
そこでこの記事では、「穴を見つけたらジャンプする」だけを担当するコンポーネントとして、JumpGap を用意してみましょう。
プレイヤーでも敵でも動く足場でも、「足元に穴を検知して自動ジャンプしたいノードにアタッチするだけ」で使えるようにします。
【Godot 4】足元の穴はコンポーネントに任せよう!「JumpGap」コンポーネント
このコンポーネントの役割はシンプルです。
- 進行方向の少し先に RayCast を飛ばす
- その先に「床」がなければ「穴」と判定
- 穴だったら、親の
CharacterBody2Dに対してジャンプ速度を与える
親ノードは CharacterBody2D を想定していますが、
「ジャンプ速度をセットするプロパティ名」などをエクスポート変数で柔軟に変えられるようにしておきます。
JumpGap.gd(フルコード)
extends Node
class_name JumpGap
"""
足元の「穴」を RayCast で検知して、自動でジャンプさせるコンポーネント。
想定する親ノード:
- CharacterBody2D または CharacterBody3D (ここでは 2D を前提に実装)
- 水平方向の移動速度 (velocity.x) を持っていること
- 垂直方向の速度をジャンプ時に上書きできること (velocity.y = jump_speed など)
このコンポーネントは「いつジャンプするか」の判断だけを担当し、
実際の移動処理 (move_and_slide など) は親ノード側で行う想定です。
"""
## ====== エクスポートパラメータ ======
@export_category("Detection")
## RayCast を飛ばすローカル座標のオフセット
## 例: (16, 0) ならキャラクターの右足の前あたり
@export var cast_offset: Vector2 = Vector2(16, 0)
## RayCast の長さ(どれくらい下方向まで床を探すか)
@export var cast_length: float = 32.0
## RayCast の更新間隔 (秒)
## 毎フレーム判定が重い場合は、少し間引くと軽くなります
@export_range(0.0, 1.0, 0.01)
@export var check_interval: float = 0.0
## どのコリジョンレイヤーを「床」とみなすか
@export_flags_2d_physics
var floor_collision_mask: int = 1
@export_category("Jump Settings")
## ジャンプ時に与える上方向速度(負の値で上ジャンプ)
@export var jump_speed: float = -400.0
## この速度以上の落下中は「ジャンプしない」などの制御がしたいときに使用
@export var max_fall_speed_to_jump: float = 200.0
## 「穴」を検知してからジャンプするまでのディレイ(秒)
## 0 なら即ジャンプ
@export_range(0.0, 0.5, 0.01)
@export var jump_delay: float = 0.0
## 連続ジャンプを防ぐクールタイム(秒)
@export_range(0.0, 2.0, 0.01)
@export var jump_cooldown: float = 0.3
@export_category("Integration")
## 親の velocity プロパティ名
## 例: "velocity" (CharacterBody2D のデフォルト)
@export var velocity_property_name: StringName = "velocity"
## 親の「地面にいるか?」を示すプロパティ名またはメソッド名
## - プロパティ例: "is_on_floor"
## - メソッド例: "is_on_floor" (引数なしの bool を返すメソッド)
@export var on_floor_member_name: StringName = "is_on_floor"
## 親が左右どちらに進んでいるかを判定する方法:
## - "velocity" : velocity.x の符号を見る
## - "custom" : 親の custom_direction_property_name を参照する
@export_enum("velocity", "custom")
var direction_source: String = "velocity"
## direction_source == "custom" のときに参照するプロパティ名
## 例: "move_direction" (Vector2) / "facing" (int: -1 or 1)
@export var custom_direction_property_name: StringName = "move_direction"
## 親のローカル座標系を使わず、常にワールド座標で RayCast したい場合は ON
@export var use_global_space: bool = false
## デバッグ用: RayCast を画面に描画
@export var debug_draw: bool = false
## デバッグ用: 実際にジャンプを行わず、ログだけ出すモード
@export var dry_run: bool = false
## ====== 内部状態 ======
var _time_since_last_check: float = 0.0
var _cooldown_timer: float = 0.0
var _pending_jump_time: float = -1.0
func _ready() -> void:
# 親が CharacterBody2D かどうかはあえて強制しないが、
# 少なくとも velocity プロパティを持っているかはチェックしておく
if not _has_member(velocity_property_name):
push_warning(
"JumpGap: Parent does not have a '%s' property. " +
"Please set 'velocity_property_name' correctly or add the property on parent." % velocity_property_name
)
func _physics_process(delta: float) -> void:
_time_since_last_check += delta
if _cooldown_timer > 0.0:
_cooldown_timer -= delta
# ジャンプ予約があれば処理
if _pending_jump_time >= 0.0:
_pending_jump_time -= delta
if _pending_jump_time <= 0.0:
_do_jump_if_possible()
_pending_jump_time = -1.0
# チェック間隔の制御
if check_interval > 0.0 and _time_since_last_check < check_interval:
return
_time_since_last_check = 0.0
# すでにクールタイム中なら何もしない
if _cooldown_timer > 0.0:
return
# 親が地面にいないときはジャンプしない
if not _is_on_floor():
return
var direction := _get_horizontal_direction()
if direction == 0:
# 停止中なら無理にジャンプしない
return
# RayCast の始点と終点を計算
var origin: Vector2 = cast_offset
if use_global_space:
origin = global_position + cast_offset
# 進行方向の少し先、下方向へ cast_length 分伸ばす
var forward_offset := Vector2(cast_offset.x * signf(direction), cast_offset.y)
var start_point := forward_offset
var end_point := forward_offset + Vector2(0, cast_length)
if use_global_space:
start_point = global_position + forward_offset
end_point = global_position + forward_offset + Vector2(0, cast_length)
# RayCast 実行
var space_state := get_world_2d().direct_space_state
var query := PhysicsRayQueryParameters2D.create(start_point, end_point)
query.collision_mask = floor_collision_mask
var result := space_state.intersect_ray(query)
if debug_draw:
_debug_draw_ray(start_point, end_point, result.is_empty())
# ヒットしていれば床があるので、穴ではない
var is_gap := result.is_empty()
if is_gap:
# 穴を検知したのでジャンプを予約 or 即実行
if jump_delay > 0.0:
_pending_jump_time = jump_delay
else:
_do_jump_if_possible()
## ====== 内部ユーティリティ関数 ======
func _do_jump_if_possible() -> void:
if _cooldown_timer > 0.0:
return
if not _is_on_floor():
# 予約中に空中に出ていたらジャンプキャンセル
return
var velocity := _get_velocity()
if velocity == null:
return
# あまりにも高速で落下中ならジャンプしない (任意の安全策)
if velocity.y > max_fall_speed_to_jump:
return
# 実際のジャンプ処理
velocity.y = jump_speed
if dry_run:
# dry_run の場合は速度を書き戻さず、ログだけ出す
print("JumpGap(dry_run): would jump with speed ", jump_speed)
return
_set_velocity(velocity)
_cooldown_timer = jump_cooldown
func _get_velocity() -> Variant:
if not _has_member(velocity_property_name):
push_warning(
"JumpGap: Parent has no '%s' property. Jump aborted." % velocity_property_name
)
return null
var parent := get_parent()
return parent.get(velocity_property_name)
func _set_velocity(v: Vector2) -> void:
var parent := get_parent()
if not _has_member(velocity_property_name):
return
parent.set(velocity_property_name, v)
func _is_on_floor() -> bool:
var parent := get_parent()
if parent == null:
return false
if parent.has_method(on_floor_member_name):
return parent.call(on_floor_member_name)
elif parent.has_variable(on_floor_member_name):
return bool(parent.get(on_floor_member_name))
else:
# CharacterBody2D の is_on_floor() はメソッドなので、
# デフォルト設定ならここに入ることはあまりないはず
return false
func _get_horizontal_direction() -> int:
var parent := get_parent()
if parent == null:
return 0
if direction_source == "velocity":
var v := _get_velocity()
if v == null:
return 0
if absf(v.x) < 0.001:
return 0
return signi(v.x)
else:
# custom プロパティから方向を決める
if not parent.has_variable(custom_direction_property_name):
return 0
var d = parent.get(custom_direction_property_name)
if typeof(d) == TYPE_VECTOR2:
if absf(d.x) < 0.001:
return 0
return signi(d.x)
elif typeof(d) == TYPE_INT or typeof(d) == TYPE_FLOAT:
if absf(float(d)) < 0.001:
return 0
return signi(int(d))
else:
return 0
func _has_member(name: StringName) -> bool:
var parent := get_parent()
if parent == null:
return false
return parent.has_variable(name)
func _debug_draw_ray(start_point: Vector2, end_point: Vector2, is_gap: bool) -> void:
# デバッグ描画用に 1 フレームだけ Line2D を生成する簡易版
var line := Line2D.new()
line.width = 1.0
line.default_color = is_gap ? Color.RED : Color.GREEN
line.points = PackedVector2Array([start_point, end_point])
# グローバル座標系で描画したいので、ルートにぶら下げる
get_tree().current_scene.add_child(line)
# 1 フレーム後に自動削除
line.call_deferred("queue_free")
使い方の手順
ここからは、実際に JumpGap をシーンに組み込んでいきましょう。
① コンポーネントスクリプトを用意する
- 上記の
JumpGap.gdをプロジェクト内のres://scripts/components/JumpGap.gdなどに保存します。 - Godot エディタを再読み込みすると、
JumpGapが「スクリプトクラス」として認識されます。
② プレイヤーにアタッチする例
まずはプレイヤーキャラに「穴飛び越え AI」をつけてみましょう。
プレイヤーの移動処理はシンプルな CharacterBody2D を想定します。
# Player.gd
extends CharacterBody2D
@export var move_speed: float = 120.0
@export var gravity: float = 900.0
@export var jump_power: float = -350.0
func _physics_process(delta: float) -> void:
# 横移動(左右キーで操作)
var input_dir := Input.get_axis("ui_left", "ui_right")
velocity.x = input_dir * move_speed
# 重力
if not is_on_floor():
velocity.y += gravity * delta
# 手動ジャンプ(スペースキーなど)
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = jump_power
move_and_slide()
このプレイヤーに JumpGap を追加したシーン構成図はこんな感じです。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── JumpGap (Node) <-- 今回のコンポーネント
エディタで Player ノードを選択し、「子ノードを追加」→ JumpGap を追加します。
インスペクタで以下のように設定すると分かりやすいです。
- cast_offset:
(16, 0)(キャラの右足の少し前) - cast_length:
24(足元の床を検知する深さ) - floor_collision_mask: 床が属しているレイヤーを ON
- jump_speed:
-350.0(Playerのjump_powerと揃えると自然) - direction_source:
"velocity"(プレイヤーの移動方向をそのまま利用)
これで、プレイヤーが自動で右方向に動くようなシーンにすれば、
足元に穴があるときだけ自動ジャンプしてくれるようになります。
③ 敵キャラにアタッチする例(パトロール敵)
次に、シンプルなパトロール敵に組み込んでみましょう。
敵は常に左右どちらかへ歩き続け、壁に当たったら反転し、
さらに「足元に穴があったらジャンプで飛び越える」ようにします。
# Enemy.gd
extends CharacterBody2D
@export var move_speed: float = 60.0
@export var gravity: float = 900.0
@export var jump_power: float = -320.0
# 左右どちらに進んでいるか (-1 or 1)
var move_dir: int = 1
func _physics_process(delta: float) -> void:
# 横移動
velocity.x = move_dir * move_speed
# 重力
if not is_on_floor():
velocity.y += gravity * delta
# 壁に当たったら反転
if is_on_wall():
move_dir *= -1
move_and_slide()
この敵に JumpGap をつけるシーン構成図はこんな感じです。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── JumpGap (Node)
設定例:
- cast_offset:
(16, 0) - cast_length:
24 - jump_speed:
-320.0(Enemyのjump_powerと同じ) - direction_source:
"custom" - custom_direction_property_name:
"move_dir"
このように、敵の進行方向は自前の move_dir で管理しつつ、JumpGap にはその情報だけを渡す形にできます。
敵の AI ロジックは Enemy.gd に集中し、「穴の検知とジャンプ」はコンポーネントに丸投げできるのがポイントですね。
④ 動く床(Moving Platform)にも適用してみる
もう一つの例として、「自動で進んでいく動く床」が、
ステージの途中にある穴をジャンプで越えてくれる…というギミックも簡単に作れます。
# MovingPlatform.gd
extends CharacterBody2D
@export var move_speed: float = 80.0
@export var gravity: float = 0.0
@export var jump_power: float = -200.0
var velocity: Vector2 = Vector2.ZERO
var move_dir: int = 1
func _physics_process(delta: float) -> void:
velocity.x = move_dir * move_speed
# 壁に当たったら反転
if is_on_wall():
move_dir *= -1
move_and_slide()
シーン構成図:
MovingPlatform (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── JumpGap (Node)
設定例:
- velocity_property_name:
"velocity"(自前で定義したvelocityを使う) - on_floor_member_name:
"is_on_floor"(メソッドを呼ぶ) - direction_source:
"custom" - custom_direction_property_name:
"move_dir" - jump_speed:
-200.0
このように、親ノードの実装が少し変わっても、JumpGap 側はエクスポート変数の設定だけで対応できるのがコンポーネント方式の強みですね。
メリットと応用
JumpGap コンポーネントを使うことで、次のようなメリットがあります。
- 継承ツリーを増やさずに機能追加できる
「穴を飛び越える敵」「飛び越えない敵」を、EnemyWithJump/EnemyWithoutJumpみたいなサブクラスに分ける必要がありません。
どちらもEnemyのまま、JumpGapを付けるかどうかだけで挙動を切り替えられます。 - シーン構造がフラットで見通しが良い
「移動」「アニメーション」「穴ジャンプ」「攻撃判定」などを、それぞれ独立したコンポーネントとしてぶら下げることで、
ノード構成図を見ただけで「このキャラが何をできるか」が一目で分かります。 - プレイヤー・敵・ギミックでロジックを共有できる
今回のJumpGapは、プレイヤーでも敵でも動く床でも、「足元に穴があるならジャンプする」というロジックを共通化できます。
バグ修正も 1 箇所を直すだけで済むので、メンテナンス性も高いです。 - レベルデザイン時に「差し替え」が楽
「この敵は穴を飛び越えない方が難易度的にちょうどいいな…」と思ったら、JumpGapノードを消すだけ。
逆に「ここはプレイヤー補助として、自動ジャンプ床を置きたい」と思ったら、MovingPlatformにJumpGapを付けるだけで済みます。
改造案:左右どちらにも RayCast を飛ばして「安全そうな方向」を選ぶ
応用として、「左右両方に穴があるならジャンプしない」「片方だけ安全ならそちらへ向きを変える」といった
もう少し賢い AI にすることもできます。
例えば、次のような関数を JumpGap に追加して、
左右両方向の足元状況をチェックすることができます。
func _check_gap_both_sides() -> int:
"""
-1 : 左側が安全(床あり)、右側は穴
1 : 右側が安全、左側は穴
0 : 両方安全 or 両方穴(特に推奨方向なし)
"""
var space_state := get_world_2d().direct_space_state
func cast(dir: int) -> bool:
var forward_offset := Vector2(cast_offset.x * dir, cast_offset.y)
var start_point := global_position + forward_offset
var end_point := start_point + Vector2(0, cast_length)
var query := PhysicsRayQueryParameters2D.create(start_point, end_point)
query.collision_mask = floor_collision_mask
var result := space_state.intersect_ray(query)
# true なら床あり、false なら穴
return not result.is_empty()
var left_safe := cast(-1)
var right_safe := cast(1)
if left_safe and not right_safe:
return -1
elif right_safe and not left_safe:
return 1
else:
return 0
これを使って、敵側のスクリプトで「安全な方向に move_dir を切り替える」ようにすれば、
足場の端でうろうろするような、ちょっと賢い挙動も簡単に作れますね。
継承より合成(Composition)で、Godot のキャラクターたちにどんどん小さな「能力コンポーネント」を足していくと、
後からの変更やバランス調整がかなり楽になります。
ぜひ、あなたのプロジェクトでも JumpGap をベースに、独自のジャンプ AI コンポーネントを育ててみてください。
