Sparta Unreal 부트캠프 119일차

정찬호·2026년 5월 21일

[TIL] Enemy 그룹 경보 시스템 구현 — GMS 타입 함정 · StateTree 외부 이벤트 수신 · 히트박스 책임 분리

Today I Learned | Unreal Engine 5 | GAS | StateTree | GameplayMessageSubsystem | DataTable | C++


배경

일반 몹 AI의 공격 사이클을 완성하는 과정에서 세 가지 작업을 진행했습니다.

  1. Enemy가 플레이어를 감지하면 주변 Enemy들도 플레이어 정보를 전파받아 Chase로 진입하는 그룹 경보 시스템 구현
  2. 구현 과정에서 드러난 GameplayMessageSubsystem(GMS) 타입 오류DistanceToTarget 초기화 버그 수정
  3. 공격 몽타주 히트박스 활성화를 위한 ANS · EnemyCombatComponent · DataTable 책임 분리 설계

1부 — GameplayMessageSubsystem 타입 함정 · DistanceToTarget 초기화 버그

함정 1 — FInstancedStruct::Make로 감싸면 구독자가 수신하지 못한다

UGameplayMessageSubsystem::BroadcastMessage는 템플릿 함수입니다.
내부에서 TBaseStructure<FMessageStructType>::Get()으로 타입을 키로 채널에 등록합니다.

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

FInstancedStruct::Make(Payload)로 감싸서 넘기면 FMessageStructTypeFInstancedStruct로 추론됩니다.
수신 측은 FEnemyPlayerSpottedPayload 타입으로 리스너를 등록했으므로 타입이 맞지 않아 콜백이 실행되지 않습니다.

❌ Before — 타입 불일치

MsgSubsys.BroadcastMessage(
    Channel_Enemy_PlayerSpotted,
    FInstancedStruct::Make(Payload));  // FInstancedStruct 타입으로 등록 → 구독자 수신 불가

✅ After — 구조체 직접 전달

MsgSubsys.BroadcastMessage(
    Channel_Enemy_PlayerSpotted,
    Payload);  // FEnemyPlayerSpottedPayload 타입으로 등록 → 정상 수신

함정 2 — AlertedTarget 소비 시 DistanceToTarget 미갱신 → Chase 진입 즉시 Attack 전이

그룹 경보 수신 후 TargetPlayer는 설정되지만 DistanceToTarget은 갱신되지 않아 초기값 0.f가 유지되었습니다.
StateTree Chase → Attack 전환 조건인 DistanceToTarget <= AttackableRange0.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) → 즉시 전이

✅ After — AlertedTarget 소비 시 거리 함께 갱신

// 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가 설정된 후 한 블록에서 갱신하면 경로가 추가되어도 갱신 누락이 발생하지 않습니다.

비교 정리

항목문제 상황수정 후
BroadcastMessageFInstancedStruct::Make(Payload)Payload 직접 전달
구독 수신 여부❌ 타입 불일치로 수신 불가✅ 정상 수신
AlertedTarget 소비DistanceToTarget = 0.f 유지실제 거리 계산 후 갱신
Chase→Attack 전이즉시 전이 (0.f ≤ 200.f)실제 범위 밖이면 Chase 유지

2부 — StateTree 외부 이벤트 수신 — 그룹 경보 시스템 설계 패턴

한 Enemy가 플레이어를 감지하면 주변 Enemy들도 Chase로 진입하는 그룹 경보 시스템을 구현하면서
StateTree Evaluator의 TargetPlayer를 외부에서 어떻게 갱신하느냐가 핵심 문제였습니다.

제약 — Evaluator InstanceData는 외부에서 직접 접근 불가

// ✅ Evaluator::Tick 내부에서만 안전
FInstanceDataType& InstanceData = Context.GetInstanceData(*this);

// ❌ TreeStart에서 RegisterListener 후 콜백에서 직접 접근 불가
// 콜백 실행 시점에 FStateTreeExecutionContext가 없어 GetInstanceData를 호출할 수 없다.
// raw 포인터를 캡처하면 StateTree가 메모리를 재배치할 때 stale해진다.

채택한 패턴 — 캐릭터 버퍼 → Evaluator Tick 소비

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 소비기존 외부 데이터 읽기 패턴과 동일, 안전

주의사항 — 핸들 해제 누락 시 크래시

FGameplayMessageListenerHandleEndPlay에서 해제하지 않으면
캐릭터가 제거된 후에도 콜백이 실행되어 크래시가 발생합니다.


3부 — 히트박스 책임 분리 — ANS · EnemyCombatComponent · DataTable 연동

공격 몽타주에 히트박스 활성화 구간을 붙이면서 책임 분리를 설계했습니다.
장비를 장착한 Enemy에서도 동일 구조로 확장 가능하도록 설계하였습니다.

책임 분리 결정

클래스책임
AnimNotifyStateBegin/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();

DataTable 스키마 확장 — FMonsterPatternRow

히트박스 설정은 패턴과 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;

EnemyCombatComponent — ActivateHitbox 흐름

void UEnemyCombatComponent::ActivateHitbox()
{
    if (!ActiveHitboxComp) return;

    const FMonsterPatternRow* Row =
        PatternTable->FindRow<FMonsterPatternRow>(ActivePatternRowName, TEXT(""));
    if (!Row) return;

    // Row에서 본 이름·반경·오프셋 읽어 충돌체 배치
    ActiveHitboxComp->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
}

컴포넌트는 이미 ActivePatternRowNamePatternTable을 보유하고 있으므로
ANS가 파라미터를 넘기지 않아도 됩니다.

장비 장착 확장 시나리오

무기 장착 시점:
  EnemyCombatComponent->SetActiveHitbox(WeaponActor->HitboxComp)

이후 ANS:
  ActivateHitbox() → ActiveHitboxComp = 무기 충돌체 (자동)

기본 주먹 충돌체든 무기 충돌체든 ANS가 호출하는 코드는 동일합니다.

패턴 테이블에 일반 공격 Row를 넣는 방법

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

핵심 요약

GameplayMessageSubsystem

BroadcastMessageFInstancedStruct::Make()로 감싸서 넘기면 타입이 FInstancedStruct로 추론되어 구독자가 수신하지 못한다. 구조체를 직접 넘길 것.

StateTree의 전환 조건에 사용되는 Evaluator 출력값은 설정과 동시에 갱신해야 합니다. 초기값 0.f가 조건을 즉시 충족할 수 있기 때문이다.

StateTree 외부 이벤트

Evaluator InstanceDataFStateTreeExecutionContext 안에서만 안전하게 수정할 수 있다.

외부 이벤트를 StateTree에 전달하는 가장 안전한 방법은 Pawn에 버퍼 프로퍼티를 두고 Evaluator Tick에서 읽어 소비하는 것이다. bOutOfChaseRange와 동일한 패턴이다.

RegisterListener 핸들은 EndPlay에서 반드시 UnregisterListener로 해제할 것.

히트박스 설계

ANS는 충돌체가 무엇인지 몰라야 한다. ActivateHitbox() 호출만 책임진다.

히트박스 파라미터는 공격 패턴과 1:1이므로 FMonsterPatternRow에 직접 추가하는 것이 가장 단순하다.

UEnemyCombatComponentSetActiveHitbox()를 두면 장비 장착 확장 시 ANS 코드를 건드릴 필요가 없다.


작업한 내용들을 일괄 분석해서 리펙토링이 시급한 것 같습니다. 급하게 구현하느라 여기저기 기워넣은 흔적들이 작업에 혼선을 일으키고 있습니다.

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

0개의 댓글