구현
시간과 판정을 다루다 샌 자리들. 전부 에러를 안 내고 조용히 틀렸습니다.
시간과 판정을 다루다 «샌» 자리들입니다. 전부 오류를 안 내고 «조용히» 틀렸습니다.
이 글의 공통점
여기 모인 버그가 하나도 오류 메시지를 안 냈습니다.
전부 돌아가긴 하는데 틀린 값을 내놨습니다. 그래서 눈으로 이상한 걸 발견하고 나서야 찾았습니다.
하나 — 「숫자를 기억하지 말고 비율을 기억해라」
남의 것을 뜯어보다 배워 온 규칙인데, 제 코드 43군데가 반대로 되어 있었습니다.
문제는 이렇습니다. 효과 하나가 「나는 3미터짜리」라고 기억하고 있으면, 나중에 설정에서 전체 크기를 바꿔도 그 3미터는 안 따라옵니다.
그래서 비율만 기억하고, 실제 크기는 화면을 그릴 때마다 다시 계산하게 바꿨습니다.
둘 — 「시간을 건드리면 «세 군데»가 샙니다」
액션 게임에는 시간을 잠깐 느리게 만드는 연출이 있습니다. 그런데 시간을 건드리면 의외의 곳이 같이 느려집니다.
| 같이 느려지면 안 되는 것 | 느려지면 |
|---|---|
| 화면 효과 | 연출이 늘어져서 이상해짐 |
| UI 애니메이션 | 메뉴가 굼떠짐 |
| 입력 처리 | 조작이 안 먹는 것처럼 느껴짐 |
세 번째가 제일 나쁩니다. 「게임이 이상하다」가 아니라 「내 조작이 안 먹힌다」로 느껴지는데, 그게 제일 짜증나는 종류입니다.
그래서 시간에 영향받는 것과 안 받는 것을 명시적으로 갈라 놨습니다.
셋 — 「한 군데만 막으면 절반만 막힙니다」
안전장치를 한 곳에만 걸어 뒀더니 절반만 막혔습니다.
이유는 들어오는 길이 둘이었기 때문입니다. 한 길에만 문을 달았습니다.
이게 흔한 실수입니다. 막았다고 생각한 순간 다른 길을 안 세어 봅니다.
막기 전에 「이 자리에 닿는 길이 몇 개인가」를 먼저 세어야 합니다.
넷 — 「막았는데 맞습니다」
제일 헷갈렸던 버그입니다.
화면상으로는 분명히 막았습니다. 그런데 피해가 들어옵니다.
원인은 판정 시점과 표시 시점이 달랐던 것이었습니다. 화면에는 「막음」이 떴는데, 실제 판정은 그 전에 이미 끝나 있었습니다.
이런 버그가 무서운 이유는 — 플레이어가 「이 게임 판정이 이상하다」로 느낀다는 것입니다. 그리고 그건 고치기 전에는 설명이 안 됩니다.
다섯 — 「신호를 재활용하지 않습니다」
「무언가 일어났다」는 신호를 하나 만들어 두고, 여러 곳에서 돌려 쓰고 싶어집니다. 편하니까요.
그런데 그러면 나중에 「이 신호가 왜 왔지?」를 알 수가 없습니다. 보낸 곳이 여러 개니까요.
그래서 비슷해 보여도 신호를 따로 만듭니다. 조금 늘어나는 대신 추적이 됩니다.
여섯 — 「«켜져 있음»이 «살아 있음»은 아닙니다」
마지막이자 제일 배울 게 많은 자리입니다.
어떤 물체가 동작 중인지 확인할 때, 「켜져 있나」를 봤습니다. 그럴듯합니다.
그런데 이 둘은 다릅니다.
| 상태 | 켜져 있나 | 실제로 동작하나 |
|---|---|---|
| 정상 | 예 | 예 |
| 끝났는데 안 치워짐 | 예 | 아니오 |
| 시작 전 | 예 | 아니오 |
「켜져 있음」은 꽤 오래 「예」로 남아 있습니다. 그래서 그걸로 판정하면 끝난 것도 살아 있는 것으로 셉니다.
「존재하나」와 「동작하나」는 다른 질문입니다. 그리고 대부분의 시스템에서 앞엣것이 훨씬 확인하기 쉬워서, 자연히 앞엣것을 보게 됩니다.
여기부터는 자세한 기록입니다
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를 살아 있음의 동의어로 쓰지 않는다 — 실체력이나 사망 플래그를 조건에 넣고, 명부는 쓸 때 공간을 다시 검증한다. 오염원이 무엇이든 막히는 쪽으로 짜는 게 낫다.