【Cocos Creator 3.8】JumpBuffer(先行入力)の実装:アタッチするだけで「着地直前のジャンプ入力を自動で保持して着地と同時にジャンプ」させる汎用スクリプト

2Dアクションゲームでよくある「ジャンプ先行入力(ジャンプバッファ)」を、1つのコンポーネントとして簡単に使えるようにします。
この JumpBuffer をプレイヤーノードにアタッチし、インスペクタでキーバインドやジャンプ力、バッファ時間などを設定するだけで、着地の少し前にジャンプボタンを押しても、着地した瞬間に自動でジャンプする挙動を実現できます。


コンポーネントの設計方針

機能要件の整理

  • ジャンプボタン(キーボード or パッドボタン)を監視する。
  • ジャンプ可能でない状態(空中)でジャンプボタンが押された場合、
    • その入力を「バッファ」として一定時間保持する。
    • その後、プレイヤーが着地(地面に接触)した瞬間に、バッファが有効なうちならジャンプさせる。
  • バッファ時間を過ぎたら、保持していたジャンプ入力は破棄する。
  • 外部の GameManager や InputManager に依存せず、このコンポーネント単体で完結させる。
  • 物理挙動は RigidBody2D を利用し、ジャンプは linearVelocity を直接書き換えるシンプルな方式にする。
  • 地面判定は 2D コリジョンの接触情報から行う(タグやレイヤーで地面を判別)。

外部依存をなくすためのアプローチ

  • キーボード入力は systemEvent.on(SystemEventType.KEY_DOWN) / KEY_UP をこのコンポーネント内で直接購読。
  • ゲームパッド入力(任意)は Input.instance.getGamepad()update() 内で直接ポーリング。
  • ジャンプ実行もこのコンポーネントが RigidBody2D に直接アクセスして行う。
  • 「地面かどうか」の判定は、インスペクタで指定したタグ名を持つ Collider2D と接触しているかで判定する。
  • 必要な標準コンポーネント(RigidBody2D / Collider2D)は onLoad で取得を試み、見つからない場合は error ログを出す。

@property で設定可能なプロパティ

  • jumpKeyCode: KeyCode
    • ジャンプに使用するキーボードキー。
    • デフォルトは KeyCode.SPACE
  • jumpButtonIndex: number
    • ゲームパッドのボタンインデックス(0 以上)。
    • -1 の場合はゲームパッド入力を無効化する。
  • jumpVelocity: number
    • ジャンプ時に RigidBody2D.linearVelocity.y に設定する上向き速度。
    • 例:jumpVelocity = 12 など。
  • bufferTime: number
    • ジャンプボタンの先行入力を保持する時間(秒)。
    • 例:0.15(150ms 前の入力まで有効)。
  • coyoteTime: number
    • 「コヨーテタイム」(足場から落ちて少しの間だけジャンプ可能にする猶予時間・任意)。
    • 0 の場合は無効。
    • 例:0.1(100ms)。
  • groundTag: string
    • 「地面」とみなす Collider2D のタグ名。
    • 接触した Collider2D の tag 文字列と比較し、一致したものを地面として扱う。
    • タグを使わない場合は空文字にして、すべての接触を地面扱いにすることも可能。
  • maxJumpCount: number
    • 連続ジャンプの最大回数(1=シングルジャンプ、2=2段ジャンプ)。
    • バッファは「次に可能なジャンプ」に対して有効。
  • resetVerticalVelocityOnJump: boolean
    • ジャンプ時に現在の y 速度をリセットしてから jumpVelocity を設定するかどうか。
    • オンにすると、落下中にジャンプしても一定の高さになりやすい。

TypeScriptコードの実装


import {
    _decorator,
    Component,
    Node,
    systemEvent,
    SystemEventType,
    EventKeyboard,
    KeyCode,
    input,
    Input,
    EventGamepad,
    RigidBody2D,
    Vec2,
    Collider2D,
    IPhysics2DContact,
    game,
} from 'cc';

const { ccclass, property } = _decorator;

/**
 * JumpBuffer
 * - ジャンプボタンの先行入力(バッファ)とコヨーテタイムをサポートする汎用コンポーネント
 * - このコンポーネントがアタッチされた Node に RigidBody2D と Collider2D があることを前提とする
 */
@ccclass('JumpBuffer')
export class JumpBuffer extends Component {

    @property({
        type: KeyCode,
        tooltip: 'ジャンプに使用するキーボードキー。\n例: SPACE, KEY_Z など。',
    })
    public jumpKeyCode: KeyCode = KeyCode.SPACE;

    @property({
        tooltip: 'ゲームパッドのボタンインデックス。\n-1 の場合はゲームパッド入力を無効にします。'
    })
    public jumpButtonIndex: number = -1;

    @property({
        tooltip: 'ジャンプ時に与える上向き速度(RigidBody2D.linearVelocity.y)。'
    })
    public jumpVelocity: number = 12;

    @property({
        tooltip: 'ジャンプ先行入力(バッファ)を保持する時間(秒)。\n例: 0.15 なら 150ms 前の入力まで有効。'
    })
    public bufferTime: number = 0.15;

    @property({
        tooltip: 'コヨーテタイム(地面から離れてもこの秒数だけジャンプ可能)。\n0 で無効。'
    })
    public coyoteTime: number = 0.1;

    @property({
        tooltip: '「地面」とみなす Collider2D のタグ名。\n空文字の場合は、接触したすべてのコライダーを地面扱いにします。'
    })
    public groundTag: string = 'Ground';

    @property({
        tooltip: '連続ジャンプの最大回数。\n1 = シングルジャンプ, 2 = 2段ジャンプ など。'
    })
    public maxJumpCount: number = 1;

    @property({
        tooltip: 'ジャンプ時に現在の垂直速度(y)を 0 にリセットしてから jumpVelocity を適用するかどうか。'
    })
    public resetVerticalVelocityOnJump: boolean = true;

    // 内部状態
    private _rb2d: RigidBody2D | null = null;
    private _collider: Collider2D | null = null;

    private _isGrounded: boolean = false;
    private _groundContactCount: number = 0;

    private _lastGroundedTime: number = 0;       // 最後に地面にいた時刻(秒)
    private _lastJumpPressedTime: number = -999; // 最後にジャンプボタンが押された時刻(秒)
    private _jumpPressed: boolean = false;       // 現在ジャンプボタンが押されているか(キーボード)
    private _gamepadButtonPrevPressed: boolean = false;

    private _currentJumpCount: number = 0;       // 現在のジャンプ回数(地面にいるとき 0)

    onLoad() {
        // 必要なコンポーネントを取得
        this._rb2d = this.getComponent(RigidBody2D);
        if (!this._rb2d) {
            console.error('[JumpBuffer] RigidBody2D が見つかりません。このノードに RigidBody2D を追加してください。');
        }

        this._collider = this.getComponent(Collider2D);
        if (!this._collider) {
            console.error('[JumpBuffer] Collider2D が見つかりません。このノードに Collider2D を追加してください。');
        } else {
            // 衝突イベントの購読
            this._collider.on('onBeginContact', this._onBeginContact, this);
            this._collider.on('onEndContact', this._onEndContact, this);
        }

        // キーボード入力の購読
        systemEvent.on(SystemEventType.KEY_DOWN, this._onKeyDown, this);
        systemEvent.on(SystemEventType.KEY_UP, this._onKeyUp, this);
    }

    onDestroy() {
        // イベントの解除
        systemEvent.off(SystemEventType.KEY_DOWN, this._onKeyDown, this);
        systemEvent.off(SystemEventType.KEY_UP, this._onKeyUp, this);

        if (this._collider) {
            this._collider.off('onBeginContact', this._onBeginContact, this);
            this._collider.off('onEndContact', this._onEndContact, this);
        }
    }

    start() {
        // 初期状態の更新
        this._updateGroundedState(0);
    }

    update(deltaTime: number) {
        const now = game.totalTime / 1000; // ミリ秒 → 秒

        // ゲームパッド入力のチェック(任意)
        this._checkGamepadInput(now);

        // 地面接触状態からコヨーテタイムを考慮した「ジャンプ可能か」を判定
        const canJump = this._canJump(now);

        // 先行入力(バッファ)の有効性チェック
        const hasBufferedJump = this._hasBufferedJump(now);

        // 条件を満たしていればジャンプを実行
        if (canJump && hasBufferedJump) {
            this._performJump();
        }
    }

    //=====================
    // 入力処理
    //=====================

    private _onKeyDown(event: EventKeyboard) {
        if (event.keyCode === this.jumpKeyCode) {
            this._jumpPressed = true;
            this._lastJumpPressedTime = game.totalTime / 1000;
        }
    }

    private _onKeyUp(event: EventKeyboard) {
        if (event.keyCode === this.jumpKeyCode) {
            this._jumpPressed = false;
        }
    }

    private _checkGamepadInput(now: number) {
        if (this.jumpButtonIndex < 0) {
            return;
        }

        const gamepads = Input.instance.getGamepads();
        if (!gamepads || gamepads.length === 0) {
            this._gamepadButtonPrevPressed = false;
            return;
        }

        const pad = gamepads[0];
        if (!pad) {
            this._gamepadButtonPrevPressed = false;
            return;
        }

        const buttons = pad.buttonStates;
        if (!buttons || this.jumpButtonIndex >= buttons.length) {
            this._gamepadButtonPrevPressed = false;
            return;
        }

        const pressed = buttons[this.jumpButtonIndex].pressed;

        // 立ち上がりエッジでのみバッファ登録
        if (pressed && !this._gamepadButtonPrevPressed) {
            this._lastJumpPressedTime = now;
        }

        this._gamepadButtonPrevPressed = pressed;
    }

    //=====================
    // 地面判定
    //=====================

    private _onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        if (this._isGroundCollider(otherCollider)) {
            this._groundContactCount++;
            if (this._groundContactCount > 0) {
                if (!this._isGrounded) {
                    this._setGrounded(true);
                }
            }
        }
    }

    private _onEndContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        if (this._isGroundCollider(otherCollider)) {
            this._groundContactCount--;
            if (this._groundContactCount <= 0) {
                this._groundContactCount = 0;
                if (this._isGrounded) {
                    this._setGrounded(false);
                }
            }
        }
    }

    private _isGroundCollider(other: Collider2D): boolean {
        if (!other) return false;
        if (!this.groundTag || this.groundTag === '') {
            // groundTag が空の場合、すべての接触を地面扱い
            return true;
        }
        return other.tag === this.groundTag;
    }

    private _setGrounded(value: boolean) {
        this._isGrounded = value;
        if (value) {
            this._currentJumpCount = 0;
            this._lastGroundedTime = game.totalTime / 1000;
        }
    }

    private _updateGroundedState(now: number) {
        // 明示的に呼び出したい場合のために残しているが、
        // 実際の更新は衝突コールバックで行っている。
        if (this._isGrounded) {
            this._lastGroundedTime = now;
        }
    }

    //=====================
    // ジャンプ判定と実行
    //=====================

    private _canJump(now: number): boolean {
        if (!this._rb2d) {
            return false;
        }

        // まだ最大ジャンプ回数に達していないか
        if (this._currentJumpCount >= this.maxJumpCount) {
            return false;
        }

        // 地面にいる or コヨーテタイム内なら OK
        if (this._isGrounded) {
            return true;
        }

        if (this.coyoteTime > 0) {
            const timeSinceGrounded = now - this._lastGroundedTime;
            if (timeSinceGrounded <= this.coyoteTime) {
                return true;
            }
        }

        // 空中での追加ジャンプ(2段ジャンプなど)
        if (this._currentJumpCount > 0 && this._currentJumpCount < this.maxJumpCount) {
            return true;
        }

        return false;
    }

    private _hasBufferedJump(now: number): boolean {
        const timeSincePressed = now - this._lastJumpPressedTime;
        return timeSincePressed <= this.bufferTime;
    }

    private _performJump() {
        if (!this._rb2d) {
            console.error('[JumpBuffer] RigidBody2D がないためジャンプできません。');
            return;
        }

        // 現在の速度を取得
        const v = this._rb2d.linearVelocity.clone();

        if (this.resetVerticalVelocityOnJump) {
            v.y = 0;
        }

        // 上向き速度を設定
        v.y = this.jumpVelocity;
        this._rb2d.linearVelocity = v;

        // ジャンプ回数を更新
        this._currentJumpCount++;

        // このジャンプに対応したバッファを消費(2回連続でジャンプしないように)
        this._lastJumpPressedTime = -999;
    }
}

コードの主要部分の解説

  • onLoad
    • RigidBody2DCollider2D を取得し、存在しなければ console.error で警告。
    • Collider2D が取れた場合は onBeginContact / onEndContact を購読して地面判定に使う。
    • systemEvent でキーボードの KEY_DOWN / KEY_UP を購読。
  • update
    • 現在時刻 nowgame.totalTime / 1000 から取得。
    • ゲームパッド入力をチェックし、ジャンプボタンの立ち上がりで _lastJumpPressedTime を更新。
    • _canJump(now) で「ジャンプ可能か」(地面 or コヨーテタイム or 空中ジャンプ)を判定。
    • _hasBufferedJump(now) で「バッファ内のジャンプ入力があるか」を判定。
    • 両方満たしていれば _performJump() を呼び出してジャンプを実行。
  • 地面判定
    • _onBeginContact / _onEndContact で、接触している「地面」コライダーの数をカウント。
    • groundTag が空文字なら、すべての接触を地面扱いにする簡易モード。
    • 地面に初めて接触したタイミングで _setGrounded(true) を呼び、_currentJumpCount を 0 にリセット。
    • 地面から完全に離れたら _setGrounded(false) を呼ぶ。
  • ジャンプバッファ
    • キーボード or ゲームパッドでジャンプボタンが押された瞬間に _lastJumpPressedTime を更新。
    • _hasBufferedJump では「現在時刻 – 最後に押された時刻 <= bufferTime」なら有効とみなす。
    • ジャンプを実行したら _lastJumpPressedTime を大きく負の値にしてバッファを消費。
  • コヨーテタイム
    • 地面にいる間は _lastGroundedTime を更新。
    • 地面から離れた後も、now - _lastGroundedTime <= coyoteTime ならジャンプ可能とする。

使用手順と動作確認

1. スクリプトファイルの作成

  1. エディタ下部の Assets パネルで、スクリプトを置きたいフォルダ(例:assets/scripts)を選択します。
  2. フォルダ上で右クリック → CreateTypeScript を選択します。
  3. 新しく作成されたファイル名を JumpBuffer.ts に変更します。
  4. JumpBuffer.ts をダブルクリックして開き、既存の中身をすべて削除し、前述のコードをそのまま貼り付けて保存します。

2. テスト用プレイヤーノードの準備

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、テスト用のプレイヤーノードを作成します。
    • 分かりやすいように名前を Player に変更しておきましょう。
  2. Inspector パネルで、Player ノードを選択した状態で以下を追加します:
    1. Add ComponentPhysics 2DRigidBody2D
      • TypeDynamic に設定します。
    2. Add ComponentPhysics 2DBoxCollider2D(または CircleCollider2D)
      • Sprite のサイズに合わせて Size を調整します。

3. 地面(Ground)ノードの作成

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、地面用のノードを作成します。
  2. 名前を Ground に変更し、InspectorTransform
    • ScaleSize を横長の床になるように調整します。
    • Position.y を 0 や -200 など、プレイヤーが落ちて乗れる位置に設定します。
  3. Inspector で以下のコンポーネントを追加します:
    1. Add ComponentPhysics 2DRigidBody2D
      • TypeStatic に設定します。
    2. Add ComponentPhysics 2DBoxCollider2D
  4. Ground の Collider2D にタグを設定
    • Ground ノードの BoxCollider2D コンポーネントを選択し、Tag フィールドに Ground と入力します。
    • これは JumpBuffer コンポーネントの groundTag と一致させるためです。

4. JumpBuffer コンポーネントをアタッチ

  1. HierarchyPlayer ノードを選択します。
  2. Inspector の下部で Add ComponentCustomJumpBuffer を選択します。
  3. JumpBuffer コンポーネントのプロパティを設定します:
    • Jump Key CodeSPACE(デフォルトのままで OK)
    • Jump Button Index-1(ゲームパッドを使う場合は 0 などに変更)
    • Jump Velocity12(ゲームのスケールに応じて 8~20 程度で調整)
    • Buffer Time0.15(0.1~0.2 の範囲がおすすめ)
    • Coyote Time0.1(任意。不要なら 0)
    • Ground TagGround(先ほど地面コライダーに設定したタグと一致させる)
    • Max Jump Count1(2段ジャンプを試したい場合は 2 に)
    • Reset Vertical Velocity On Jump:チェックを入れる(オン)

5. シーンの物理設定を確認

  • ProjectProject SettingsPhysics 2D を開き、物理が有効になっていることを確認します。
  • 重力(Gravity)が適切な値(例:(0, -10) など)になっているかを確認します。

6. 動作確認

  1. シーンを保存し、上部の Play ボタン(▶)を押して再生します。
  2. ゲーム画面でプレイヤーが地面の上に落ちて静止することを確認します。
  3. 通常ジャンプの確認
    • プレイヤーが地面にいる状態で SPACE キーを押すと、指定した jumpVelocity でジャンプするはずです。
  4. 先行入力(バッファ)の確認
    • プレイヤーが空中から地面に落ちてくるタイミングで、着地より少し早めに SPACE キーを押します。
    • キーを押した時点では空中なので通常ならジャンプできませんが、このコンポーネントが入力をバッファしているため、着地した瞬間に自動でジャンプするはずです。
    • バッファ時間 bufferTime を 0.05 にしてみると、かなりシビアな先行入力に、0.25 にするとかなり余裕のある先行入力になります。
  5. コヨーテタイムの確認(任意)
    • プレイヤーを小さな足場の端まで歩かせ、足場から落ちた直後に SPACE を押します。
    • coyoteTime を 0.1 にしている場合、足場から少し離れていてもジャンプが発動することを確認できます。
  6. 2段ジャンプの確認(任意)
    • JumpBufferMax Jump Count を 2 に変更します。
    • プレイヤーをジャンプさせ、空中でもう一度 SPACE を押すと 2 段目のジャンプが発動します。
    • このときも、空中でのジャンプボタン押下に対してバッファが効くため、次に可能なジャンプタイミングで自動発動します。

まとめ

この JumpBuffer コンポーネントを使うことで、

  • 先行入力(ジャンプバッファ)
  • コヨーテタイム
  • 最大ジャンプ回数(2段ジャンプなど)
  • キーボード / ゲームパッド両対応

といった、2Dアクションゲームのジャンプに必要な要素を、1つのスクリプトをプレイヤーノードにアタッチするだけでまとめて実現できます。

このスクリプトは他のカスタムスクリプトに依存していないため、

  • 別プロジェクトへのコピペ
  • 他のキャラクターへの流用(別の Node にアタッチしてパラメータだけ変える)
  • プロトタイプ段階での素早い調整

に非常に向いています。
ジャンプの「気持ちよさ」はアクションゲームのプレイ感を大きく左右します。
bufferTimecoyoteTimejumpVelocity をゲームに合わせて調整しながら、理想のジャンプフィールを作り込んでみてください。