Sparta Unreal 부트캠프 124일차

정찬호·2026년 5월 29일

[TIL] GAS 이벤트 구독으로 애니메이션 비의존 카운터 윈도우 만들기 + CheatManager 검증

Today I Learned | GAS | GenericGameplayEventCallbacks | FDelegateHandle | CheatManager | UE5.7

오늘은 몬스터 패턴 카운터 시스템의 윈도우 오픈 경로를 GAS 이벤트로 연결하고, 박하민님의 플레이어 패링 GA 없이도 카운터 체인을 검증할 수 있는 CheatManager 콘솔 명령을 만들었다.


1. 카운터 윈도우는 애니메이션이 아니라 GameplayEvent로 연다

처음엔 AnimNotify로 윈도우를 열 생각이었지만, 애니메이션 작업은 보원님 담당이라 의존하면 진행이 막힌다. 대신 GameplayEvent 태그를 ASC로 보내 윈도우를 여는 방식으로 분리했다.

// 보내는 쪽 (CheatManager 또는 향후 GA/패턴)
FGameplayEventData Payload;
Payload.EventTag       = RetrieveGameplayTags::GameplayEvent_PatternCounterWindow;
Payload.EventMagnitude = Duration;   // 윈도우 지속시간
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(
    Enemy, RetrieveGameplayTags::GameplayEvent_PatternCounterWindow, Payload);
// 받는 쪽 (PatternCounterComponent)
void UPatternCounterComponent::HandleCounterWindowEvent(const FGameplayEventData* Payload)
{
    const float Duration = Payload ? Payload->EventMagnitude : 0.f;
    OpenCounterWindow(Duration);
}
비교AnimNotify로 윈도우 열기GameplayEvent로 윈도우 열기
애니메이션 의존✅ 있음 (몽타주/노티파이 필요)❌ 없음 (태그만 있으면 됨)
작업 병렬화애니 담당자 작업 대기즉시 진행 가능
테스트PIE에서 몽타주 재생 필요콘솔 명령으로 즉시 발사
데이터 전달노티파이 파라미터 한정FGameplayEventData(Magnitude/Tag/Instigator)

왜 분리했나: 윈도우 오픈 트리거를 애니메이션과 분리하면, 나중에 AnimNotify가 같은 GameplayEvent를 쏘도록만 연결하면 된다. 즉 수신부 코드를 안 고치고 트리거만 바꿔 끼울 수 있다.


2. ASC 초기화 타이밍 — BeginPlay에서 구독하면 안 된다

GenericGameplayEventCallbacks에 구독하려면 ASC가 이미 초기화돼 있어야 한다. 그런데 컴포넌트 BeginPlay 시점엔 ASC가 아직 준비 안 됐을 수 있다(초기화 순서 문제).

해결은 Lyra 패턴인 OnAbilitySystemInitialized_RegisterAndCall. 이미 준비됐으면 즉시 호출, 아니면 준비될 때 호출한다.

void UPatternCounterComponent::BeginPlay()
{
    Super::BeginPlay();
    // ASC 준비 시점에 OnAbilitySystemInitialized가 호출되도록 등록
    PawnExtComp->OnAbilitySystemInitialized_RegisterAndCall(
        FSimpleMulticastDelegate::FDelegate::CreateUObject(
            this, &UPatternCounterComponent::OnAbilitySystemInitialized));
}
void UPatternCounterComponent::OnAbilitySystemInitialized()
{
    URetrieveAbilitySystemComponent* ASC = GetASC();
    CounterWindowEventHandle = ASC->GenericGameplayEventCallbacks
        .FindOrAdd(RetrieveGameplayTags::GameplayEvent_PatternCounterWindow)
        .AddUObject(this, &UPatternCounterComponent::HandleCounterWindowEvent);
}

GenericGameplayEventCallbacksTMap<FGameplayTag, FGameplayEventMulticastDelegate>다. FindOrAdd(tag)로 해당 태그의 멀티캐스트 델리게이트를 꺼내 거기에 핸들러를 붙인다.


3. 델리게이트 등록 ↔ 해제는 반드시 짝을 맞춘다

AddUObjectFDelegateHandle(영수증)을 돌려준다. 이걸 보관했다가 EndPlay에서 Remove로 떼야 한다. 안 떼면 컴포넌트가 죽은 뒤에도 ASC가 죽은 포인터를 호출 → 크래시.

void UPatternCounterComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    if (URetrieveAbilitySystemComponent* ASC = GetASC())
    {
        ASC->GenericGameplayEventCallbacks
            .FindOrAdd(RetrieveGameplayTags::GameplayEvent_PatternCounterWindow)
            .Remove(CounterWindowEventHandle);   // 등록의 짝
    }
    CounterWindowEventHandle.Reset();
    CloseCounterWindow();
    Super::EndPlay(EndPlayReason);
}
구분Dynamic 델리게이트Non-dynamic 멀티캐스트 (이번 케이스)
핸들러에 UFUNCTION필요 ✅불필요 ❌
구독 방식AddDynamicAddUObjectFDelegateHandle 반환
해제 방식RemoveDynamicRemove(handle)
블루프린트 노출가능불가

핸들러 HandleCounterWindowEventUFUNCTION을 안 붙인 이유가 이것. non-dynamic 멀티캐스트는 UFUNCTION이 필요 없다.


4. 헤더에서 포인터 파라미터 = 전방 선언 필수

HandleCounterWindowEvent(const FGameplayEventData* Payload)를 헤더에 선언했더니 컴파일이 막혔다. 포인터 타입이라도 헤더에 이름이 등장하면 최소한 전방 선언은 있어야 한다.

// PatternCounterComponent.h
class URetrieveAbilitySystemComponent;
struct FGameplayEventData;   // ❌ 없으면 컴파일 에러 → ✅ 추가하니 통과

포인터/참조는 크기가 고정이라 정의(include)는 .cpp에서, 선언(forward decl)은 .h에서면 충분하다. 헤더 include를 줄여 컴파일 의존성을 낮추는 기본 패턴.


5. UCheatManager — 플레이어 컨트롤러마다 붙는 디버그 콘솔 객체

UCheatManager전역 클래스가 아니다. PlayerController마다 1개씩 생성되는 UObject이고, 개발 빌드에서만 살아있다. Exec 콘솔 명령을 모아두는 곳.

// RetrievePlayerController 생성자
CheatClass = URetrieveCheatManager::StaticClass();   // 이 한 줄로 연결
// Exec 명령 선언
UFUNCTION(Exec, Category = "Retrieve|Debug")
void RetrieveTestCounter();

Exec 라우팅 체인: 콘솔 입력 → PlayerController → Pawn → PlayerInput → CheatManager → GameMode → HUD → LocalPlayer. 어디선가 매칭되면 거기서 실행.


6. 소거법 디버깅 — "열렸는데 닫혀 있다"

CheatManager로 30초 윈도우를 연 뒤 RetrieveTryCounter를 쳤는데 WindowWasOpen=false. 윈도우가 안 열린 건지, 열렸다 닫힌 건지부터 갈라야 했다.

추적 결과 범인은 락온한 적이 백그라운드에서 플레이어를 계속 공격하고 있던 것:

적의 공격 종료
  → StateTreeTask_EnemyAttack::ExitState
    → EnemyCombatComponent::StopCurrentPattern
      → PatternCounter->CloseCounterWindow()   // 수동으로 연 윈도우를 닫아버림

이건 버그가 아니라 정상 게임플레이 동작이다(패턴이 끝나면 카운터 기회도 닫힘). 단지 치트 테스트와 충돌한 것뿐. 그래서 같은 프레임에 열고 즉시 카운터하는 명령으로 우회:

void URetrieveCheatManager::RetrieveTestCounter()
{
    UPatternCounterComponent* Counter = GetLockedOnPatternCounter();
    if (!Counter) return;

    Counter->OpenCounterWindow(0.f);            // 같은 프레임에 즉시 열고
    const bool bWasOpen = Counter->IsWindowOpen();
    Counter->TryCounter(                        // 바로 카운터 (AI 개입 불가)
        FGameplayTag::EmptyTag, FGameplayTag::EmptyTag,
        GetOuterAPlayerController()->GetPawn());
    const bool bCountered = bWasOpen && !Counter->IsWindowOpen();
    // 결과: Opened=true, 카운터 성공 ✅
}

배운 것: 윈도우 상태처럼 여러 주체가 쓰는 공유 상태에서 테스트가 실패하면, "다른 시스템이 같은 상태를 건드리나"를 먼저 의심해보자. 공유 상태에서의 디버깅은 소거법이 더 빠르다.


핵심 요약

  • 카운터 윈도우는 GameplayEvent 태그로 연다 → 애니메이션 작업과 분리, 트리거만 갈아끼우면 됨.
  • ASC 구독은 BeginPlay가 아니라 OnAbilitySystemInitialized_RegisterAndCall 시점에 — 초기화 순서 문제 회피.
  • AddUObject가 준 FDelegateHandle은 EndPlay에서 반드시 Remove — 등록/해제 짝 안 맞추면 댕글링 크래시.
  • non-dynamic 멀티캐스트 핸들러엔 UFUNCTION 불필요, 헤더 포인터 파라미터엔 전방 선언 필수.
  • UCheatManager는 전역이 아니라 PC마다 붙는 개발용 객체, CheatClass 한 줄로 연결, Exec로 콘솔 명령 노출.
  • 공유 상태 테스트가 실패하면 다른 시스템의 개입을 소거법으로 먼저 의심한다.

Retrieve Project — 몬스터 패턴 카운터 시스템 (2026-05-29)

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

0개의 댓글