Sparta Unreal 부트캠프 118일차

정찬호·2026년 5월 20일

[TIL] 일반 몬스터 AI 통합 — StateTree 안정화 + AIPerception 팀 인터페이스

Today I Learned | Unreal Engine 5 | StateTree | AIPerception | GAS | C++ | AI


배경

일반 몹의 풀 사이클(Patrol → Chase → Attack → 피격 반응 → 복귀)을 한 번에 굴리려고 했는데, 두 개의 큰 함정 그룹에 막혔다.

  1. StateTree 측 — GA가 발동되고 ASC에 태그까지 박히는데 상태가 전환되지 않거나, 한 번 들어간 상태에서 나오지 못함
  2. AIPerception 측 — 시야·NavMesh가 정상인데 적이 플레이어를 절대 인지하지 못함

각 그룹마다 별개 원인이 누적되어 있었고, 하나씩 풀어야 했다.


1부 — ST_Monster_Normal 안정화 (StateTree 디버깅 함정 5선)

함정 1 — Transition Trigger 기본값은 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 명시.


함정 2 — 같은 이름의 상태가 중복으로 남아 있음

복사·재배치 과정에서 같은 이름(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 → 텍스트 에디터)로 확인.


함정 3 — Parameters로 우회하지 말고 Evaluator 출력에 직접 바인딩

StateTree의 Parameters는 트리 설정 시점에 고정되는 정적 입력이다. Evaluator의 동적 출력을 Parameters로 라우팅하려는 UI 표시("OUT → Parameters.X")는 실제 컴파일된 바인딩 테이블에 등록되지 않는 경우가 있다.

❌ Before — 우회 라우팅

Evaluator.OwnedTags ─(추정 라우팅)─▶ Parameters.TagContainer
                                          │
                            EnterCondition.TagContainer ← Parameters.TagContainer

Parameters는 비어 있고 조건은 항상 false.

✅ After — 직접 바인딩

Evaluator.OwnedTags
       │
       └─▶ EnterCondition.TagContainer (직접 바인딩)

Parameters는 AttackableRange·ChaseRange·PatrolRadius 같은 진짜 정적 상수만 둔다. 동적 값은 Evaluator 출력을 직접 참조.


함정 4 — GAS와 StateTree는 자동 연결되지 않는다

GA의 ActivationOwnedTags가 ASC에 박혀도 StateTree는 그것을 자동으로 보지 못한다. Evaluator가 Tick마다 ASC의 OwnedTags를 InstanceData로 복사해야 비로소 조건에 활용 가능.

Evaluator 측 (C++)

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);
        }
    }
}

StateTree 측

Hit 상태의 EnterCondition GameplayTagMatchConditionTag Container 입력을 Evaluator.OwnedTags에 바인딩, Tag 값에 State.Enemy.Hit.


함정 5 — GA 수명이 곧 ActivationOwnedTag 수명이다

ActivationOwnedTags는 GA가 활성화된 동안만 ASC에 박힌다. EndAbility 호출 시점에 자동 제거.

ActivateAbility → PrintString → EndAbility   // 1프레임 미만 수명

이러면 태그가 박혔다가 같은 프레임에 사라져 Evaluator도 showdebug도 못 잡는다.

처방

GA 안에서 명시적인 수명을 만들 것:

ActivateAbility
  → PrintString
  → Delay (2.0s)         // 또는 PlayMontageAndWait
  → EndAbility

테스트 단계에서는 Delay로 충분. 실사용은 PlayMontageAndWait + 몽타주 종료 시 EndAbility.


두 방향 통합 패턴 정리

같은 StateTree에 State → GAGA → State 두 방향이 공존하는 게 정상이다.

시맨틱방향
AI가 의사결정해서 능동적으로 행동State → GAAttack 상태 → SendGameplayEventGA_EnemyBasicAttack
외부 자극이 AI를 강제로 반응 상태로 끌어들임GA → State외부 GA가 State.Enemy.Hit 부여 → StateTree가 Hit 상태로 전환

Attack은 능동(A), Hit·Stagger·Groggy는 수동(B). 두 방향이 한 트리에 섞여도 의미적으로 분리된다.


2부 — AIPerception 팀 인터페이스 (listener 등록 함정)

문제: 적이 플레이어를 절대 인지 못 함

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 시스템에 등록되지 않는다.


1단계 — 팀 인터페이스 도입

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. 추가 처방이 필요.


2단계 — 두 줄의 결정적 추가

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();   // ← 추가
}

함정 정리 (Before / After)

❌ Before — SetPerceptionComponent 누락

AIPerceptionComp = CreateDefaultSubobject<UAIPerceptionComponent>(...);
SetGenericTeamId(FGenericTeamId(2));
// GetPerceptionComponent()=nullptr → listener update silent skip

✅ After — 명시 바인딩 후 TeamId

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 명시경고 사라짐 / 감지 정상해결

디버깅에 유용했던 패턴

  1. Evaluator Tick에 1초 단위 진단 로그
    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());
  2. GetTeamAttitudeTowards에 호출 시점·상대 액터·결과 로그
  3. showdebug ai / showdebug abilitysystem 콘솔 명령

→ 두 로그가 "listener 미등록 vs Cast 실패 vs 거리 미달" 단계 분기 판정에 결정적이었다.


핵심 요약

StateTree

  • Transition Trigger는 기본값 OnStateCompleted다. 조건 기반 동적 전환에는 반드시 OnTick 명시.
  • 이름이 같은 상태가 둘 이상 있으면 Root.Transitions는 ID로 가니까 사용자가 보지 못하는 옛 상태로 갈 수 있다. 복사·재배치 후엔 dump 확인.
  • Parameters는 정적 상수용. Evaluator 동적 출력은 Parameters로 우회하지 말고 조건에 직접 바인딩한다.
  • GAS와 StateTree는 자동 연결 없음. Evaluator가 ASC.GetOwnedGameplayTags()를 InstanceData로 복사하는 브릿지가 필요하다.
  • ActivationOwnedTags는 GA 수명 동안만 유효. Delay 또는 PlayMontageAndWait으로 명시 수명 부여.

AIPerception

  • 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)

profile
게임 개발 지망생입니다.

0개의 댓글