적 AI 시스템을 구축하며 정리한 개념들입니다. GAS 태그 시스템, StateTree 노드 구조, AI 간 메시지 전파까지 다룹니다.
GAS에서 자주 헷갈리는 두 함수의 차이를 정리합니다.
AddLooseGameplayTagASC에 태그를 직접 꽂는 것입니다.
RemoveLooseGameplayTag)하기 전까지 유지됨BlockingTags / RequiredTags, 기타 시스템이 읽기용으로 사용ASC: [State.Enemy.Idle]
// ↑ 태그만 존재, 아무 동작 없음
SendGameplayEventToActorASC에 일회성 신호를 쏘는 것입니다.
AbilityTriggers에 해당 이벤트 태그가 등록된 GA를 깨움SendGameplayEventToActor(GameplayEvent.Enemy.Attack)
→ GA_Enemy_Attack의 AbilityTriggers가 매칭
→ GA 활성화
→ GA 내부에서 State.Enemy.Attack 태그 부여 + 몽타주 재생 + 데미지 처리
| 구분 | AddLooseGameplayTag | SendGameplayEventToActor |
|---|---|---|
| 태그가 ASC에 남는가 | ✅ 남음 (명시적 제거 전까지) | ❌ 안 남음 (1회성 신호) |
| GA를 트리거하는가 | ❌ | ✅ (AbilityTriggers 매칭 시) |
| 용도 | 상태 표시 (읽기용) | 동작 발동 (쓰기용) |
| 제거 방법 | RemoveLooseGameplayTag | 해당 없음 |
💡 한 줄 정리
AddLooseGameplayTag→ 상태 태그 부여SendGameplayEventToActor→ GA 활성화를 위해 ASC에 태그 전달
FinishTask의 역할FinishTask(bSuccess)를 호출하면 StateTree에 "이 태스크가 끝났다" 고 알립니다.
이것이 의미 있는 경우는 전이 트리거가 On State Completed 일 때뿐입니다.
| 상태 | 전이 트리거 | FinishTask 필요? |
|---|---|---|
Idle → Chase | AIPerception 조건 | ❌ |
Chase → Attack | 거리 조건 | ❌ |
Attack → Chase | State.Enemy.Attack 태그 제거됨 (태그 조건) | ❌ |
Hit → Chase | State.Enemy.Hit 태그 제거됨 (태그 조건) | ❌ |
Return → Idle | 거리 조건 | ❌ |
Any → Dead | State.Enemy.Dead 태그 / 이벤트 | ❌ |
모두 태그·거리·Perception 조건 기반이므로 On State Completed를 사용하지 않습니다.
FinishTask를 넣으면 오히려 태스크가 즉시 완료 처리되어 의도치 않은 전이가 발생할 수 있습니다.
⚠️ 현재 설계상
FinishTask는 사용하지 않는 것이 좋습니다.
모듈이 Public 헤더(.h)에 노출되지 않고 .cpp에서만 사용될 때 Private에 둡니다.
AIModule을 전이 의존성으로 강제하지 않음| 구분 | EnterConditions | Transitions |
|---|---|---|
| 성격 | 수동 게이트 ("이 상태에 들어올 수 있는가?") | 능동 트리거 ("지금 상태를 바꿔라") |
| 평가 시점 | StateTree가 이 상태를 선택하려 할 때 | 현재 상태에서 매 틱 조건 감시 |
| 단독으로 전이 유발? | Selection Behavior에 따라 가능 | 항상 가능 |
StateTree Root의 Selection Behavior가 Try Select Children In Order 이면, EnterConditions만으로도 전이가 됩니다.
매 틱: Root가 자식 상태를 위→아래 순서로 평가
└─ 첫 번째로 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만으로 전체 상태 전이가 동작합니다.
Unreal Engine의 StateTree 노드(Evaluator, Task, Condition)는 값 타입입니다.
UObject가 아니라 FStateTreeEvaluatorBase / FStateTreeTaskBase를 상속하는 USTRUCT 입니다.
이 때문에 UE 에디터의 "New C++ Class" 마법사로 만들 수 없고, 파일을 직접 작성해야 합니다.
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 타입이 뭔지" 알려주는 메서드StateTree 노드에서 AIController나 Pawn에 접근하려면 직접 포인터를 들고 있을 수 없습니다
(USTRUCT는 GC 대상이 아님). 대신 다음과 같이 합니다:
// 헤더: 핸들 선언 (실제 포인터가 아님, 인덱스 같은 것)
TStateTreeExternalDataHandle<AAIController> AIControllerHandle;
// Link()에서 등록
bool FRetrieveEnemyTargetEvaluator::Link(FStateTreeLinker& Linker)
{
Linker.LinkExternalData(AIControllerHandle);
// ↑ "이 핸들은 AIController를 가리킴"
return true;
}
// 실행 시점에 실제 포인터 획득
AAIController* AIController = Context.GetExternalDataPtr(AIControllerHandle);
UStateTreeAIComponent가 StateTree를 실행할 때 AAIController와 APawn을 자동으로 외부 데이터로 등록해줍니다.
LinkExternalData → "나중에 그 데이터를 이 핸들로 꺼낼 거야" 라고 예약GetExternalDataPtr → 실행 시점에 실제 포인터 획득
Link()가bool을 반환하는 이유: 필요한 외부 데이터가 없으면false를 반환해 StateTree 초기화 실패를 알립니다.
Evaluator는 StateTree 전체 실행 동안 매 틱 실행됩니다.
Task처럼 특정 상태에만 들어가는 게 아니라, 항상 돌면서 공유 데이터를 갱신하는 역할입니다.
StateTree 시작
└─ TreeStart() ← 초기화 1회
매 틱
└─ Tick() ← 매 프레임 호출
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값으로 초기화해서 첫 프레임에 바로 쿼리하게 합니다.
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: 원래부터 타깃 없음 → 아무것도 안 함
}
AEnemyAIController의PerceivedTarget필드는protected라 외부에서 접근 불가합니다.
그래서 AIController에 getter를 추가하는 대신,AIPerceptionComponent를 직접 쿼리해서 현재 인지 중인 액터 목록을 가져옵니다. 이 방식이 더 독립적입니다.
Task는 특정 State에 진입할 때 실행되고, 상태를 벗어나면 멈춥니다.
Attack State 진입
└─ EnterState() ← GA 활성화 이벤트 발송, Running 반환
매 틱 (Running인 동안)
└─ Tick() ← GA 종료 감지, Succeeded 반환하면 상태 전이
Attack State 이탈
└─ ExitState() ← 정리 작업
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를 쓰는 이유
AEnemyCharacter를 직접 참조하면 강한 결합이 생깁니다.HandleGameplayEvent vs TryActivateAbilityByClass
TryActivateAbilityByClass는 GA를 직접 강제 실행합니다. 설계 규칙은 StateTree에서 이 방식 금지입니다.HandleGameplayEvent는 이벤트를 발송하고, GA가 FAbilityTriggerData에 GameplayEvent_Enemy_Attack 태그를 등록해 두었을 때만 활성화됩니다. 더 느슨한 연결입니다.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이 활성화될 때 붙고, 종료될 때 제거됩니다.EnterState에서 이벤트를 발송했지만 GA가 아직 시작 안 된 프레임에 HasMatchingGameplayTag가 false를 반환해 즉시 Succeeded가 될 수 있습니다.BroadcastMessage 페이로드 전달 방법UGameplayMessageSubsystem::BroadcastMessage는 템플릿 함수입니다:
template<typename FMessageStructType>
void BroadcastMessage(FGameplayTag Channel, const FMessageStructType& Message);
FInstancedStruct() 자리에 USTRUCT를 직접 넘기면 됩니다.
수신 측도 같은 타입으로 등록해야 합니다.
메시지 타입 전용 헤더를 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;
};
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);
}
}
동료 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 타입 구조체 |
| 수신 등록 타입 | FInstancedStruct | FEnemyPlayerSpottedPayload |
| 전달 정보 | 없음 | SpottedActor, SpottedLocation, InstigatorLocation |
💡
InstigatorLocation을 포함시키는 이유는 AlertRadius 필터
(Pawn.Location - Payload.Location거리 <AlertRadius) 구현에 직접 사용되기 때문입니다.
AddLooseGameplayTag는 상태 표시, SendGameplayEventToActor는 GA 트리거입니다. 용도가 완전히 다릅니다.FinishTask 사용을 지양합니다. 의도치 않은 즉시 전이가 발생할 수 있습니다.HandleGameplayEvent를 우선으로 합니다. TryActivateAbilityByClass는 결합도가 높습니다.