8
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】汎用的なジャンプ機構の実装例 - コヨーテタイム・先行入力・多段ジャンプ・可変ジャンプを共通化する

Last updated at Posted at 2025-12-03

この記事は、サイバーエージェント26卒内定者 Advent Calendarの4日目の記事です。

まえがき

アクションゲームには欠かせないジャンプ機能ですが、
操作性を良くするための工夫や、ステージギミックによる影響、多段ジャンプ、壁キックの仕様など、実際に実装しようとすると案外考えなければならないことが多いです。

本記事では、それらの基本的な仕様を共通化したジャンプのコアロジックの実装例を紹介します。
ゲームジャムなどで、ゲーム固有の機能に専念できるよう、ジャンプの共通機能を手軽かつ柔軟に実装できるようにすることを目的に設計しました。

ジャンプ機能の一つの実装例として、参考になる部分があれば幸いです。

対象読者

  • ジャンプの操作性を高める工夫について知りたい人
  • ジャンプ機能をどう発展させていいかわからない人
  • アクションゲームのロジック部分の切り出し、責務の分離に興味がある人

やっていること、やっていないこと

本機構には、Rigidbodyに力を加えたり、BoxCastで接地判定をとったりといった、
ジャンプの具体的な挙動に関わる処理は一切含まれません。

それらの処理は、2Dか、3Dかはもちろん、CharacterControllerを使うかRigidbodyを使うか、可変ジャンプの実現方法、壁キックの有無など、
プロジェクトによって大きく変わる部分であり、ゲームごとに細かく調整したい部分だからです。

では何を実装しているのかと言うと、ジャンプ機構に共通して必要となる、入力とジャンプ可否の対応です。
具体的に今回の機構で実現している機能は以下のとおりです。

  • コヨーテタイム
    • ジャンプの踏み切りに失敗して足場から踏み外しても、一定時間はジャンプを可能にする機能
  • 先行入力
    • 着地する直前など、ジャンプ不可能な時点で入力されたジャンプ入力を保存し、ジャンプ可能になった時点でジャンプするようにする機能
  • 可変ジャンプ
    • 入力の長さに応じてジャンプ速度が可変する(小ジャンプ・大ジャンプを出し分けできるようにする)機能
  • 多段ジャンプ
    • 空中での多段ジャンプを行えるようにする機能
  • ギミックによるジャンプ
    • バネや、敵を踏んだときなど、プレイヤーの入力に寄らないジャンプを実現する機能
  • 壁キックによるジャンプ
    • 機能として実装しているわけではないですが、壁キックにも簡単に対応できます

ひとつひとつは単純でも、組み合わさると複雑になってしまいがちなこれらの機能を、Unity非依存のクラスとして実装しています。

コード解説

JumpLogic<TJumpSource>

今回の軸となるのが、以下のJumpLogic<TJumpSource>です。
このクラスのインスタンスで、各種ジャンプの機能にアクセスすることとなります。
中身の部分は後述するので、ざっくりとどんなメソッドがあるのか流し読みしてください。


/// <summary>
/// ジャンプの挙動を制御するロジッククラス
/// コヨーテタイム、先行入力、多段ジャンプなどの機能を提供
/// </summary>
/// <typeparam name="TJumpSource">
/// ジャンプの踏み切り元となる対象や環境の情報
/// (例: 壁キック時の法線、踏みつけた敵、地面の材質など)
/// </typeparam>
public sealed class JumpLogic<TJumpSource>
{
    public JumpLogic(IJumpLogicSetting setting)
    {
        ...
    }

    /// <summary>ジャンプが開始された時に発火するイベント</summary>
    public event Action<JumpContext<TJumpSource>> OnJumpStarted;
    /// <summary>ジャンプの上昇が停止した時に発火するイベント</summary>
    public event Action OnJumpStopped;

    /// <summary>地面や壁など、ジャンプ可能な足場に接触しているか</summary>
    public bool HasFooting { get; private set; }
    /// <summary>ジャンプ上昇中かどうか</summary>
    public bool IsJumpingUp { get; private set; }
    /// <summary>前回のジャンプからの経過時間</summary>
    public float JumpElapsedTime { get; private set; } = float.PositiveInfinity;
    /// <summary>コヨーテタイム中かどうか</summary>
    public bool IsCoyoteTime => _coyoteTimeTimer < _setting.CoyoteTime;
    /// <summary>先行入力が有効かどうか</summary>
    public bool IsBuffered => _bufferTimeTimer < _setting.BufferTime;

    /// <summary>
    /// ジャンプを開始するか、先行入力を登録する
    /// ジャンプ可能な状態であれば即座にジャンプ、不可能であれば先行入力として記録
    /// </summary>
    public void TryJumpStart()
    {
        ...
    }

    /// <summary>
    /// 強制的にジャンプさせる(バネ、敵の踏みつけなど)
    /// </summary>
    /// <param name="source">ジャンプの踏み切り元となる対象や環境の情報</param>
    public void ForceJump(TJumpSource source)
    {
        ...
    }

    /// <summary>
    ///     ジャンプの上昇を停止する
    /// </summary>
    public void StopJumpingUp()
    {
        ...
    }

    /// <summary>
    /// ジャンプ可能かどうかを判定する
    /// </summary>
    /// <returns>ジャンプ可能ならtrue</returns>
    public bool CanJump()
    {
        ...
    }

    ...
    
    /// <summary>
    /// 毎フレームの更新処理
    /// </summary>
    /// <param name="deltaTime">1フレームの経過時間</param>
    public void Tick(float deltaTime)
    {
        ...
    }

    /// <summary>
    /// 壁や地面との接触を設定する
    /// </summary>
    /// <param name="source">ジャンプの踏み切り元となる対象や環境の情報</param>
    public void SetContact(TJumpSource source = default)
    {
        ...
    }
    /// <summary>
    /// 壁や地面との接触が失われた際に呼び出す
    /// </summary>
    public void ClearContact()
    {
        ...
    }
    
    /// <summary>
    /// 多段ジャンプ回数をリセットする
    /// </summary>
    public void ResetExtraJumpCount()
    {
        ...
    }
}

型引数のTJumpSourceは、壁キックの際の方向や、ジャンプ台など、ジャンプする踏み切り元の情報を任意の型で渡せるようにしています。

基本的な使い方は以下のとおりです。

1. 入力に合わせて、TryJumpStart()を呼ぶ

Tryとついているのは、空中で押した際などに即座にはジャンプせず、先行入力(予約)として保持されるケースがあるためです。
可変ジャンプ(ボタンを押す長さで高さが変わる挙動)を実現するために、
IsJumpingUp中にジャンプボタンが離されていたら StopJumpingUp() を呼び出し、上昇をキャンセルします。
(離したタイミングでだけ呼び出さないのは、先行入力や後述するForceJumpなどの際に、入力なしにジャンプすることがあるためです。また、一定時間立つまでStopJumpingUpを呼び出さないようにもできます)

2. イベントの購読

ジャンプすると言いましたが、最初に書いたように、
ジャンプの具体的な挙動は利用側で定義する必要があります。
Action<JumpContext<TJumpSource>> OnJumpStarted
Action OnJumpStoppedの2つのイベントを購読して、ジャンプの挙動を実装します。

JumpContextには、型引数で指定したTJumpSourceと、ジャンプの回数、ジャンプのタイミングの情報が入っています。
これらの情報をもとに、Rigidbody等で力を加える処理を実装すればOKです。

3. 接地・壁判定

接地判定などによるジャンプの可否は、以下の2つの関数を呼ぶことで管理します。
SetContact(TJumpSource source)
ClearContact()
壁キックなどを実装する際は、SetContactTJumpSourceを切り替え、それに合わせてジャンプ挙動を出し分けすることで実現できます。

4. 毎フレーム実行

内部での時間を進めるため、毎フレームTick(float deltaTime)を呼ぶ必要があります。

5. ギミックでのジャンプについて

ForceJump(TJumpSource source)では、任意のタイミングでジャンプを実行できます。
特にジャンプ回数の回復などは行われない点に注意が必要ですが、
敵を踏みつけた際や、ジャンプ台を踏んだときなど、任意のタイミングでジャンプさせたいときに使うといいでしょう。
壁キックと同様、TJumpSourceによってジャンプの挙動を制御することを想定しています。

コード

省略していない完全版のコードと、実際の単純な利用例は以下のとおりです。

JumpLogic.cs

// JumpLogic.cs
using System;

/// <summary>
/// ジャンプが実行されたタイミング
/// </summary>
public enum JumpTiming
{
    /// <summary>入力直後のジャンプ
    Immediate,
    /// <summary>先行入力によるジャンプ</summary>
    Buffered,
    /// <summary>コヨーテタイム中のジャンプ</summary>
    CoyoteTime,
    /// <summary>強制ジャンプ(バネ、踏みつけなど)</summary>
    Forced,
    /// <summary>多段ジャンプ</summary>
    Extra
}

/// <summary>
/// ジャンプが発生した際のコンテキスト情報
/// </summary>
/// <typeparam name="TJumpSource">ジャンプ元の型(地面、壁など)</typeparam>
public readonly struct JumpContext<TJumpSource>
{
    /// <summary>ジャンプが発生したタイミング</summary>
    public readonly JumpTiming Timing;
    /// <summary>ジャンプの踏み切り元となる対象や環境の情報</summary>
    public readonly TJumpSource Source;
    /// <summary>現在の多段ジャンプ回数</summary>
    public readonly int JumpCount;

    public JumpContext(TJumpSource source, JumpTiming timing, int jumpCount)
    {
        Source = source;
        Timing = timing;
        JumpCount = jumpCount;
    }
}

/// <summary>
/// ジャンプロジックの設定インターフェース
/// </summary>
public interface IJumpLogicSetting
{
    /// <summary>コヨーテタイムの長さ(秒)</summary>
    float CoyoteTime { get; }
    /// <summary>先行入力の受付時間(秒)</summary>
    float BufferTime { get; }
    /// <summary>ジャンプボタン長押しによる上昇時間の最大値(秒)</summary>
    float JumpUpLimitTime { get; }
    /// <summary>空中で可能な多段ジャンプの最大回数</summary>
    int MaxExtraJumpCount { get; }
    /// <summary>連続ジャンプを防止する最小間隔(秒)</summary>
    float JumpThrottle { get; }
}

/// <summary>
/// ジャンプの挙動を制御するロジッククラス
/// コヨーテタイム、先行入力、多段ジャンプなどの機能を提供
/// </summary>
/// <typeparam name="TJumpSource">
/// ジャンプの踏み切り元となる対象や環境の情報
/// (例: 壁キック時の法線、踏みつけた敵、地面の材質など)
/// </typeparam>
public sealed class JumpLogic<TJumpSource>
{
    private const float DisabledTimer = float.PositiveInfinity;

    public JumpLogic(IJumpLogicSetting setting)
    {
        _setting = setting;
    }

    private int _extraJumpCount;
    private float _coyoteTimeTimer = DisabledTimer;
    private float _bufferTimeTimer = DisabledTimer;
    private TJumpSource _lastJumpSource;
    private readonly IJumpLogicSetting _setting;

    /// <summary>ジャンプが開始された時に発火するイベント</summary>
    public event Action<JumpContext<TJumpSource>> OnJumpStarted;
    /// <summary>ジャンプの上昇が停止した時に発火するイベント</summary>
    public event Action OnJumpStopped;

    /// <summary>地面や壁など、ジャンプ可能な足場に接触しているか</summary>
    public bool HasFooting { get; private set; }
    /// <summary>ジャンプ上昇中かどうか</summary>
    public bool IsJumpingUp { get; private set; }
    /// <summary>前回のジャンプからの経過時間</summary>
    public float JumpElapsedTime { get; private set; } = float.PositiveInfinity;
    /// <summary>コヨーテタイム中かどうか</summary>
    public bool IsCoyoteTime => _coyoteTimeTimer < _setting.CoyoteTime;
    /// <summary>先行入力が有効かどうか</summary>
    public bool IsBuffered => _bufferTimeTimer < _setting.BufferTime;

    /// <summary>
    /// ジャンプを開始するか、先行入力を登録する
    /// ジャンプ可能な状態であれば即座にジャンプ、不可能であれば先行入力として記録
    /// </summary>
    public void TryJumpStart()
    {
        if (CanJump())
        {
            ExecuteJump(true);
            return;
        }

        StartBufferTimer();
    }

    /// <summary>
    /// 強制的にジャンプさせる(バネ、敵の踏みつけなど)
    /// </summary>
    /// <param name="source">ジャンプの踏み切り元となる対象や環境の情報</param>
    public void ForceJump(TJumpSource source)
    {
        IsJumpingUp = true;
        JumpElapsedTime = 0f;

        ConsumeCoyoteTime();
        ConsumeBufferTime();

        OnJumpStarted?.Invoke(new JumpContext<TJumpSource>(
            source, 
            JumpTiming.Forced,
            _extraJumpCount
        ));
    }

    /// <summary>
    ///     ジャンプの上昇を停止する
    /// </summary>
    public void StopJumpingUp()
    {
        if (!IsJumpingUp)
        {
            return;
        }

        IsJumpingUp = false;
        OnJumpStopped?.Invoke();
    }

    /// <summary>
    /// ジャンプ可能かどうかを判定する
    /// </summary>
    /// <returns>ジャンプ可能ならtrue</returns>
    public bool CanJump()
    {
        // 短時間の連続ジャンプの抑制
        if (JumpElapsedTime < _setting.JumpThrottle)
        {
            return false;
        }
        if (HasFooting || IsCoyoteTime)
        {
            return true;
        }
        return _extraJumpCount < _setting.MaxExtraJumpCount;
    }

    private void ExecuteJump(bool isImmediate)
    {
        JumpTiming timing;
        if(HasFooting)
        {
            timing = isImmediate
                ? JumpTiming.Immediate
                : JumpTiming.Buffered;
        }
        else if (IsCoyoteTime)
        {
            timing = JumpTiming.CoyoteTime;
            ConsumeCoyoteTime();
        }
        else
        {
            timing = JumpTiming.Extra;
            _extraJumpCount++;
        }

        IsJumpingUp = true;
        JumpElapsedTime = 0f;
        OnJumpStarted?.Invoke(new JumpContext<TJumpSource>(
            _lastJumpSource,
            timing,
            _extraJumpCount
        ));
    }

    /// <summary>
    /// 毎フレームの更新処理
    /// </summary>
    /// <param name="deltaTime">1フレームの経過時間</param>
    public void Tick(float deltaTime)
    {
        JumpElapsedTime += deltaTime;

        if (!HasFooting)
        {
            _coyoteTimeTimer += deltaTime;
        }

        if (IsBuffered)
        {
            _bufferTimeTimer += deltaTime;
        }

        if (IsJumpingUp && JumpElapsedTime > _setting.JumpUpLimitTime)
        {
            StopJumpingUp();
        }

        if (IsBuffered && CanJump())
        {
            ConsumeBufferTime();
            ExecuteJump(false);
        }
    }

    /// <summary>
    /// 壁や地面との接触を設定する
    /// </summary>
    /// <param name="source">ジャンプの踏み切り元となる対象や環境の情報</param>
    public void SetContact(TJumpSource source = default)
    {
        _lastJumpSource = source;
        if (HasFooting)
        {
            return;
        }
        HasFooting = true;
        ResetExtraJumpCount();
        StopJumpingUp();
        ConsumeCoyoteTime();
    }
    /// <summary>
    /// 壁や地面との接触が失われた際に呼び出す
    /// </summary>
    public void ClearContact()
    {
        if (!HasFooting)
        {
            return;
        }
        HasFooting = false;

        if (IsJumpingUp)
        {
            ConsumeCoyoteTime();
        }
        else
        {
            StartCoyoteTimer();
        }
    }
    
    /// <summary>
    /// 多段ジャンプ回数をリセットする
    /// </summary>
    public void ResetExtraJumpCount()
    {
        _extraJumpCount = 0;
    }

    private void ConsumeCoyoteTime()
    {
        _coyoteTimeTimer = DisabledTimer;
    }

    private void StartCoyoteTimer()
    {
        _coyoteTimeTimer = 0f;
    }

    private void ConsumeBufferTime()
    {
        _bufferTimeTimer = DisabledTimer;
    }

    private void StartBufferTimer()
    {
        _bufferTimeTimer = 0f;
    }
}

SimpleJumpExample.cs

以下は、JumpLogicの簡易的な利用例です。

Unity6以降のAPI(Rigidbody.linearVelocity)を使っています。
古いバージョンでは、Rigidbody.velocityを使用して下さい

// SimpleJumpExample.cs
using UnityEngine;
using UnityEngine.InputSystem;

/// <summary>
/// JumpLogicの使い方を示す簡易的なサンプルコード
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public sealed class SimpleJumpExample : MonoBehaviour
{
    [Header("ジャンプ設定")]
    [SerializeField] private float jumpPower = 5f;

    // JumpLogicで使用する設定クラス (ScriptableObjectで外部化したり、プレイ中の状態で変えることを想定)
    private sealed class SimpleJumpSettings : IJumpLogicSetting
    {
        public float CoyoteTime { get; set; } = 0.15f;
        public float BufferTime { get; set; } = 0.15f;
        public float JumpUpLimitTime { get; set; } = 0.5f;
        public int MaxExtraJumpCount { get; set; } = 1;
        public float JumpThrottle { get; set; } = 0.1f;
    }

    // ジャンプのソース情報(何からジャンプしたか)
    private sealed class SimpleJumpSource
    {
        public string Name { get; set; }
        public float Power { get; set; }
    }

    private Rigidbody _rigidbody;
    private JumpLogic<SimpleJumpSource> _jumpLogic;
    private SimpleJumpSource _groundJumpSource;
    private bool _isGrounded;

    private void Awake()
    {
        _rigidbody = GetComponent<Rigidbody>();

        // 設定を準備
        var settings = new SimpleJumpSettings();

        // JumpLogicを初期化
        _jumpLogic = new JumpLogic<SimpleJumpSource>(settings);

        // ジャンプソースを準備(本来は地面やギミックから生成することを想定)
        _groundJumpSource = new SimpleJumpSource
        {
            Name = "Ground",
            Power = jumpPower
        };

        // イベントハンドラを登録
        _jumpLogic.OnJumpStarted += OnJumpStarted;
        _jumpLogic.OnJumpStopped += OnJumpStopped;
    }

    private void Update()
    {
        // 1. 接地判定を行う
        CheckGround();

        // 2. JumpLogicを更新(コヨーテタイム、先行入力などの時間管理)
        _jumpLogic.Tick(Time.deltaTime);

        // 3. ジャンプ入力を処理
        HandleJumpInput();
    }

    /// <summary>
    /// 簡易的な接地判定
    /// </summary>
    private void CheckGround()
    {
        // 下方向にRaycastして接地判定
        bool wasGrounded = _isGrounded;
        _isGrounded = Physics.Raycast(transform.position, Vector3.down, 1.1f);

        // 接地状態が変化したらJumpLogicに通知
        if (_isGrounded && !wasGrounded)
        {
            _jumpLogic.SetContact(_groundJumpSource);
        }
        else if (!_isGrounded && wasGrounded)
        {
            _jumpLogic.ClearContact();
        }
    }

    /// <summary>
    /// ジャンプ入力の処理
    /// </summary>
    private void HandleJumpInput()
    {
        // スペースキーが押されたらジャンプを試みる
        if (Keyboard.current.spaceKey.wasPressedThisFrame)
        {
            // TryJumpStartは以下の場合にジャンプを実行します:
            // - 接地している場合(即座にジャンプ)
            // - コヨーテタイム中の場合(コヨーテタイムジャンプ)
            // - 空中の場合は先行入力として記録され、着地時に自動実行
            _jumpLogic.TryJumpStart();
        }
        // 上昇中にスペースキーが離されていたらジャンプ上昇を停止
        if (Keyboard.current.spaceKey.isPressed == false && _jumpLogic.IsJumpingUp)
        {
            _jumpLogic.StopJumpingUp();
        }
    }

    /// <summary>
    /// ジャンプ開始時のイベントハンドラ
    /// </summary>
    private void OnJumpStarted(JumpContext<SimpleJumpSource> context)
    {
        Debug.Log($"ジャンプ開始: {context.Timing} from {context.Source.Name}");

        // Rigidbodyに上向きの速度を与える
        Vector3 velocity = _rigidbody.linearVelocity;
        velocity.y = context.Source.Power;
        _rigidbody.linearVelocity = velocity;
    }

    /// <summary>
    /// ジャンプ停止時のイベントハンドラ
    /// </summary>
    private void OnJumpStopped()
    {
        Debug.Log("ジャンプ上昇停止");

        // 上昇速度を減衰(ジャンプボタンを離した時の可変ジャンプ)
        Vector3 velocity = _rigidbody.linearVelocity;
        if (velocity.y > 0)
        {
            velocity.y *= 0.5f;
            _rigidbody.linearVelocity = velocity;
        }
    }

    private void OnDrawGizmos()
    {
        // 接地判定の可視化
        Gizmos.color = _isGrounded ? Color.green : Color.red;
        Gizmos.DrawRay(transform.position, Vector3.down * 1.1f);
    }
}

感想

ジャンプ機能について、実際の挙動以外の部分を切り出すことで、
手軽に操作性のいいジャンプ挙動を実現できるようになりました。

利用側の実装量としては多そうに見えますが、実際に活用する際は、当たり判定や物理挙動もそれぞれ別のクラスとして切り出すことで、より責務が明確になり、簡潔で拡張性の高い形で実装することができると思います。

Player関係のコードは肥大化しがちなので、適切な粒度で分割して処理の複雑さを軽減するよう意識していきたいです。

参考

8
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?