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