# 구현

> 시간과 판정을 다루다 샌 자리들. 전부 에러를 안 내고 조용히 틀렸습니다.

- 수치: Unity 6
- 상태: wip
- 사람이 보는 화면: https://aitryed.com/ko/built/moon-studio/wuxia/client/
- 반대 언어: https://aitryed.com/en/built/moon-studio/wuxia/client/index.md
- 이 사이트 안내(AI용): https://aitryed.com/ko/llms.txt

---

**시간과 판정을 다루다 «샌» 자리들입니다. 전부 오류를 안 내고 «조용히» 틀렸습니다.**

## 이 글의 공통점

여기 모인 버그가 **하나도 오류 메시지를 안 냈습니다.**

전부 **돌아가긴 하는데 틀린 값**을 내놨습니다. 그래서 **눈으로 이상한 걸 발견하고 나서야** 찾았습니다.

## 하나 — 「숫자를 기억하지 말고 비율을 기억해라」

남의 것을 뜯어보다 배워 온 규칙인데, 제 코드 **43군데**가 반대로 되어 있었습니다.

문제는 이렇습니다. 효과 하나가 <strong>「나는 3미터짜리」</strong>라고 기억하고 있으면, 나중에 설정에서 전체 크기를 바꿔도 **그 3미터는 안 따라옵니다.**

그래서 **비율만 기억하고, 실제 크기는 화면을 그릴 때마다 다시 계산**하게 바꿨습니다.

## 둘 — 「시간을 건드리면 «세 군데»가 샙니다」

액션 게임에는 **시간을 잠깐 느리게** 만드는 연출이 있습니다. 그런데 시간을 건드리면 **의외의 곳이 같이 느려집니다.**

| 같이 느려지면 안 되는 것 | 느려지면 |
|---|---|
| **화면 효과** | 연출이 늘어져서 이상해짐 |
| **UI 애니메이션** | 메뉴가 굼떠짐 |
| **입력 처리** | **조작이 안 먹는 것처럼 느껴짐** |

세 번째가 제일 나쁩니다. <strong>「게임이 이상하다」가 아니라 「내 조작이 안 먹힌다」</strong>로 느껴지는데, 그게 제일 짜증나는 종류입니다.

그래서 **시간에 영향받는 것과 안 받는 것을 명시적으로 갈라** 놨습니다.

## 셋 — 「한 군데만 막으면 절반만 막힙니다」

안전장치를 **한 곳에만** 걸어 뒀더니 **절반만 막혔습니다.**

이유는 **들어오는 길이 둘**이었기 때문입니다. 한 길에만 문을 달았습니다.

이게 흔한 실수입니다. **막았다고 생각한 순간 다른 길을 안 세어 봅니다.**

> **막기 전에 「이 자리에 닿는 길이 몇 개인가」를 먼저 세어야 합니다.**

## 넷 — 「막았는데 맞습니다」

제일 헷갈렸던 버그입니다.

화면상으로는 **분명히 막았습니다.** 그런데 **피해가 들어옵니다.**

원인은 **판정 시점과 표시 시점이 달랐던 것**이었습니다. 화면에는 「막음」이 떴는데, **실제 판정은 그 전에 이미 끝나** 있었습니다.

이런 버그가 무서운 이유는 — <strong>플레이어가 「이 게임 판정이 이상하다」</strong>로 느낀다는 것입니다. 그리고 그건 **고치기 전에는 설명이 안 됩니다.**

## 다섯 — 「신호를 재활용하지 않습니다」

「무언가 일어났다」는 신호를 하나 만들어 두고, **여러 곳에서 돌려 쓰고** 싶어집니다. 편하니까요.

그런데 그러면 **나중에 「이 신호가 왜 왔지?」를 알 수가 없습니다.** 보낸 곳이 여러 개니까요.

그래서 **비슷해 보여도 신호를 따로** 만듭니다. 조금 늘어나는 대신 **추적이 됩니다.**

## 여섯 — 「«켜져 있음»이 «살아 있음»은 아닙니다」

마지막이자 제일 배울 게 많은 자리입니다.

어떤 물체가 **동작 중인지** 확인할 때, **「켜져 있나」를 봤습니다.** 그럴듯합니다.

그런데 이 둘은 다릅니다.

| 상태 | 켜져 있나 | 실제로 동작하나 |
|---|---|---|
| 정상 | **예** | 예 |
| **끝났는데 안 치워짐** | **예** | **아니오** |
| 시작 전 | 예 | 아니오 |

**「켜져 있음」은 꽤 오래 「예」로 남아 있습니다.** 그래서 그걸로 판정하면 **끝난 것도 살아 있는 것으로** 셉니다.

> **「존재하나」와 「동작하나」는 다른 질문입니다.** 그리고 대부분의 시스템에서 앞엣것이 훨씬 확인하기 쉬워서, 자연히 앞엣것을 보게 됩니다.

## 여기부터는 자세한 기록입니다

Unity 6, URP. 여기 적는 건 프레임워크 이야기가 아니라 **시간과 판정을 다루다 실제로 샌 자리**들이다. 공통점이 하나 있다 — 전부 예외를 안 던진다.

## 기록은 무차원으로, 치수는 매 프레임

전조 시간을 실시간으로 돌려야 하는데, 처음 코드는 대기 시간을 **고정 값으로 한 번 넣고 기다리는** 방식이었다. 재보니 그 방식이 43곳, 매 프레임 다시 해석하는 방식이 22곳이었다.

원인은 "편집 가능한 에디터가 없어서"가 아니다. **치수를 언제 굳히느냐**다. `yield return new WaitForSeconds(x)` 한 줄이 그 시점에 x를 굳혀 버린다. 그다음엔 게임을 멈추고 인스펙터를 아무리 만져도 안 변한다 — 값이 이미 코루틴 안에 복사돼 있기 때문이다.

<div class="ex">
<div class="x"><b>BAD — 이 한 줄이 x를 굳힌다</b>
<p class="m">yield return new WaitForSeconds(x);</p></div>
<div class="o"><b>GOOD — x를 매 프레임 다시 읽는다</b>
<p class="m">float t0 = Time.time;
while (Time.time - t0 &lt; x) yield return null;</p></div>
</div>

아래쪽은 x를 매 프레임 다시 읽는다. 그래서 이미 도는 전조가 **그 자리에서** 바뀐다.

`const float`은 코루틴에 굳히는 것보다 **더 나쁘다.** 후자는 적어도 다음 플레이에 반영되는데, 상수는 빌드를 다시 해야 한다. 연출 길이는 예외 없이 필드로 둔다.

구간이 둘 이상이면 시작 시각도 하나로 합친다. 페이드와 플래시에 시작 시각을 각각 두면, 진행 중에 총 길이가 바뀔 때 **두 구간의 합이 총 길이와 어긋난다.** 전조와 실제 타격 시점이 갈라진다는 뜻이다. 진행률 하나를 매 프레임 구하고 구간은 그 안에서 나눈다.

## 시간을 건드리면 세 군데가 샌다

히트스톱과 슬로우모션을 넣고 나서 같은 날 셋이 나왔다.

| 증상 | 원인 |
|---|---|
| 0.3배속에서 3초 대기가 **실제 10초** | 대기가 게임 시간을 따라간다 |
| 슬로우 중 씬 전환하면 **배율이 0.3에 영구 고정** | 복원 코드를 안 거치고 나감 |
| 죽은 뒤에 **히트박스가 발동** | 차지 대기 중 사망 → 대기가 끝나며 그대로 실행 |

세 번째가 제일 고약하다. 코루틴은 자기를 시작시킨 개체가 죽었는지 모른다. 그래서 `yield` 직후마다 살아 있는지 다시 묻게 했다.

셋 다 **에러를 안 낸다.** 게임이 멈추거나, 죽은 적이 때리거나, 대기가 세 배로 늘어날 뿐이다.

## 가드를 `Update()`에만 두면 절반만 막힌다

일시정지나 컷신처럼 "지금은 입력을 받으면 안 되는" 구간에 가드를 걸었다. `Update()` 안에서 검사했다.

**절반만 막혔다.**

`Update()`는 시간 배율에 따라 멈추지만, **입력 액션 콜백·이벤트 콜백·배율 무관 시간을 쓰는 코드는 그대로 돈다.** 화면은 멈춰 있는데 입력은 들어가는 상태가 만들어진다.

**"멈춘다"는 하나가 아니다.** 시간이 멈추는 것, 프레임 루프가 멈추는 것, 입력이 안 들어오는 것이 전부 다른 스위치다. 가드는 막으려는 것과 **같은 층에** 걸어야 한다.

## 막았는데 맞는다

파훼 판정에서 나온 것이고, 이 프로젝트에서 제일 배운 게 많은 버그다.

초식을 막으면 그 판정을 **목록에서 즉시 빼도록** 짰다. 깔끔해 보였다. 그런데 나중에 피해 계산이 "이 공격 막혔어?"라고 물으면 목록에 없으니 **`없음`이 돌아오고**, 없음은 "안 막았다"로 해석돼 정타가 그대로 통과했다.

고친 방향은 **빼지 말고 표시하는 것**이다. 막혔다는 사실을 결과로 남기고, 정리는 소비 시점에 맡긴다. 그러면 호출하는 쪽이 조기 반환을 깜빡해도 피해가 0이 되어 **이중으로 막힌다.**

배운 건 이거다. **"처리했으니 지운다"는 그 사실을 나중에 물어볼 사람이 없을 때만 맞다.**

## 신호를 재활용하지 않는다

파훼 성공 신호를 기존 "공격 모션" 이벤트로 받았다. 이미 있는 이벤트라 편했다.

그 이벤트는 **평타에서도 발화한다.** 결과적으로 좌클릭을 난타하면 적의 초식이 깨졌다. 계열도 타이밍도 아무 상관 없이.

전용 이벤트를 만들고 발화 지점을 **초식 발동 성공 직후 한 곳**으로 못박았다. 신호를 아끼려다 시스템 하나를 통째로 무의미하게 만든 경우다.

## `activeSelf`는 "살아 있음"이 아니다

오브젝트 풀을 쓰면서 두 번 데였다.

**반납은 물리 콜백을 안 쏜다.** `SetActive(false)`로 돌려보내면 `OnTriggerExit`가 발화하지 않아서, "지금 범위 안에 있는 대상" 명부에 반납된 개체가 그대로 남는다. 같은 인스턴스가 맵 반대편에서 다시 스폰되면 **거기서 얻어맞는다.**

**타격감 때문에 죽어도 바로 안 꺼진다.** 사망 연출이 도는 동안 `activeSelf`는 계속 참이라, 그것만 보는 스캐너는 **시체를 유효한 대상으로 잡는다.**

둘 다 처방은 같은 방향이다. `activeSelf`를 살아 있음의 동의어로 쓰지 않는다 — 실체력이나 사망 플래그를 조건에 넣고, 명부는 쓸 때 공간을 다시 검증한다. **오염원이 무엇이든 막히는 쪽으로** 짜는 게 낫다.
