플랫폼 게임의 조작감을 살리는 Jump Buffer와 Coyote Time

플랫폼 게임의 조작감을 살리는 Jump Buffer와 Coyote Time

점프 입력과 접지 판정을 짧은 시간 동안 보존해 플랫폼 게임의 답답함을 줄이는 방법을 정리합니다. Unity C# 예제로 타이머 기반 구현과 흔한 오류를 함께 설명합니다.

왜 점프가 가끔 답답하게 느껴질까

플랫폼 게임에서 플레이어는 발판 끝을 막 벗어난 순간에도 점프할 수 있기를 기대합니다. 착지 직전에 점프 버튼을 눌렀다면 착지와 동시에 자연스럽게 다시 뛰어오르기를 기대하기도 합니다.

하지만 물리 갱신과 입력 판정은 프레임 단위로 이루어집니다. 그 짧은 틈에 입력이나 접지 상태가 엇갈리면 플레이어는 분명 점프를 눌렀는데도 아무 일도 일어나지 않는 경험을 하게 됩니다. Jump Buffer와 Coyote Time은 이 틈을 의도적으로 보정하는 기법입니다.

발판 끝에서 뛰려는 캐릭터와 착지 직전 점프 입력을 보여 주는 플랫폼 게임 장면

두 기법의 역할

Coyote Time

Coyote Time은 캐릭터가 바닥을 떠난 뒤에도 잠시 점프를 허용하는 시간입니다. 이름은 절벽 밖으로 나간 뒤 한동안 공중에 떠 있는 만화 표현에서 유래했습니다.

캐릭터가 마지막으로 바닥에 닿아 있던 시점부터 경과한 시간이 설정값 이하라면 점프를 허용합니다. 보통 0.05초에서 0.15초 사이부터 시작해 게임의 이동 속도와 발판 크기에 맞춰 조정합니다.

Jump Buffer

Jump Buffer는 점프 버튼을 누른 입력을 짧게 보관하는 시간입니다. 공중에서 점프를 눌렀더라도 곧 착지한다면 그 입력을 소비해 즉시 점프합니다.

이 역시 대개 0.05초에서 0.15초 정도로 설정합니다. 너무 길면 플레이어가 의도하지 않은 자동 점프처럼 느낄 수 있습니다.

핵심 판정은 두 타이머의 교집합이다

점프를 실행하려면 다음 두 조건이 동시에 참이어야 합니다.

  • 최근에 점프 입력이 있었다.
  • 현재 접지 중이거나 최근까지 접지 상태였다.

시간을 현재 시각 tt, 마지막 점프 입력 시각 tinputt_{input}, 마지막 접지 시각 tgroundt_{ground}라고 하겠습니다. Jump Buffer 시간은 BB, Coyote Time은 CC입니다.

ttinputBt - t_{input} \leq B ttgroundCt - t_{ground} \leq C

두 식이 모두 참이면 점프를 실행할 수 있습니다. 실제 구현에서는 매 프레임 시간을 비교해도 되지만 남은 시간을 감소시키는 카운트다운 타이머가 읽기 쉽고 디버깅하기도 편합니다.

flowchart TD
    A[점프 버튼 입력] --> B[Jump Buffer 타이머 갱신]
    C[바닥 접지 확인] --> D[접지 상태면 Coyote 타이머 갱신]
    B --> E{두 타이머가 모두 남았는가?}
    D --> E
    E -- 예 --> F[점프 속도 적용]
    F --> G[두 타이머 소비]
    E -- 아니오 --> H[다음 프레임까지 대기]

중요한 점은 점프가 성공했을 때 입력 버퍼를 반드시 소비하는 것입니다. 소비하지 않으면 한 번의 입력이 다음 착지까지 남아 의도치 않은 연속 점프를 만들 수 있습니다.

Unity 2D 예제

아래 예제는 Rigidbody2DOverlapCircle로 바닥을 검사합니다. 입력은 Update에서 읽고 물리 속도 변경은 FixedUpdate에서 처리합니다. 이렇게 나누면 물리 갱신 주기와 렌더링 프레임이 달라도 입력을 놓치기 어렵습니다.

using UnityEngine;

public class PlayerJump : MonoBehaviour
{
    [Header("References")]
    [SerializeField] private Rigidbody2D body;
    [SerializeField] private Transform groundCheck;
    [SerializeField] private LayerMask groundLayer;

    [Header("Jump")]
    [SerializeField] private float jumpVelocity = 12f;
    [SerializeField] private float coyoteTime = 0.1f;
    [SerializeField] private float jumpBufferTime = 0.1f;
    [SerializeField] private float groundCheckRadius = 0.15f;

    private float coyoteCounter;
    private float jumpBufferCounter;

    private void Update()
    {
        if (Input.GetButtonDown("Jump"))
        {
            jumpBufferCounter = jumpBufferTime;
        }

        jumpBufferCounter -= Time.deltaTime;
    }

    private void FixedUpdate()
    {
        bool isGrounded = Physics2D.OverlapCircle(
            groundCheck.position,
            groundCheckRadius,
            groundLayer
        );

        if (isGrounded)
        {
            coyoteCounter = coyoteTime;
        }
        else
        {
            coyoteCounter -= Time.fixedDeltaTime;
        }

        TryJump();
    }

    private void TryJump()
    {
        bool hasBufferedJump = jumpBufferCounter > 0f;
        bool canUseGroundGrace = coyoteCounter > 0f;

        if (!hasBufferedJump || !canUseGroundGrace)
        {
            return;
        }

        body.linearVelocity = new Vector2(body.linearVelocity.x, jumpVelocity);
        jumpBufferCounter = 0f;
        coyoteCounter = 0f;
    }

    private void OnDrawGizmosSelected()
    {
        if (groundCheck == null)
        {
            return;
        }

        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
    }
}

groundCheck는 캐릭터 발밑에 두고 groundLayer에는 발판과 지형 레이어를 지정합니다. Unity 버전에 따라 Rigidbody2D.linearVelocity 대신 Rigidbody2D.velocity를 사용해야 할 수 있습니다.

Unity 2D 캐릭터 발밑의 Ground Check 원과 발판 콜라이더를 표시한 장면

구현할 때 자주 생기는 문제

점프 입력을 FixedUpdate에서만 읽는 경우

GetButtonDown은 버튼을 누른 한 프레임만 참이 됩니다. 렌더링 프레임과 물리 프레임의 시점이 맞지 않으면 입력을 놓칠 수 있습니다. 입력 감지는 Update에서 하고 실제 점프 적용은 FixedUpdate에서 수행하는 구성이 안전합니다.

점프 후에도 Coyote Time을 남겨 두는 경우

점프를 실행한 직후 Coyote Time을 초기화하지 않으면 버퍼 입력이나 다른 점프 로직과 결합했을 때 같은 접지 기회를 여러 번 사용할 여지가 생깁니다. 일반적인 단일 점프에서는 성공 직후 두 타이머를 모두 0으로 만듭니다.

타이머가 음수가 되는 경우

카운터가 음수가 되어도 > 0f 비교를 한다면 기능에는 문제가 없습니다. 다만 인스펙터나 디버그 로그에서 값을 보기 좋게 유지하려면 Mathf.Max(0f, counter - deltaTime)으로 제한할 수 있습니다.

경사면과 움직이는 발판

단순한 원형 검사만으로는 경사면 가장자리나 빠르게 움직이는 발판에서 접지가 불안정할 수 있습니다. 캐릭터 콜라이더의 형태에 맞춰 BoxCastCapsuleCast를 사용하고 이동 발판 위에서는 발판의 속도 또는 부모 관계를 별도로 처리하는 편이 안정적입니다.

값 조정 기준

처음에는 Coyote Time과 Jump Buffer를 모두 0.1초로 두고 테스트하는 방법이 좋습니다. 다음 상황을 반복해 보면서 값을 조절합니다.

  • 발판 끝을 지난 직후 점프가 자연스럽게 되는가
  • 낙하 중 점프 입력 후 착지했을 때 즉시 점프하는가
  • 너무 이른 입력이 나중에 원치 않는 점프로 이어지지 않는가
  • 빠른 이동 속도와 작은 발판에서도 일관되게 동작하는가

두 값은 항상 같을 필요가 없습니다. 발판 끝에서의 실수를 더 관대하게 다루고 싶다면 Coyote Time을 조금 길게 두고 의도치 않은 자동 점프를 줄이고 싶다면 Jump Buffer를 더 짧게 두는 식으로 조정할 수 있습니다.

마무리

Jump Buffer는 입력을 잠시 기억하고 Coyote Time은 최근 접지 상태를 잠시 기억합니다. 두 기억이 겹치는 순간에만 점프를 실행하면 물리적으로는 엄격한 판정을 유지하면서도 플레이어에게는 훨씬 자연스러운 조작감을 줄 수 있습니다.

이 로직은 이단 점프, 벽 점프, 대시처럼 별도의 이동 상태가 늘어나도 그대로 확장할 수 있습니다. 다만 각 행동이 어떤 입력 버퍼와 어떤 상태 유예 시간을 소비하는지 명확히 정해 두어야 예측 가능한 조작감을 유지할 수 있습니다.

#Unity#플랫폼 게임#게임플레이 프로그래밍#C##입력 처리

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs