Today I Learned |
Unreal Engine 5|GAS|StateTree|GameplayMessageSubsystem|DataTable|C++
일반 몹 AI의 공격 사이클을 완성하는 과정에서 세 가지 작업을 진행했습니다.
UGameplayMessageSubsystem::BroadcastMessage는 템플릿 함수입니다.
내부에서 TBaseStructure<FMessageStructType>::Get()으로 타입을 키로 채널에 등록합니다.
template <typename FMessageStructType>
void BroadcastMessage(FGameplayTag Channel, const FMessageStructType& Message);
FInstancedStruct::Make(Payload)로 감싸서 넘기면 FMessageStructType이 FInstancedStruct로 추론됩니다.
수신 측은 FEnemyPlayerSpottedPayload 타입으로 리스너를 등록했으므로 타입이 맞지 않아 콜백이 실행되지 않습니다.
MsgSubsys.BroadcastMessage(
Channel_Enemy_PlayerSpotted,
FInstancedStruct::Make(Payload)); // FInstancedStruct 타입으로 등록 → 구독자 수신 불가
MsgSubsys.BroadcastMessage(
Channel_Enemy_PlayerSpotted,
Payload); // FEnemyPlayerSpottedPayload 타입으로 등록 → 정상 수신
그룹 경보 수신 후 TargetPlayer는 설정되지만 DistanceToTarget은 갱신되지 않아 초기값 0.f가 유지되었습니다.
StateTree Chase → Attack 전환 조건인 DistanceToTarget <= AttackableRange가 0.f <= 200.f로 즉시 참이 되어
Chase 진입 직후 Attack으로 전이되는 현상이 발생했습니다.
AlertedTarget 소비
→ TargetPlayer = Player ← 갱신됨
→ DistanceToTarget = 0.f ← 갱신 안 됨 (초기값 유지)
StateTree Tick:
Idle → Chase (IsValid(TargetPlayer) = TRUE)
Chase → Attack (0.f <= AttackableRange(200.f) = TRUE) → 즉시 전이
// Evaluator::Tick — Perception이 없을 때(else 분기)
if (ARetrieveEnemyCharacter* EnemyChar = Cast<ARetrieveEnemyCharacter>(Pawn))
{
if (AActor* Alerted = EnemyChar->AlertedTarget)
{
InstanceData.TargetPlayer = Alerted;
InstanceData.bTargetLost = false;
EnemyChar->AlertedTarget = nullptr;
}
}
// 두 경로(Perception / AlertedTarget) 처리 후 공통 갱신 블록
if (IsValid(InstanceData.TargetPlayer))
{
InstanceData.TargetLocation = InstanceData.TargetPlayer->GetActorLocation();
InstanceData.DistanceToTarget =
FVector::Dist(Pawn->GetActorLocation(), InstanceData.TargetLocation);
}
Perception 경유든 AlertedTarget 경유든 TargetPlayer가 설정된 후 한 블록에서 갱신하면 경로가 추가되어도 갱신 누락이 발생하지 않습니다.
| 항목 | 문제 상황 | 수정 후 |
|---|---|---|
| BroadcastMessage | FInstancedStruct::Make(Payload) | Payload 직접 전달 |
| 구독 수신 여부 | ❌ 타입 불일치로 수신 불가 | ✅ 정상 수신 |
| AlertedTarget 소비 | DistanceToTarget = 0.f 유지 | 실제 거리 계산 후 갱신 |
| Chase→Attack 전이 | 즉시 전이 (0.f ≤ 200.f) | 실제 범위 밖이면 Chase 유지 |
한 Enemy가 플레이어를 감지하면 주변 Enemy들도 Chase로 진입하는 그룹 경보 시스템을 구현하면서
StateTree Evaluator의 TargetPlayer를 외부에서 어떻게 갱신하느냐가 핵심 문제였습니다.
// ✅ Evaluator::Tick 내부에서만 안전
FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
// ❌ TreeStart에서 RegisterListener 후 콜백에서 직접 접근 불가
// 콜백 실행 시점에 FStateTreeExecutionContext가 없어 GetInstanceData를 호출할 수 없다.
// raw 포인터를 캡처하면 StateTree가 메모리를 재배치할 때 stale해진다.
StateTree가 이미 Pawn의 프로퍼티를 Tick마다 읽는 방식(bOutOfChaseRange 등)을 그대로 확장합니다.
BroadcastMessage (Channel.Enemy.PlayerSpotted)
│
▼
ARetrieveEnemyCharacter::OnAlerted()
거리 체크 → AlertedTarget = Player (버퍼)
│
▼ (다음 Evaluator Tick)
FRetrieveEnemyTargetEvaluator::Tick()
AlertedTarget 소비 → TargetPlayer 설정 → 버퍼 클리어
// ARetrieveEnemyCharacter.h
FGameplayMessageListenerHandle GroupAlertHandle;
UPROPERTY()
TObjectPtr<AActor> AlertedTarget;
UPROPERTY(EditAnywhere, Category = "Retrieve|AI")
float GroupAlertRadius = 1500.f;
// BeginPlay — 구독
GroupAlertHandle = MsgSubsys.RegisterListener<FEnemyPlayerSpottedPayload>(
RetrieveGameplayTags::Channel_Enemy_PlayerSpotted,
this, &ARetrieveEnemyCharacter::OnAlerted);
// EndPlay — 반드시 해제
if (GroupAlertHandle.IsValid())
MsgSubsys.UnregisterListener(GroupAlertHandle);
// OnAlerted — 콜백
void ARetrieveEnemyCharacter::OnAlerted(FGameplayTag Channel,
const FEnemyPlayerSpottedPayload& Payload)
{
if (Payload.InstigatorEnemy == this) return; // 자기 자신 이벤트 무시
if (AlertedTarget) return; // 이미 경보 대기 중
const float Dist = FVector::Dist(GetActorLocation(), Payload.InstigatorLocation);
if (Dist <= GroupAlertRadius)
AlertedTarget = Payload.SpottedActor.Get();
}
| 방식 | 가능 여부 | 이유 |
|---|---|---|
| Evaluator TreeStart에서 직접 구독 | ❌ | 콜백 시점에 Context 없음 |
| GameplayEvent → GA → TargetPlayer 설정 | ❌ | GA가 StateTree InstanceData에 접근 불가 |
| 별도 AlertComponent 신설 | 가능하나 과함 | 단일 필드를 위한 컴포넌트는 과설계 |
| 캐릭터 버퍼 → Evaluator Tick 소비 | ✅ | 기존 외부 데이터 읽기 패턴과 동일, 안전 |
FGameplayMessageListenerHandle을 EndPlay에서 해제하지 않으면
캐릭터가 제거된 후에도 콜백이 실행되어 크래시가 발생합니다.
공격 몽타주에 히트박스 활성화 구간을 붙이면서 책임 분리를 설계했습니다.
장비를 장착한 Enemy에서도 동일 구조로 확장 가능하도록 설계하였습니다.
| 클래스 | 책임 |
|---|---|
AnimNotifyState | Begin/End 시점에 컴포넌트 함수 호출만 |
UEnemyCombatComponent | 활성 충돌체 관리, 활성화/비활성화, Overlap 처리 |
ARetrieveEnemyCharacter | 기본 주먹 USphereComponent 소유 및 컴포넌트에 등록 |
DT_MonsterPattern | 패턴별 히트박스 본 이름·반경·오프셋 정의 |
ANS는 "어떤 충돌체인지" 알 필요가 없습니다. FindComponentByClass 후 함수만 호출합니다.
// ANS::NotifyBegin
UEnemyCombatComponent* Combat =
MeshComp->GetOwner()->FindComponentByClass<UEnemyCombatComponent>();
if (Combat) Combat->ActivateHitbox();
// ANS::NotifyEnd
if (Combat) Combat->DeactivateHitbox();
히트박스 설정은 패턴과 1:1 대응하므로 FMonsterPatternRow에 직접 추가했습니다.
별도 테이블을 만들면 패턴 이름으로 조인하는 간접 참조만 늘어납니다.
// RetrieveDataTableTypes.h 추가
UPROPERTY(EditDefaultsOnly, Category="Monster|Pattern|Hitbox")
FName HitboxBoneName = NAME_None; // 공격 본 (예: "hand_r")
UPROPERTY(EditDefaultsOnly, Category="Monster|Pattern|Hitbox", meta=(ClampMin="0.0"))
float HitboxRadius = 30.f;
UPROPERTY(EditDefaultsOnly, Category="Monster|Pattern|Hitbox")
FVector HitboxOffset = FVector::ZeroVector;
void UEnemyCombatComponent::ActivateHitbox()
{
if (!ActiveHitboxComp) return;
const FMonsterPatternRow* Row =
PatternTable->FindRow<FMonsterPatternRow>(ActivePatternRowName, TEXT(""));
if (!Row) return;
// Row에서 본 이름·반경·오프셋 읽어 충돌체 배치
ActiveHitboxComp->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
}
컴포넌트는 이미 ActivePatternRowName과 PatternTable을 보유하고 있으므로
ANS가 파라미터를 넘기지 않아도 됩니다.
무기 장착 시점:
EnemyCombatComponent->SetActiveHitbox(WeaponActor->HitboxComp)
이후 ANS:
ActivateHitbox() → ActiveHitboxComp = 무기 충돌체 (자동)
기본 주먹 충돌체든 무기 충돌체든 ANS가 호출하는 코드는 동일합니다.
PatternSlots가 몬스터마다 다르게 등록되므로 Row 이름 컨벤션으로 구분하면 충분합니다.
DT_MonsterPattern Row 예시:
"Zombie_Normal_Attack" — BoneName=hand_r, Radius=30, Priority=0
"Orc_Normal_Attack" — BoneName=hand_r, Radius=50, Priority=0
"Orc_Slam_Attack" — BoneName=hand_r, Radius=100, Priority=1
BroadcastMessage에FInstancedStruct::Make()로 감싸서 넘기면 타입이FInstancedStruct로 추론되어 구독자가 수신하지 못한다. 구조체를 직접 넘길 것.StateTree의 전환 조건에 사용되는 Evaluator 출력값은 설정과 동시에 갱신해야 합니다. 초기값
0.f가 조건을 즉시 충족할 수 있기 때문이다.
Evaluator
InstanceData는FStateTreeExecutionContext안에서만 안전하게 수정할 수 있다.외부 이벤트를 StateTree에 전달하는 가장 안전한 방법은 Pawn에 버퍼 프로퍼티를 두고 Evaluator Tick에서 읽어 소비하는 것이다.
bOutOfChaseRange와 동일한 패턴이다.
RegisterListener핸들은EndPlay에서 반드시UnregisterListener로 해제할 것.
ANS는 충돌체가 무엇인지 몰라야 한다.
ActivateHitbox()호출만 책임진다.히트박스 파라미터는 공격 패턴과 1:1이므로
FMonsterPatternRow에 직접 추가하는 것이 가장 단순하다.
UEnemyCombatComponent에SetActiveHitbox()를 두면 장비 장착 확장 시 ANS 코드를 건드릴 필요가 없다.
작업한 내용들을 일괄 분석해서 리펙토링이 시급한 것 같습니다. 급하게 구현하느라 여기저기 기워넣은 흔적들이 작업에 혼선을 일으키고 있습니다.