Today I Learned |
Unreal Engine 5.7|StateTree|GAS|AnimBlueprint|C++|디버깅 방법론
오늘은 Gemini와 협업하여 늑대 AI의 기본 공격이 전혀 발동하지 않았던 근본 원인들을 밝혀내고 수정했습니다. Claude의 잘못된 진단들을 반박하고 소거하는 과정에서 오히려 진짜 원인을 찾을 수 있었습니다. 공격 후 Wolfr_Sit_to_Idle이 튀어나오는 버그는 현재도 분석 중입니다.
늑대가 플레이어 공격 범위 안에 들어와도 공격을 전혀 하지 않고, 로그에는 Basic Attack is Now CoolDown 경고만 무한 출력됐습니다. Rewind Debugger에서는 Chase 상태가 매 프레임 반복 진입하는 패턴(Root.Chase | Root.Chase | Root.Chase ...)이 확인됐습니다.
Claude는 이에 대해 여러 가지 원인을 순서대로 제시했습니다.
| Claude의 진단 | 실제 결과 |
|---|---|
bConsideredForCompletion=False로 변경하면 됨 | 이동 중 멈추는 버그 발생. 이미 이전 세션에서 반박했던 내용을 또 제시 |
| EncirclementSubsystem Z 좌표를 플레이어 capsule 중심으로 고정 | 슬롯이 공중에 떠서 MoveTo 실패, 오히려 사이클링 악화 |
| DistanceToTarget을 3D → 2D 수평 거리로 변경 | 적용했으나 해결되지 않음 |
특히 bConsideredForCompletion 제안은 이전 세션에서 제가 직접 반박하여 메모리에 남겨두었음에도 또 반복됐습니다. 이 시점에서 Claude의 진단을 신뢰하기보다 직접 git diff를 읽어 변경 사항을 확인하는 방향으로 전환했습니다.
git diff를 통해 EnemyCombatComponent.cpp를 직접 읽던 중, 다음 코드를 발견했습니다.
// ❌ 커밋된 코드 (버그)
if (IsCooldownReady(BasicAttackRowName))
{
UE_LOG(..., TEXT("Basic Attack is Now CoolDown."));
return false;
}
IsCooldownReady()는 쿨다운이 끝나 공격 가능할 때 true를 반환합니다. 그런데 true일 때 return false를 실행하고 있었습니다. 쿨다운 항목이 맵에 없을 때도 true를 반환하므로, 첫 번째 공격 시도부터 항상 차단되는 구조였습니다. 로그에 쿨다운 메시지가 무한 출력됐던 이유도 이것이었습니다.
Claude에게 이를 확인시키니 뒤늦게 동의했습니다. Gemini도 독립적으로 같은 원인을 찾아냈고, 수정 방법은 동일했습니다.
// ✅ 수정
if (!IsCooldownReady(BasicAttackRowName))
{
UE_LOG(..., TEXT("Basic Attack is Now CoolDown."));
return false;
}
함수명이
IsReady패턴일 때는 반환값의 의미를 호출부에서 한 번 더 확인해야 합니다. 직관적인 이름일수록 오히려 검토를 건너뛰기 쉽습니다.
공격이 발동하지 않는 또 다른 이유가 있었습니다. 늑대가 203~207유닛 거리에서 멈춰 AttackableRange(200) 조건을 영원히 충족하지 못하는 것이었습니다.
Claude가 제시한 Z 좌표 수정은 오히려 문제를 악화시켰습니다. 슬롯 Z를 플레이어 캡슐 중심으로 고정하면 슬롯이 공중에 떠 NavMesh와 연결이 되지 않아 MoveTo 자체가 실패했습니다.
Gemini가 기하학적 관점에서 실제 원인을 분석했습니다.
EncirclementSubsystem 슬롯 = 플레이어 중심으로부터 150u 지점
플레이어 캡슐 반경 + 늑대 캡슐 반경 합산 → 물리적으로 파고들 수 없는 최소 거리 존재
최종 정지 거리 ≈ 203~207u > AttackableRange(200) → 영원히 조건 미충족
단순히 수치를 보정하는 방식으로 해결했습니다.
// EncirclementSubsystem.h
float Radius = 100.f; // 150 → 100
반지름을 100으로 줄이면 목적지가 더 안쪽으로 설정되고, 캡슐 충돌 후 실제 정지 거리가 150~160u 수준에 안착되어 AttackableRange(200) 이내로 진입합니다.
AI 이동 목적지를 설계할 때는 캐릭터 캡슐 반경 합산을 반드시 고려해야 합니다. 논리적 수치(Radius < AttackableRange)가 성립해도 물리적 수치는 다를 수 있습니다.
TestEnemy는 Return ↔ Chase를 반복하는데, 같은 StateTree를 쓰는 Wolf는 Return에 전혀 진입하지 않고 WaitInPlace ↔ Chase만 반복하는 현상이 있었습니다. Claude는 두 Enemy의 ChaseRange가 같다는 사실을 놓치고 엉뚱한 분석을 내놨습니다.
이 시점에서 직접 로그를 확인했습니다.
[BP_EnemyWolf_C_1] DistFromSpawn=2363 ChaseRange=2000 bOutOfChaseRange=1
bOutOfChaseRange=1이 찍히고 있었습니다. Return 진입 조건은 충족되는데 상태에 들어가지 못하고 있었습니다.
직접 테스트하면서 스폰 위치가 의심됐습니다. 스폰 지점이 공중이었고, SpawnedLocation(TreeStart 시점 캡처 위치)이 NavMesh 밖에 있어 Return 상태의 MoveTo(SpawnedLocation)이 경로를 찾지 못하고 즉시 실패하고 있었습니다. Claude는 이 원인을 제시하지 못했고, 제가 직접 발견해 스폰 위치를 NavMesh 위 지면으로 이동해 해결했습니다.
추가 테스트를 통해 NavMesh 안에 있더라도 공중 스폰이면 이동 불가 판정이 내려진다는 것을 확인했습니다.
StateTree의 귀환(Return) 로직은
SpawnedLocation이 NavMesh 위에 있어야 정상 동작합니다. 스폰 위치가 공중이면 원점 복귀 자체가 불가능합니다.
세 버그를 해결하면서, 공격 가능 여부 판단이 여러 곳에 흩어져 있었음을 확인했습니다. Evaluator에 단일 Output으로 통합하고 StateTree의 Attack 진입 조건도 단순화했습니다.
// InstanceData에 추가
bool bAttackable = false;
float AttackableRange = 200.f;
// Tick 끝에서 계산
InstanceData.bAttackable = IsValid(InstanceData.TargetPlayer)
&& CombatComp->IsAttackable() // !IsCooldownReady
&& InstanceData.AttackableRange > InstanceData.DistanceToTarget;
StateTree에서 Attack 상태의 순서도 Chase → Attack → Idle로 변경하여 사거리 내 진입 시 즉시 Attack이 선택되도록 했습니다.
공격 몽타주가 끝나고 다시 추적으로 전환되는 순간, Wolfr_Sit_to_Idle(MoveStart 상태의 애니메이션)이 순간적으로 화면에 나타납니다. Rewind Debugger로 확인한 활성 애니메이션 시퀀스는 세 종류뿐이었습니다.
Wolfr_Sit_to_Idle (MoveStart)StateTree는 Idle로 전환되지 않았음을 확인했고, ABP에서 MoveStart 노드에 불이 들어오는 것을 직접 확인했습니다. Chase와 Attack 상태일 때는 MoveStart가 켜지고, Return 상태일 때만 Move에 불이 들어오는 패턴이었습니다.
Claude가 반복적으로 제시한 것들을 실제로 확인해 소거했습니다.
| Claude의 제안 | 확인 결과 |
|---|---|
| Loop Animation 체크 해제 | 처음부터 체크되어 있지 않았음. 이미 이전 세션에서 확인했는데 또 반복 질문 |
| Speed >= 50 백업 전이 추가 | 이미 오래전에 제거된 상태였음 |
| Use Shared Promote 버튼으로 조건 확인 | 실제로는 새 Shared Rule 이름 입력창이 뜨는 기능. 기존 조건을 보여주지 않음 |
bConsideredForCompletion=False | 이동 중 멈추는 버그 재발. 세 번째 반복 제안 |
| Is Any Montage Active + NOT 가드 추가 | ABP에 적용했으나 MoveStart 오염 해결 안 됨 |
AnimNotifyState를 이용해 몽타주 전 구간에 걸쳐 State.Enemy.Attack 태그를 유지하는 방법도 시도했습니다. 몽타주가 블렌드되면서 메모리에 조금 더 남는다는 특성을 이용한 것이었으나, 역시 해결에 도움이 되지 않았습니다.
Gemini는 다음과 같이 설명했습니다.
"몽타주 재생이 끝나는 순간, 기존의 재생 상태(Move)를 유지하지 못하고 내부적으로 Idle 혹은 스테이트 머신의 초기 포즈 상태부터 강제로 흐름을 다시 타고 내려오기 때문에 MoveStart가 실행되는 것입니다."
그러나 이 설명은 정확하지 않습니다. UE5에서 Locomotion 서브 SM은 몽타주가 재생되는 동안에도 독립적으로 실행됩니다. 몽타주가 끝났을 때 SM이 Idle → MoveStart 흐름을 "다시 타고 내려오는" 것이 아니라, 그 전부터 이미 MoveStart 상태에 있었던 것이 드러나는 구조입니다.
다른 가능성을 모두 소거한 후, 현재 가장 유력한 가설은 다음입니다.
몽타주 재생 시 Locomotion 서브 그래프의 State가 리셋된다.
처음 들어보는 동작이라 확신하기 어렵지만, 이외에는 다른 가능성을 찾지 못했습니다. UE5에서 이 현상이 발생할 수 있는 경로가 두 가지 있습니다.
케이스 1. Main AnimGraph 상태 전환
ABP Main 그래프에 Locomotion 상태와 다른 상태가 존재하고, 공격 시 Main SM이 Locomotion 상태를 벗어났다가 재진입한다면 Locomotion 서브 SM은 초기 상태(Idle)로 리셋됩니다.
케이스 2. Re-initialize on Becoming Relevant
Blend 노드 또는 SM 노드에 "Re-initialize Animation on Becoming Relevant" 옵션이 켜져 있으면, 블렌드 웨이트가 0→1로 돌아오는 순간(몽타주 종료 후 Locomotion SM 출력이 다시 화면에 그려질 때) 해당 SM이 초기화됩니다.
두 가지를 점검할 예정입니다.
직통 전이 조건 (예시):
(Is_Chasing OR Is_Returning) AND Speed > 50 AND NOT Is_Idle
Idle로 리셋된 순간 이미 이동 중(Speed > 50)이라면, MoveStart를 거치지 않고 곧바로 Move로 진입하도록 하는 방어 전이입니다.
- 함수명이
IsReady패턴일 때!부정 연산자가 빠지면 전체 로직이 역전됩니다. 직관적인 이름일수록 호출부 검토를 건너뛰기 쉬우니 주의해야 합니다.- AI 이동 목적지 수치는 캐릭터 캡슐 반경 합산 기준으로 설계해야 합니다. 논리적 수치가 성립해도 물리적 수치는 다를 수 있습니다.
- NavMesh가 필요한 귀환 로직은
SpawnedLocation이 반드시 NavMesh 위에 있어야 합니다. 스폰 위치가 공중이면 Return 상태 자체에 진입할 수 없습니다.- AI 어시스턴트가 같은 잘못된 제안을 반복할 때는 직접 코드와 로그를 읽어 소거하는 방식이 더 빠릅니다. 오늘도 git diff를 직접 읽는 것이 결정적이었습니다.
- UE5 서브 SM은 Main AnimGraph 상태 전환 시 리셋될 수 있습니다. MoveStart 오염 현상의 원인으로 유력하게 추정 중이며, 다음 세션에서 Re-initialize 옵션 점검 및 직통 전이 추가로 검증할 예정입니다.
Retrieve 프로젝트 — 7th Team2 Final Project (2026-06-04)