얼마전에 흥미로운 버그가 발생했습니다
심할 때는 고치는데 이틀도 걸리는 버그도 있는 것에 비하면 이 버그는 그리 해결하기 어려운 버그는 아니었지만
그 원인을 찾아나가는 과정이 꽤 흥미롭다고 생각이 들어서 이렇게 글로 남겨보려고 합니다.
< 설명 >
지오파이트에서는 플레이어가 계단을 따라 한 층 내려갈 때 (혹은 올라갈 때)
플레이어 주변 인접한 몬스터들이 플레이어를 따라 던전을 내려가기를 원한다면
플레이어를 뒤따라 던전을 내려가는 행위가 가능합니다.
예를 들어 보자면 다음과 같습니다
플레이어 - @, 몬스터 - O, m, n
. | m | . | . | . |
. | . | O | . | . |
. | m | @ | . | . |
m | . | m | m | . |
. | . | . | . |
< 지하 1층 (플레이어는 몬스터들에게 쫓기고 있다. 플레이어가 위치한 정 중앙 타일이 계단 타일이다. 플레이어는 내려가는 계단 타일 위에 서있다.) >
- 플레이어 계단 내려가는중 . . . -
n | ||||
@ | ||||
< 지하 2층 (내려옴, 플레이어는 올라가는 계단 타일 위에 서있다. 플레이어에게 적대적인 몬스터가 플레이어를 발견한다.) >
n | ||||
@ | ||||
< 지하 2층 (플레이어는 제자리에 가만히 서서 한턴을 넘긴다. 위층에서 계단을 내려오는 중인 몬스터는 플레이어에게 가로막혀 계단 밖으로 나오지 못하고 대기한다 (계단을 내려가는 중인 이 몬스터는 아직 지하 1층에 있는 것으로 취급한다). 한편 지하 2층의 몬스터는 플레이어에게 접근한다.) >
n | ||||
@ | m | |||
< 지하 2층 (플레이어가 왼쪽으로 한 칸 이동. 지하 1층에서 m 몬스터가 계단을 따라 내려왔다. O 몬스터가 아니라 m 몬스터가 내려온 이유는, 여러 몬스터가 동시에 계단을 내려오려 시도할 때 속도가 빠른 몬스터부터 먼저 내려오도록 구현해두었기 때문이다.) >
n | ||||
@ | m | m | ||
< 지하 2층 (플레이어가 왼쪽으로 한 번 더 이동, 계단이 비자 그 다음 몬스터가 계단을 따라 내려왔다.) >
<버그 설명>
그런데 위와 동일한 상황에서 다음과 같이 행동하면 버그가 발생합니다.
. | m | . | . | . |
. | . | O | . | . |
. | m | @ | . | . |
m | . | m | m | . |
. | . | . | . |
< 지하 1층 (플레이어는 내려가는 계단 타일 위에 서있다) >
- 플레이어 계단 내려가는중 . . . -
n | ||||
@ | ||||
< 지하 2층 (내려옴, 플레이어는 올라가는 계단 타일 위에 서있다. 아주 위협적인 n 몬스터가 플레이어를 발견한다.) >
n | ||||
@ | ||||
< 지하 2층 (플레이어는 제자리에 가만히 서서 한턴을 넘긴다. 몬스터는 지하 1층에서 플레이어를 따라 계단을 내려오려 시도하나 플레이어에게 가로막혀 계단 밖으로 나오지 못하고 대기한다. n 몬스터는 플레이어에게 접근한다. 플레이어는 위협적인 n 몬스터를 피해 다시 1층으로 도망친다.) >
- 플레이어 계단 올라가는 중 . . . -
. | m | . | . | . |
. | . | O | . | . |
. | m | @ | . | . |
m | . | m | m | . |
. | . | . | . |
< 지하 1층 (플레이어가 없는 동안에는 층에 시간이 흐르지 않기 때문에 플레이어가 내려가기 전과 동일한 상황이다.) >
. | . | . | . | . |
. | m | O | . | . |
. | . | @ | . | . |
. | m | m | m | . |
. | . | . | . |
< 지하 1층 (플레이어가 바로 왼쪽에 있는 몬스터를 죽인다. 지나치게 많은 몬스터들에게 둘러쌓인 플레이어는 차라리 비교적 몬스터가 없는 지하 2층에서 몬스터들을 피해 도망치는 게 좋겠다고 판단해 지하 2층으로 다시 내려간다.) >
- 플레이어 계단 내려가는 중 . . . -
n | ||||
@ | ||||
< 지하 2층 (올라가기 이전과 동일한 상황이다.) >
n | ||||
@ | m | |||
< 지하 2층 (플레이어는 왼쪽으로 한 칸 움직인다. 지하 1층에서 몬스터가 플레이어를 따라 내려왔다.) >
n | m | |||
@ | ||||
< 지하 2층 (플레이어는 몬스터를 피해 한 칸 움직인다. n 몬스터는 플레이어를 따라 이동한다. 이상하게도 (3,3)에 위치한 몬스터는 움직이지 않는다.) >
m | ||||
n | ||||
@ |
< 지하 2층 (플레이어는 몬스터를 피해 한 칸 움직인다. (3,3)에 위치한 몬스터는 여전히 알 수 없는 이유로 움직이지 않는다.) >
m | ||||
n | @ | |||
< 지하 2층 (이상함을 느낀 플레이어는 혹시 몬스터에게 버그가 발생한 게 아닌지 확인하기 위해 (3.3)에 위치한 몬스터를 향해 이동한다. 몬스터는 여전히 움직이지 않는다.) >
m | ||||
n | @ | |||
이 상태에서 플레이어가 대각선 오른쪽 위 몬스터를 공격하려 시도하자,
게임 로그 창에 다음과 같은 메세지가 뜨며 공격이 처리되지 않는다.
로그: "길이 막혀있다."
<원인 규명 과정>
그렇다면 왜 이런 버그가 발생했을까요?
제가 버그를 해결하며 거친 사고의 흐름을 정리해보았습니다.
1. 버그 발생의 시작점을 추측해본다
일단 그동안 수많은 테스트에서 이러한 버그가 발생한 적이 없었으며,
몬스터가 계단을 내려온 직후에 이러한 현상이 발생했고,
또 몬스터가 계단을 내려가는 기능이 추가된지 얼마 되지 않았다는 것을 미루어 볼 때
이 버그는 몬스터가 계단을 내려오는 과정 도중 발생한 무언가로 인해 야기되었다고 생각하였습니다.
2. 빠르게 가장 먼저 떠오르는 원인들을 테스트 해본다 (과반수 이상의 버그는 이 단계에서 해결됩니다)
코드의 어느 부분이 잘못되었을까를 찾기 위해서 저는 발생 가능한 경우들을 떠올려보았습니다.
위 상황에서 플레이어를 눈 앞에 두고도 몬스터가 움직이지 않는다는 것은
몬스터의 타겟팅 시스템이 제대로 동작하지 않음을 의미합니다.
즉 가능한 경우로 다음 정도를 떠올려볼 수 있었습니다.
1) 층계가 변경되는 과정에서 몬스터의 시야에 문제가 생겨 플레이어가 앞에 있는 것을 인지하지 못했다
2) 알 수 없는 이유로 몬스터가 플레이어에게 더이상 적대적이지 않게 되었다.
그러나 확인 결과 두가지 모두 아니었습니다.
몬스터는 제대로 플레이어가 눈 앞에 있음을 인지하고 있었고,
또 여전히 플레이어에게 적대적이었습니다.
3. 구체적인 버그 발생 지점을 짚어낸다
저는 버그 발생지점을 보다 정확히 찾아보고자 게임 로그창에 뜬 "길이 막혀있다"라는 문구를 탐색의 시작점으로 잡았습니다.
게임 전체를 통틀어 "길이 막혀있다" 라는 로그를 출력하는 경우들을 살펴봤습니다.
그 결과 "길이 막혀있다"라는 로그를 출력하는 경우는 다음과 같았습니다.
1) 플레이어가 맵 경계 바깥으로 이동하려는 경우
2) 플레이어가 걸을 수 없는 타일로 이동하려는 경우
3) 플레이어가 이동하려는 타일에 물리적으로 길을 막는 무언가가 존재할 경우
(ex. 바위가 있는 타일로 이동하려는 플레이어)
이 세 부분에 breakpoint를 잡아두고 다시 테스트를 해본 결과 버그의 발생 지점은 3번이었습니다.
4. 이상한 점에 대한 의문을 가진다
여기서 저는 의문이 들었습니다.
왜 '공격'이 아니라 '이동'이 호출되었을까?
(위에서 볼 수 있듯이 "길이 막혀있다"라는 메세지는 오직 이동 과정에서만 출력될 수 있습니다.)
지오파이트에서는 공격과 이동 모두를 방향키로 조작합니다.
언제 공격을 하고 언제 이동을 할지는 게임 내적으로 결정하는 방식이 있습니다.
이를 아주아주아주 단순하게 나타내면 다음과 같습니다.
입력한 방향에 몬스터가 있는가? -> 입력한 방향으로 공격을 시도
그 외 -> 입력한 방향으로 이동을 시도
그런데 위 상황에서 플레이어가 입력한 방향은 오른쪽이고.
플레이어의 오른쪽에는 분명 몬스터가 존재함에도 공격 대신 이동이 호출되었다는 것을 알 수 있습니다.
5. 의문을 해결하기 위해 코드를 살펴본다 (99%의 버그는 이 단계에서 해결됩니다)
그렇다면 게임은 오른쪽에 몬스터가 있음에도 "몬스터가 없다"라고 잘못 판정한 것이라고 결론내릴 수 있습니다.
그렇다면 게임이 그렇게 잘못 판정한 이유는 무엇일까요?
이를 확인하기 위해 저는 "특정 위치에 몬스터가 있는지 없는지"를 판정하는 함수 코드를 살펴보았습니다.
해당 함수는 다음과 같은데,
def get_actor_at_location(self, x: int, y: int) -> Optional[Actor]:
for actor in self.actors:
if actor.x == x and actor.y == y:
return actor
return None
단순히 몬스터 하나하나의 위치가 내가 찾고자 하는 위치와 같은지를 비교하는, 어떠한 논리적 오류도 없어보이는 코드입니다. 여기서 더 진행해야 할 방향이 보이지 않자, 저는 이 버그가 발생했다는 것이 무엇을 의미하는지를 더 깊게 생각해보기 시작했습니다.
앞서 살펴보았듯이, 이 버그는
3) 플레이어가 이동하려는 타일에 물리적으로 길을 막는 무언가가 존재할 경우
발생합니다.
여기서 저는
"게임은 플레이어의 오른쪽에 물리적으로 무언가 막는 것이 있다는 것은 제대로 판정했지만,
그 무언가가 몬스터라는 것을 판정하지 못했다"는 사실을 깨닫습니다.
즉 해당 버그가 발생했다는 것은,
n | ||||
@ | m | |||
게임이 (3,3)에 위치한 몬스터 m이 물리적으로 형체가 있는 존재라는 것은 제대로 판정했지만, 게임 내적으로 몬스터로 취급하지 않았다는 것을 의미합니다.
6. 이상한 점에 대해 의문을 가지고, 그 의문에 답하기 위해 코드를 살펴보는 과정을 반복한다. (4, 5번을 반복한다.)
그렇게 반복하다 답을 구한다면, 그 답을 통해 그 이전 질문에 대한 답을 구한다.
그렇다면 게임은 대체 왜 몬스터 m만 이렇게 이상하게 판정했을까요?
(다른 몬스터들은 정상적으로 동작하는 것을 테스트했습니다)
저는 (3,3)에 위치한 m과 나머지 제대로 동작하는 몬스터들 간에는 어떠한 큰 차이가 있으리라 생각해,
(3,3)에 위치한 저 몬스터의 세부적인 수치들을 살펴보기로 생각했습니다.
수치를 살펴보던 도중, 저는 굉장히 이상한 점을 발견합니다.
(3,3) 몬스터는 게임 상에서 "죽었다" 라고 판정되어있다는 사실을 발견합니다.
어떻게 죽은 몬스터가 맵 상에 존재하는지는 뒤로 하고, 저는 일단 이 몬스터가 왜 죽었는지부터 생각해보았습니다.
"죽었다"라는 키워드와 "m" 타입 몬스터라는 점에 기인해서,
저는 "앞서 지하 1층에서 플레이어가 죽인 하나의 몬스터와 지금 지하 2층으로 내려온 이 몬스터가 같은 객체가 아닐까?"라는 생각을 하게 됩니다. 게임 데이터를 확인해본 결과 이 둘은 실제로 메모리 주소가 같은 하나의 객체였습니다. 즉, 해당 몬스터는 이전 층에서 플레이어에 의해 죽었던 것입니다.
다시 돌아와서, 그럼 어떻게 죽은 몬스터가 맵 상에 존재할 수 있을까요?
제 사고의 흐름을 기술하기에 앞서, 우선 몬스터가 어떻게 플레이어를 따라 층계를 내려오는지를 설명하겠습니다.
플레이어가 층계를 내려갈 때, 플레이어와 인접한 살아있는 몬스터들 중 플레이어를 추격중인 몬스터들은 내려가는 층계에 해당하는 맵에 저장됩니다.
즉 플레이어가 지하 1층에서 지하 2층으로 내려갈 때, 플레이어 옆에 개미 몬스터가 있었다면 이 개미 몬스터는 2층 맵 데이터 내부의 "위층에서 내려오는 몬스터 목록"에 저장됩니다.
그 후 게임은 매 게임 루프마다 현재 플레이어가 위치한 맵의 계단이 비어있다면 "위층에서 내려오는 몬스터 목록"에서 몬스터를 하나씩 빼내 그 몬스터를 스폰하고, 비어있지 않다면 몬스터를 빼내지 않습니다.
즉, 위 방식대로라면 죽은 몬스터가 "위층에서 내려오는 몬스터 목록"에 저장되는 일은 없어야 합니다. 플레이어와 인접한 살아있는 몬스터들만이 층계를 내려가는 대상이 될 수 있기 때문입니다.
그렇다면 대체 어떻게 죽은 몬스터가 "위층에서 내려오는 몬스터 목록"에 저장된 것일까요?
원인은 파이썬 자체에 있습니다. 파이썬 List는 Mutable합니다. 즉 오브젝트를 외부에서 수정하더라도 그 수정사항은 반영됩니다. "위층에서 내려오는 몬스터 목록"에 몬스터를 저장했는데, 만약 저장한 이후 그 몬스터가 사망한다면
"위층에서 내려오는 몬스터 목록"에는 여전히 그 몬스터가 사망한 채로 들어있게 됩니다.
(몬스터가 죽는 순간 지하 1층 맵 상에서 몬스터가 삭제되고, 또 게임 내의 엔티티 목록에서도 죽은 몬스터를 가리키는 레퍼런스가 제거되었지만, "위층에서 내려오는 몬스터 목록"에 여전히 이 몬스터를 가리키는 레퍼런스가 존재하기 때문에 GC는 메모리를 해제하지 않고 계속 가지고 있게 됩니다.)
위 현상의 원인도 마찬가지였습니다.
지금 문제가 되고 있는 몬스터를 A라고 두겠습니다.
1) 플레이어는 지하 1층에서 지하 2층으로 내려갑니다. 이 과정에서 지하 2층의 "위층에서 내려오는 몬스터 목록"에 A가 추가됩니다.
2) 플레이어가 지하 2층에서 가만히 서서 턴을 보냅니다. 플레이어가 계단 위에서 길을 가로막고 있기 때문에 A는 지하 2층으로 내려오지 못합니다.
3) 플레이어가 지하 2층에서 지하 1층으로 올라갑니다. 지하 2층의 "위층에서 내려오는 몬스터 목록"에는 여전히 A가 저장되어 있습니다.
4) 플레이어가 지하 1층에서 A를 죽입니다.
5) 플레이어가 지하 2층으로 내려갑니다.
6) 플레이어가 계단이 있는 타일에서 벗어납니다. 계단이 비자 "위층에서 내려오는 몬스터 목록"에 들어있던 A는 지하 2층에 등장합니다. 그러나 A는 게임 내적으로는 이미 죽은 상태입니다.
이렇게 저는 "어떻게 죽은 몬스터가 맵 상에 존재하는가?" 에 대한 답을 찾아냅니다.
이 답을 가지고 저는 그 이전의 질문으로 돌아갑니다.
"왜 몬스터 A만 판정이 이상하게 처리되었는가?"
즉 왜 몬스터 A는 물리적 판정은 제대로 되었지만 몬스터가 존재하는지에 대한 판정은 제대로 되지 않았을까?
def get_actor_at_location(self, x: int, y: int) -> Optional[Actor]:
for actor in self.actors:
if actor.x == x and actor.y == y:
return actor
return None
앞서 잠시 훑어보았던 함수입니다.
함수만 보기엔 아무런 이상이 없지만, 여기서 몬스터들의 리스트인 self.actors가 단순히 존재하는 모든 몬스터들의 리스트가 아니라는 것에 주목하셔야 합니다.
@property
def actors(self) -> Iterator[Actor]:
"""Iterate over this maps living actors."""
yield from (
entity
for entity in self.entities
if isinstance(entity, Actor) and not entity.is_dead
)
그리고 해당 프로퍼티를 살펴보면 해당 프로퍼티는 살아있는 몬스터들만 반환하는 것을 알 수 있습니다.
즉 get_actor_at_location()이라는 함수는, 해당 위치에 존재하면서 동시에 살아있는 몬스터를 반환하는 것을 알 수 있습니다.
A라는 몬스터는 지하 2층에 내려온 시점에서 이미 죽은 상태였기 때문에 게임은 이 몬스터가 존재하는 것을 감지할 수 없었던 것이었습니다.
함수명에 이러한 사항이 명시적으로 기재되어있지 않기 때문에 코드를 처음 살펴볼 때 이러한 사실을 간과했고, 이 사소한 간과가 부메랑이 되어 큰 버그로 돌아온 셈입니다.
이렇게 저는 "왜 몬스터 A만 판정이 이상하게 처리되었는가?" 에 대한 답까지 찾아냅니다.
그럼 그 이전의 질문: "왜 '공격'이 아니라 '이동'이 호출되었을까?"에도 답을 할 수 있게 됩니다.
플레이어가 방향키를 누른 위치에 몬스터가 존재하지 않는다고 판정이 되어버렸기 때문에 게임은 '공격'이 아니라 '이동'을 호출한 것이었습니다.
이렇게 플레이어가 A를 공격했을 때 공격이 되지 않았던 버그의 원인을 규명해내었습니다.
A가 지하 2층으로 내려온 이후 전혀 움직이지 않았던 원인은 무엇이었는가? 에 대한 답도 쉽게 구할 수 있습니다. 원인이 같기 때문입니다.
def handle_enemy_turns(self) -> None:
for entity in set(self.game_map.actors) - {self.player}:
if entity.ai and not entity.actor_state.is_dead:
while entity.action_point >= 60:
try:
entity.ai.perform()
except exceptions.Impossible:
pass # Ignore impossible action exceptions from AI.
entity.spend_action_point()
세번째 줄에서 볼 수 있듯이 몬스터의 ai는 몬스터가 죽었다면 실행되지 않습니다.
ai가 실행되지 않았기에 몬스터는 이동하지 않았던 것입니다.
결국 몬스터 A와 관련된 모든 이상한 현상들은 actors 프로퍼티가 살아있는 몬스터만을 반환한다는 사실을 간과해 발생한 일이었습니다.
개인적으로 이 버그가 흥미로웠던 이유는
1) 보자마자 직관적으로 해결하지 못했다는 점
2) 버그의 원인을 찾기 위해 거쳐야 하는 과정이 길다는 점
3) 파이썬 Mutable의 위험성을 보여준다는 점
4) 함수명의 명확함이 왜 중요한지를 보여준다는 점
때문이었습니다.
나중에 또 재미있는 버그를 발견하면 글로 적어보겠습니다
긴 글 읽어주셔서 감사합니다!
'게임 제작 > Geophyte (2020~2021)' 카테고리의 다른 글
Geophyte 알파 버전 릴리즈! (4) | 2021.10.08 |
---|---|
[Geophyte] 파이썬? (1) | 2021.05.15 |
[Geophyte] 사진으로 보는 개발과정 (0) | 2021.05.15 |
Geophyte(지오파이트) 소개 (3) | 2020.12.19 |