Godot 4でアクションゲームを作っていると、プレイヤーの「2段ジャンプ」をどう実装するかで悩むことが多いですよね。素直にやると、プレイヤーのスクリプト(たいてい CharacterBody2D / CharacterBody3D)の中に

  • ジャンプ入力
  • 落下判定
  • 2段目のジャンプ回数管理
  • パーティクル再生

などが全部入りになって、あっという間に 500 行オーバーの「なんでも屋プレイヤースクリプト」になりがちです。

さらに悪いことに、敵キャラや動く足場にも「2段ジャンプっぽい挙動」を入れたくなったとき、

  • プレイヤーのスクリプトをコピペして改造する
  • ベースクラスを継承して EnemyWithDoubleJump.gd を作る

…みたいな「継承地獄」に入りやすいんですよね。

そこで今回は、「2段ジャンプ」を 1 つの再利用可能なコンポーネントとして切り出した DoubleJump コンポーネントを用意しました。
プレイヤーにも敵にも、動く床にも、ノードにポン付けするだけで 2 段ジャンプ機能を追加できるようにしていきましょう。

【Godot 4】空中でもう一歩踏み出せ!「DoubleJump」コンポーネント

このコンポーネントの思想はシンプルです。

  • 「ジャンプの実装」はホスト(プレイヤーなど)側に任せる
  • 「2段目をいつ・何回・どんな演出で出すか」だけをコンポーネントが管理する

つまり、プレイヤーは「ジャンプする関数」を持っていて、
DoubleJump は「今ならもう一回ジャンプしていいよ」と判断して、その関数を呼ぶだけ、という分担です。


フルコード(GDScript / Godot 4)


extends Node
class_name DoubleJump
## 2段ジャンプ(空中で1回だけ追加ジャンプ)を提供するコンポーネント。
##
## 前提:
## - 親ノード(ホスト)は通常の「1段目ジャンプ」を自前で実装している。
## - ホストは `is_on_floor()` で接地判定ができる (CharacterBody2D/3D を想定)。
## - ホストは「ジャンプ処理用の関数」を持っている (デフォルト名: `perform_jump` だが変更可)。
##
## このコンポーネントは:
## - 空中で1回だけ2段目ジャンプを許可
## - ジャンプ時に任意のパーティクルを再生
## - 入力はホスト側から `request_jump()` を呼び出してもらう方式
##   (入力処理とジャンプロジックを綺麗に分離するため)

@export_group("基本設定")
## ホストが持っている「実際にジャンプを行う関数名」。
## 例: プレイヤー側で `func perform_jump(): velocity.y = -jump_force` のように定義しておく。
@export var jump_method_name: StringName = &"perform_jump"

## 2段ジャンプを空中で許可するかどうか。
## (将来的に「地上でも2回までジャンプ」みたいな拡張をする場合のフラグ)
@export var allow_only_in_air: bool = true

## 2段ジャンプ可能回数。標準の「1回だけ」だが、2や3にすると3段ジャンプなども実現可能。
@export_range(1, 5, 1)
@export var max_extra_jumps: int = 1


@export_group("パーティクル演出")
## 2段ジャンプ時に再生するパーティクルノード。
## - CPUパーティクル: GPUParticles2D / GPUParticles3D / CPUParticles2D 等
## - Optional: 未設定ならパーティクルは再生しない。
@export var particle_node: NodePath

## パーティクルをホストの位置にスナップさせるかどうか。
@export var particle_follow_host: bool = true

## パーティクル再生時に、そのパーティクルの `restart()` を呼ぶかどうか。
## 連続でジャンプするゲームでは true 推奨。
@export var restart_particle_on_jump: bool = true


@export_group("デバッグ")
## 2段ジャンプの状態をログに出すかどうか。
@export var debug_log: bool = false


## 内部状態: 残りの追加ジャンプ回数。
var _remaining_extra_jumps: int = 0

## 親ノード(ホスト)への参照。
var _host: Node = null

## キャッシュされたパーティクルノード。
var _particle: Node = null


func _ready() -> void:
    _host = get_parent()
    if _host == null:
        push_warning("[DoubleJump] 親ノードが存在しません。このコンポーネントは何もできません。")

    # パーティクルノードをキャッシュ
    if particle_node != NodePath():
        _particle = get_node_or_null(particle_node)
        if _particle == null:
            push_warning("[DoubleJump] particle_node で指定されたノードが見つかりません。パーティクルは再生されません。")

    # 初期化(接地していれば extra_jumps をリセット)
    _reset_extra_jumps_if_needed()


func _physics_process(_delta: float) -> void:
    # ホストが CharacterBody2D/3D を想定しているので、
    # is_on_floor() を持っていれば接地判定として使う。
    _reset_extra_jumps_if_needed()


func _reset_extra_jumps_if_needed() -> void:
    if _host == null:
        return

    # is_on_floor() を持っているか確認
    if not _host.has_method("is_on_floor"):
        return

    var on_floor := _host.is_on_floor()
    if on_floor:
        # 地上にいるなら、追加ジャンプ回数を全回復
        if _remaining_extra_jumps != max_extra_jumps:
            if debug_log:
                print("[DoubleJump] Reset extra jumps to ", max_extra_jumps)
        _remaining_extra_jumps = max_extra_jumps


## 入力側から呼び出す「ジャンプリクエスト」。
## 典型的にはホスト側の `_physics_process` で:
##   if Input.is_action_just_pressed("jump"):
##       if not double_jump.request_jump():
##           perform_jump() # 1段目の処理
##
## のように使います。
func request_jump() -> bool:
    if _host == null:
        return false

    # 2段ジャンプを許可できる状態かチェック
    if not _can_use_extra_jump():
        return false

    # 実際にホスト側のジャンプ関数を呼ぶ
    if not _host.has_method(jump_method_name):
        push_warning("[DoubleJump] ホストがジャンプメソッド '%s' を持っていません。" % jump_method_name)
        return false

    # ここでホストのジャンプを実行
    _host.call(jump_method_name)

    # 2段ジャンプを1回消費
    _remaining_extra_jumps -= 1
    if debug_log:
        print("[DoubleJump] Extra jump used. Remaining: ", _remaining_extra_jumps)

    # パーティクルを再生
    _play_particle()

    return true


func _can_use_extra_jump() -> bool:
    # まだ追加ジャンプが残っているか
    if _remaining_extra_jumps <= 0:
        return false

    # 空中でだけ許可する場合、接地中はNG
    if allow_only_in_air and _host != null and _host.has_method("is_on_floor"):
        if _host.is_on_floor():
            return false

    return true


func _play_particle() -> void:
    if _particle == null:
        return

    # パーティクルの位置をホストに合わせる
    if particle_follow_host and _host is Node2D and _particle is Node2D:
        (_particle as Node2D).global_position = (_host as Node2D).global_position
    elif particle_follow_host and _host is Node3D and _particle is Node3D:
        (_particle as Node3D).global_transform.origin = (_host as Node3D).global_transform.origin

    # パーティクルの再生処理
    # Godot 4 では `emitting` プロパティや `restart()` が使える。
    if "emitting" in _particle:
        _particle.emitting = false
        _particle.emitting = true

    if restart_particle_on_jump and _particle.has_method("restart"):
        _particle.call("restart")


## --- 便利メソッド群(任意で利用) ---

## 残りの追加ジャンプ回数を取得
func get_remaining_extra_jumps() -> int:
    return _remaining_extra_jumps


## 追加ジャンプ回数を外部からリセット(チェックポイントやアイテム取得時など)
func refill_extra_jumps() -> void:
    _remaining_extra_jumps = max_extra_jumps
    if debug_log:
        print("[DoubleJump] Extra jumps refilled to ", max_extra_jumps)

使い方の手順

ここでは 2D のプレイヤーを例にしますが、3D でも基本は同じです。

手順①: プレイヤー側に「通常ジャンプ関数」を用意する

プレイヤー(CharacterBody2D)に、1段目ジャンプを行う関数を用意します。
デフォルトでは perform_jump という名前を想定しているので、その名前で定義すると楽です。


# Player.gd (例)
extends CharacterBody2D

@export var jump_force: float = 400.0
@export var gravity: float = 900.0
@export var move_speed: float = 200.0

var double_jump: DoubleJump

func _ready() -> void:
    # 同じノード直下にアタッチした DoubleJump を取得
    double_jump = $DoubleJump

func _physics_process(delta: float) -> void:
    # 重力
    if not is_on_floor():
        velocity.y += gravity * delta
    else:
        # 着地時に縦速度をリセット
        if velocity.y > 0:
            velocity.y = 0

    # 左右移動
    var dir := Input.get_axis("move_left", "move_right")
    velocity.x = dir * move_speed

    # ジャンプ入力
    if Input.is_action_just_pressed("jump"):
        # まずは 2段ジャンプにお願いしてみる
        var used_extra := false
        if double_jump:
            used_extra = double_jump.request_jump()

        # 2段ジャンプが使えなかった場合は、通常ジャンプを試す
        if not used_extra and is_on_floor():
            perform_jump()

    move_and_slide()


func perform_jump() -> void:
    # 上向きに速度を与えるだけのシンプルなジャンプ処理
    velocity.y = -jump_force

手順②: シーンに DoubleJump コンポーネントを追加する

プレイヤーシーンの構成例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── JumpDust (GPUParticles2D)  ← 2段ジャンプ用パーティクル
 └── DoubleJump (Node)          ← 今回のコンポーネント

ポイント:

  • DoubleJump.gd を新規スクリプトとして作成し、Node にアタッチして DoubleJump ノードを生やす。
  • パーティクル用に GPUParticles2D(ここでは JumpDust)を用意しておく。

手順③: DoubleJump のエクスポート変数を設定する

エディタで DoubleJump ノードを選択し、インスペクタから:

  • jump_method_name: perform_jump(プレイヤー側の関数名)
  • max_extra_jumps: 1(標準の2段ジャンプ)
  • particle_node: ../JumpDust など、パーティクルノードへのパス
  • particle_follow_host: On(プレイヤーの位置に追従)

これで、プレイヤーは:

  • 地上でジャンプ … 1段目
  • 空中でもう一度ジャンプ … 2段目(DoubleJump が処理+パーティクル再生)

という挙動になります。

手順④: 敵や動く床にも「コピペなし」で再利用する

敵キャラにも 2 段ジャンプを与えたい場合、敵側も perform_jump を持っていれば、同じ DoubleJump コンポーネントをポン付けするだけで OK です。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── JumpDust (GPUParticles2D)
 └── DoubleJump (Node)

敵側のスクリプト例:


extends CharacterBody2D

@export var jump_force: float = 350.0

var double_jump: DoubleJump

func _ready() -> void:
    double_jump = $DoubleJump

func _physics_process(delta: float) -> void:
    # 何かのAIロジックでジャンプしたくなったとき:
    if should_jump_now():
        # まず2段ジャンプを試す
        if not double_jump.request_jump() and is_on_floor():
            perform_jump()

    move_and_slide()

func perform_jump() -> void:
    velocity.y = -jump_force

func should_jump_now() -> bool:
    # 適当なAI条件
    return false

このように、ジャンプの中身は各キャラクターが持ち、2段目の管理はコンポーネントに丸投げできるのがポイントです。


メリットと応用

DoubleJump コンポーネントを使うことで、次のようなメリットがあります。

  • プレイヤースクリプトがスリムになる
    2段ジャンプの回数管理やパーティクル制御がプレイヤーから消えるので、「移動」「攻撃」「入力処理」など他のロジックと混ざらずに済みます。
  • 再利用性が高い
    敵、動く足場、ギミックなど、CharacterBody2D/3D ならほぼそのまま使い回せます。
    perform_jump の中身を変えれば「ホバージャンプ」「ロケットジャンプ」などにも対応可能です。
  • シーン構造がフラットで見通しが良い
    「ジャンプ系の機能」は DoubleJump ノードに集約されるので、シーンツリーを見ただけで「このキャラは2段ジャンプできるんだな」と一目で分かります。
  • 継承に縛られない
    「2段ジャンプ付きプレイヤー」「2段ジャンプ付き敵」みたいなクラス階層を作らずに済むので、将来的な仕様変更にも強いです。

応用として、例えば「空中ダッシュ」と組み合わせるときも、AirDash コンポーネントを別途作り、Player に複数コンポーネントをアタッチするだけで済みます。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── JumpDust (GPUParticles2D)
 ├── DashTrail (GPUParticles2D)
 ├── DoubleJump (Node)
 └── AirDash (Node)

こういう「レゴブロック的な組み合わせ」ができるのが、コンポーネント指向の気持ちいいところですね。

改造案:ジャンプ直後だけ「ジャンプバッファ」を許可する

例えば、「落下中にジャンプボタンを押しておくと、着地直後に自動で2段ジャンプする」みたいな甘めの操作感を入れたい場合、DoubleJump に簡単なバッファ機能を追加できます。


# DoubleJump.gd のどこかに追加する例

@export_group("入力バッファ")
@export var jump_buffer_time: float = 0.1  # 秒
var _buffer_timer: float = 0.0

func _physics_process(delta: float) -> void:
    _reset_extra_jumps_if_needed()

    # バッファ時間のカウントダウン
    if _buffer_timer > 0.0:
        _buffer_timer -= delta
        # 着地していて、バッファが残っているなら自動でジャンプ
        if _buffer_timer >= 0.0 and _host and _host.has_method("is_on_floor") and _host.is_on_floor():
            if request_jump():
                _buffer_timer = 0.0

# 入力側から呼ぶ関数を少し拡張(元の request_jump をラップするイメージ)
func request_jump_with_buffer() -> bool:
    # まずは通常の request_jump を試す
    if request_jump():
        return true
    # 失敗したら、バッファに記録
    _buffer_timer = jump_buffer_time
    return false

プレイヤー側では double_jump.request_jump() の代わりに request_jump_with_buffer() を呼ぶようにすれば、
「ちょっと早めにボタンを押しても、着地直後に2段ジャンプが出る」気持ちいい操作感にできます。


こんな感じで、「2段ジャンプ」というよくある機能も、1つのコンポーネントに切り出すだけでシーン構造とコードの両方がかなりスッキリします。
継承ベースの巨大プレイヤークラスから卒業して、レゴブロック的に機能を足していく Godot ライフを楽しんでいきましょう。