구현
시간과 판정을 다루다 샌 자리들. 전부 에러를 안 내고 조용히 틀렸습니다.
Unity 6, URP. 여기 적는 건 프레임워크 이야기가 아니라 시간과 판정을 다루다 실제로 샌 자리들이다. 공통점이 하나 있다 — 전부 예외를 안 던진다.
기록은 무차원으로, 치수는 매 프레임
전조 시간을 실시간으로 돌려야 하는데, 처음 코드는 대기 시간을 고정 값으로 한 번 넣고 기다리는 방식이었다. 재보니 그 방식이 43곳, 매 프레임 다시 해석하는 방식이 22곳이었다.
원인은 “편집 가능한 에디터가 없어서”가 아니다. 치수를 언제 굳히느냐다. yield return new WaitForSeconds(x) 한 줄이 그 시점에 x를 굳혀 버린다. 그다음엔 게임을 멈추고 인스펙터를 아무리 만져도 안 변한다 — 값이 이미 코루틴 안에 복사돼 있기 때문이다.
yield return new WaitForSeconds(x);
float t0 = Time.time; while (Time.time - t0 < x) yield return null;
아래쪽은 x를 매 프레임 다시 읽는다. 그래서 이미 도는 전조가 그 자리에서 바뀐다.
const float은 코루틴에 굳히는 것보다 더 나쁘다. 후자는 적어도 다음 플레이에 반영되는데, 상수는 빌드를 다시 해야 한다. 연출 길이는 예외 없이 필드로 둔다.
구간이 둘 이상이면 시작 시각도 하나로 합친다. 페이드와 플래시에 시작 시각을 각각 두면, 진행 중에 총 길이가 바뀔 때 두 구간의 합이 총 길이와 어긋난다. 전조와 실제 타격 시점이 갈라진다는 뜻이다. 진행률 하나를 매 프레임 구하고 구간은 그 안에서 나눈다.
시간을 건드리면 세 군데가 샌다
히트스톱과 슬로우모션을 넣고 나서 같은 날 셋이 나왔다.
| 증상 | 원인 |
|---|---|
| 0.3배속에서 3초 대기가 실제 10초 | 대기가 게임 시간을 따라간다 |
| 슬로우 중 씬 전환하면 배율이 0.3에 영구 고정 | 복원 코드를 안 거치고 나감 |
| 죽은 뒤에 히트박스가 발동 | 차지 대기 중 사망 → 대기가 끝나며 그대로 실행 |
세 번째가 제일 고약하다. 코루틴은 자기를 시작시킨 개체가 죽었는지 모른다. 그래서 yield 직후마다 살아 있는지 다시 묻게 했다.
셋 다 에러를 안 낸다. 게임이 멈추거나, 죽은 적이 때리거나, 대기가 세 배로 늘어날 뿐이다.
가드를 Update()에만 두면 절반만 막힌다
일시정지나 컷신처럼 “지금은 입력을 받으면 안 되는” 구간에 가드를 걸었다. Update() 안에서 검사했다.
절반만 막혔다.
Update()는 시간 배율에 따라 멈추지만, 입력 액션 콜백·이벤트 콜백·배율 무관 시간을 쓰는 코드는 그대로 돈다. 화면은 멈춰 있는데 입력은 들어가는 상태가 만들어진다.
“멈춘다”는 하나가 아니다. 시간이 멈추는 것, 프레임 루프가 멈추는 것, 입력이 안 들어오는 것이 전부 다른 스위치다. 가드는 막으려는 것과 같은 층에 걸어야 한다.
막았는데 맞는다
파훼 판정에서 나온 것이고, 이 프로젝트에서 제일 배운 게 많은 버그다.
초식을 막으면 그 판정을 목록에서 즉시 빼도록 짰다. 깔끔해 보였다. 그런데 나중에 피해 계산이 “이 공격 막혔어?“라고 물으면 목록에 없으니 없음이 돌아오고, 없음은 “안 막았다”로 해석돼 정타가 그대로 통과했다.
고친 방향은 빼지 말고 표시하는 것이다. 막혔다는 사실을 결과로 남기고, 정리는 소비 시점에 맡긴다. 그러면 호출하는 쪽이 조기 반환을 깜빡해도 피해가 0이 되어 이중으로 막힌다.
배운 건 이거다. “처리했으니 지운다”는 그 사실을 나중에 물어볼 사람이 없을 때만 맞다.
신호를 재활용하지 않는다
파훼 성공 신호를 기존 “공격 모션” 이벤트로 받았다. 이미 있는 이벤트라 편했다.
그 이벤트는 평타에서도 발화한다. 결과적으로 좌클릭을 난타하면 적의 초식이 깨졌다. 계열도 타이밍도 아무 상관 없이.
전용 이벤트를 만들고 발화 지점을 초식 발동 성공 직후 한 곳으로 못박았다. 신호를 아끼려다 시스템 하나를 통째로 무의미하게 만든 경우다.
activeSelf는 “살아 있음”이 아니다
오브젝트 풀을 쓰면서 두 번 데였다.
반납은 물리 콜백을 안 쏜다. SetActive(false)로 돌려보내면 OnTriggerExit가 발화하지 않아서, “지금 범위 안에 있는 대상” 명부에 반납된 개체가 그대로 남는다. 같은 인스턴스가 맵 반대편에서 다시 스폰되면 거기서 얻어맞는다.
타격감 때문에 죽어도 바로 안 꺼진다. 사망 연출이 도는 동안 activeSelf는 계속 참이라, 그것만 보는 스캐너는 시체를 유효한 대상으로 잡는다.
둘 다 처방은 같은 방향이다. activeSelf를 살아 있음의 동의어로 쓰지 않는다 — 실체력이나 사망 플래그를 조건에 넣고, 명부는 쓸 때 공간을 다시 검증한다. 오염원이 무엇이든 막히는 쪽으로 짜는 게 낫다.