"교전 중에도 높은 자리 가능하게 해줘. 포복 자세에서 이동 및 사격도 가능해야 함."
요청은 두 줄이었고, 만드는 데는 오래 안 걸렸습니다. 오래 걸린 건 왜 안 되는지를 알아내는 쪽이었습니다.
포복
Z 토글입니다. 서 있을 때의 30% 높이가 되고, 복셀 몸과 히트박스가 같이 납작해집니다. 이동 속도는 1.15m/s 로 기어가는 수준이지만 이동도 사격도 됩니다. 탄퍼짐은 앉기 배수 위에 0.62 를 더 곱하고, 점프는 막힙니다.
만들자마자 몸이 덜덜 떨렸습니다.
캡슐 콜라이더는 지름보다 낮아질 수 없습니다. 반지름이 0.4m 면 최소 높이가 0.8m 입니다. 그런데 제가 계산해서 넣은 값은 1.8 × 0.30 = 0.54m 였습니다. 엔진은 말없이 0.8 로 올려 잡고, 중심은 제가 준 0.27 에 그대로 둡니다. 결과적으로 캡슐 아랫부분이 바닥을 파고들고, 물리엔진이 매 프레임 그걸 밀어 올립니다.
# 엔진이 몰래 올리기 전에 내가 먼저 맞춘다
var capsule_height := maxf(height, _capsule.radius * 2.0)
_capsule.height = capsule_height
collider.position.y = capsule_height * 0.5
봇도 같은 코드를 씁니다. 봇은 자리를 지킬 때만 엎드립니다 — 13m 밖이고, 이동할 일이 없고, 높은 자리로 올라가는 중이 아닐 때. 이동해야 하는 상황에서 엎드리면 영영 못 갑니다.
전원이 바닥에 붙었다
처음 버전은 조건만 맞으면 무조건 엎드리게 했습니다. 스모크를 돌려 보니 봇-틱의 33~44%가 포복이었습니다. 일곱 명이 다 같이 바닥에 누워 있는 전장은 우스꽝스럽습니다.
교전마다 한 번만 확률을 굴리게 바꿨습니다.
if target != best: # 새 적을 발견한 순간에만
_prone_wants = _rng.randf() < prone_affinity
매 프레임 굴리면 확률이 아무 의미가 없어집니다. 0.35 를 매 프레임 굴리면 몇 초 안에 반드시 한 번은 참이 되고, 그러면 결국 전원이 눕습니다. 난이도별로 0.15 / 0.35 / 0.55 를 주고 교전당 한 번만 굴리니 5~18% 로 내려왔습니다.
교전 중 고지
봇은 원래 순찰할 때만 높은 데를 밟았습니다. 적을 보는 순간 평면 거리만 보고 움직여서, 계단 앞에서 적을 발견하면 계단을 등지고 평지에서 싸웠습니다.
var to_target := global_position - target.global_position
to_target.y = 0.0 # ← 높이를 버리고 있었다
_pick_high_ground() 를 넣었습니다. 순찰 지점 중에서 적보다 1.6m 이상 높고, 사거리 안이고, 20m 이내인 자리를 골라 교전 목적지로 밀어 넣습니다. 올라가는 동안은 좌우 흔들기를 끄고(계단에서 옆으로 밀면 그대로 떨어집니다) 속도를 올립니다.
포기 조건을 둘 뒀습니다. 9초 예산을 넘기거나, 네비게이션이 "다 왔다"는데 아직 15m 가 남았거나. 둘 다 없으면 봇이 못 가는 자리를 향해 영원히 벽을 밉니다.
그리고 봇이 얼어붙었다
기능을 켜자 봇 하나가 9초 내내 한 자리에 서 있는 로그가 찍혔습니다.
y0.0 d=14.7 v=4.6 다음2.3
y0.0 d=14.7 v=0.0 다음0.9 ← 여기서부터
y0.0 d=14.7 v=0.0 다음0.9
y0.0 d=14.7 v=0.0 다음0.9
목적지까지 14.7m 가 남았는데 속도가 0 입니다. 그런데 다음 경로점은 0.9m 앞에 있습니다. 경로는 있는데 안 움직입니다.
네비메시가 "저 위로 갈 수 있다"고 답하는데 몸이 못 오르는 턱이 있으면, 다음 경로점이 머리 바로 위에 놓입니다. 이동 방향에서 높이를 빼고 나면 수평 성분이 0 이 되고, 봇은 벽을 향해 선 채로 영원히 멈춥니다.
# 갈 길이 남았는데 0.6m/s 도 못 내면 1.1초 뒤 목적지를 버리고 옆으로 비켜 간다
if moving or agent.is_navigation_finished() or _escape_left > 0.0:
_stuck_left = STUCK_TIME
return
고지 전용이 아니라 모든 이동에 겁니다. 봇이 가만히 서서 벽을 미는 상황은 어떤 이유로 생기든 틀린 상황입니다.
계단 셋이 애초에 못 올라가는 물건이었다
여기까지 하고 나서 맵별로 재 봤습니다. 부두에서 봇이 도달한 최고 높이가 0.06m 였습니다. 컨테이너 위로 올라가는 계단을 만들어 둔 맵인데도요.
계단은 보이는 계단을 장식으로 두고 그 위에 보이지 않는 경사 콜라이더를 깝니다. 0.34m 턱은 move_and_slide 에게 경사가 아니라 벽이라, 계단을 콜라이더로 두면 아무도 못 올라가기 때문입니다.
그런데 그 경사면의 밑변 위치를 눈대중으로 잡아 뒀습니다.
컨테이너 가장자리 z = 9.5
올라가는 거리 5.58m
따라서 밑변 z = 15.1
내가 적어 둔 값 z = 11.2 ← 3.9m 모자람
밑변이 모자라면 경사면 꼭대기가 발판 안쪽을 파고듭니다. 봇은 0.85m 까지 올라가다 컨테이너 옆구리에 붙어 멈췄습니다. 사람도 마찬가지였고, 이 맵은 몇 주째 그 상태로 배포되어 있었습니다.
계산을 함수로 빼서 호출부가 역산하게 했습니다.
var base_z: float = (9.5 + stairs_run(2.8)) * sz
격납고 측면 데크는 다른 이유였습니다. 계단은 제자리에 있었는데 ㄱ자 엄폐물의 세로 벽이 계단을 관통하고 있었습니다. 3m 폭 중 1m 를 벽이 먹어서, 에이전트 반지름을 빼면 남는 통로가 1.0m. 네비메시는 겨우 이어지는 척만 하고, 봇은 계단 앞 2m 에서 좌우로 튕기기만 했습니다. 계단을 바깥으로 물렸습니다.
그리고 올라갈 자리가 없는 맵이 셋
교차로 · 숲 · 해변에는 높은 자리 자체가 없었습니다. 교차로 건물은 5.5m 인데 올라가는 방법이 없고, 숲과 해변은 완전한 평지였습니다. 고지 선호를 아무리 잘 만들어도 후보가 없으면 아무 일도 안 일어납니다.
- 교차로 — 건물 두 채에 걸친 외부 발코니. 밑으로 지나다닐 수 있는 다리가 됩니다
- 숲 — 바위 언덕. 계단 소품을 쓰되 재질만 바위라 숲에서 안 튑니다
- 해변 — 방파제 위로 올라가는 나무 계단. 2.2m 밖에 안 되지만 모래밭 전체를 내려다봅니다
20초 매치에서 봇이 도달한 최고 높이:
| 맵 | 전 | 후 |
|---|---|---|
| 부두 | 0.06m | 3.45m |
| 격납고 | 0.06m | 3.56m |
| 교차로 | 자리 없음 | 2.75m |
| 숲 | 자리 없음 | 2.60m |
| 해변 | 자리 없음 | 2.36m |
화물선만 뺐습니다. 선교가 홍팀 스폰 바로 앞이라 지붕을 열어 주면 팀 균형이 깨지고, 갑판이 21m 밖에 안 돼서 계단 낼 자리가 없습니다. 이 맵의 성격은 "높이"가 아니라 "가장자리 너머는 바다"입니다.
원인이 여섯이었다
"봇이 높은 데를 안 간다"는 한 문장짜리 증상이었는데, 그 밑에 원인이 여섯 개 쌓여 있었습니다. 난간이 입구를 막고, 계단이 콜라이더고, 순찰 지점에 높은 곳이 없고, 겹침을 양쪽에 줘서 턱이 생기고, 밑변이 모자라 벽에 처박히고, 교전에 들어가면 아예 안 쳐다보고.
앞의 다섯을 고치는 동안 여섯 번째는 보이지도 않았습니다. 하나를 고칠 때마다 다음 원인이 드러나는 종류의 문제였고, 중간에 "고쳤다"고 판단할 기회가 다섯 번 있었습니다.
증상 하나에 원인 하나를 기대하면, 첫 번째 원인을 고친 자리에서 멈추게 됩니다.