【Cocos Creator 3.8】CoyoteTime の実装:アタッチするだけで「足場から落ちた直後もジャンプ可能」にする汎用スクリプト

2D/3Dの横スクロールアクションでよくある「コヨーテタイム(Coyote Time)」を、1コンポーネントだけで実現します。
このコンポーネントをプレイヤーキャラのノードにアタッチするだけで、「足場(接地状態)から離れてから一定時間だけジャンプ入力を受け付ける」挙動を実装できます。

ジャンプ処理そのもの(上方向の速度を与える・アニメーションを再生する等)は、このコンポーネント内で完結させており、他のGameManagerや外部スクリプトには一切依存しません
インスペクタで「コヨーテタイムの長さ」「ジャンプキー」「ジャンプ力」「接地判定のレイキャスト設定」などを調整可能にし、どのプロジェクトにもそのまま持ち込んで使えるようにします。


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

1. 機能要件の整理

  • ノードが「地面に接地しているかどうか」を自前のレイキャストで判定する。
  • 接地している間は「地上状態」とし、離れた瞬間に「空中状態」へ遷移する。
  • 空中状態になってから一定時間だけ「コヨーテタイム有効」としてジャンプ入力を受け付ける。
  • その時間を過ぎたら、ジャンプ入力は無効にする。
  • ジャンプ入力が押されたら、Rigidbody2D に上方向の速度を与えてジャンプさせる。
  • ジャンプは「接地中」または「コヨーテタイム中」にだけ許可する。
  • 物理挙動は Box2D(Rigidbody2D)に任せる。

重要:このコンポーネントは完全に独立して動作するため、他のスクリプトから「ジャンプして」などと呼ばれることを前提にしません
「ジャンプキー入力の取得」「接地判定」「ジャンプの物理処理」を全てこのコンポーネント内部で行います。

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

  • 入力: Cocos Creator の input.on API を直接利用し、指定キー(例: Space, Z, X など)を監視。
  • 物理: 自身のノードにアタッチされた Rigidbody2D を取得してジャンプ力を付与。
  • 接地判定: 自身のノードの位置から下方向にレイキャストして、一定距離内にコライダーがあれば接地とみなす。
  • 時間管理: Cocos の deltaTime を使って経過時間を管理し、コヨーテタイムの残り時間をカウントダウン。

防御的実装:
Rigidbody2D が見つからない場合は console.error でエラーを出し、動作を停止します。
また、物理レイヤーやレイキャスト距離の設定はプロパティ化して、インスペクタから調整できるようにします。

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

本コンポーネント CoyoteTime で用意する主なプロパティは以下の通りです。

  • coyoteTimeDuration: number
    – コヨーテタイムの長さ(秒)。
    – 例: 0.10.2 くらいが一般的。
    – 足場から離れてからこの時間だけジャンプ入力を許可します。
  • jumpVelocity: number
    – ジャンプ時に Rigidbody2D の linearVelocity.y に設定する上方向の速度。
    – 例: 1020 など、ゲームのスケールに合わせて調整。
  • groundCheckDistance: number
    – 接地判定のために下方向に飛ばすレイキャストの長さ(メートル)。
    – 小さすぎると接地を取りこぼし、大きすぎると空中でも地面判定してしまうので注意。
    – 例: 0.10.3
  • groundCheckOffset: Vec2
    – レイキャストの開始位置をノードの中心からずらしたい場合のオフセット。
    – 例: キャラの足元に合わせるために (0, -0.5) など。
  • groundLayerMask: number
    – 接地判定対象とする物理レイヤーのマスク。
    – 物理レイヤー設定に応じて、地面だけを判定対象にしたい場合に使用。
    – 例: 「Default」レイヤーだけなら 1 << 0 など。
  • jumpKey: string
    – ジャンプに使用するキーボードキーを文字列で指定。
    – 例: "Space", "Z", "X", "W" など。
    – Cocos の KeyCode 名と対応するように実装します。
  • allowHoldJump: boolean
    – true の場合、ジャンプキーを押しっぱなしでもジャンプを許可(1回だけ)。
    – false の場合、キーを押した瞬間(押下イベント)のみジャンプを受け付ける。
  • debugDraw: boolean
    – true の場合、接地判定レイの情報をログに出すなど、簡易デバッグ出力を有効にする。

TypeScriptコードの実装

以下が完成した CoyoteTime.ts コンポーネントの全コードです。


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

/**
 * CoyoteTime
 * 足場から落ちた直後の短時間だけ、ジャンプ入力を許容するコンポーネント。
 * - 自身のノードに Rigidbody2D が必要です。
 * - 接地判定は下方向レイキャストで行います。
 * - ジャンプ入力はキーボードから取得します(指定キー)。
 */
@ccclass('CoyoteTime')
export class CoyoteTime extends Component {

    @property({
        tooltip: 'コヨーテタイムの長さ(秒)。足場から離れてからこの時間だけジャンプ入力を許可します。',
    })
    public coyoteTimeDuration: number = 0.15;

    @property({
        tooltip: 'ジャンプ時に与える上方向の速度(Rigidbody2D.linearVelocity.y に設定されます)。',
    })
    public jumpVelocity: number = 12;

    @property({
        tooltip: '接地判定のために下方向に飛ばすレイキャストの長さ(メートル)。',
    })
    public groundCheckDistance: number = 0.2;

    @property({
        tooltip: '接地判定レイの開始位置オフセット(ノードのローカル座標系)。',
    })
    public groundCheckOffset: Vec2 = new Vec2(0, -0.5);

    @property({
        tooltip: '接地判定対象とする物理レイヤーマスク(Physics2D のレイヤー設定に合わせてください)。\n例: Default レイヤーのみ = 1 << 0',
    })
    public groundLayerMask: number = 0xffffffff; // 全レイヤー対象

    @property({
        tooltip: 'ジャンプに使用するキー名。\n例: "Space", "Z", "X", "W" など。大文字・小文字は区別されません。',
    })
    public jumpKey: string = 'Space';

    @property({
        tooltip: 'true: キー押しっぱなしでもジャンプを許可(1回だけ)。\nfalse: キーを押した瞬間のみジャンプを受け付けます。',
    })
    public allowHoldJump: boolean = false;

    @property({
        tooltip: 'true にすると接地判定レイの結果などをログ出力します(開発用)。',
    })
    public debugDraw: boolean = false;

    // 内部状態
    private _rb2d: RigidBody2D | null = null;
    private _isGrounded: boolean = false;
    private _wasGrounded: boolean = false;
    private _coyoteTimer: number = 0;
    private _jumpKeyCode: KeyCode = KeyCode.SPACE;
    private _jumpKeyPressed: boolean = false;
    private _jumpKeyHeld: boolean = false;

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

        // ジャンプキー文字列を KeyCode に変換
        this._jumpKeyCode = this._convertKeyStringToKeyCode(this.jumpKey);

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

    start() {
        // 初期接地状態をチェック
        this._isGrounded = this._checkGrounded();
        this._wasGrounded = this._isGrounded;
        this._coyoteTimer = 0;
    }

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

    update(deltaTime: number) {
        if (!this._rb2d) {
            // 必須コンポーネントが無い場合は何もしない
            return;
        }

        // 接地状態を更新
        this._wasGrounded = this._isGrounded;
        this._isGrounded = this._checkGrounded();

        // 地面から離れた瞬間にコヨーテタイム開始
        if (this._wasGrounded && !this._isGrounded) {
            this._coyoteTimer = this.coyoteTimeDuration;
            if (this.debugDraw) {
                console.log('[CoyoteTime] 離陸: コヨーテタイム開始', this._coyoteTimer);
            }
        }

        // 接地している間はコヨーテタイムタイマーをリセット
        if (this._isGrounded) {
            this._coyoteTimer = this.coyoteTimeDuration;
        } else {
            // 空中ではコヨーテタイムをカウントダウン
            if (this._coyoteTimer > 0) {
                this._coyoteTimer -= deltaTime;
                if (this._coyoteTimer < 0) {
                    this._coyoteTimer = 0;
                }
            }
        }

        // ジャンプ処理
        this._handleJump();
    }

    /**
     * キー押下イベント
     */
    private _onKeyDown(event: EventKeyboard) {
        if (event.keyCode === this._jumpKeyCode) {
            this._jumpKeyPressed = true;
            this._jumpKeyHeld = true;
        }
    }

    /**
     * キー離しイベント
     */
    private _onKeyUp(event: EventKeyboard) {
        if (event.keyCode === this._jumpKeyCode) {
            this._jumpKeyHeld = false;
        }
    }

    /**
     * ジャンプ処理の判定と実行
     */
    private _handleJump() {
        if (!this._rb2d) {
            return;
        }

        const canUseCoyote = this._coyoteTimer > 0;
        const canJumpNow = this._isGrounded || canUseCoyote;

        let wantJump = false;
        if (this.allowHoldJump) {
            // 押しっぱなしでもジャンプ許可(ただし 1 回)
            wantJump = this._jumpKeyHeld;
        } else {
            // 押した瞬間のみ
            wantJump = this._jumpKeyPressed;
        }

        if (wantJump && canJumpNow) {
            // 実際にジャンプを実行
            const v = this._rb2d.linearVelocity;
            v.y = this.jumpVelocity;
            this._rb2d.linearVelocity = v;

            // コヨーテタイムを消費
            this._coyoteTimer = 0;

            if (this.debugDraw) {
                console.log('[CoyoteTime] ジャンプ実行: isGrounded=', this._isGrounded, ' coyote=', canUseCoyote);
            }
        }

        // 1 フレームだけ有効な「押した瞬間」フラグをリセット
        this._jumpKeyPressed = false;
    }

    /**
     * 接地判定を行う
     * 下方向にレイキャストして、指定距離内に Collider2D があれば接地とみなします。
     */
    private _checkGrounded(): boolean {
        const physics = PhysicsSystem2D.instance;
        if (!physics) {
            console.error('[CoyoteTime] PhysicsSystem2D が有効になっていません。Project Settings の Physics で 2D Physics を有効にしてください。');
            return false;
        }

        const worldPos = this.node.worldPosition;
        const origin = new Vec2(
            worldPos.x + this.groundCheckOffset.x,
            worldPos.y + this.groundCheckOffset.y
        );
        const target = new Vec2(origin.x, origin.y - this.groundCheckDistance);

        const results = physics.raycast(
            origin,
            target,
            ERaycast2DType.Closest,
            this.groundLayerMask
        );

        const grounded = results.length > 0;

        if (this.debugDraw) {
            console.log(
                '[CoyoteTime] GroundCheck:',
                'origin=', origin,
                'target=', target,
                'hit=', grounded
            );
        }

        return grounded;
    }

    /**
     * インスペクタの jumpKey 文字列を Cocos の KeyCode に変換します。
     * 未対応の文字列の場合は Space にフォールバックします。
     */
    private _convertKeyStringToKeyCode(key: string): KeyCode {
        const normalized = key.trim().toUpperCase();

        switch (normalized) {
            case 'SPACE':
                return KeyCode.SPACE;
            case 'Z':
                return KeyCode.KEY_Z;
            case 'X':
                return KeyCode.KEY_X;
            case 'C':
                return KeyCode.KEY_C;
            case 'W':
                return KeyCode.KEY_W;
            case 'A':
                return KeyCode.KEY_A;
            case 'S':
                return KeyCode.KEY_S;
            case 'D':
                return KeyCode.KEY_D;
            case 'UP':
            case 'ARROWUP':
                return KeyCode.ARROW_UP;
            case 'DOWN':
            case 'ARROWDOWN':
                return KeyCode.ARROW_DOWN;
            case 'LEFT':
            case 'ARROWLEFT':
                return KeyCode.ARROW_LEFT;
            case 'RIGHT':
            case 'ARROWRIGHT':
                return KeyCode.ARROW_RIGHT;
            default:
                console.warn(`[CoyoteTime] 未対応の jumpKey "${key}" が指定されました。Space にフォールバックします。`);
                return KeyCode.SPACE;
        }
    }
}

コードのポイント解説

  • onLoad
    Rigidbody2D を取得し、存在しなければ console.error
    – インスペクタで指定された jumpKeyKeyCode に変換。
    – キーボード入力イベント (KEY_DOWN, KEY_UP) を登録。
  • start
    – ゲーム開始時の接地状態を _checkGrounded() で判定し、内部フラグを初期化。
  • update
    – 毎フレーム、現在の接地状態をレイキャストで更新。
    – 地面から離れた瞬間に _coyoteTimercoyoteTimeDuration をセット。
    – 接地している間はタイマーを常にリセット、空中では deltaTime でカウントダウン。
    – 最後に _handleJump() を呼び出し、ジャンプ入力とコヨーテタイム条件を満たす場合にジャンプを実行。
  • _checkGrounded
    PhysicsSystem2D.instance.raycast を使用して、ノードの足元から下方向へレイを飛ばし、groundLayerMask で指定されたレイヤーにヒットすれば接地と判定。
    groundCheckOffsetgroundCheckDistance でレイの位置と長さを調整可能。
  • _handleJump
    allowHoldJump に応じて、「押しっぱなし」または「押した瞬間」のどちらでジャンプを受け付けるかを切り替え。
    _isGrounded または _coyoteTimer > 0 の場合にだけジャンプを許可。
    – ジャンプ時には Rigidbody2D.linearVelocity.yjumpVelocity に設定し、コヨーテタイムを 0 にリセット。
  • _convertKeyStringToKeyCode
    – インスペクタに入力された文字列(例: “Space”, “Z”, “ArrowUp”)を KeyCode に変換します。
    – 想定外の文字列の場合は警告を出しつつ Space にフォールバック。

使用手順と動作確認

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

  1. エディタ上部の Assets パネルで、任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create > TypeScript を選択し、ファイル名を CoyoteTime.ts とします。
  3. 自動生成されたファイルをダブルクリックして開き、内容をすべて削除して、前述のコードを丸ごと貼り付けて保存します。

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

ここでは 2D プロジェクトを前提に、簡単なテストシーンを作ります。

  1. Hierarchy パネルで右クリック → Create > 2D Object > Sprite を選択し、ノード名を Player に変更します。
  2. Player ノードを選択し、Inspector で以下を設定します:
    • Add Component > Physics 2D > RigidBody2D を追加
      • Type: Dynamic
      • Gravity Scale: 必要に応じて調整(例: 1
    • Add Component > Physics 2D > BoxCollider2D を追加
      • サイズをスプライトに合うように調整(自動サイズでも可)。

注意: CoyoteTimeRigidbody2D を必須とするため、これを付け忘れるとコンソールにエラーが出てジャンプしません。

3. 地面(足場)の作成

  1. Hierarchy で右クリック → Create > 2D Object > Sprite を選択し、ノード名を Ground に変更します。
  2. Ground ノードを選択し、Inspector で以下を設定します:
    • Transform の Scale を X 方向に大きくして床っぽくします(例: (10, 1, 1))。
    • Add Component > Physics 2D > BoxCollider2D を追加。
    • Add Component > Physics 2D > RigidBody2D を追加し、Type を Static に設定。
  3. Player の初期位置を Ground の少し上に配置しておきます。

物理レイヤーをカスタマイズしている場合は、Ground の Collider2D が属するレイヤーと、CoyoteTimegroundLayerMask が一致するように調整してください。
(デフォルト設定のままなら、groundLayerMask = 0xffffffff のままで全レイヤー対象として機能します)

4. CoyoteTime コンポーネントのアタッチ

  1. Player ノードを選択します。
  2. Inspector の一番下で Add Component > Custom > CoyoteTime を選択して追加します。
  3. 追加された CoyoteTime コンポーネントのプロパティを設定します:
    • Coyote Time Duration: 0.15(まずは 0.15 秒程度から試す)
    • Jump Velocity: 12(シーンのスケールに応じて 8〜20 程度で調整)
    • Ground Check Distance: 0.2
    • Ground Check Offset: (0, -0.5)(プレイヤーの足元に合わせて微調整)
    • Ground Layer Mask: 0xffffffff(デフォルトのまま)
    • Jump Key: Space
    • Allow Hold Jump: 好みで true or false
    • Debug Draw: 最初は true にしてログを見ながら調整すると便利

5. 動作確認

  1. エディタ右上の Play ボタンでシーンを実行します。
  2. ゲームウィンドウで、PlayerGround の上に立っていることを確認します。
  3. Space キー(または設定したジャンプキー)を押してジャンプできることを確認します。
  4. 次に、走る機能がない場合は、Player の初期位置を Ground の端ギリギリに配置し、落ちる直前〜落ちた直後に Space を押してみてください
    • 足場から完全に離れても、Coyote Time Duration で指定した時間内であればジャンプが発動するはずです。
    • 時間を過ぎると、空中ではジャンプできなくなります。
  5. コンソールに [CoyoteTime] GroundCheck:[CoyoteTime] ジャンプ実行: のログが出ていれば、接地判定とコヨーテタイムが正しく動いています。

6. よくある調整ポイント

  • ジャンプが高すぎる / 低すぎる
    Jump Velocity を上下して調整します。
  • コヨーテタイムが長すぎる / 短すぎる
    Coyote Time Duration0.050.25 秒くらいで微調整すると、自然な操作感を得やすいです。
  • 接地しているのに「落ちている」と判定される
    Ground Check Distance を少し長くする(例: 0.2 → 0.3)。
    Ground Check Offset をプレイヤーの足元にしっかり合わせる。
  • 空中でもずっと接地扱いになる
    Ground Check Distance が長すぎる可能性があります。短くしてみてください。
    → 物理レイヤーが広すぎて、別のオブジェクトを拾っている場合もあるので、Ground Layer Mask を地面専用レイヤーに絞るのも有効です。

まとめ

この CoyoteTime コンポーネントは、

  • Rigidbody2D とレイキャストだけで接地判定とコヨーテタイムを実装
  • 入力取得からジャンプ処理までを1ファイルで完結
  • インスペクタからパラメータを調整可能で、どのプロジェクトにも簡単に持ち込める

という特徴を持つ、完全独立型の汎用コンポーネントです。

プレイヤー制御の中でも「ジャンプ入力の猶予時間」は、ゲームの手触りを大きく左右する重要な要素です。
このスクリプトをそのまま使うだけでも十分ですが、慣れてきたら

  • 多段ジャンプや壁ジャンプと組み合わせる
  • アニメーション再生や SE 再生を _handleJump() に追加する
  • 接地判定の方法を「フットコライダーの OnCollisionEnter2D / Exit2D」に切り替える

などの発展も可能です。

まずはこのままプロジェクトに組み込んで、「足場から落ちても一瞬だけジャンプできる」感覚を体験しながら、Coyote Time Duration を調整してみてください。
それだけで、プレイヤーの操作感がぐっと「遊びやすい」方向に変わるはずです。