Unityでアクションゲームを作り始めると、ついプレイヤーの移動やジャンプ処理を Update に全部書いてしまいがちですよね。移動、アニメーション、入力、当たり判定、さらにはエフェクト制御までが一つのスクリプトに詰め込まれていくと、少し仕様を変えたいだけでも地獄のような修正作業になります。

特に「ジャンプ処理」は条件が増えやすく、
「地上にいるときだけジャンプ」「二段ジャンプ」「壁キック」「ダッシュ中はジャンプ不可」など、分岐が雪だるま式に増えていきます。

そこでこの記事では、ジャンプの気持ちよさをグッと上げるテクニックである「コヨーテタイム(Coyote Time)」を、単独コンポーネントとして切り出して実装してみます。
「足場から落ちた瞬間にジャンプが効かなくなる」ストレスを、「ほんの数フレームだけ猶予を与える」ことで解消するコンポーネントです。

【Unity】遊び心地が一気にアップ!「CoyoteTime」コンポーネント

ここでは以下のような設計をします。

  • CoyoteTime は「ジャンプ入力を受け付けるかどうか」を管理するだけのコンポーネント
  • 実際に Rigidbody を動かしてジャンプさせるのは、別の「ジャンプ実行コンポーネント」に任せる前提
  • 「地上かどうか」の判定は、他のコンポーネント(例: GroundChecker)から SetGrounded(bool) で教えてもらう

こうすることで、「地上判定の方法を変えたい」「空中ジャンプを追加したい」といった変更のときにも、CoyoteTime 単体を差し替えたり拡張したりしやすくなります。

フルコード:CoyoteTime.cs


using UnityEngine;

namespace SamplePlatformer
{
    /// <summary>
    /// コヨーテタイム(足場から落ちた直後のジャンプ猶予)を管理するコンポーネント。
    /// 
    /// このコンポーネントは「いつジャンプ入力を受け付けるか」だけを担当します。
    /// 実際のジャンプ(Rigidbody に力を加えるなど)は別コンポーネントで行い、
    /// そのコンポーネントから IsJumpAllowed() を参照する構成を想定しています。
    /// </summary>
    public class CoyoteTime : MonoBehaviour
    {
        // --- 設定項目 ---

        [Header("コヨーテタイム設定")]

        [SerializeField]
        [Tooltip("足場から落ちたあと、ジャンプ入力を猶予する時間(秒)。")]
        private float coyoteTimeDuration = 0.15f;

        [SerializeField]
        [Tooltip("入力をバッファしておく時間(秒)。0 で無効。")]
        private float jumpBufferDuration = 0.1f;

        // --- 内部状態 ---

        // 現在地上にいるかどうか(外部から SetGrounded で更新してもらう)
        private bool isGrounded = false;

        // 最後に「地上だった」時間(Time.time 基準)
        private float lastGroundedTime = Mathf.NegativeInfinity;

        // 最後にジャンプ入力があった時間(Time.time 基準)
        private float lastJumpInputTime = Mathf.NegativeInfinity;

        // 直近フレームでジャンプが実行されたかどうか(実行側が ResetJumpConsumed を呼ぶ想定でもOK)
        private bool jumpConsumed = false;

        /// <summary>
        /// 現在地上かどうか(読み取り専用)。
        /// 外部からは SetGrounded で更新します。
        /// </summary>
        public bool IsGrounded => isGrounded;

        /// <summary>
        /// コヨーテタイム中かどうか。
        /// 「直近で地上にいた時間」から一定時間以内なら true。
        /// </summary>
        public bool IsInCoyoteTime
        {
            get
            {
                // すでに地上なら「コヨーテタイム中」とみなさない
                if (isGrounded) return false;

                // 地上を離れてからの経過時間が猶予時間以内ならコヨーテタイム中
                return Time.time - lastGroundedTime <= coyoteTimeDuration;
            }
        }

        /// <summary>
        /// 現在「ジャンプを許可できる状態」かどうかを返します。
        /// 
        /// 具体的には:
        /// - 地上にいる
        /// - もしくはコヨーテタイム中
        /// かつ
        /// - 直近でジャンプ入力があり、まだ消費されていない
        /// という条件を満たすときに true になります。
        /// </summary>
        public bool IsJumpAllowed
        {
            get
            {
                if (jumpConsumed) return false;

                bool canUseGroundOrCoyote =
                    isGrounded || (Time.time - lastGroundedTime <= coyoteTimeDuration);

                bool hasBufferedInput =
                    Time.time - lastJumpInputTime <= jumpBufferDuration;

                return canUseGroundOrCoyote && hasBufferedInput;
            }
        }

        private void Update()
        {
            // ここでは時間ベースの管理のみを行い、
            // 実際の「ジャンプをするかどうか」の決定は他コンポーネントに任せます。
            //
            // 例えば別コンポーネントで:
            //
            // if (coyoteTime.IsJumpAllowed)
            // {
            //     実際にジャンプさせる処理...
            //     coyoteTime.ConsumeJump();
            // }
            //
            // といった使い方を想定しています。
        }

        /// <summary>
        /// 外部から「地上にいるかどうか」を通知するためのメソッド。
        /// GroundChecker などのコンポーネントから毎フレーム呼び出してもらう想定です。
        /// </summary>
        public void SetGrounded(bool grounded)
        {
            // 地上状態が true になった瞬間に、lastGroundedTime を更新
            if (grounded && !isGrounded)
            {
                lastGroundedTime = Time.time;
                // 地上に戻ったので、ジャンプ消費フラグをリセットしてもよい
                jumpConsumed = false;
            }

            isGrounded = grounded;
        }

        /// <summary>
        /// 外部から「ジャンプ入力があった」ことを通知するメソッド。
        /// 
        /// Input System などでボタンが押された瞬間に呼び出してください。
        /// </summary>
        public void RegisterJumpInput()
        {
            lastJumpInputTime = Time.time;
            // 入力があったタイミングで、まだジャンプしていない状態に戻すこともできる
            jumpConsumed = false;
        }

        /// <summary>
        /// ジャンプを実行したあとに呼ぶことで、
        /// 同じ入力での連続ジャンプを防ぎます。
        /// </summary>
        public void ConsumeJump()
        {
            jumpConsumed = true;
        }

        /// <summary>
        /// 外部から強制的に状態をリセットしたいとき用。
        /// 例: リスポーン時やシーン切り替え時など。
        /// </summary>
        public void ResetState(bool startGrounded)
        {
            isGrounded = startGrounded;
            lastGroundedTime = startGrounded ? Time.time : Mathf.NegativeInfinity;
            lastJumpInputTime = Mathf.NegativeInfinity;
            jumpConsumed = false;
        }
    }
}

おまけ:シンプルな GroundChecker とジャンプ実行例

上の CoyoteTime だけでは動きが見えないので、簡単な「地上判定」と「ジャンプ実行」の例も載せておきます。
この2つはあくまで参考実装で、用途に合わせて自由に差し替えてください。


using UnityEngine;

namespace SamplePlatformer
{
    /// <summary>
    /// 足元に SphereCast を飛ばして「地上かどうか」を判定するコンポーネント。
    /// 結果を CoyoteTime に通知するだけの、シンプルな責務にしています。
    /// </summary>
    [RequireComponent(typeof(CoyoteTime))]
    public class GroundChecker : MonoBehaviour
    {
        [Header("地上判定設定")]

        [SerializeField]
        [Tooltip("足元の中心位置(Transform からのローカルオフセット)。")]
        private Vector3 groundCheckOffset = new Vector3(0f, -0.9f, 0f);

        [SerializeField]
        [Tooltip("足元の判定用半径。")]
        private float groundCheckRadius = 0.25f;

        [SerializeField]
        [Tooltip("何のレイヤーを地面とみなすか。")]
        private LayerMask groundLayerMask = ~0; // デフォルト: すべて

        private CoyoteTime coyoteTime;

        private void Awake()
        {
            coyoteTime = GetComponent<CoyoteTime>();
        }

        private void Update()
        {
            bool grounded = CheckGrounded();
            coyoteTime.SetGrounded(grounded);
        }

        private bool CheckGrounded()
        {
            Vector3 origin = transform.position + groundCheckOffset;
            // 下方向に短い SphereCast を飛ばして地面判定
            float castDistance = 0.05f;

            RaycastHit hit;
            bool hasHit = Physics.SphereCast(
                origin,
                groundCheckRadius,
                Vector3.down,
                out hit,
                castDistance,
                groundLayerMask,
                QueryTriggerInteraction.Ignore
            );

            return hasHit;
        }

        private void OnDrawGizmosSelected()
        {
            // Scene ビューで地上判定の位置を可視化
            Gizmos.color = Color.yellow;
            Vector3 origin = Application.isPlaying
                ? transform.position + groundCheckOffset
                : transform.position + groundCheckOffset;

            Gizmos.DrawWireSphere(origin, groundCheckRadius);
        }
    }
}

using UnityEngine;
using UnityEngine.InputSystem;

namespace SamplePlatformer
{
    /// <summary>
    /// 実際に Rigidbody を使ってジャンプさせるコンポーネント。
    /// 
    /// - 入力(Input System)
    /// - CoyoteTime への通知と参照
    /// - Rigidbody への速度設定
    /// 
    /// という、ジャンプにまつわる「実行部分」だけを担当します。
    /// </summary>
    [RequireComponent(typeof(Rigidbody))]
    [RequireComponent(typeof(CoyoteTime))]
    public class SimpleJumpController : MonoBehaviour
    {
        [Header("ジャンプ設定")]

        [SerializeField]
        [Tooltip("ジャンプの初速度(上方向)。")]
        private float jumpVelocity = 7f;

        [SerializeField]
        [Tooltip("空中での横移動速度。0 で移動なし。")]
        private float moveSpeed = 4f;

        private Rigidbody rb;
        private CoyoteTime coyoteTime;

        // Input System からの入力値
        private Vector2 moveInput;
        private bool jumpPressedThisFrame;

        private void Awake()
        {
            rb = GetComponent<Rigidbody>();
            coyoteTime = GetComponent<CoyoteTime>();

            // Rigidbody の基本設定(任意)
            rb.constraints = RigidbodyConstraints.FreezeRotation;
        }

        private void Update()
        {
            // 1フレーム限定のジャンプ入力フラグを初期化
            jumpPressedThisFrame = false;
        }

        private void FixedUpdate()
        {
            // 横移動(X/Z 平面)
            Vector3 velocity = rb.velocity;
            Vector3 horizontal = new Vector3(moveInput.x, 0f, moveInput.y) * moveSpeed;
            velocity.x = horizontal.x;
            velocity.z = horizontal.z;

            // コヨーテタイムを考慮したジャンプ判定
            if (jumpPressedThisFrame && coyoteTime.IsJumpAllowed)
            {
                // 上方向の速度を上書き
                velocity.y = jumpVelocity;

                // このフレームでジャンプを消費
                coyoteTime.ConsumeJump();
            }

            rb.velocity = velocity;
        }

        // --- Input System 用コールバック ---

        /// <summary>
        /// PlayerInput から「Move」アクションで呼ばれる想定のメソッド。
        /// Vector2 (x:左右, y:前後) を受け取ります。
        /// </summary>
        /// <param name="context"></param>
        public void OnMove(InputAction.CallbackContext context)
        {
            moveInput = context.ReadValue<Vector2>();
        }

        /// <summary>
        /// PlayerInput から「Jump」アクションで呼ばれる想定のメソッド。
        /// ボタンが押された瞬間に CoyoteTime へ通知します。
        /// </summary>
        /// <param name="context"></param>
        public void OnJump(InputAction.CallbackContext context)
        {
            if (context.performed)
            {
                // 1フレームだけ有効なフラグ
                jumpPressedThisFrame = true;
                // CoyoteTime に「ジャンプ入力があった」ことを登録
                coyoteTime.RegisterJumpInput();
            }
        }
    }
}

使い方の手順

  1. プレイヤー用プレハブを用意する
    • Hierarchy で空の GameObject を作成し、名前を Player にします。
    • Rigidbody コンポーネントを追加します(Use Gravity 有効、Rotation を Freeze すると扱いやすいです)。
  2. コンポーネントを追加する
    • CoyoteTime コンポーネントを追加します。
    • GroundChecker コンポーネントを追加します。
    • SimpleJumpController コンポーネントを追加します。
    • 地面オブジェクト(床、足場など)の Layer を Ground などにして、GroundCheckerGround Layer Mask にその Layer を指定します。
  3. Input System を設定する
    • Unity の Input System(新しい方)を有効にしておきます。
    • PlayerInput コンポーネントを Player に追加します。
    • Input Actions アセットを作成し、以下のような Action を定義します:
      • Move(Type: Value, Control Type: Vector2, WASD/Stick などをバインド)
      • Jump(Type: Button, Space などをバインド)
    • PlayerInput の「Behavior」を Send Messages にし、
      「Move」アクションに対して OnMove
      「Jump」アクションに対して OnJump が呼ばれるように設定します(アクション名とメソッド名を揃えれば自動で呼ばれます)。
  4. テストして調整する
    • シーンに地面(Cube など)を配置し、Ground レイヤーを設定します。
    • プレイヤーを少し上に配置して再生し、落ちる直前・直後にジャンプボタンを押して挙動を確認します。
    • CoyoteTimeCoyote Time Duration を 0.1〜0.2 秒の範囲で調整すると、かなり感触が変わります。
    • 同様に Jump Buffer Duration を 0.05〜0.15 秒程度にすると、「地面に着地するちょっと前に押したジャンプ」も拾えるようになり、より快適になります。

例えば、2D/3D の横スクロールアクションでプレイヤーにこのセットを付けておけば、
「足場ギリギリでジャンプし損ねてストレス…」という状況がかなり減ります。
同じように、敵キャラの AI ジャンプや、動く床の上のオブジェクトにも CoyoteTime を組み込めば、見た目に自然なジャンプ挙動を作りやすくなります。

メリットと応用

CoyoteTime をコンポーネントとして分離しておくメリットはたくさんあります。

  • プレハブの再利用性が高い
    「ジャンプの気持ちよさ」のロジックを CoyoteTime に閉じ込めておくことで、
    プレイヤーだけでなく、敵、動く床、ギミックなど、ジャンプを使うあらゆるオブジェクトに簡単に転用できます。
    プレハブに CoyoteTime をポンと追加するだけで、同じコヨーテタイム挙動を共有できます。
  • レベルデザインの自由度が上がる
    足場の幅や距離を少しシビアにしても、コヨーテタイムとジャンプバッファのおかげで、
    プレイヤー体験としては「理不尽さ」が減り、「ギリギリ成功した!」という気持ちよさが増えます。
    その結果、ステージの難易度を数字上は高くしつつ、体験としては遊びやすく保てます。
  • 責務が小さいので拡張しやすい
    「地上判定の方法(Raycast / CharacterController / 2D Collider など)」や、
    「ジャンプの物理挙動(Rigidbody / Translate / DOTS など)」を差し替えても、
    コヨーテタイムのロジックはそのまま使い回せます。
    これは「小さな責務のコンポーネント」に分けているからこそのメリットですね。

応用として、例えば「空中ダッシュにもコヨーテタイムを付けたい」「壁キックにも猶予を持たせたい」など、
ジャンプ以外のアクションにも同じコンセプトを使えます。その場合は CoyoteTime を継承・改造してもいいですし、
別名のコンポーネント(例: DashCoyoteTime)として複製してもよいでしょう。

改造案:最大ジャンプ回数と組み合わせる

例えば「地上+コヨーテタイム中は1回だけジャンプ可、空中では2段ジャンプまで可」といった仕様を作るなら、
CoyoteTime に「残りジャンプ回数」を持たせるのではなく、SimpleJumpController 側で管理したほうが責務が分かれてスッキリします。

以下は SimpleJumpController に追加する形の、簡単な「残りジャンプ回数」管理の例です。


[SerializeField]
[Tooltip("最大ジャンプ回数(1 なら通常ジャンプのみ、2 なら二段ジャンプまで)。")]
private int maxJumpCount = 2;

private int currentJumpCount = 0;

private void FixedUpdate()
{
    Vector3 velocity = rb.velocity;
    Vector3 horizontal = new Vector3(moveInput.x, 0f, moveInput.y) * moveSpeed;
    velocity.x = horizontal.x;
    velocity.z = horizontal.z;

    // 地上にいるならジャンプ回数をリセット
    if (coyoteTime.IsGrounded)
    {
        currentJumpCount = 0;
    }

    // ジャンプ可能かどうか
    bool canUseCoyote = coyoteTime.IsJumpAllowed && currentJumpCount == 0;
    bool canUseAirJump = !coyoteTime.IsGrounded && currentJumpCount < maxJumpCount;

    if (jumpPressedThisFrame && (canUseCoyote || canUseAirJump))
    {
        velocity.y = jumpVelocity;
        currentJumpCount++;

        if (canUseCoyote)
        {
            coyoteTime.ConsumeJump();
        }
    }

    rb.velocity = velocity;
}

このように、「コヨーテタイム」「地上判定」「ジャンプ実行」「ジャンプ回数管理」を別々の責務に分けておくと、
後から仕様を変えたくなったときでも、特定のコンポーネントだけを差し替えたり、継承して拡張したりしやすくなります。
Godクラスを避けて、小さなコンポーネントを組み合わせていくスタイルで、気持ちいいアクションゲームを作っていきましょう。