横スクロールのアクションゲームを作っていると、敵や味方の 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 をシーンに組み込んでいきましょう。

① コンポーネントスクリプトを用意する

  1. 上記の JumpGap.gd をプロジェクト内の res://scripts/components/JumpGap.gd などに保存します。
  2. 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.0Playerjump_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.0Enemyjump_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 ノードを消すだけ。
    逆に「ここはプレイヤー補助として、自動ジャンプ床を置きたい」と思ったら、MovingPlatformJumpGap を付けるだけで済みます。

改造案:左右どちらにも 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 コンポーネントを育ててみてください。