Today I Learned |
GAS|GenericGameplayEventCallbacks|FDelegateHandle|CheatManager|UE5.7
오늘은 몬스터 패턴 카운터 시스템의 윈도우 오픈 경로를 GAS 이벤트로 연결하고, 박하민님의 플레이어 패링 GA 없이도 카운터 체인을 검증할 수 있는 CheatManager 콘솔 명령을 만들었다.
처음엔 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를 쏘도록만 연결하면 된다. 즉 수신부 코드를 안 고치고 트리거만 바꿔 끼울 수 있다.
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);
}
GenericGameplayEventCallbacks는TMap<FGameplayTag, FGameplayEventMulticastDelegate>다.FindOrAdd(tag)로 해당 태그의 멀티캐스트 델리게이트를 꺼내 거기에 핸들러를 붙인다.
AddUObject는 FDelegateHandle(영수증)을 돌려준다. 이걸 보관했다가 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 | 필요 ✅ | 불필요 ❌ |
| 구독 방식 | AddDynamic | AddUObject → FDelegateHandle 반환 |
| 해제 방식 | RemoveDynamic | Remove(handle) |
| 블루프린트 노출 | 가능 | 불가 |
핸들러
HandleCounterWindowEvent에UFUNCTION을 안 붙인 이유가 이것. non-dynamic 멀티캐스트는 UFUNCTION이 필요 없다.
HandleCounterWindowEvent(const FGameplayEventData* Payload)를 헤더에 선언했더니 컴파일이 막혔다. 포인터 타입이라도 헤더에 이름이 등장하면 최소한 전방 선언은 있어야 한다.
// PatternCounterComponent.h
class URetrieveAbilitySystemComponent;
struct FGameplayEventData; // ❌ 없으면 컴파일 에러 → ✅ 추가하니 통과
포인터/참조는 크기가 고정이라 정의(include)는 .cpp에서, 선언(forward decl)은 .h에서면 충분하다. 헤더 include를 줄여 컴파일 의존성을 낮추는 기본 패턴.
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. 어디선가 매칭되면 거기서 실행.
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)