Godot 4 でキャラの移動を作っていると、CharacterBody2D のスクリプトがどんどん肥大化しがちですよね。歩く、ジャンプする、ダッシュする、壁ジャンプする… そして「梯子(はしご)での昇り降り」まで同じスクリプトに詰め込むと、_physics_process() が if 文だらけになってカオスになりがちです。
さらに、階段や動く床、泳ぎ(スイミング)など別の移動モードを追加したくなると、「プレイヤー継承ツリー」を増やしたり、「状態ごとの巨大ステートマシン」を書いたりして、保守がつらくなります。
そこで今回は、梯子の挙動だけをきれいに「コンポーネント化」する LadderClimber を用意しました。
プレイヤー本体には「通常の移動処理」だけを書いておき、梯子に入ったときだけこのコンポーネントが重力オフ+Y軸移動を肩代わりしてくれる構成です。
【Godot 4】重力オフでスイスイ昇降!「LadderClimber」コンポーネント
このコンポーネントはざっくり言うと:
- 指定した「プレイヤー(
CharacterBody2D)」を対象にする - 指定した「梯子エリア(
Area2D)」に入っている間だけ有効になる - 有効な間は
- 重力を無効化(外部の移動スクリプトから書き換えられても毎フレームで打ち消す)
- 上下入力で Y 軸方向だけに移動速度を与える
- 左右入力は無視(もしくはオプションで許可)
つまり「はしごにいる間だけ、移動ロジックを乗っ取る」コンポーネントです。
プレイヤー側のスクリプトは「通常移動」だけに集中できるので、かなりスッキリします。
フルコード:LadderClimber.gd
extends Node
class_name LadderClimber
"""
LadderClimber コンポーネント
- 梯子用 Area2D にアタッチすることを想定。
- 対象の CharacterBody2D(プレイヤーなど)を指定しておくと、
そのキャラが梯子エリア内にいる間だけ、重力を打ち消して
上下入力による Y 軸移動を行う。
想定するプレイヤー側の前提:
- velocity プロパティを持つ CharacterBody2D(Godot 標準の CharacterBody2D)を想定
- 通常時の移動はプレイヤー自身のスクリプトで行う
- 「梯子中かどうか」はこのコンポーネントが管理する
"""
@export_category("基本設定")
## 対象となるキャラクター(通常はプレイヤー)
@export var target_body: CharacterBody2D
## 梯子の範囲を表す Area2D
## 通常はこのコンポーネントと同じノードにアタッチしておく
@export var ladder_area: Area2D
## 上下方向の移動速度(ピクセル/秒)
@export var climb_speed: float = 120.0
## 梯子にいる間、X 方向の速度を完全にゼロにするかどうか
## false の場合、「横入力で梯子から降りる」などの応用も可能
@export var lock_x_velocity: bool = true
@export_category("入力設定")
## 上昇に使うアクション名(InputMap で定義しておく)
@export var action_up: StringName = &"ui_up"
## 下降に使うアクション名
@export var action_down: StringName = &"ui_down"
## 梯子から降りる(キャンセル)に使うアクション名(任意)
## 空文字列のままなら無効
@export var action_leave_ladder: StringName = &""
@export_category("高度な設定")
## 梯子に入った瞬間に、Y 速度をゼロにリセットするかどうか
@export var reset_vertical_velocity_on_enter: bool = true
## 梯子にいる間、重力を完全に打ち消すかどうか
## false にすると「ゆっくり落下しながら登る」などの特殊挙動も作れる
@export var cancel_gravity: bool = true
## 梯子にいる間だけ、target_body の "is_on_ladder" という変数を true/false で書き換える
## プレイヤー側スクリプトで状態判定に使いたい場合に便利
@export var write_flag_to_target: bool = true
@export var flag_name_in_target: StringName = &"is_on_ladder"
## デバッグ表示用:現在誰かがこの梯子を使っているか
var _is_body_on_ladder: bool = false
## 内部状態:今このコンポーネントが制御している対象
var _current_body: CharacterBody2D = null
func _ready() -> void:
# ladder_area が未設定なら、自分の親か自分自身を自動検出してみる
if ladder_area == null:
if self is Area2D:
ladder_area = self
else:
# 親が Area2D ならそれを使う
if get_parent() is Area2D:
ladder_area = get_parent()
if ladder_area == null:
push_warning("LadderClimber: ladder_area が設定されていません。梯子の Area2D を export で指定してください。")
return
# Area2D のシグナルに接続して、出入りを検知する
ladder_area.body_entered.connect(_on_body_entered)
ladder_area.body_exited.connect(_on_body_exited)
func _physics_process(delta: float) -> void:
if _current_body == null:
return
# 対象が queue_free されていた場合の保険
if not is_instance_valid(_current_body):
_clear_current_body()
return
# 対象が CharacterBody2D でなければ何もしない
if not (_current_body is CharacterBody2D):
return
var body := _current_body
var v := body.velocity
# 梯子から降りる入力があれば、制御を解除する
if action_leave_ladder != &"" and Input.is_action_just_pressed(action_leave_ladder):
_leave_ladder()
return
# 重力を打ち消す(プレイヤー側が毎フレーム重力加算していても、ここでゼロにする)
if cancel_gravity:
v.y = 0.0
# X 速度を固定する場合
if lock_x_velocity:
v.x = 0.0
# 入力から上下方向の速度を決定
var input_y := 0.0
if Input.is_action_pressed(action_up):
input_y -= 1.0
if Input.is_action_pressed(action_down):
input_y += 1.0
v.y = input_y * climb_speed
body.velocity = v
body.move_and_slide()
func _on_body_entered(body: Node) -> void:
# 対象が CharacterBody2D でなければ無視
if not (body is CharacterBody2D):
return
# target_body が指定されている場合は、それと一致するものだけを対象にする
if target_body != null and body != target_body:
return
# すでに別のボディを制御している場合はスキップ(単純化のため)
if _current_body != null and _current_body != body:
return
_current_body = body
_is_body_on_ladder = true
# Y 速度をリセットしたい場合
if reset_vertical_velocity_on_enter:
var v := _current_body.velocity
v.y = 0.0
_current_body.velocity = v
# プレイヤー側にフラグを書き込む
if write_flag_to_target:
_set_flag_in_target(_current_body, true)
func _on_body_exited(body: Node) -> void:
if body == _current_body:
_leave_ladder()
func _leave_ladder() -> void:
if _current_body == null:
return
# フラグを戻す
if write_flag_to_target:
_set_flag_in_target(_current_body, false)
_clear_current_body()
func _clear_current_body() -> void:
_current_body = null
_is_body_on_ladder = false
func _set_flag_in_target(body: Node, value: bool) -> void:
# body に flag_name_in_target というメンバがあれば書き込む
# ない場合は何もしない(エラーにはしない)
if body.has_variable(flag_name_in_target):
body.set(flag_name_in_target, value)
使い方の手順
ここでは典型的な 2D プラットフォーマーを想定して、プレイヤーが梯子を登る例で説明します。
手順①:プレイヤー側のノード構成
まずはシンプルなプレイヤーシーンを用意します。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── PlayerController (スクリプトのみ or Node)
プレイヤーのスクリプトは、通常の移動だけを書いておきます。
例として、最低限の移動スクリプトはこんな感じです(梯子ロジックは書かない):
# Player.gd (例)
extends CharacterBody2D
@export var move_speed := 200.0
@export var gravity := 900.0
@export var jump_speed := -350.0
var is_on_ladder: bool = false # LadderClimber から書き込まれるフラグ
func _physics_process(delta: float) -> void:
# 梯子にいる間は、ここでの重力・移動は基本的に無視されるが、
# 「梯子中は移動を止めたい」などのときにフラグで分岐できる
if not is_on_ladder:
# 通常の重力
if not is_on_floor():
velocity.y += gravity * delta
# 左右移動
var input_x := Input.get_axis("ui_left", "ui_right")
velocity.x = input_x * move_speed
# ジャンプ
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
velocity.y = jump_speed
move_and_slide()
ポイントは is_on_ladder フラグだけです。
このフラグは LadderClimber コンポーネントが自動で true/false を書き換えてくれます。
手順②:梯子シーンのノード構成
次に梯子用のシーンを作ります。
Ladder (Node2D) ├── Sprite2D # 梯子の見た目 ├── Area2D # 梯子判定用 │ └── CollisionShape2D # 梯子の当たり判定(矩形など) └── LadderClimber (Node) # ← このコンポーネントをアタッチ
実際のシーン構成図の例:
Ladder (Node2D) ├── Sprite2D ├── LadderArea (Area2D) │ └── CollisionShape2D └── LadderClimber (Node)
Inspector 上で LadderClimber の export 変数を設定します:
- ladder_area …
LadderArea (Area2D)をドラッグ&ドロップ - target_body … プレイヤーシーンを直接指定してもいいですし、空のままにして「誰でも登れる梯子」にしても OK
- climb_speed … 120〜200 あたりから調整
- action_up / action_down … デフォルトの
ui_up / ui_downでよければそのまま - action_leave_ladder … 例えば
"ui_cancel"や"jump"を設定すると、「ボタンで梯子から降りる」挙動が作れます
InputMap には最低限以下を定義しておきましょう:
ui_up(例: ↑キー / W)ui_down(例: ↓キー / S)ui_left,ui_right(プレイヤー移動で使用)ui_accept(ジャンプ用など)
手順③:シーンに配置してテスト
メインシーンにプレイヤーと梯子シーンを配置します。
Main (Node2D) ├── Player (CharacterBody2D) ├── Ladder (Node2D) └── TileMap / その他
ゲームを再生すると:
- プレイヤーが梯子エリア(
LadderArea)に入ると、LadderClimberがプレイヤーを「捕まえる」 - その間は
- 重力がキャンセルされて落ちなくなる
ui_up/ui_downで上下にだけ移動する- 左右速度はゼロに固定(設定で変更可)
is_on_ladderフラグが true になる
- 梯子エリアから出る、または
action_leave_ladderを押すと、制御を解除して通常の移動に戻る
プレイヤー側のスクリプトは「梯子中かどうか」を is_on_ladder で知ることができるので、例えば:
- 梯子中はジャンプ無効にする
- 梯子中は攻撃モーションに制限をかける
- アニメーションを「登りアニメ」に切り替える
といった制御が簡単に書けます。
手順④:敵や動く足場にも使い回す
コンポーネント指向の良いところは、「プレイヤー専用コード」にならないことです。
target_body を空のままにしておけば、どの CharacterBody2D でも梯子に入れば登れるようになります。
例えば:
Enemy (CharacterBody2D) ├── Sprite2D └── EnemyAI.gd
この敵が AI の都合で梯子に登りたい場合も、Enemy をそのまま梯子エリアに突っ込めば、LadderClimber が同じように Y 軸だけ移動させてくれます。
「敵用の梯子登りロジック」を別に書く必要はありません。
メリットと応用
この LadderClimber コンポーネントを使うことで:
- プレイヤーのスクリプトがスリムになる
梯子ロジックを巨大なif is_on_ladder:ブロックで抱え込む必要がなくなります。 - シーン構造がフラットで見通しが良い
「梯子専用プレイヤーシーン」を継承で増やすのではなく、梯子側にコンポーネントを生やすだけで済みます。 - 再利用性が高い
プレイヤーでも敵でも、動く足場に乗った NPC でも、同じコンポーネントを使い回せます。 - レベルデザインが楽
レベルデザイナーは「梯子シーンをポンと置く」だけで、ゲーム中のどのキャラでも登れるようになります。
「継承より合成(Composition)」の考え方とも相性が良くて、
「移動系の特殊挙動」はどんどんコンポーネントに分割していくのがおすすめです。
改造案:梯子の上端で自動的に降りる
例えば、「梯子の一番上まで登ったら自動で梯子を離れる」挙動を足したい場合、
LadderClimber に次のような補助関数を追加することができます:
func _auto_leave_when_above_top(top_y: float) -> void:
"""
body の Y 座標が top_y より上に来たら、自動で梯子を離れる。
top_y はワールド座標で指定する想定。
例えば梯子の一番上のタイルの Y などを渡す。
"""
if _current_body == null:
return
if _current_body.global_position.y < top_y:
_leave_ladder()
これを _physics_process() の末尾あたりで呼び出して:
func _physics_process(delta: float) -> void:
if _current_body == null:
return
# ... 既存の処理 ...
body.move_and_slide()
# 例: この梯子の一番上の Y を基準にする
var top_y := ladder_area.global_position.y - ladder_area.shape_owner_get_shape(0, 0).get_extents().y
_auto_leave_when_above_top(top_y)
のようにすれば、「一番上まで来たら自動で梯子を離れる」ような自然な挙動も作れます。
(実際には CollisionShape2D の種類によって shape の扱いが変わるので、プロジェクトに合わせて調整してください。)
このように、梯子のロジックはすべて LadderClimber 側に閉じ込めることで、
プレイヤーや敵のスクリプトは「歩く・走る・攻撃する」などの本質的なロジックだけに集中させることができます。
ぜひ自分のプロジェクト用にカスタマイズして、コンポーネント指向の快適さを体験してみてください。




