Today I Learned |
Unreal Engine 5.7|StateTree|C++|AI|디버깅 방법론
오늘은 Gemini와 함께 Attack Token 패턴과 Strafe(배회) 상태를 구현했습니다. 설계서를 받아 코드를 직접 대조하는 과정에서 이미 구현된 항목을 확인하고, Claude가 놓친 버그를 직접 발견하며 수정했습니다. Strafe 상태 전이 문제는 원인이 파악된 상태로 다음 작업에서 이어갑니다.
ShiftSlotExplicit — EncirclementSubsystem에 추가RequestAttackToken / ReleaseAttackToken bAttackable + 토큰 요청 로직 — Evaluator Tick에 추가 Claude는 작업의 전후 관계를 알지 못하기에 설계서 작성 이후에 구현된 내용을 설계서 작성 이전에 진행했다고 혼동하는 경우가 종종 있습니다. 때문에 작업이 꼬일 때도 있습니다.
TIL 초안 작성해 달라니까 Gemini랑 작성한 계획서의 내용을 Gemini가 못 알아차린 내용으로 작성을 해놓네요.
Evaluator.cpp에서 작업을 이어갔습니다.
추가된 내용
if (InstanceData.bAttackable)
{
InstanceData.bAttackable = EncircleSubsystem->RequestAttackToken(...);
}
이 코드는 0.2초마다(Throttle 주기) 실행됩니다. 같은 Enemy가 매 틱 RequestAttackToken을 호출하지만 ReleaseAttackToken은 Evaluator 어디에도 없었습니다.
시뮬레이션하면:
T=0.0s: CurrentIssuedTokens = 1
T=0.2s: 같은 늑대가 다시 요청 → CurrentIssuedTokens = 2
T=0.4s: MaxAttackTokens(=3) 도달 → 모든 적 공격 불가
원인: 카운터만 존재하고 누가 보유하는지 추적하지 않음.
수정: Set 기반 보유자 추적으로 교체했습니다.
// EncirclementSubsystem.h
TSet<TWeakObjectPtr<AActor>> TokenHolders;
bool RequestAttackToken(AActor* Target, AActor* Requester)
{
if (TokenHolders.Contains(Requester)) return true; // 이미 보유 중 → 중복 발급 없음
if (TokenHolders.Num() >= MaxAttackTokens) return false;
TokenHolders.Add(Requester);
return true;
}
void ReleaseAttackToken(AActor* Target, AActor* Requester) { TokenHolders.Remove(Requester); }
ReleaseAttackToken은 StateTreeTask_EnemyAttack.ExitState에 추가해 Attack 상태 탈출 시 반납하도록 했습니다.
SlotIndex를 캐싱하는 대신 서브시스템에서 매번 새로 가져오기로 결정했습니다.
기존 문제: Task가 ShiftSlotExplicit를 호출해 서브시스템의 슬롯 배열을 바꿔도, Evaluator의 SlotIndex 캐시가 구버전을 유지해 ChaseLocation이 이전 위치로 덮어씌워졌습니다.
수정 방향: 서브시스템을 단일 진실 공급원으로 삼고 매 틱 직접 조회합니다.
// EncirclementSubsystem에 추가
int32 GetCurrentSlot(AActor* Target, AActor* Requester) const
{
const FRing* Ring = Rings.Find(Target);
if (!Ring) return INDEX_NONE;
return Ring->Slots.IndexOfByKey(Requester);
}
// Evaluator Tick — InstanceData.SlotIndex 제거, 지역 변수로 처리
int32 SlotIndex = EncSubsystem->GetCurrentSlot(TargetPlayer, Pawn);
if (SlotIndex == INDEX_NONE)
SlotIndex = EncSubsystem->RequestSlot(TargetPlayer, Pawn);
InstanceData.ChaseLocation = (SlotIndex != INDEX_NONE)
? EncSubsystem->GetSlotLocation(TargetPlayer, SlotIndex)
: TargetLocation;
Task가 슬롯을 변경하면 다음 Evaluator 틱에서 GetCurrentSlot이 새 슬롯을 반환해 ChaseLocation이 자동 갱신됩니다.
태스크를 직접 작성했으나 태스크 피커에 나타나지 않았습니다. 코드를 검토해 컴파일 오류 3종을 발견하고 수정했습니다.
\ 오타// 오류
#include "Enemy/StateTree/StateTreeTask_ShiftOrbitSlot.h"\
// 수정
#include "Enemy/StateTree/StateTreeTask_ShiftOrbitSlot.h"
C++ 전처리기에서 \는 줄 이음 기호입니다. 뒤에 오는 #include "EditorCategoryUtils.h"가 앞 경로에 합쳐져 컴파일 전체가 실패했습니다. 태스크가 등록되지 않은 근본 원인이었습니다.
StateTreeTask_EnemyAttack의 코드를 가져다 StateTreeTask_ShiftOrbitSlot을 만들었기에 meta 데이터 설정을 빼먹었습니다.
// 오류 — 기존 EnemyAttack과 이름 충돌
USTRUCT(BlueprintType, meta = (DisplayName = "Enemy Attack", Category = "Retrieve|AI"))
// 수정
USTRUCT(BlueprintType, meta = (DisplayName = "Shift Orbit Slot", Category = "Retrieve|AI"))
두 가지 수정 후 태스크 피커에 정상 등록됐습니다.
ShiftOrbitSlot Task가 동작하며 슬롯을 주기적으로 이동합니다.StrafeOffRange = AttackableRange * 1.5를 추가해 히스테리시스를 적용하여 해결했습니다.
증상: Strafe 모드에서 이동이 매우 느려지고, Rewind Debugger에서 주기적으로 Strafe를 탈출하는 것이 확인됐습니다.
원인 파악: Strafe를 Chase의 자식 상태로 배치한 것이 문제였습니다.
UE5 StateTree에서 부모 상태의 Task는 자식 상태 활성 중에도 계속 실행됩니다. Chase의 MoveTo와 Strafe의 ShiftOrbitSlot이 동시에 이동을 제어하면서 충돌이 발생했습니다.
주기적 탈출 원인: Chase의 MoveTo(bConsideredForCompletion=True)가 늑대가 슬롯에 도착할 때 Succeeded를 반환 → Chase 상태 완료 → 자식인 Strafe도 함께 종료됩니다. StrafeInterval 설정(5초·1초)을 바꿔도 동일하게 발생하는 이유가 이것이었습니다.
해결 방향: Strafe를 Chase의 자식이 아닌 Root 레벨 형제 상태로 재배치합니다. 현재 Strafe로의 전환이 정상적으로 동작하지 못합니다. 추가적인 원인 분석이 필요합니다.
Root
├── Attack (bAttackable = true)
├── Strafe (DistanceToTarget ≤ AttackableRange && !bAttackable) ← 이동 예정
├── Chase (bTargetLost = false)
└── ...
- Attack Token은 카운터 대신 Set 기반 보유자 추적이 필요합니다. 카운터만 있으면 같은 Enemy가 매 틱 중복 요청해 빠르게 소진됩니다.
- Evaluator의 캐시와 서브시스템 데이터는 Task가 서브시스템을 변경해도 자동 동기화되지 않습니다. 서브시스템을 단일 진실 공급원으로 삼고 매 틱 직접 조회하는 방식이 안전합니다.
- UE5 StateTree에서 부모 상태 Task는 자식 상태 활성 중에도 계속 실행됩니다. 이동처럼 독점 제어가 필요한 Task가 있다면 자식 구조 대신 형제 상태로 배치해야 합니다.
- 태스크가 피커에 나타나지 않을 때는 컴파일 오류부터 확인해야 합니다.
\한 글자가 전처리기 오류로 태스크 등록 전체를 막을 수 있습니다.
Retrieve 프로젝트 — 7th Team2 Final Project (2026-06-05)