横スクロールアクションを作っていると、プレイヤーが「あと1ピクセル届かない…!」みたいな場面、よくありますよね。
素直に CharacterBody2D を継承して _physics_process() の中にジャンプ・ダッシュ・二段ジャンプ・壁キック・崖掴まり…とロジックを全部詰め込むと、あっという間に「巨大な God クラス」が完成します。

さらに、崖掴まり(Ledge Grab)を実装しようとすると:

  • プレイヤーの頭・手の位置に合わせた RayCast2D を複数生やす
  • 床の角だけを検出したいのに、壁や天井にも当たってしまう
  • 「届く距離」と「引き上げる位置」のチューニングが毎回大変
  • 敵キャラや別プレイヤーにも同じロジックをコピー&ペースト

…と、メンテしづらいコードになりがちです。
そこで今回は、「崖掴まり」だけを独立したコンポーネントに切り出して、どのキャラクターにもポン付けできる LedgeGrab コンポーネントを作っていきましょう。

【Godot 4】崖際の1ピクセルを救え!「LedgeGrab」コンポーネント

このコンポーネントは:

  • 足場の角を RayCast2D で検出
  • 「ギリギリ届く」距離なら、キャラの位置を角までスナップ(引き上げ)
  • 入力(例: 右方向移動中)や速度条件(上方向に移動中など)を満たしたときだけ発動
  • CharacterBody2D にアタッチするだけでOK(継承不要)

という、シンプルな「崖掴まりアシスト」です。
では、フルコードから見ていきましょう。


フルコード(GDScript / Godot 4)


extends Node
class_name LedgeGrab
## 崖掴まり(足場の角を検出してプレイヤーを引き上げる)コンポーネント
##
## 想定:
## - 親ノードは CharacterBody2D
## - 親側で通常の移動・ジャンプ処理を行う
## - このコンポーネントは「ギリギリ届かないジャンプ」を補正する役割

@export_group("References")
## 崖掴まり対象となる CharacterBody2D(通常は親ノードを指定)
@export var character: CharacterBody2D

## 足場と判定したいレイヤー(例: TileMap, MovingPlatform など)
@export_flags_2d_physics var ledge_collision_mask: int = 1

@export_group("Ledge Detection")
## 水平方向の検出距離(キャラの手が届く最大距離)
@export var forward_check_distance: float = 16.0
## 垂直方向の検出高さ(どの高さまで崖を探すか)
@export var vertical_check_height: float = 24.0
## 崖の角を探すときの、上方向の「のぞき込み」距離
@export var ledge_top_check_distance: float = 8.0

## 引き上げるとき、足場の上に何ピクセル分「浮かせる」か
## (コリジョンのめり込み防止用。キャラのColliderの形状に合わせて調整)
@export var vertical_offset_on_snap: float = -2.0

@export_group("Conditions")
## 崖掴まりを許可する最大落下速度(これより速いと「落下中」とみなして無効化)
@export var max_fall_speed_to_grab: float = 200.0
## 崖掴まりを許可する最小の水平方向速度(ほぼ静止状態では発動させない)
@export var min_horizontal_speed_to_grab: float = 10.0

## 崖掴まりを有効にする入力アクション名(例: "ui_right", "ui_left")
## 空文字の場合は入力チェックを行わない
@export var required_move_action: String = ""

@export_group("Debug")
## デバッグ用に Ray のラインを描画するか
@export var debug_draw: bool = false
## デバッグ描画の色
@export var debug_color: Color = Color.YELLOW

var _space_state: PhysicsDirectSpaceState2D

func _ready() -> void:
    if character == null:
        # デフォルトでは親ノードをキャラクターとして扱う
        if owner is CharacterBody2D:
            character = owner as CharacterBody2D
        elif get_parent() is CharacterBody2D:
            character = get_parent() as CharacterBody2D

    if character == null:
        push_warning("LedgeGrab: 'character' が設定されていません。CharacterBody2D を指定してください。")

    _space_state = get_world_2d().direct_space_state


func _physics_process(delta: float) -> void:
    if character == null:
        return

    # 1. 条件を満たさない場合は何もしない
    if not _can_attempt_ledge_grab():
        return

    # 2. 進行方向を決める(速度ベース)
    var dir_sign := sign(character.velocity.x)
    if dir_sign == 0.0:
        return

    # 3. 足場の角を探す
    var ledge_position := _find_ledge_position(dir_sign)
    if ledge_position == null:
        return

    # 4. 見つかったらキャラクターをスナップ(引き上げ)する
    _snap_character_to_ledge(ledge_position)

    # 5. 引き上げ後の速度調整(上方向・横方向の速度を軽く抑える)
    character.velocity.y = min(character.velocity.y, 0.0)
    character.velocity.x *= 0.5


func _can_attempt_ledge_grab() -> bool:
    ## 崖掴まりを試みてよい状況かどうかを判定する
    ##
    ## - すでに床の上にいるなら不要
    ## - あまりにも高速落下しているときは無効
    ## - ほぼ静止状態(横速度が小さい)では無効
    ## - 必要なら入力アクションもチェック

    # 床の上なら崖掴まりする必要なし
    if character.is_on_floor():
        return false

    # 高速落下中は無効(好みで調整)
    if character.velocity.y > max_fall_speed_to_grab:
        return false

    # 横方向の移動がほぼゼロなら無効
    if abs(character.velocity.x) < min_horizontal_speed_to_grab:
        return false

    # 入力チェック(設定されている場合のみ)
    if required_move_action != "":
        if not Input.is_action_pressed(required_move_action):
            return false

    return true


func _find_ledge_position(dir_sign: float) -> Vector2:
    ## 足場の「角」のワールド座標を探す
    ##
    ## 概要:
    ## 1. キャラの胸〜頭あたりの高さから、進行方向に Ray を飛ばして壁/足場を検出
    ## 2. ヒットした位置の少し上に、さらに Ray を上向きに飛ばして「上が空いているか」を確認
    ## 3. 上が空いている境界(=角)を「崖の位置」とみなす

    var origin := character.global_position

    # キャラの中心から少し上(胸〜頭くらい)を起点にする
    var vertical_origin_offset := -vertical_check_height * 0.3
    var start := origin + Vector2(0, vertical_origin_offset)

    # 1. 進行方向への Ray
    var end_forward := start + Vector2(forward_check_distance * dir_sign, 0.0)

    var query := PhysicsRayQueryParameters2D.create(start, end_forward)
    query.collision_mask = ledge_collision_mask
    query.exclude = [character]

    var result := _space_state.intersect_ray(query)
    if result.is_empty():
        # 正面に足場がない
        return null

    var hit_position: Vector2 = result.position
    var hit_normal: Vector2 = result.normal

    # 「壁」だけでなく、ある程度上向きの法線も許可(斜めの足場など)
    if hit_normal.y > -0.1 and abs(hit_normal.x) < 0.9:
        # ほぼ水平な面ではない(垂直な壁)と判定
        # ここは好みによって調整してもよい
        pass

    # 2. ヒット位置の少し上から、さらに上向きに Ray を飛ばして「上が空いている」位置を探す
    var top_start := hit_position + Vector2(0, -vertical_check_height)
    var top_end := top_start + Vector2(0, ledge_top_check_distance)

    var top_query := PhysicsRayQueryParameters2D.create(top_start, top_end)
    top_query.collision_mask = ledge_collision_mask
    top_query.exclude = [character]

    var top_result := _space_state.intersect_ray(top_query)

    # 上方向に何もヒットしなければ、単純に「ヒット位置の少し上」を崖の位置とみなす
    var ledge_pos: Vector2
    if top_result.is_empty():
        ledge_pos = hit_position + Vector2(0, -vertical_check_height * 0.5)
    else:
        # 上方向にも何かある場合は、その手前を崖位置とみなす
        var top_hit_pos: Vector2 = top_result.position
        ledge_pos = top_hit_pos + Vector2(0, -4.0)  # 少しだけ上にオフセット

    # 必要ならここでさらに微調整(キャラのコリジョンサイズなど)
    if debug_draw:
        update()

    return ledge_pos


func _snap_character_to_ledge(ledge_pos: Vector2) -> void:
    ## 見つけた崖の位置にキャラをスナップ(引き上げ)する
    ##
    ## キャラのコリジョンの形状によっては、少し手前 or 少し上にオフセットしたほうが
    ## ひっかかりにくくなります。

    # キャラの現在位置との差分を計算
    var current_pos := character.global_position
    var target_pos := Vector2(ledge_pos.x, ledge_pos.y + vertical_offset_on_snap)

    character.global_position = target_pos


func _draw() -> void:
    if not debug_draw or character == null:
        return

    var origin := character.global_position
    var vertical_origin_offset := -vertical_check_height * 0.3
    var start := origin + Vector2(0, vertical_origin_offset)

    # デバッグ用に左右両方向の Ray を描画
    var right_end := start + Vector2(forward_check_distance, 0)
    var left_end := start + Vector2(-forward_check_distance, 0)

    draw_line(to_local(start), to_local(right_end), debug_color, 1.0)
    draw_line(to_local(start), to_local(left_end), debug_color, 1.0)

使い方の手順

ここでは、典型的な 2D アクションのプレイヤーに崖掴まりを付与する例で説明します。

シーン構成例

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── LedgeGrab (Node)  ← このコンポーネントをアタッチ

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

上の LedgeGrab.gd を新規スクリプトとして保存します。

  1. プロジェクト内で res://components/LedgeGrab.gd など好きな場所に保存
  2. Godot が自動的に class_name LedgeGrab を認識するので、ノード追加ダイアログから追加できるようになります

手順②:Player シーンに LedgeGrab ノードを追加

  1. Player.tscn を開く
  2. ルートの CharacterBody2D を右クリック → 「子ノードを追加」
  3. 検索欄に LedgeGrab と入力し、先ほどのコンポーネントを選択

最終的な構成はこんな感じになります:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── LedgeGrab (Node)

この時点で、プレイヤーの移動ロジックには一切手を入れていません
「崖掴まり」という機能だけを、あとから合成(Composition)で足している状態ですね。

手順③:インスペクタでパラメータを調整する

LedgeGrab ノードを選択すると、インスペクタにいくつかの項目が出てきます。

  • character
    通常は自動で親の CharacterBody2D が入ります。もし別のノードにしたい場合は手動で指定。
  • ledge_collision_mask
    崖として扱うレイヤー。TileMap や足場のノードが属する Physics Layer をチェックしましょう。
  • forward_check_distance
    手が届く最大距離。キャラの幅の半分〜1倍くらいから調整するとよいです。
  • vertical_check_height
    どの高さから崖を探すか。キャラの身長の 1/3〜1/2 くらいが目安。
  • ledge_top_check_distance
    「足場の上」をのぞき込む距離。小さすぎると角として認識しづらく、大きすぎると変な場所を拾います。
  • max_fall_speed_to_grab
    これより速く落ちているときは崖掴まりをしません。落下ダメージなどと組み合わせるときに便利。
  • min_horizontal_speed_to_grab
    ほぼ静止しているときに誤作動しないようにするための下限速度。
  • required_move_action
    例: "ui_right""ui_left" を入れておくと、「その方向に入力しているときだけ」崖掴まりします。空欄なら常に許可。
  • debug_draw
    ON にすると、エディタの「デバッグ実行」で Ray のラインが描かれます。判定のズレを可視化するのに便利です。

手順④:実際に動かしてみる

基本的なプレイヤー移動(左右移動+ジャンプ)ができている前提で、以下を確認します。

  • 床の端ギリギリからジャンプして、少しだけ届かない高さの足場を用意
  • プレイヤーを走らせてジャンプし、足場に「かすめる」ような軌道で飛ぶ
  • 条件を満たすと、プレイヤーが足場の角にスッと引き上げられます

これだけで、プレイヤーのスクリプトには一行も追記せずに 崖掴まりが実装できました。


別の使用例:敵キャラにも崖掴まりをつける

同じコンポーネントを、敵キャラにもそのまま使い回せます。

Enemy (CharacterBody2D)
 ├── AnimatedSprite2D
 ├── CollisionShape2D
 └── LedgeGrab (Node)

敵の移動スクリプトは「常に右に歩く」だけでも、LedgeGrab を付けておけば:

  • 足場の端から落ちそうになったとき
  • 少し高い足場にギリギリ届くとき

に、自然と角に引っかかってくれるようになります。
「プレイヤー専用の崖掴まりロジック」ではなく、「キャラクター用の崖掴まりコンポーネント」として再利用できるのがポイントですね。


メリットと応用

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

  • プレイヤーのスクリプトが肥大化しない
    ジャンプ、ダッシュ、壁キック、崖掴まり…といった機能を、各コンポーネントに分割できます。
  • シーン構造がフラットで見通しが良い
    「Player の子に LedgeGrab が付いている」という構造が一目でわかり、深いノード継承ツリーに悩まされません。
  • 他のキャラにもポン付け可能
    敵キャラ、協力プレイ用の 2P キャラ、動く足場など、CharacterBody2D さえあれば再利用できます。
  • チューニングがしやすい
    崖掴まりの距離や条件を、インスペクタから個別に調整できます。キャラごとに「掴まりやすさ」を変えるのも簡単です。

「継承より合成」でゲームプレイ要素を組み立てていくと、後から仕様変更が入っても壊れにくいのが大きな利点ですね。

改造案:崖に掴まった瞬間にアニメーションを再生する

崖掴まりを検出した瞬間に、プレイヤー側に通知を飛ばしてアニメーションを切り替えたい場合、signal を追加するのがおすすめです。


signal ledge_grabbed(ledge_position: Vector2)

func _snap_character_to_ledge(ledge_pos: Vector2) -> void:
    var target_pos := Vector2(ledge_pos.x, ledge_pos.y + vertical_offset_on_snap)
    character.global_position = target_pos

    # 崖掴まりイベントを通知
    emit_signal("ledge_grabbed", ledge_pos)

あとは、プレイヤー側で:

  • LedgeGrab.ledge_grabbed に接続して
  • 受け取ったら AnimatedSprite2D のアニメーションを "ledge_grab" に切り替える

といった拡張が簡単にできます。
このように、「1つの責務(崖掴まり)」を持ったコンポーネントを積み重ねていくと、Godot 4 でも気持ちよくコンポーネント指向の開発ができますね。