横スクロールアクションを作っていると、プレイヤーが「あと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 を新規スクリプトとして保存します。
- プロジェクト内で
res://components/LedgeGrab.gdなど好きな場所に保存 - Godot が自動的に
class_name LedgeGrabを認識するので、ノード追加ダイアログから追加できるようになります
手順②:Player シーンに LedgeGrab ノードを追加
Player.tscnを開く- ルートの
CharacterBody2Dを右クリック → 「子ノードを追加」 - 検索欄に
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 でも気持ちよくコンポーネント指向の開発ができますね。
