점프 게임을 만들다 보면 분명히 유저 입장에서는 점프를 눌렀다고 생각했는데, 실제로는 입력이 씹히는 경우가 있었습니다.

예를 들어 착지 직전에 점프 버튼을 조금 빨리 눌렀는데 아직 지면 판정이 나지 않아 점프가 실행되지 않거나, 반대로 발판 끝에서 점프를 눌렀지만 물리적으로는 이미 공중으로 판정되어 점프가 되지 않는 경우가 있었습니다.

이런 상황이 반복되면 유저는 조작이 뻑뻑하다고 느끼게 됩니다.
그래서 이번에는 점프 입력을 조금 더 관대하게 처리하기 위해 점프 버퍼코요테 타임을 적용했습니다.


1. 코요테 타임 / 점프 버퍼 개념

먼저 두 기능은 비슷해 보이지만, 실제로는 보정하는 방향이 다릅니다.

점프 버퍼(Jump Buffer) 는 점프가 가능한 시점보다 조금 빨리 눌린 입력을 잠깐 저장해두는 기능입니다.

예를 들어 공중에서 내려오다가 착지 직전에 점프 버튼을 눌렀다고 해도, 그 순간에는 아직 지면 판정이 아니기 때문에 원래라면 입력이 무시될 수 있습니다.
이때 최근에 눌린 점프 입력을 잠시 기억해두었다가, 착지하는 순간 바로 점프를 실행하면 입력이 훨씬 자연스럽게 들어오게 됩니다.

반대로 코요테 타임(Coyote Time) 은 점프가 가능한 시점보다 조금 늦게 눌린 입력을 허용하는 기능입니다.

예를 들어 발판 끝에서 점프를 눌렀다고 생각했지만, 실제 물리 판정상 이미 발이 바닥에서 떨어진 뒤라면 원래는 점프가 되지 않습니다.
이때 마지막으로 지면에 닿아 있었던 시점을 잠깐 유지해서, 아주 짧은 시간 동안은 아직 점프 가능한 상태로 취급해주는 방식입니다.

정리하면 다음과 같습니다.

  1. 점프 버퍼 = 착지 직전에 너무 빨리 누른 입력 보정
  2. 코요테 타임 = 발판 끝에서 조금 늦게 누른 입력 보정

NOTE) 처음에 제가 해결하고 싶었던 문제는 착지 직전에 점프 버튼을 눌렀는데 입력이 안 먹는 문제였습니다.

즉, 시작점은 점프 버퍼가 해결하는 영역이 맞았습니다. 다만 실제 플레이를 해보니 발판 끝에서 아주 살짝 늦게 눌렀을 때도 점프가 씹히는 경우가 있었고, 그 부분까지 함께 보완하기 위해 코요테 타임도 추가하게 되었습니다.


2. 입력 기록 방식

이 기능을 구현하려면 단순히 “지금 점프 버튼이 눌렸는가?” 만으로는 부족했습니다.

중요한 것은

  • 언제 점프 버튼을 눌렀는지
  • 언제 마지막으로 지면에 닿아 있었는지

이 두 가지를 같이 기록하는 것이었습니다.

먼저 입력은 PlayerInputController 에서 관리하고 있습니다. 여기서는 점프 입력을 두 가지 값으로 나누어 저장하고 있습니다.

  • JumpHeld
  • JumpPressedThisFrame

JumpHeld 는 점프 버튼을 계속 누르고 있는 상태이고, JumpPressedThisFrame 는 이번 물리 처리 전에 점프 버튼이 눌렸는지를 나타내는 1회성 입력 플래그입니다.

실제로 점프 버튼을 누르면 아래처럼 처리됩니다.

public void JumpDown()
{
if (!JumpHeld)
JumpPressedThisFrame = true;

JumpHeld = true;
}

즉, 버튼을 처음 누른 순간에만 JumpPressedThisFrame = true 가 되고, 누르고 있는 동안은 JumpHeld = true 상태가 유지됩니다.

이렇게 구분한 이유는 “이번에 눌렸는가” 와 “지금도 누르고 있는가” 를 나누어 처리하기 위해서입니다.


3. 점프 관련 시간 값 준비

실제 이동과 점프 처리는 PlayerMoveController 에서 담당하고 있습니다. 여기에는 점프 입력 보정용 시간 값이 따로 들어 있습니다.

[Header("Jump Forgiveness")]
[Min(0f)][SerializeField] private float coyoteTime = 0.12f;
[Min(0f)][SerializeField] private float jumpBufferTime = 0.12f;

각 값의 의미는 다음과 같습니다.

  • coyoteTime = 0.12f
    → 지면에서 떨어진 뒤 0.12초까지는 점프 허용
  • jumpBufferTime = 0.12f
    → 점프 버튼을 누른 뒤 0.12초 동안은 입력을 저장

그리고 내부적으로는 아래 두 시간을 기록하고 있습니다.

  • _lastGroundedTime
  • _lastJumpPressedTime

각각의 의미는 다음과 같습니다.

  • _lastGroundedTime
    → 마지막으로 지면에 닿아 있었던 시각
  • _lastJumpPressedTime
    → 마지막으로 점프 버튼을 눌렀던 시각

즉, 이 시스템은 단순히 현재 상태만 보는 것이 아니라 최근 입력과 최근 지면 접촉 이력을 시간으로 저장해두는 방식으로 동작합니다.


4. 지면 체크 / 입력 시간 기록

이제 매 물리 프레임마다 Tick() 에서 지면 체크와 입력 시간을 갱신합니다.

// 이동/점프/중력을 한 번 처리
// PlayerController.FixedUpdate()에서 호출됨
public void Tick(bool leftHeld, bool rightHeld, bool jumpHeld, bool jumpPressedThisFrame, bool blockMovement)
{
    DidJumpThisFrame = false;
    IsMovingInput = leftHeld || rightHeld;

    // 지면 체크
    _grounded = CheckGroundedNonAlloc();
    if (_grounded)
        _lastGroundedTime = Time.time;

    // 점프 버튼이 눌린 순간 기록
    if (jumpPressedThisFrame)
        _lastJumpPressedTime = Time.time;

    // 스턴 등으로 이동이 막혀 있지 않을 때만 이동/점프 처리
    if (!blockMovement)
    {
        TryConsumeBufferedJump(jumpHeld);
        ApplyGroundMove(leftHeld, rightHeld);
    }

    // 공중에서는 커스텀 중력 적용
    if (!_grounded)
        ApplyCustomGravity();
}

// RaycastNonAlloc 기반 지면 체크
private bool CheckGroundedNonAlloc()
{
    Vector3 origin = rb.position + Up * groundOriginUpOffset;
    Ray ray = new Ray(origin, Vector3.down);

    int hitCount = Physics.RaycastNonAlloc(
        ray,
        _groundHits,
        groundRayLength,
        groundMask,
        QueryTriggerInteraction.Ignore
    );

    return hitCount > 0;
}

흐름은 단순합니다.

  1. 현재 땅에 닿아 있다면 _lastGroundedTime 갱신
  2. 이번 프레임에 점프 버튼이 눌렸다면 _lastJumpPressedTime 갱신

즉, 점프 가능 여부를 한순간의 판정으로만 보는 것이 아니라 “최근에 눌렀는가?”, “최근까지 땅에 닿아 있었는가?” 를 함께 계산하는 구조입니다.


5. 실제 점프 처리

실제 점프 실행은 TryConsumeBufferedJump() 에서 처리합니다. 여기서 핵심은 buffered와 coyote bool 변수입니다.

// 점프 버퍼 + 코요테 타임을 적용해 실제 점프 실행
// 점프 컷 기능은 제거된 상태
private void TryConsumeBufferedJump(bool jumpHeld)
{
    bool buffered = (Time.time - _lastJumpPressedTime) <= jumpBufferTime;
    bool coyote = (Time.time - _lastGroundedTime) <= coyoteTime;

    // 점프 버튼이 유지 중이어야 하고
    // 최근에 누른 기록이 있어야 하며
    // 최근까지 지면에 닿아 있어야 함
    if (!jumpHeld) return;
    if (!buffered) return;
    if (!coyote) return;

    _lastJumpPressedTime = -999f;
    _lastGroundedTime = -999f;

    // 아래로 떨어지는 중이면 하강 속도 제거 후 점프
    Vector3 v = rb.linearVelocity;
    if (v.y < 0f)
        v.y = 0f;

    rb.linearVelocity = v;

    float impulse = rb.mass * initialUpVelocity;
    rb.AddForce(Up * impulse, ForceMode.Impulse);

    DidJumpThisFrame = true;
}

여기서

  • buffered
    → 최근 점프 입력이 아직 유효한가
  • coyote
    → 최근까지 지면에 닿아 있었는가

를 판단합니다.

 

즉 정리하면

  1. 현재 점프 버튼을 누르고 있어야 하고
  2. 최근 점프 입력이 아직 유효해야 하며
  3. 최근 지면 접촉 기록도 허용 시간 안에 있어야 합니다.

이 세 조건을 만족하면 비로소 실제 점프가 실행됩니다.


6. 점프 실행 시 처리

실제로 점프를 할 때는 먼저 아래 방향 속도를 제거한 뒤, 위로 힘을 주는 방식으로 처리하고 있습니다.

// 5번 TryConsumeBufferedJump() 일부 내용
{
    Vector3 v = rb.linearVelocity;
    if (v.y < 0f)
    v.y = 0f;

    rb.linearVelocity = v;

    float impulse = rb.mass * initialUpVelocity;
    rb.AddForce(Up * impulse, ForceMode.Impulse);
}

이렇게 한 이유는 낙하 중일 때 기존 하강 속도가 남아 있으면, 점프 타이밍이 같더라도 점프 높이가 애매하게 줄어들 수 있기 때문입니다.

그래서 먼저 아래로 떨어지는 속도를 0으로 정리한 뒤, 그 다음 동일한 기준으로 점프를 시작하게 만들어 점프감이 더 안정적으로 유지되도록 했습니다.


7. 왜 점프 버퍼만 넣지 않고 코요테 타임도 같이 넣었는가

처음에 제가 해결하려던 문제는 착지 직전에 눌렀는데 입력이 안 먹는 문제였습니다. 그래서 그 문제만 놓고 보면 핵심은 점프 버퍼가 맞습니다.

하지만 실제 플레이를 해보면 반대로 발판 끝에서 점프를 눌렀는데 아주 살짝 늦어서 입력이 안 먹는 경우도 있었습니다.

결국 둘의 역할은 아래처럼 나뉘었습니다.

  1. 점프 버퍼
    → 착지 직전에 미리 누른 입력 보정
  2. 코요테 타임
    → 발판 끝에서 늦게 누른 입력 보정

즉, 둘 다 점프 조작을 더 부드럽게 만들어주지만 보정하는 방향은 서로 반대입니다.

NOTE) 착지 직전 입력 씹힘 문제만 해결하고 싶다면 점프 버퍼만으로도 충분할 수 있습니다. 다만 실제 게임 전체 조작감을 생각하면, 코요테 타임까지 함께 적용했을 때 훨씬 자연스럽게 느껴졌습니다.


8. PlayerController에서의 연결 흐름

최종적으로는 PlayerController.FixedUpdate() 에서 입력값을 PlayerMoveController.Tick() 으로 넘겨주고 있습니다.

private void FixedUpdate()
{
    // 이번 물리 프레임에서 사용할 순간 입력값 복사
    bool jumpPressedThisFrame = inputController.JumpPressedThisFrame;

    moveController.Tick(
        inputController.LeftHeld,
        inputController.RightHeld,
        inputController.JumpHeld,
        jumpPressedThisFrame,
        stateController.IsStunned
    );
}

즉 전체 흐름은 아래와 같습니다.

  1. PlayerInputController
    → 점프 입력 상태 저장
  2. PlayerController
    → 입력값을 이동 처리 쪽으로 전달
  3. PlayerMoveController
    → 최근 입력 시간 / 최근 지면 시간 계산 후 실제 점프 실행

구조를 이렇게 나누어두니 입력 관리, 물리 처리, 실제 점프 판정을 각각 분리해서 보기 편하다는 장점도 있었습니다.


점프 게임에서는 유저가 정확히 한 프레임에 맞춰 점프를 눌러야만 입력이 들어가도록 만들면, 실제 체감 조작감이 많이 뻑뻑해질 수 있습니다.

그래서 이번에는

  • 착지 직전 입력을 살려주는 점프 버퍼
  • 발판 끝의 늦은 입력을 살려주는 코요테 타임

을 함께 적용해서 점프 입력을 조금 더 관대하게 처리했습니다.

결과적으로 유저 입장에서는 “분명 눌렀는데 왜 안되지?”라는 느낌이 줄어들고, 전체적인 점프 조작감도 훨씬 부드러워졌습니다.

 

처음에는 착지 직전 입력 씹힘 문제를 해결하기 위해 점프 버퍼를 적용했지만, 실제 플레이 경험을 다듬다 보니 코요테 타임까지 함께 넣는 것이 더 자연스럽다고 느껴졌습니다.

결국 두 기능은 단순히 점프를 쉽게 만드는 것이 아니라, 유저가 의도한 입력을 더 정확하게 게임이 받아들이도록 보정해주는 장치라고 생각하면 될 것 같습니다.