Today I Learned |
Unreal Engine 5.7|StateTree|AIPerception|AnimBlueprint|WorldSubsystem|C++|디버깅 방법론
오늘은 포위 슬롯 시스템을 설계·구현하고, BP_EnemyBase 상속 구조를 구성했습니다. 그러나 작업 대부분은 WaitInPlace 깜빡임 버그를 끝내 잡지 못한 채 소거 과정에 소모됐습니다. 이 과정에서 AI 어시스턴트(Claude)가 코드를 확인하지 않고 틀린 진단을 반복 제시했고, 이를 제 쪽에서 직접 반증하며 교정하는 일이 많았습니다. 오늘의 기록은 "해결된 것"보다 "잘못된 가설을 어떻게 소거했는가"에 더 집중돼 있습니다.
적 여러 마리가 플레이어 한 명에게 달려들면 전부 같은 좌표를 목적지로 삼아 서로 밀리거나 겹쳤습니다. RVO(속도 회피)는 이동 중 분리만 담당하고, 도착 좌표 자체는 분산시켜 주지 않습니다.
선택한 해결책은 WorldSubsystem 기반의 슬롯 링입니다. 플레이어(타깃) 주위에 원형 슬롯 10개를 배치하고, 각 적이 자신의 방위에서 가장 가까운 빈 슬롯을 요청해 그 좌표로 이동하게 했습니다.
UEncirclementSubsystem (UTickableWorldSubsystem)
├── NumSlots = 10
├── Radius = 175 // AttackableRange(200) 안쪽
├── TMap<TWeakObjectPtr<AActor>, FRing> Rings
│ └── FRing: TArray<TWeakObjectPtr<AActor>> Slots
├── RequestSlot(Target, Requester) → int32
├── ReleaseSlot(Target, Requester)
├── GetSlotLocation(Target, SlotIndex) → FVector (NavMesh 투영)
└── DrawDebug() // Encircle.Debug CVar
슬롯 위치는 단순 오프셋이 아니라 NavMesh 투영을 거칩니다. 슬롯이 장애물 안에 있거나 NavMesh 밖이면 도달 불가 좌표가 되므로, ProjectPointToNavigation(Raw, Projected, FVector(80,80,1000))로 보정했습니다.
const float Angle = SlotIndex * (2.f * PI / NumSlots);
const FVector Raw = Target->GetActorLocation()
+ FVector(FMath::Cos(Angle) * Radius, FMath::Sin(Angle) * Radius, 0.f);
FNavLocation Projected;
if (NavSys->ProjectPointToNavigation(Raw, Projected, FVector(80.f, 80.f, 1000.f)))
return Projected.Location;
return Raw;
슬롯 요청 위치를 Evaluator에서 직접 관리합니다. 매 틱의 위치 갱신 블록에서, SlotIndex == INDEX_NONE이면 즉시 슬롯을 요청합니다. 이렇게 하면 알림(Alert) 경로·타깃 교체 등 어떤 경로로 타깃이 설정되더라도 자동으로 슬롯이 확보됩니다.
// 매 틱 (throttle 밖)
if (InstanceData.SlotIndex == INDEX_NONE)
InstanceData.SlotIndex = EncSubsystem->RequestSlot(TargetPlayer, Pawn);
InstanceData.ChaseLocation = (InstanceData.SlotIndex != INDEX_NONE)
? EncSubsystem->GetSlotLocation(TargetPlayer, InstanceData.SlotIndex)
: InstanceData.TargetLocation;
StateTree의 MoveTo 목적지를 TargetLocation 대신 ChaseLocation으로 바인딩하면, 적마다 서로 다른 슬롯을 향해 이동합니다.
배운 것: 슬롯 시스템은 회피 시스템이 아니라 목적지 분산 시스템입니다. RVO는 여전히 필요하고 두 시스템이 역할을 분담합니다. NavMesh 투영 없이 원형 오프셋만 쓰면 지형 위에 있지 않은 좌표가 목적지가 돼 MoveTo가 즉시 실패합니다.
WaitInPlace가 깜빡이는 현상의 초기 가설은 AIPerception이 플레이어를 인식하지 못하는 구간이 있다는 것이었습니다. GetCurrentlyPerceivedActors는 "지금 이 순간" 보이는 것만 반환해서 인식이 끊기면 타깃이 사라진다는 분석이었습니다.
GetKnownPerceivedActors(5초 메모리)로 교체했으나 현상이 그대로였습니다.
Perceived / Nearest / Target / Lost / Slot직접 디버그 로그를 추가해 한 틱씩 확인했습니다.
[BP_EnemyWolf_C_1] Perceived=1 Nearest=BP_SovereignCharacter_C_0 Target=BP_SovereignCharacter_C_0 Lost=0 Unseen=0.0 Slot=0
bTargetLost는 단 한 번도 True가 된 적이 없었습니다. → Chase→Idle 전이(bTargetLost==True)는 발동하지 않았음.bOutOfChaseRange도 추가로 찍어봤습니다.[BP_EnemyWolf_C_1] DistFromSpawn=501 ChaseRange=2000 bOutOfChaseRange=0
이로써 StateTree에서 Chase를 종료시키는 3개 전이 중 bTargetLost, bOutOfChaseRange 두 가지가 소거됐습니다.
배운 것: "왜 안 되지?"보다 "어디서 안 되는지"를 먼저 격리하는 게 빠릅니다. 추측으로 5가지를 시도하는 것보다, 로그 한 줄이 2가지를 한 번에 소거해 줍니다.
오늘 작업의 상당 부분은 Claude가 제시한 틀린 진단을 반증하는 것이었습니다. 중요한 사례를 기록합니다.
Claude는 "ABP Locomotion SM의 Chase → Move 전이가 문제"라고 진단했습니다. 그런데 ABP_EnemyBase의 Locomotion SM에는 Chase 상태가 없습니다. 상태는 Idle, IdleBreak, MoveStart, Move, MoveEnd뿐입니다. Chase는 StateTree의 상태이지, ABP 상태머신의 상태가 아닙니다.
내부 그래프를 직접 공유해 반증했습니다. Claude는 오류를 인정했지만, 이미 이 진단을 바탕으로 한 후속 제안들이 줄줄이 나온 뒤였습니다.
Claude는 "MoveStart → Move 전이에서 source Sequence Player가 루핑으로 설정돼 전이가 안 된다"고 진단했습니다. 그런데 ABP를 직접 열어보니 Loop Animation은 체크 해제 상태였고, 애니메이션 에셋도 비어 있는 템플릿이었습니다.
이전에 MoveStart → Move가 정상 작동한 이력이 있었는데, 코드를 확인하지 않고 컴파일 경고 메시지만 보고 내린 결론이었습니다. 스크린샷을 공유하자 Claude가 인정했지만, 이미 헛된 방향으로 수정 작업을 진행한 뒤였습니다.
MoveTo가 슬롯 도달 후 Tick을 멈추는 것이 버그 2의 원인이라는 분석 과정에서, Claude가 AcceptableRadius를 5→200으로 올리라고 제안했습니다.
AcceptableRadius는 "목적지에서 이 거리 안에 들어오면 도착으로 간주"하는 허용 오차입니다. 200으로 올리면 슬롯(175)에서 더 멀리 떨어진 200 유닛 밖에서 멈추므로, 오히려 더 많은 Enemy가 더 먼 거리에서 멈추는 결과가 났습니다. 원래 5로 원복했습니다.
저는 이미 이전 대화에서 IsDeadOrDyingActor를 직접 구현했고, 이를 Claude에게 알려준 상태였습니다. 그런데 Claude가 이 함수에 대해 "어떤 파일인지 알려달라"고 되물었습니다. diff를 확인하면 RetrieveEnemyTargetEvaluator.cpp에 직접 작성된 것이 명확히 보이는데도, 코드를 확인하지 않았다는 의미입니다.
이로 인해 주간 토큰의 30% 가량을 오늘 소모했습니다. 앞으로 중요한 변경 사항을 알릴 때는 커밋 메시지 작성과 diff 확인을 먼저 요청하는 것이 낫겠다는 교훈을 얻었습니다.
배운 것: AI 어시스턴트의 출력을 "제안"으로 받아들이되, 코드 베이스에 없는 전제를 근거로 삼은 진단은 즉시 의심해야 합니다. "ABP에 Chase 상태가 있다", "Loop Animation이 켜져 있다" 같은 사실 관계는 직접 에디터에서 확인해야 합니다. 할루시네이션은 틀릴 때만 문제가 아니라 맞는 척하며 틀릴 때 가장 비쌉니다.
BP_EnemyBase를 신규 생성하고 BP_EnemyWolf의 부모 클래스를 교체하는 과정에서 DT_MonsterPattern의 참조 링크가 끊겼습니다. RequestBasicAttack이 항상 실패해 공격 자체가 발동되지 않았습니다.
StateTreeTask_EnemyAttack에 에러 로그를 분리해 추가하자 "RequestBasicAttack failed" 메시지가 명확히 찍혔고, DT를 다시 연결해 해결했습니다.
// 분리 전: 조건 하나에 묶여 어디서 실패했는지 불명확
if (!Combat || !Combat->RequestBasicAttack(InstanceData.TargetActor))
return EStateTreeRunStatus::Failed;
// 분리 후: 실패 경로가 명확히 찍힘
if (!Combat)
{
UE_LOG(LogStateTree, Error, TEXT("EnemyCombat Component is missing"));
return EStateTreeRunStatus::Failed;
}
if (!Combat->RequestBasicAttack(InstanceData.TargetActor))
{
UE_LOG(LogStateTree, Error, TEXT("RequestBasicAttack failed"));
return EStateTreeRunStatus::Failed;
}
배운 것: 복합 조건 하나로 묶인
if는 어느 조건에서 실패했는지 로그가 안 찍힙니다. 디버깅 단계에서만이라도 분리해 두면 추적 시간이 크게 줄어듭니다.
플레이어가 사망해도 5초 가량 적이 시체 위에서 회전하는 현상이 있었습니다. Evaluator의 타이머(TargetLostDelay=3s)가 만료되기 전까지 STT_SetFocus가 죽은 플레이어를 계속 포커스했기 때문입니다.
두 가지 경로로 처리했습니다.
// 1. 신규 감지 루프: 감지 목록에서 사망 Actor 제외
if (IsDeadOrDyingActor(Actor)) continue;
// 2. 기존 TargetPlayer가 죽었을 때: 즉시 소실 처리
if (IsDeadOrDyingActor(InstanceData.TargetPlayer))
{
EncirComp->ReleaseSlot(InstanceData.TargetPlayer, Pawn);
InstanceData.TargetPlayer = nullptr;
InstanceData.TimeSinceLastSeen = TargetLostDelay; // 다음 틱에 즉시 bTargetLost
InstanceData.bTargetLost = true;
...
}
// IsDeadOrDyingActor — Evaluator 파일 내 anonymous namespace
bool IsDeadOrDyingActor(AActor* Actor)
{
if (!IsValid(Actor)) return true;
if (URetrieveHealthComponent* HP = Actor->GetComponentByClass<URetrieveHealthComponent>())
return HP->IsDeadOrDying();
return false;
}
배운 것: "타이머 만료 후 처리"로 충분한 경우와 "즉시 처리"가 필요한 경우를 구분해야 합니다. 포커스(회전)처럼 매 틱 시각적으로 드러나는 것은 타이머를 기다리는 것이 유저 경험상 허용되지 않습니다.
WaitInPlace 깜빡임의 원인 소거 과정에서, Chase 상태의 Task 중 bConsideredForCompletion=True인 것들이 즉시 Succeeded를 반환하면 Chase가 강제 종료될 수 있다는 가설이 있었습니다. 두 Task를 직접 열어 확인했습니다.
Event EnterState → SetFocus, Event ExitState → ClearFocus. FinishTask 노드 없음 → Running 유지.Event EnterState → Add Loose Gameplay Tags, Event ExitState → Remove Loose Gameplay Tags. FinishTask 노드 없음 → Running 유지.두 Task 모두 원인이 아니었습니다. 이 사실 자체는 소거 과정에서 의미 있는 진전이었지만, 근본 원인은 아직 미확인 상태입니다.
BP_EnemyZombie를 부모로 삼아 늑대를 파생시키던 구조에서, BP_EnemyBase를 신규 생성하고 Wolf·Zombie 양쪽이 이를 상속하도록 변경했습니다. ABP_EnemyBase도 공통 AnimBlueprint로 구성해 공유 Locomotion SM(Idle/MoveStart/Move/MoveEnd/MoveEnd)을 한 곳에서 관리합니다.
BP_EnemyBase (신규)
├── BP_EnemyWolf (Characters/Normal/)
└── BP_EnemyZombie (Characters/Normal/, 신규 위치)
ABP_EnemyBase
└── Locomotion SM: Idle → MoveStart → Move ↔ MoveEnd → Idle
앞으로 에디터에서 Enemy 공통 설정(RVO, Collision Response 등)은 BP_EnemyBase에서 한 번만 수정하면 됩니다.
배운 것: 부모 클래스 교체 시 DataTable 링크, ABP 변수 기본값, AIController 지정이 자식에서 끊기거나 리셋될 수 있습니다. 교체 직후 반드시 자식 BP의 CDO를 확인해야 합니다. 오늘 DT_MonsterPattern 링크 소실이 정확히 이 경우였습니다.
오늘의 주요 목표였지만 끝내 원인을 확정하지 못했습니다.
소거된 것:
bTargetLost 기반 Chase→Idle 전이 (로그에서 항상 False)bOutOfChaseRange 기반 Chase→Return 전이 (로그에서 항상 0)남은 용의자: Chase→Attack→Root 재선택 사이클에서 State.Enemy.Chase 태그가 1프레임 소실돼 ABP Idle→MoveStart가 트리거되는 것으로 추정 중입니다. StateTree Debugger로 어느 전이가 발동하는지 직접 확인하는 것이 다음 단계입니다.
- 포위 슬롯(EncirclementSubsystem): 타깃 주변 원형 슬롯을 WorldSubsystem으로 관리. 슬롯 좌표는 반드시 NavMesh 투영을 거칩니다. 슬롯 요청은 Evaluator 매 틱 블록에서 Lazy 방식으로 처리합니다.
- 디버그 로그가 추측보다 빠릅니다:
bTargetLost·bOutOfChaseRange두 값으로 다섯 가지 가설 중 두 가지를 한 번에 소거했습니다.- AI 어시스턴트의 진단은 코드 베이스 사실 관계를 먼저 검증해야 합니다: "ABP에 Chase 상태가 있다", "Loop Animation이 켜져 있다"는 사실이 아니었습니다. 에디터에서 직접 확인하지 않은 진단은 신뢰 비용이 높습니다.
- 변경 사항은 반드시 알린 즉시 인지시켜야 합니다: 중요한 코드 수정 후 커밋 메시지·diff 확인을 먼저 요청해야 할루시네이션으로 인한 토큰 낭비를 줄일 수 있습니다.
- 복합 조건
if는 디버깅 시 분리합니다: 어느 조건에서 실패했는지 로그로 확인할 수 없으면 추적 시간이 배가됩니다.
Retrieve Project — 포위 슬롯 시스템·AI 추적 버그 소거·BP 상속 구조 (2026-06-02)