Sparta Unreal 부트캠프 117일차

정찬호·2026년 5월 19일

📌 TIL — GAS · StateTree · GameplayMessage

적 AI 시스템을 구축하며 정리한 개념들입니다. GAS 태그 시스템, StateTree 노드 구조, AI 간 메시지 전파까지 다룹니다.


🏷️ AddLooseGameplayTag vs SendGameplayEventToActor

GAS에서 자주 헷갈리는 두 함수의 차이를 정리합니다.

AddLooseGameplayTag

ASC에 태그를 직접 꽂는 것입니다.

  • 태그가 ASC에 즉시 존재하게 됨
  • 누군가 명시적으로 제거(RemoveLooseGameplayTag)하기 전까지 유지됨
  • GA를 트리거하지 않음 — 그냥 태그만 있는 상태
  • StateTree, GA의 BlockingTags / RequiredTags, 기타 시스템이 읽기용으로 사용
ASC: [State.Enemy.Idle]
// ↑ 태그만 존재, 아무 동작 없음

SendGameplayEventToActor

ASC에 일회성 신호를 쏘는 것입니다.

  • 태그가 ASC에 남지 않음 — 신호를 받는 순간 소멸
  • AbilityTriggers에 해당 이벤트 태그가 등록된 GA를 깨움
  • GA가 없으면 아무 일도 안 일어남
SendGameplayEventToActor(GameplayEvent.Enemy.Attack)
  → GA_Enemy_Attack의 AbilityTriggers가 매칭
  → GA 활성화
  → GA 내부에서 State.Enemy.Attack 태그 부여 + 몽타주 재생 + 데미지 처리

비교 요약

구분AddLooseGameplayTagSendGameplayEventToActor
태그가 ASC에 남는가✅ 남음 (명시적 제거 전까지)❌ 안 남음 (1회성 신호)
GA를 트리거하는가✅ (AbilityTriggers 매칭 시)
용도상태 표시 (읽기용)동작 발동 (쓰기용)
제거 방법RemoveLooseGameplayTag해당 없음

💡 한 줄 정리

  • AddLooseGameplayTag상태 태그 부여
  • SendGameplayEventToActorGA 활성화를 위해 ASC에 태그 전달

⏹️ FinishTask 필요성 고려

FinishTask의 역할

FinishTask(bSuccess)를 호출하면 StateTree에 "이 태스크가 끝났다" 고 알립니다.
이것이 의미 있는 경우는 전이 트리거가 On State Completed 일 때뿐입니다.


현재 설계의 전이 방식

상태전이 트리거FinishTask 필요?
Idle → ChaseAIPerception 조건
Chase → Attack거리 조건
Attack → ChaseState.Enemy.Attack 태그 제거됨 (태그 조건)
Hit → ChaseState.Enemy.Hit 태그 제거됨 (태그 조건)
Return → Idle거리 조건
Any → DeadState.Enemy.Dead 태그 / 이벤트

모두 태그·거리·Perception 조건 기반이므로 On State Completed를 사용하지 않습니다.
FinishTask를 넣으면 오히려 태스크가 즉시 완료 처리되어 의도치 않은 전이가 발생할 수 있습니다.

⚠️ 현재 설계상 FinishTask는 사용하지 않는 것이 좋습니다.


📦 PrivateDependencyModuleNames에 두는 이유

모듈이 Public 헤더(.h)에 노출되지 않고 .cpp에서만 사용될 때 Private에 둡니다.

  • 다른 모듈이 우리 모듈을 가져다 쓸 때 AIModule전이 의존성으로 강제하지 않음
  • 불필요한 컴파일 의존성 차단 → 빌드 속도 이점

🚪 EnterConditions vs Transitions

개념 차이

구분EnterConditionsTransitions
성격수동 게이트 ("이 상태에 들어올 수 있는가?")능동 트리거 ("지금 상태를 바꿔라")
평가 시점StateTree가 이 상태를 선택하려 할 때현재 상태에서 매 틱 조건 감시
단독으로 전이 유발?Selection Behavior에 따라 가능항상 가능

Selection Behavior가 핵심

StateTree Root의 Selection Behavior가 Try Select Children In Order 이면, EnterConditions만으로도 전이가 됩니다.

매 틱: Root가 자식 상태를 위→아래 순서로 평가
  └─ 첫 번째로 EnterConditions를 통과한 상태로 즉시 전환

우선순위는 나열 순서입니다.


Transition 조건은 충족했지만 EnterConditions가 안 될 때

  • Transition = "목적지 상태로 가려는 시도"
  • EnterConditions = "목적지 상태의 입장 허가"
현재: Chase
Transition 조건: 공격 사거리 이내 → Attack으로 가려 함
Attack의 EnterConditions: (별도 조건 있을 경우) 미충족
→ 전이 실패, Chase 유지

💡 EnterConditions가 없으면 Transition은 항상 성공합니다.


권장 상태 나열 순서

현재 순서(Idle → Chase → Attack → Hit → Return → Dead)는 Try Select Children In Order에서 문제가 됩니다.
Idle이 조건 없이 맨 위에 있으면 항상 Idle이 선택됩니다.

우선순위 높은 것을 위에 배치해야 합니다:

Root (Try Select Children In Order)
├── Dead     ← 최우선 (태그 있으면 즉시)
├── Hit      ← 피격 시 즉시
├── Attack   ← 사거리 이내
├── Chase    ← 감지됨
├── Return   ← 감지 해제
└── Idle     ← 기본 (조건 없음, 항상 통과)

이 순서라면 Transitions 없이 EnterConditions만으로 전체 상태 전이가 동작합니다.


🧩 StateTree의 Evaluator, Task 코드 분석하기

Unreal Engine의 StateTree 노드(Evaluator, Task, Condition)는 값 타입입니다.
UObject가 아니라 FStateTreeEvaluatorBase / FStateTreeTaskBase를 상속하는 USTRUCT 입니다.

이 때문에 UE 에디터의 "New C++ Class" 마법사로 만들 수 없고, 파일을 직접 작성해야 합니다.


🔹 InstanceData 패턴

StateTree 노드 struct는 에셋에 박혀 있는 설정값(config)런타임 중 변하는 상태값을 분리해야 합니다.

FRetrieveEnemyTargetEvaluator
// ← 에셋에 저장. TickInterval, TargetLostDelay만 여기.

FRetrieveEnemyTargetEvalInstanceData
// ← 실행 중 각 AI 인스턴스마다 별도로 할당됨

몬스터가 맵에 10마리 있으면, Evaluator struct 자체는 StateTree 에셋에 1개뿐입니다.
런타임 상태(AccumulatedTime, TargetPlayer 등)는 몬스터마다 다르므로 InstanceData를 10개 따로 만들어 줍니다.

// Evaluator의 Tick는 const 메서드지만 InstanceData는 수정 가능
// struct 자체(= 에셋에 저장된 것)는 변경 안 함, 인스턴스 상태만 바꿈
void FRetrieveEnemyTargetEvaluator::Tick(...) const
{
    FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
    // InstanceData는 mutable 참조 — struct 자체(this)는 const
}

GetInstanceData(*this)"이 노드(this)에 대응하는 InstanceData를 꺼내줘" 라는 의미입니다.
const 메서드지만 반환값이 & (non-const ref) 인 이유가 이것입니다.

연결 선언:

using FInstanceDataType = FRetrieveEnemyTargetEvalInstanceData;
virtual const UStruct* GetInstanceDataType() const override
{
    return FInstanceDataType::StaticStruct();
}
  • using → 타입 별칭
  • GetInstanceDataType() → StateTree에게 "내 InstanceData 타입이 뭔지" 알려주는 메서드

🔹 External Data 핸들 패턴

StateTree 노드에서 AIControllerPawn에 접근하려면 직접 포인터를 들고 있을 수 없습니다
(USTRUCT는 GC 대상이 아님). 대신 다음과 같이 합니다:

// 헤더: 핸들 선언 (실제 포인터가 아님, 인덱스 같은 것)
TStateTreeExternalDataHandle<AAIController> AIControllerHandle;

// Link()에서 등록
bool FRetrieveEnemyTargetEvaluator::Link(FStateTreeLinker& Linker)
{
    Linker.LinkExternalData(AIControllerHandle);
    // ↑ "이 핸들은 AIController를 가리킴"
    return true;
}

// 실행 시점에 실제 포인터 획득
AAIController* AIController = Context.GetExternalDataPtr(AIControllerHandle);

UStateTreeAIComponent가 StateTree를 실행할 때 AAIControllerAPawn을 자동으로 외부 데이터로 등록해줍니다.

  • LinkExternalData → "나중에 그 데이터를 이 핸들로 꺼낼 거야" 라고 예약
  • GetExternalDataPtr실행 시점에 실제 포인터 획득

Link()bool을 반환하는 이유: 필요한 외부 데이터가 없으면 false를 반환해 StateTree 초기화 실패를 알립니다.


🔹 Evaluator 전체 흐름

Evaluator는 StateTree 전체 실행 동안 매 틱 실행됩니다.
Task처럼 특정 상태에만 들어가는 게 아니라, 항상 돌면서 공유 데이터를 갱신하는 역할입니다.

StateTree 시작
    └─ TreeStart()  ← 초기화 1회

매 틱
    └─ Tick()       ← 매 프레임 호출

TreeStart 코드

void FRetrieveEnemyTargetEvaluator::TreeStart(FStateTreeExecutionContext& Context) const
{
    FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
    InstanceData.TargetPlayer      = nullptr;
    InstanceData.DistanceToTarget  = 0.f;
    InstanceData.bTargetLost       = false;
    InstanceData.TimeSinceLastSeen = 0.f;
    InstanceData.AccumulatedTime   = TickInterval; // ← 첫 Tick에서 즉시 쿼리되도록
}

💡 AccumulatedTime = TickInterval로 시작하는 이유
Tick에서 AccumulatedTime < TickInterval이면 스킵합니다. 0으로 시작하면 첫 TickInterval 동안 아무것도 안 하므로, TickInterval 값으로 초기화해서 첫 프레임에 바로 쿼리하게 합니다.

Tick 코드

void FRetrieveEnemyTargetEvaluator::Tick(...) const
{
    FInstanceDataType& InstanceData = Context.GetInstanceData(*this);

    // ① 성능 절감: TickInterval마다만 실행
    InstanceData.AccumulatedTime += DeltaTime;
    if (InstanceData.AccumulatedTime < TickInterval) { return; }
    InstanceData.AccumulatedTime = 0.f;

    // ② AIController에서 AIPerceptionComponent 가져옴
    AAIController* AIController = Context.GetExternalDataPtr(AIControllerHandle);
    APawn*         Pawn         = Context.GetExternalDataPtr(PawnHandle);
    if (!AIController || !Pawn) { return; }

    UAIPerceptionComponent* PerceptionComp = AIController->GetAIPerceptionComponent();
    if (!PerceptionComp) { return; }

    // ③ 현재 인지 중인 액터 전체 목록
    TArray<AActor*> PerceivedActors;
    PerceptionComp->GetCurrentlyPerceivedActors(nullptr, PerceivedActors);
    // nullptr 인자 = 모든 Sense 포함

    // ④ 가장 가까운 액터 찾기 (DistSquared가 Sqrt보다 연산 비용 낮음)
    AActor* NearestTarget = nullptr;
    float   NearestDistSq = MAX_FLT;
    for (AActor* Actor : PerceivedActors)
    {
        float DistSq = FVector::DistSquared(
            Pawn->GetActorLocation(), Actor->GetActorLocation());
        if (DistSq < NearestDistSq)
        {
            NearestDistSq = DistSq;
            NearestTarget = Actor;
        }
    }

    // ⑤ 결과 업데이트
    if (NearestTarget)                          // 타깃 감지 중
    {
        InstanceData.TargetPlayer      = NearestTarget;
        InstanceData.DistanceToTarget  = FMath::Sqrt(NearestDistSq);
        InstanceData.TimeSinceLastSeen = 0.f;
        InstanceData.bTargetLost       = false;
    }
    else if (InstanceData.TargetPlayer)         // 타깃 있었는데 이번에 못 봄
    {
        InstanceData.TimeSinceLastSeen += TickInterval;
        if (InstanceData.TimeSinceLastSeen >= TargetLostDelay)
        {
            InstanceData.TargetPlayer  = nullptr;
            InstanceData.bTargetLost   = true;  // Chase → Return 전환 조건
        }
    }
    // else: 원래부터 타깃 없음 → 아무것도 안 함
}

AEnemyAIControllerPerceivedTarget 필드는 protected라 외부에서 접근 불가합니다.
그래서 AIController에 getter를 추가하는 대신, AIPerceptionComponent를 직접 쿼리해서 현재 인지 중인 액터 목록을 가져옵니다. 이 방식이 더 독립적입니다.


🔹 Task 전체 흐름

Task는 특정 State에 진입할 때 실행되고, 상태를 벗어나면 멈춥니다.

Attack State 진입
    └─ EnterState()  ← GA 활성화 이벤트 발송, Running 반환

매 틱 (Running인 동안)
    └─ Tick()        ← GA 종료 감지, Succeeded 반환하면 상태 전이

Attack State 이탈
    └─ ExitState()   ← 정리 작업

EnterState 코드

EStateTreeRunStatus FStateTreeTask_RequestPattern::EnterState(...) const
{
    // ① 타이머 리셋
    FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
    InstanceData.ElapsedTime = 0.f;

    APawn* Pawn = Context.GetExternalDataPtr(PawnHandle);
    if (!Pawn) { return EStateTreeRunStatus::Failed; }

    // ② Pawn이 IAbilitySystemInterface를 구현하는지 확인
    IAbilitySystemInterface* ASCInterface = Cast<IAbilitySystemInterface>(Pawn);
    if (!ASCInterface) { return EStateTreeRunStatus::Failed; }

    UAbilitySystemComponent* ASC = ASCInterface->GetAbilitySystemComponent();
    if (!ASC) { return EStateTreeRunStatus::Failed; }

    // ③ GameplayEvent로 GA 간접 활성화
    FGameplayEventData EventData;
    EventData.EventTag = RetrieveGameplayTags::GameplayEvent_Enemy_Attack;
    ASC->HandleGameplayEvent(
        RetrieveGameplayTags::GameplayEvent_Enemy_Attack, &EventData);

    return EStateTreeRunStatus::Running;  // ④ 아직 끝나지 않음
}

IAbilitySystemInterface를 쓰는 이유

  • Task가 AEnemyCharacter를 직접 참조하면 강한 결합이 생깁니다.
  • 인터페이스를 통해 ASC를 꺼내면 어떤 Pawn 타입이든 동작합니다.

HandleGameplayEvent vs TryActivateAbilityByClass

  • TryActivateAbilityByClass는 GA를 직접 강제 실행합니다. 설계 규칙은 StateTree에서 이 방식 금지입니다.
  • HandleGameplayEvent는 이벤트를 발송하고, GA가 FAbilityTriggerDataGameplayEvent_Enemy_Attack 태그를 등록해 두었을 때만 활성화됩니다. 더 느슨한 연결입니다.

Tick 코드

EStateTreeRunStatus FStateTreeTask_RequestPattern::Tick(...) const
{
    FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
    InstanceData.ElapsedTime += DeltaTime;

    // ① 최소 0.3초 대기 (GA가 실제로 시작되기 전 프레임을 피함)
    if (InstanceData.ElapsedTime < 0.3f) { return EStateTreeRunStatus::Running; }

    // ② GA가 끝났는지: State_Enemy_Attack 태그가 사라졌으면 완료
    APawn* Pawn = Context.GetExternalDataPtr(PawnHandle);
    if (Pawn)
    {
        IAbilitySystemInterface* ASCInterface = Cast<IAbilitySystemInterface>(Pawn);
        UAbilitySystemComponent* ASC =
            ASCInterface ? ASCInterface->GetAbilitySystemComponent() : nullptr;

        if (ASC && !ASC->HasMatchingGameplayTag(
                RetrieveGameplayTags::State_Enemy_Attack))
        {
            return EStateTreeRunStatus::Succeeded;
            // ← Attack State 이탈, 다음 상태로
        }
    }

    // ③ GA가 비정상적으로 안 끝나면 타임아웃으로 강제 완료
    if (InstanceData.ElapsedTime >= InstanceData.MaxAttackDuration)
    {
        return EStateTreeRunStatus::Succeeded;
    }

    return EStateTreeRunStatus::Running;
}
  • State_Enemy_Attack 태그는 GA_EnemyAttack이 활성화될 때 붙고, 종료될 때 제거됩니다.
  • Task는 태그를 보고 GA가 끝났는지 판단합니다. 태그를 쓰는 이유는 ASC 내부 상태를 직접 읽지 않아도 되기 때문입니다.
  • 0.3초 최소 대기가 없으면, EnterState에서 이벤트를 발송했지만 GA가 아직 시작 안 된 프레임에 HasMatchingGameplayTagfalse를 반환해 즉시 Succeeded가 될 수 있습니다.

📡 동료 AI에게 플레이어 감지 전파 — GameplayMessage ver.

BroadcastMessage 페이로드 전달 방법

UGameplayMessageSubsystem::BroadcastMessage는 템플릿 함수입니다:

template<typename FMessageStructType>
void BroadcastMessage(FGameplayTag Channel, const FMessageStructType& Message);

FInstancedStruct() 자리에 USTRUCT를 직접 넘기면 됩니다.
수신 측도 같은 타입으로 등록해야 합니다.


1단계 — 페이로드 구조체 정의

메시지 타입 전용 헤더를 GameplayTags 옆에 생성합니다.

Source/Retrieve/GameplayMessages/RetrieveMessageTypes.h (신규 파일):

#pragma once

#include "CoreMinimal.h"
#include "RetrieveMessageTypes.generated.h"

USTRUCT(BlueprintType)
struct FEnemyPlayerSpottedPayload
{
    GENERATED_BODY()

    // 감지된 플레이어 액터
    UPROPERTY(BlueprintReadOnly)
    TObjectPtr<AActor> SpottedActor = nullptr;

    // 감지 시점의 위치 (감지자가 나중에 소멸해도 위치 정보 유효)
    UPROPERTY(BlueprintReadOnly)
    FVector SpottedLocation = FVector::ZeroVector;

    // 감지한 적 AI의 위치 (AlertRadius 필터링에 사용)
    UPROPERTY(BlueprintReadOnly)
    FVector InstigatorLocation = FVector::ZeroVector;
};

2단계 — 발송 측 수정 (EnemyAIController.cpp)

#include "GameplayMessages/RetrieveMessageTypes.h"

void AEnemyAIController::OnTargetPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus)
{
    if (!Actor || !Stimulus.WasSuccessfullySensed()) return;

    if (Cast<ASovereignCharacter>(Actor))
    {
        FEnemyPlayerSpottedPayload Payload;
        Payload.SpottedActor       = Actor;
        Payload.SpottedLocation    = Actor->GetActorLocation();
        Payload.InstigatorLocation = GetPawn()
            ? GetPawn()->GetActorLocation()
            : FVector::ZeroVector;

        UGameplayMessageSubsystem& MsgSubsys =
            UGameplayMessageSubsystem::Get(GetWorld());
        MsgSubsys.BroadcastMessage(
            RetrieveGameplayTags::Channel_Enemy_PlayerSpotted, Payload);
    }
}

3단계 — 수신 측 등록 패턴

동료 AI의 AEnemyAIController에서 군집 알림을 받는 경우:

// OnPossess 등 초기화 시점에 등록
ListenerHandle = UGameplayMessageSubsystem::Get(GetWorld())
    .RegisterListener<FEnemyPlayerSpottedPayload>(
        RetrieveGameplayTags::Channel_Enemy_PlayerSpotted,
        this,
        &AEnemyAIController::OnPlayerSpottedBroadcast);

// 콜백
void AEnemyAIController::OnPlayerSpottedBroadcast(
    FGameplayTag Channel, const FEnemyPlayerSpottedPayload& Payload)
{
    // AlertRadius 필터
    if (!GetPawn()) return;
    const float DistSq = FVector::DistSquared(
        GetPawn()->GetActorLocation(), Payload.InstigatorLocation);
    if (DistSq > AlertRadius * AlertRadius) return;

    // StateTree에 감지 이벤트 전달
    // (StateTreeAIComp->SendStateTreeEvent 등)
}

// OnUnPossess에서 해제
UGameplayMessageSubsystem::Get(GetWorld()).UnregisterListener(ListenerHandle);

정리

항목변경 전변경 후
페이로드FInstancedStruct() 빈 구조체FEnemyPlayerSpottedPayload 타입 구조체
수신 등록 타입FInstancedStructFEnemyPlayerSpottedPayload
전달 정보없음SpottedActor, SpottedLocation, InstigatorLocation

💡 InstigatorLocation을 포함시키는 이유는 AlertRadius 필터
(Pawn.Location - Payload.Location 거리 < AlertRadius) 구현에 직접 사용되기 때문입니다.


🔑 핵심 요약

  1. AddLooseGameplayTag는 상태 표시, SendGameplayEventToActor는 GA 트리거입니다. 용도가 완전히 다릅니다.
  2. 태그·거리 기반 전이 설계라면 FinishTask 사용을 지양합니다. 의도치 않은 즉시 전이가 발생할 수 있습니다.
  3. StateTree 노드는 USTRUCT 값 타입입니다. Config는 struct 본체에, 런타임 상태는 InstanceData에 분리합니다.
  4. External Data는 핸들 패턴으로 접근합니다. Link 시점에 예약, 실행 시점에 포인터 획득.
  5. GA 활성화는 HandleGameplayEvent를 우선으로 합니다. TryActivateAbilityByClass는 결합도가 높습니다.
  6. GameplayMessage는 타입 안전한 USTRUCT 페이로드를 그대로 넘길 수 있습니다. 발송·수신 측의 타입만 일치하면 됩니다.
profile
게임 개발 지망생입니다.

0개의 댓글