무적시간이 왜 체감상 더 길지? — 판정 주기와 무적시간의 숨은 상호작용

UDangTangTang 개발기 — 트러블슈팅 / 판정 버그

“무적시간이 너무 관대한 것 같은데?”

플레이어가 적과 접촉했을 때 데미지를 입고, 이후 짧은 무적시간(Invincibility Frame) 동안은 추가 피격을 받지 않도록 구현했습니다. 코드상 설정값은 0.5초였습니다.

그런데 플레이테스트 중 팀원에게서 이런 얘기가 나왔습니다.

“무적시간이 체감상 0.5초보다 훨씬 긴 것 같은데? 적 사이를 그냥 통과해도 거의 안 맞아.”

설정값은 분명 0.5초인데, 실제 플레이에서는 그보다 관대하게 느껴진다는 것이었습니다. 코드를 다시 열어봐야 했습니다.

원인을 찾기 위해 판정 로직 다시 보기

피격 판정이 어떻게 이루어지는지 확인해봤습니다. 접촉 데미지는 매 프레임 충돌을 검사하는 대신, 일정 주기(0.2초)마다 판정하는 방식으로 구현되어 있었습니다.

private float _hitCheckInterval = 0.2f;
private float _hitCheckTimer;

private void Update()
{
    _hitCheckTimer -= Time.deltaTime;

    if (_hitCheckTimer <= 0f && IsCollidingWithEnemy())
    {
        TakeDamage();
        _hitCheckTimer = _hitCheckInterval;
    }
}

그리고 데미지를 입으면 별도로 무적시간이 시작됩니다.

private float _invincibilityDuration = 0.5f;
private bool _isInvincible;

private void TakeDamage()
{
    if (_isInvincible) return;

    _currentHp -= damageAmount;
    StartCoroutine(InvincibilityRoutine());
}

private IEnumerator InvincibilityRoutine()
{
    _isInvincible = true;
    yield return new WaitForSeconds(_invincibilityDuration);
    _isInvincible = false;
}

각각의 로직은 개별적으로 보면 문제가 없어 보입니다. 그런데 이 둘을 같이 놓고 시간 흐름을 그려보니 문제가 보였습니다.

시간 흐름으로 그려보기

두 타이머가 서로 독립적으로 돌아간다는 게 핵심입니다.

0.0초: 피격 판정 발생 → 데미지 적용, 무적 시작 (0.5초간)
0.0초: 판정 주기 타이머도 리셋 (다음 판정까지 0.2초)
0.2초: 판정 주기 도래 → 하지만 아직 무적 상태라 데미지 무시
0.4초: 판정 주기 도래 → 여전히 무적 상태라 데미지 무시
0.5초: 무적 종료
0.6초: 판정 주기 도래 → 이제서야 다시 데미지 판정 가능

여기서 실제 무적 종료(0.5초)와 다음 판정 시점(0.6초) 사이에 0.1초의 추가 여유가 생깁니다. 설정한 무적시간은 0.5초지만, 판정 주기와 맞물려 실질적으로는 최대 0.7초 가까이(0.5초 + 판정 주기 최대 대기시간 0.2초) 무적처럼 동작할 수 있는 구조였던 겁니다.

왜 이런 구조가 됐을까

판정 주기(0.2초)를 둔 이유 자체는 합리적이었습니다 — 매 프레임 충돌 검사를 하면 불필요한 연산이 늘어나니, 일정 주기로만 검사해서 성능을 아끼려는 의도였습니다. 이전 글에서 다뤘던 “매 프레임 반복 연산을 줄이자”는 최적화 원칙과 같은 맥락입니다.

문제는 이 판정 주기 타이머와 무적시간 타이머가 서로의 존재를 모른 채 독립적으로 돌아간다는 점이었습니다. 각각 따로 보면 정상 동작이지만, 두 시스템이 상호작용하는 지점에서 의도치 않은 “추가 유예시간”이 생긴 겁니다.

해결 방법

두 가지 방식으로 해결할 수 있었습니다.

방법 1: 무적 종료 시 판정 타이머를 리셋하기

private IEnumerator InvincibilityRoutine()
{
    _isInvincible = true;
    yield return new WaitForSeconds(_invincibilityDuration);
    _isInvincible = false;

    _hitCheckTimer = 0f; // 무적이 끝나자마자 바로 판정 가능하도록
}

무적이 끝나는 순간 판정 타이머를 0으로 초기화해서, 다음 프레임에 바로 판정이 가능하도록 만드는 방법입니다. 구조를 크게 바꾸지 않고 빠르게 적용할 수 있는 수정입니다.

방법 2: 판정 주기 자체를 없애고 충돌 이벤트 기반으로 전환하기

private void OnTriggerStay2D(Collider2D other)
{
    if (_isInvincible) return;
    if (other.CompareTag("Enemy"))
    {
        TakeDamage();
    }
}

Unity의 트리거 이벤트를 활용하면, 별도의 시간 기반 판정 주기 없이 무적 상태 여부만으로 데미지 발생을 제어할 수 있습니다. 이렇게 하면 애초에 두 타이머가 어긋날 여지 자체가 사라집니다. 다만 이 방식은 접촉 중인 모든 프레임에 OnTriggerStay2D가 호출되므로, 몬스터가 많을 때는 성능을 고려해야 합니다.

이번에는 기존 구조를 크게 바꾸지 않고도 문제를 해결할 수 있는 방법 1을 적용했습니다.

배운 점

  1. 개별 로직이 정상이어도, 두 시스템이 상호작용하는 지점에서 문제가 생길 수 있습니다. 판정 주기와 무적시간은 각각 코드 리뷰에서 문제없어 보였지만, 시간 흐름을 함께 그려보니 숨겨진 간극이 드러났습니다.
  2. “왜 체감이 다르지?”라는 질문에는 실제 시간 흐름을 표나 다이어그램으로 그려보는 게 효과적입니다. 코드를 눈으로만 읽어서는 두 타이머의 상호작용을 알아채기 어려웠지만, 시간순으로 나열하니 바로 보였습니다.
  3. 성능을 위한 최적화(판정 주기)가 다른 시스템(무적시간)과 맞물릴 때는 항상 상호작용을 검토해야 합니다. 최적화 자체는 문제가 아니었지만, 그로 인해 생기는 부수 효과까지 고려하지 못했던 것이 원인이었습니다.

시간 기반 로직 두 개 이상이 같은 대상(플레이어, 적 등)에 동시에 작용하고 있다면, 각각 따로 테스트하기보다 실제 시간 흐름을 같이 그려보시길 추천합니다. 개별로는 안 보이던 문제가 생각보다 자주 드러납니다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤