【Cocos Creator 3.8】JumpController の実装:アタッチするだけで「ジャンプボタン入力+接地判定付きジャンプ」を実現する汎用スクリプト

2D/3D プラットフォーマーやアクションゲームで必須の「ジャンプ」。本記事では、Rigidbody2D を持つノードにアタッチするだけで、ジャンプボタン入力を検知し、接地中のみ上方向に速度を与えてジャンプさせる汎用コンポーネント JumpController を実装します。外部の GameManager などには一切依存せず、インスペクタからキー設定やジャンプ力、接地判定方法を調整できるようにします。


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

実現したい機能

  • ジャンプボタン(キーボード or タッチ)入力を検知する。
  • 親ノード(自身のノード)が「床にいる(接地している)」ときだけジャンプできる。
  • ジャンプ時には Rigidbody2D に上向きの速度(velocity)を与える。
  • 接地判定は「足元の小さなレイキャスト(下方向に Line)」「または Rigidbody2D の接触イベント」を使う。
  • すべての設定はインスペクタから行え、他のカスタムスクリプトへの依存はゼロ。

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

  • 必須コンポーネントは Rigidbody2D のみとし、onLoadgetComponent による取得と存在チェックを行う。
  • 接地判定には PhysicsSystem2D のレイキャスト を用い、床レイヤーや判定距離をプロパティ化する。
  • 入力は Input キーイベント と、任意で タッチ/マウスクリック に対応する。
  • ジャンプの「クールタイム」や「ボタンを押した瞬間だけ反応(押しっぱなし無視)」などもプロパティで制御できるようにする。

インスペクタで設定可能なプロパティ設計

以下のようなプロパティを用意します。

  • jumpKeyKeyCode
    • ジャンプに使用するキーボードキー。
    • 例: KeyCode.SPACE(デフォルト)。
  • enableTouchJump(boolean)
    • true の場合、画面タップ/クリックでもジャンプを行う。
  • jumpVelocity(number)
    • ジャンプ時に Rigidbody2D に与える上方向の速度。
    • 正の値で上方向。例: 8 ~ 15 程度。
  • overrideHorizontalVelocity(boolean)
    • true の場合、ジャンプ時に X 方向の速度を 0 にリセットする。
    • 横移動と組み合わせる場合、X 速度を保持したいなら false にする。
  • groundCheckDistance(number)
    • 接地判定用のレイキャスト距離(ノードの位置から下向き)。
    • 例: 0.1 ~ 0.3 くらい。キャラの足元から床までの隙間に合わせて調整。
  • groundLayerMask(number)
    • 床として判定する 2D 物理レイヤーのビットマスク。
    • 例: 1 << 1(デフォルトで DEFAULT が 1、UI が 2 などプロジェクト設定に依存)。
    • インスペクタ上では数値で指定するが、1 << レイヤー番号 で計算する。
  • groundCheckOffsetY(number)
    • レイキャストの開始位置をノードの中心からどれだけ下にずらすか。
    • 例: -0.5 なら、ノードの中心より 0.5 下からレイを飛ばす。
  • allowAirJump(boolean)
    • true の場合、接地していなくても一定回数まで空中ジャンプを許可する。
  • maxAirJumpCount(number)
    • 空中ジャンプを許可する回数(通常のジャンプを除く)。
    • 例: 1 なら二段ジャンプ、2 なら三段ジャンプ。
  • jumpCooldown(number)
    • 連続ジャンプを防ぐためのクールタイム(秒)。
    • 0 ならクールタイムなし。
  • usePressOnce(boolean)
    • true の場合、ボタンを押した瞬間だけジャンプし、押しっぱなしでは連続ジャンプをしない。

TypeScriptコードの実装

以下が完成した JumpController.ts の全コードです。


import {
    _decorator,
    Component,
    Node,
    RigidBody2D,
    Vec2,
    PhysicsSystem2D,
    EPhysics2DDrawFlags,
    ERaycast2DType,
    Contact2DType,
    IPhysics2DContact,
    Collider2D,
    input,
    Input,
    EventKeyboard,
    EventTouch,
    EventMouse,
    KeyCode,
    Vec3,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * JumpController
 * - Rigidbody2D を持つノードにアタッチするだけで、
 *   キー入力/タッチ入力に応じてジャンプさせる汎用コンポーネント。
 * - 接地判定は PhysicsSystem2D のレイキャストで行う。
 */
@ccclass('JumpController')
export class JumpController extends Component {

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

    @property({
        tooltip: 'true の場合、画面タップ/クリックでもジャンプします。'
    })
    public enableTouchJump: boolean = true;

    @property({
        tooltip: 'ジャンプ時に与える上方向の速度(positive で上方向)。\nRigidbody2D の LinearVelocity.y にこの値を設定します。',
    })
    public jumpVelocity: number = 10;

    @property({
        tooltip: 'true の場合、ジャンプ時に X 方向の速度を 0 にリセットします。\n横移動スクリプトと併用する場合は false 推奨。'
    })
    public overrideHorizontalVelocity: boolean = false;

    @property({
        tooltip: '接地判定に使用するレイキャスト距離(ノード位置から下方向)。\nキャラクターの足元のサイズに合わせて調整してください。',
        min: 0.01,
    })
    public groundCheckDistance: number = 0.2;

    @property({
        tooltip: '接地判定に使用するレイキャストの開始位置のオフセット(Y)。\n負の値でノードの中心より下からレイを飛ばします。',
    })
    public groundCheckOffsetY: number = -0.5;

    @property({
        tooltip: '床として扱う 2D 物理レイヤーのビットマスク。\n例: レイヤー1を床にしたい場合は 1 << 1 = 2 を指定。',
    })
    public groundLayerMask: number = 0xffffffff; // デフォルトは全レイヤー

    @property({
        tooltip: 'true の場合、空中でもジャンプ可能になります(多段ジャンプ)。',
    })
    public allowAirJump: boolean = false;

    @property({
        tooltip: '許可する空中ジャンプの回数(通常のジャンプは含まない)。\n例: 1 で二段ジャンプ、2 で三段ジャンプ。',
        min: 0,
    })
    public maxAirJumpCount: number = 1;

    @property({
        tooltip: 'ジャンプのクールタイム(秒)。\n0 でクールタイムなし。',
        min: 0,
    })
    public jumpCooldown: number = 0.0;

    @property({
        tooltip: 'true の場合、ボタンを押した瞬間だけジャンプします。\n押しっぱなしでは連続ジャンプしません。',
    })
    public usePressOnce: boolean = true;

    @property({
        tooltip: 'デバッグ用にレイキャストと接地状態をログ表示します。\n開発中のみ true 推奨。',
    })
    public debugLog: boolean = false;

    private _rigidbody: RigidBody2D | null = null;
    private _isGrounded: boolean = false;
    private _airJumpCount: number = 0;
    private _lastJumpTime: number = -999;
    private _jumpKeyPressed: boolean = false;

    onLoad() {
        // 必須コンポーネントの取得
        this._rigidbody = this.getComponent(RigidBody2D);
        if (!this._rigidbody) {
            console.error(
                '[JumpController] このコンポーネントを使用するには、同じノードに RigidBody2D コンポーネントが必要です。',
                this.node.name
            );
        }

        // 入力イベント登録
        input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.on(Input.EventType.KEY_UP, this._onKeyUp, this);

        if (this.enableTouchJump) {
            input.on(Input.EventType.TOUCH_START, this._onTouchStart, this);
            input.on(Input.EventType.MOUSE_DOWN, this._onMouseDown, this);
        }

        // 2D物理のデバッグ描画(必要に応じて有効化)
        // PhysicsSystem2D.instance.debugDrawFlags = EPhysics2DDrawFlags.All;
    }

    start() {
        // 開始時に接地状態を初期チェック
        this._updateGroundedState();
        this._resetAirJumpCountIfGrounded();
    }

    onDestroy() {
        // 入力イベント解除
        input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.off(Input.EventType.KEY_UP, this._onKeyUp, this);

        if (this.enableTouchJump) {
            input.off(Input.EventType.TOUCH_START, this._onTouchStart, this);
            input.off(Input.EventType.MOUSE_DOWN, this._onMouseDown, this);
        }
    }

    update(dt: number) {
        if (!this._rigidbody) {
            return;
        }

        // 毎フレーム接地判定を更新
        this._updateGroundedState();
        this._resetAirJumpCountIfGrounded();

        // 入力に応じてジャンプ試行
        this._handleJumpInput();
    }

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

    private _onKeyDown(event: EventKeyboard) {
        if (event.keyCode === this.jumpKey) {
            if (this.usePressOnce) {
                this._jumpKeyPressed = true;
            } else {
                // 押しっぱなしでもジャンプを許可する場合は、ここで直接ジャンプ処理を呼ぶ
                this._jumpKeyPressed = true;
            }
        }
    }

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

    private _onTouchStart(event: EventTouch) {
        if (!this.enableTouchJump) return;
        this._jumpKeyPressed = true;
    }

    private _onMouseDown(event: EventMouse) {
        if (!this.enableTouchJump) return;
        this._jumpKeyPressed = true;
    }

    private _consumeJumpPress(): boolean {
        if (!this._jumpKeyPressed) {
            return false;
        }

        if (this.usePressOnce) {
            // 一度処理したらフラグを落とす
            this._jumpKeyPressed = false;
        }
        return true;
    }

    //======================
    // ジャンプ処理
    //======================

    private _handleJumpInput() {
        if (!this._rigidbody) return;

        // 入力がなければ何もしない
        if (!this._consumeJumpPress()) {
            return;
        }

        const now = performance.now() / 1000; // 秒単位
        if (this.jumpCooldown > 0) {
            const elapsed = now - this._lastJumpTime;
            if (elapsed < this.jumpCooldown) {
                if (this.debugLog) {
                    console.log('[JumpController] クールタイム中のためジャンプ不可。残り秒数:', this.jumpCooldown - elapsed);
                }
                return;
            }
        }

        // 接地中 or 空中ジャンプ許可チェック
        if (this._isGrounded) {
            this._doJump();
            this._lastJumpTime = now;
            this._airJumpCount = 0; // 地上からのジャンプでリセット
        } else if (this.allowAirJump && this._airJumpCount < this.maxAirJumpCount) {
            this._doJump();
            this._lastJumpTime = now;
            this._airJumpCount++;
            if (this.debugLog) {
                console.log('[JumpController] 空中ジャンプ:', this._airJumpCount, '/', this.maxAirJumpCount);
            }
        } else {
            if (this.debugLog) {
                console.log('[JumpController] ジャンプ不可(未接地 or 空中ジャンプ回数上限)。');
            }
        }
    }

    private _doJump() {
        if (!this._rigidbody) return;

        const currentVel = this._rigidbody.linearVelocity.clone();

        let newVelX = currentVel.x;
        if (this.overrideHorizontalVelocity) {
            newVelX = 0;
        }

        const newVel = new Vec2(newVelX, this.jumpVelocity);
        this._rigidbody.linearVelocity = newVel;

        if (this.debugLog) {
            console.log('[JumpController] ジャンプ実行。velocity:', newVel);
        }
    }

    //======================
    // 接地判定
    //======================

    private _updateGroundedState() {
        const physics = PhysicsSystem2D.instance;
        if (!physics) {
            console.error('[JumpController] PhysicsSystem2D が有効になっていません。2D Physics を有効にしてください。');
            this._isGrounded = false;
            return;
        }

        const worldPos = this.node.worldPosition;
        const start = new Vec2(worldPos.x, worldPos.y + this.groundCheckOffsetY);
        const end = new Vec2(start.x, start.y - this.groundCheckDistance);

        const results = physics.raycast(
            start,
            end,
            ERaycast2DType.Closest,
            this.groundLayerMask
        );

        const wasGrounded = this._isGrounded;
        this._isGrounded = results.length > 0;

        if (this.debugLog && wasGrounded !== this._isGrounded) {
            console.log('[JumpController] 接地状態変更:', this._isGrounded, ' レイ結果数:', results.length);
        }
    }

    private _resetAirJumpCountIfGrounded() {
        if (this._isGrounded) {
            if (this._airJumpCount !== 0) {
                this._airJumpCount = 0;
                if (this.debugLog) {
                    console.log('[JumpController] 接地により空中ジャンプ回数をリセット。');
                }
            }
        }
    }
}

コードの要点解説

  • onLoad
    • RigidBody2D を取得し、存在しない場合は console.error を出力。
    • input.on でキーボード・タッチ・マウスの入力イベントを登録。
  • start
    • 開始時に一度だけ接地判定を行い、空中ジャンプカウンタを初期化。
  • update
    • 毎フレーム _updateGroundedState() でレイキャストによる接地判定。
    • _resetAirJumpCountIfGrounded() で地上に戻ったら空中ジャンプ回数をリセット。
    • _handleJumpInput() で入力フラグに応じてジャンプ処理。
  • 入力処理
    • _onKeyDown, _onKeyUp, _onTouchStart, _onMouseDown_jumpKeyPressed フラグを制御。
    • usePressOncetrue の場合、押した瞬間だけ有効にするため、_consumeJumpPress() 内でフラグを落とす。
  • ジャンプ処理
    • _handleJumpInput() でクールタイム・接地状態・空中ジャンプ回数をチェック。
    • 条件を満たした場合 _doJump() を呼び、Rigidbody2D.linearVelocityjumpVelocity を設定。
    • overrideHorizontalVelocitytrue の場合は X 速度を 0 にリセット。
  • 接地判定
    • PhysicsSystem2D.raycast を下方向に飛ばし、結果が 1 件以上あれば接地とみなす。
    • groundCheckOffsetYgroundCheckDistance でレイの開始位置と長さを調整可能。
    • groundLayerMask で床レイヤーを制限(不要ならデフォルトの全レイヤーで OK)。

使用手順と動作確認

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

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

2. テスト用ノード(プレイヤー)の作成

ここでは 2D プロジェクトを想定します。

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、テスト用のプレイヤーノードを作成します。
  2. 作成されたノードの名前を Player などに変更します。
  3. InspectorPlayer ノードを選択し、以下のコンポーネントを追加します:
    • Add ComponentPhysics 2DRigidBody2D
    • 必要に応じて Collider2D(例: BoxCollider2D)も追加し、形状をスプライトに合わせて調整します。
  4. RigidBody2D の設定例:
    • Body Type: Dynamic
    • Gravity Scale: 1(または好みで調整)
    • 他の項目はデフォルトで問題ありません。

3. 床(Ground)ノードの作成

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、床用ノードを作成します。
  2. 名前を Ground に変更し、Transform で位置とスケールを調整して足場になるようにします。
  3. InspectorGround を選択し、以下を追加します:
    • Add ComponentPhysics 2DRigidBody2D
    • Add ComponentPhysics 2DBoxCollider2D(など)
  4. Ground の RigidBody2D 設定:
    • Body Type: Static(床は動かないので Static 推奨)
  5. 床用の 2D 物理レイヤーを分けたい場合:
    • メニューから ProjectProject SettingsPhysics 2DLayer Collision Matrix を開きます。
    • 任意のレイヤー番号(例: 1 番)を Ground 用に使うと決め、Ground ノードの Collider2DGroup をそのレイヤーに設定します。
    • 後で JumpController.groundLayerMask1 << レイヤー番号 に設定して床だけを判定できるようにします。

4. JumpController のアタッチ

  1. HierarchyPlayer ノードを選択します。
  2. Inspector の下部で Add ComponentCustomJumpController を選択します。
  3. これで Player ノードに JumpController がアタッチされます。

5. インスペクタでプロパティを設定

Player ノードの JumpController を選択し、以下のように設定します(例):

  • jumpKey: SPACE(デフォルトのままで OK)
  • enableTouchJump: true(エディタ上でマウスクリックでもジャンプさせたい場合)
  • jumpVelocity: 10(重力やキャラサイズに応じて 8 ~ 15 の間で調整)
  • overrideHorizontalVelocity: false(将来横移動を追加する前提なら false 推奨)
  • groundCheckDistance: 0.2
  • groundCheckOffsetY: -0.5(キャラの足元に合わせて微調整)
  • groundLayerMask:
    • 床のレイヤーを特に分けていない場合: 0xffffffff(全レイヤー)
    • 床をレイヤー1にした場合: 2(= 1 << 1
  • allowAirJump: false(まずはシンプルに 1 段ジャンプから)
  • maxAirJumpCount: 1(allowAirJump を true にする場合は 1 で二段ジャンプ)
  • jumpCooldown: 0(まずはクールタイムなしで挙動確認)
  • usePressOnce: true(押しっぱなしで連打されないように)
  • debugLog: true(最初は true にしてコンソールログで接地判定を確認すると安心)

6. 動作確認

  1. 上部メニューから ProjectPlay(または再生ボタン)を押してゲームを実行します。
  2. ゲームビューで Player が床の上に乗っていることを確認します。
  3. キーボードの Space キーを押すと、Player が上方向にジャンプするはずです。
  4. 床に着地すると再び Space キーでジャンプできることを確認します。
  5. allowAirJumptrue にして maxAirJumpCount1 にすると、空中でさらに 1 回ジャンプできる(二段ジャンプ)ことを確認しましょう。
  6. デバッグログを有効にしている場合、接地状態の切り替わりやジャンプ実行時に Console パネルにログが出力されます。

まとめ

本記事では、JumpController コンポーネントを実装し、

  • Rigidbody2D を持つノードにアタッチするだけでジャンプ機能を追加できる
  • 接地判定はレイキャストにより自己完結して行う
  • キー/タッチ入力、ジャンプ力、空中ジャンプ、クールタイムなどをインスペクタから柔軟に調整できる

という構成を実現しました。

このコンポーネントは完全に独立しており、外部の GameManager やシングルトンに一切依存しません。
そのため、今後別のプロジェクトでも JumpController.tsコピーしてアタッチするだけで、すぐにジャンプ機能を再利用できます。

応用例としては:

  • 横移動用の MoveController と組み合わせて 2D プラットフォーマーの基本移動を構築
  • 空中ジャンプ回数を増やしてアクション性の高いゲームを実現
  • 接地判定の距離やオフセットを調整して、キャラのサイズが異なる複数プレイヤーにそのまま使い回す

このような「インスペクタ完結・外部依存なし」の汎用コンポーネントを積み重ねていくことで、Cocos Creator プロジェクトの再利用性と保守性が大きく向上します。ぜひ自分のプロジェクト用にカスタマイズしつつ、共通ライブラリとして育ててみてください。