Today I Learned | Unreal Engine 5 | StateTree | AIPerception | GAS | C++ | AI
일반 몹의 풀 사이클(Patrol → Chase → Attack → 피격 반응 → 복귀)을 한 번에 굴리려고 했는데, 두 개의 큰 함정 그룹에 막혔다.
각 그룹마다 별개 원인이 누적되어 있었고, 하나씩 풀어야 했다.
OnStateCompleted다StateTree의 Transition에서 Trigger를 명시하지 않으면 기본값은 OnStateCompleted. 상태의 bConsideredForCompletion=true 태스크들이 모두 완료될 때 발화한다.
DebugText 같은 "절대 완료되지 않는" 태스크만 있는 상태에서는 transition이 영원히 평가되지 않는다.
Hit 상태
Tasks: DebugTextTask("Hit!") // bConsideredForCompletion=false
Transitions:
Trans(0): → Chase, 조건 !State.Enemy.Hit, Trigger 미명시 ← OnStateCompleted
→ GA가 끝나서 태그가 사라져도 Trans(0)이 평가되지 않아 Hit에 영원히 머무름.
Trans(0): Trigger = OnTick (명시), 조건 !State.Enemy.Hit
조건 기반 동적 전환은 반드시 OnTick 명시.
복사·재배치 과정에서 같은 이름(Groggy)의 상태가 두 개 이상 Children 배열에 남는 경우. 에디터 UI에서는 한 개로 보일 수 있어도 dump에는 둘 다 존재.
Root의 Transitions는 상태 ID로 타겟을 지정하기 때문에, 사용자가 보고 수정하는 상태와 실제 전환되는 상태가 다른 인스턴스일 수 있다.
Children(1) = StateTreeState_7 (Groggy) ← Root.Transitions(1)이 가리킴
Children(2) = StateTreeState_8 (Groggy) ← 사용자가 에디터에서 수정 중
수정·바인딩이 모두 StateTreeState_8에 들어갔지만 런타임은 StateTreeState_7로 진입 → 사일런트 버그.
States 패널에서 같은 이름이 둘 이상 보이면 즉시 정리. UI에 한 개만 보여도 의심되면 dump(Copy → 텍스트 에디터)로 확인.
StateTree의 Parameters는 트리 설정 시점에 고정되는 정적 입력이다. Evaluator의 동적 출력을 Parameters로 라우팅하려는 UI 표시("OUT → Parameters.X")는 실제 컴파일된 바인딩 테이블에 등록되지 않는 경우가 있다.
Evaluator.OwnedTags ─(추정 라우팅)─▶ Parameters.TagContainer
│
EnterCondition.TagContainer ← Parameters.TagContainer
Parameters는 비어 있고 조건은 항상 false.
Evaluator.OwnedTags
│
└─▶ EnterCondition.TagContainer (직접 바인딩)
Parameters는 AttackableRange·ChaseRange·PatrolRadius 같은 진짜 정적 상수만 둔다. 동적 값은 Evaluator 출력을 직접 참조.
GA의 ActivationOwnedTags가 ASC에 박혀도 StateTree는 그것을 자동으로 보지 못한다. Evaluator가 Tick마다 ASC의 OwnedTags를 InstanceData로 복사해야 비로소 조건에 활용 가능.
USTRUCT()
struct FRetrieveEnemyTargetEvalInstanceData
{
UPROPERTY(EditAnywhere, Category = "Output")
FGameplayTagContainer OwnedTags;
// ...
};
void FRetrieveEnemyTargetEvaluator::Tick(...)
{
// ...
if (const IAbilitySystemInterface* ASCIf = Cast<IAbilitySystemInterface>(Pawn))
{
if (UAbilitySystemComponent* ASC = ASCIf->GetAbilitySystemComponent())
{
InstanceData.OwnedTags.Reset();
ASC->GetOwnedGameplayTags(InstanceData.OwnedTags);
}
}
}
Hit 상태의 EnterCondition GameplayTagMatchCondition → Tag Container 입력을 Evaluator.OwnedTags에 바인딩, Tag 값에 State.Enemy.Hit.
ActivationOwnedTags는 GA가 활성화된 동안만 ASC에 박힌다. EndAbility 호출 시점에 자동 제거.
ActivateAbility → PrintString → EndAbility // 1프레임 미만 수명
이러면 태그가 박혔다가 같은 프레임에 사라져 Evaluator도 showdebug도 못 잡는다.
GA 안에서 명시적인 수명을 만들 것:
ActivateAbility
→ PrintString
→ Delay (2.0s) // 또는 PlayMontageAndWait
→ EndAbility
테스트 단계에서는 Delay로 충분. 실사용은 PlayMontageAndWait + 몽타주 종료 시 EndAbility.
같은 StateTree에 State → GA 와 GA → State 두 방향이 공존하는 게 정상이다.
| 시맨틱 | 방향 | 예 |
|---|---|---|
| AI가 의사결정해서 능동적으로 행동 | State → GA | Attack 상태 → SendGameplayEvent → GA_EnemyBasicAttack |
| 외부 자극이 AI를 강제로 반응 상태로 끌어들임 | GA → State | 외부 GA가 State.Enemy.Hit 부여 → StateTree가 Hit 상태로 전환 |
Attack은 능동(A), Hit·Stagger·Groggy는 수동(B). 두 방향이 한 트리에 섞여도 의미적으로 분리된다.
AIController에 AIPerceptionComponent + SightConfig 구성. 시야 1500uu·각도 60°·NavMesh 정상 빌드 상태에서도 OnTargetPerceptionUpdated가 단 한 번도 발화하지 않는다.
PIE 로그의 의심스러운 경고:
LogAIPerception: Warning: Listener must have a valid id to update its sense config
진단 로그로 확인:
[Eval] PerceivedCount=0, Target=null, TeamId=255
TeamId=255 = FGenericTeamId::NoTeam. listener가 valid TeamId 없이는 perception 시스템에 등록되지 않는다.
AAIController는 이미 IGenericTeamAgentInterface를 상속하지만, TeamId가 명시적으로 설정되지 않으면 NoTeam이다.
// EnemyAIController.h
UPROPERTY(EditDefaultsOnly, Category = "Retrieve|AI|Team")
uint8 EnemyTeamId = 2;
virtual ETeamAttitude::Type GetTeamAttitudeTowards(const AActor& Other) const override;
// EnemyAIController.cpp
ETeamAttitude::Type AEnemyAIController::GetTeamAttitudeTowards(const AActor& Other) const
{
if (const APawn* OtherPawn = Cast<APawn>(&Other))
{
if (auto* TA = Cast<IGenericTeamAgentInterface>(OtherPawn->GetController()))
return FGenericTeamId::GetAttitude(GetGenericTeamId(), TA->GetGenericTeamId());
if (auto* PT = Cast<IGenericTeamAgentInterface>(OtherPawn))
return FGenericTeamId::GetAttitude(GetGenericTeamId(), PT->GetGenericTeamId());
}
return ETeamAttitude::Neutral;
}
플레이어 측에도 인터페이스 구현:
class ASovereignCharacter
: public ARetrieveCombatCharacter,
public IGenericTeamAgentInterface
{
public:
virtual FGenericTeamId GetGenericTeamId() const override
{ return FGenericTeamId(PlayerTeamId); } // 기본 1
};
→ TeamId=2 출력은 되지만, perception은 여전히 0. 추가 처방이 필요.
SetPerceptionComponent(*AIPerceptionComp) — 생성자에서 명시 호출AAIController는 자체 PerceptionComponent 멤버 포인터로 perception API를 처리한다. 자동 바인딩(BindPerceptionComponent)이 PostInitializeComponents에서 일어나지만, 그보다 먼저 생성자의 SetGenericTeamId가 내부적으로 GetPerceptionComponent()을 호출한다.
// 엔진 내부
void AAIController::SetGenericTeamId(const FGenericTeamId& NewTeamID)
{
if (TeamID != NewTeamID)
{
TeamID = NewTeamID;
if (UAIPerceptionComponent* PC = GetPerceptionComponent())
{
PC->RequestStimuliListenerUpdate();
}
}
}
이 시점에 GetPerceptionComponent()가 nullptr이면 listener update가 silent skip된다.
// 생성자
AIPerceptionComp = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("AIPerceptionComponent"));
SetPerceptionComponent(*AIPerceptionComp); // ← 추가: 즉시 바인딩
SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
SetGenericTeamId(FGenericTeamId(EnemyTeamId)); // 이제 정상 전파
RequestStimuliListenerUpdate() — InitSightConfig 끝에 명시 호출ConfigureSense 내부:
void UAIPerceptionComponent::ConfigureSense(UAISenseConfig& SenseConfig)
{
SensesConfig.AddUnique(&SenseConfig);
SenseConfig.OnSenseEnabled.AddUObject(this, ...);
SetMaxStimulusAge(...);
if (HasAnyFlags(RF_ClassDefaultObject | RF_ArchetypeObject) == false)
{
RequestStimuliListenerUpdate(); // CDO에서는 skip
}
}
CDO 컨텍스트에서는 listener update가 skip된다. 인스턴스의 sight 설정 후 외부에서 한 번 더 명시 호출하면 등록을 확실하게 트리거할 수 있다.
void AEnemyAIController::InitSightConfig()
{
// ... config 값 적용 ...
AIPerceptionComp->ConfigureSense(*SightConfig);
AIPerceptionComp->SetDominantSense(SightConfig->GetSenseImplementation());
AIPerceptionComp->RequestStimuliListenerUpdate(); // ← 추가
}
AIPerceptionComp = CreateDefaultSubobject<UAIPerceptionComponent>(...);
SetGenericTeamId(FGenericTeamId(2));
// GetPerceptionComponent()=nullptr → listener update silent skip
AIPerceptionComp = CreateDefaultSubobject<UAIPerceptionComponent>(...);
SetPerceptionComponent(*AIPerceptionComp);
SetGenericTeamId(FGenericTeamId(2));
// listener update가 정상 전파됨
| 단계 | 결과 | 잔여 문제 |
|---|---|---|
| Perception 컴포넌트 생성만 | Listener must have a valid id 경고 | listener 등록 실패 |
bDetectNeutrals=true 우회 | 변화 없음 | listener 미등록 그대로 |
SetGenericTeamId(2) 추가 | TeamId=2 적용 / 경고 잔존 | listener 미등록 |
SetPerceptionComponent + RequestStimuliListenerUpdate 명시 | 경고 사라짐 / 감지 정상 | 해결 |
UE_LOG(LogTemp, Warning,
TEXT("[Eval] PerceivedCount=%d, Target=%s, TeamId=%d"),
PerceivedActors.Num(),
InstanceData.TargetPlayer ? *InstanceData.TargetPlayer->GetName() : TEXT("null"),
(int32)AIController->GetGenericTeamId().GetId());GetTeamAttitudeTowards에 호출 시점·상대 액터·결과 로그showdebug ai / showdebug abilitysystem 콘솔 명령→ 두 로그가 "listener 미등록 vs Cast 실패 vs 거리 미달" 단계 분기 판정에 결정적이었다.
- Transition
Trigger는 기본값 OnStateCompleted다. 조건 기반 동적 전환에는 반드시OnTick명시.- 이름이 같은 상태가 둘 이상 있으면 Root.Transitions는 ID로 가니까 사용자가 보지 못하는 옛 상태로 갈 수 있다. 복사·재배치 후엔 dump 확인.
- Parameters는 정적 상수용. Evaluator 동적 출력은 Parameters로 우회하지 말고 조건에 직접 바인딩한다.
- GAS와 StateTree는 자동 연결 없음. Evaluator가 ASC.GetOwnedGameplayTags()를 InstanceData로 복사하는 브릿지가 필요하다.
ActivationOwnedTags는 GA 수명 동안만 유효. Delay 또는 PlayMontageAndWait으로 명시 수명 부여.
- AAIController는
PerceptionComponent멤버 포인터를 통해 perception API를 처리하므로, 커스텀 AIPerceptionComponent를 만들었다면 생성자에서SetPerceptionComponent(*컴포넌트)로 명시 바인딩이 필수다.SetGenericTeamId는 내부적으로RequestStimuliListenerUpdate를 호출하지만, perception 컴포넌트가 바인딩되지 않았으면 silent skip된다.ConfigureSense의 listener update는 CDO 컨텍스트에서 skip되므로, 인스턴스의 sight 설정 후 명시적으로RequestStimuliListenerUpdate()호출이 안전하다.Listener must have a valid id경고는 listener 자체가 perception 시스템에 등록되지 않은 상태를 의미한다. TeamId 미설정은 그 원인 중 하나일 뿐, 직접적 원인은 listener 바인딩이다.bDetectNeutrals=true우회는 affiliation 필터 단계 우회일 뿐, listener 미등록 단계에서는 무력하다.
Retrieve 프로젝트 — 일반 몹 AI 통합 작업 (2026-05-20)