내일배움캠프_Unreal/TIL

내일배움캠프_Unreal/TIL - Day 137 (260617)

하임 2026. 6. 17. 21:56

 

GE Spec에 전투 태그를 실어 Enemy 패턴 파훼 판정을 일관화하기

1. 문제: "이번 타격이 무엇이었나"를 역추적하면 안 됨

Enemy 패턴 파훼(counter)는 *"정해진 원소 + 공격 타입으로 때렸는가"* 를 판정해야 함.
처음엔 Enemy가 플레이어의 현재 ASC 태그 / PlayerState를 조회해서 원소·공격타입을 추정하려 했음.
→ 부정확함. 피격 결과가 아니라 현재 상태를 보기 때문임.

역추적 방식 (현재 상태 조회)

// Enemy 쪽에서 플레이어를 거꾸로 들여다봄
FGameplayTag Element = Player->GetCurrentElementTag(); // 명중 시점 상태일 뿐
  • 투사체: 발사 시점과 명중 시점의 원소가 다를 수 있음
  • 지연 타격/장판: 실행 중인 Ability ≠ 실제 타격 원인
  • 공격 Ability가 이미 끝났으면 ActionTag를 알 길이 없음

GE Spec 동봉 방식 (이번 타격의 송장)

// 데미지를 일으킨 GE Spec 자체에 "이 타격은 불속성 일반공격" 라벨을 붙여 보냄
Spec.AddDynamicAssetTag(Element_Fire);
Spec.AddDynamicAssetTag(Attack_Type_Normal);
  • 타격을 발생시킨 순간의 정보가 데이터에 박혀 이동함 → 시점 불일치 원천 차단
  • 핵심: 판정 기준은 "공격자의 현재 상태"가 아니라 "이번 데미지를 발생시킨 GE Spec" 이어야 함. 상태는 변하지만 송장은 변하지 않음.

2. GAS에서 태그를 데이터에 싣는 법 — DynamicAssetTag

FGameplayEffectSpec::AddDynamicAssetTag() 로 런타임에 그 한 방의 GE Spec에만 태그를 붙임.
GE 애셋 자체를 건드리지 않고 이번 적용분에만 부여됨.

누락 방지를 위해 공용 헬퍼 하나로 통일 (경로마다 손으로 붙이면 신규 공격에서 빠뜨리기 쉬움)

inline void AddCombatTagsToDamageSpec(
    FGameplayEffectSpec& Spec,
    const FGameplayTag& ElementTag,      // Element.None/무효는 스킵
    const FGameplayTag& AttackTypeTag,
    const FGameplayTag& AttackPropertyTag = {},
    const FGameplayTag& HitReactTag = {})
{
    if (ElementTag.IsValid() && ElementTag != Element_None) Spec.AddDynamicAssetTag(ElementTag);
    if (AttackTypeTag.IsValid())     Spec.AddDynamicAssetTag(AttackTypeTag);
    // ... 나머지 동일
}
  • 원소 출처는 경로별로 다름: 일반공격=현재 원소 조회, 강공격=원소 내재, 투사체=발사 시점에 저장해둔 멤버(명중 때 재조회 금지)

3. Hit 이벤트로 relay — SourceTags 전체를 넘기지 말 것

데미지 적용 후 AttributeSet이 피격자에게 Hit 이벤트를 쏠 때, GE Spec 태그를 통째로 넘기면 다른 시스템과 충돌함.
필요한 계열만 선별해서 EventData.TargetTags로 전달.

for (const FGameplayTag& Tag : SourceTags)
{
    if (Tag.MatchesTag(HitReact_Type) || Tag.MatchesTag(Element) ||
        Tag.MatchesTag(Attack_Type)  || Tag.MatchesTag(Attack_Property))
    {
        EventData.TargetTags.AddTag(Tag);
    }
}

함정: MatchesTag의 기준 태그는 실제로 등록돼 있어야 함

Tag.MatchesTag(Element) 처럼 부모로 거르려면 부모 태그(Element, Attack.Type)가 네이티브로 선언돼 있어야 함.
리프(Element.Fire)만 있으면 부모 기준 매칭이 깔끔하지 않음 → 부모 태그를 명시적으로 UE_DEFINE_GAMEPLAY_TAG 해줌.

4. 컴포넌트 디커플 — GameplayEvent 구독

AttributeSet이 Enemy 컴포넌트를 직접 호출하지 않고, GameplayEvent만 발행 → 컴포넌트가 구독.

// PatternCounterComponent: ASC 초기화 시 구독
ASC->GenericGameplayEventCallbacks
   .FindOrAdd(GameplayEvent_Hit_Normal)
   .AddUObject(this, &UPatternCounterComponent::HandleHitEvent);
  • GenericGameplayEventCallbacks정확히 일치하는 태그로만 발동됨 → Hit.Normal/Hit.Heavy 둘 다 따로 등록해야 함 (부모 Hit 구독으로 자식이 자동으로 안 잡힘)
  • EndPlay에서 핸들 Remove 필수

5. 빌드 오류

FGameplayEventData::Instigator는 raw 포인터가 아님
TObjectPtr<const AActor> 라서 .Get() 후 const 해제해야 함.

// ❌ const_cast<AActor*>(Payload->Instigator)        // C2440
// ✅ const_cast<AActor*>(Payload->Instigator.Get())

비교 정리

항목 역추적 방식 GE Spec 동봉 방식
정보 출처 명중 시점의 공격자 상태 타격을 발생시킨 GE Spec
투사체/지연타격 시점 불일치로 부정확 발사 시점 정보 그대로 유지
Ability 종료 후 ActionTag 소실 영향 없음
결합도 Enemy가 Player 내부를 앎 태그 데이터로만 소통 (디커플)

핵심 요약

  • 판정은 "공격자의 현재 상태"가 아니라 "이번 데미지를 발생시킨 GE Spec" 기준으로 해야 시점 불일치를 피함.
  • 태그는 AddDynamicAssetTag로 그 한 방에만 싣고, 공용 헬퍼로 통일해 경로별 누락을 막음.
  • relay는 필요한 계열만 선별 전달, 부모 기준 MatchesTag를 쓰려면 부모 태그를 네이티브 선언해야 함.
  • AttributeSet → GameplayEvent 발행 → 컴포넌트 구독으로 디커플. GenericGameplayEventCallbacks는 정확 일치 매칭임.
  • FGameplayEventData::InstigatorTObjectPtr<const AActor>.Get() 필요.

직접 어트리뷰트 수정을 버리고 GE로 — 비용·회복·지속소모

1. 안티패턴: 어트리뷰트를 직접 깎고/더하기

처음 스태미너는 어빌리티에서 직접 차감하고, 회복은 컴포넌트 Tick에서 직접 가산했음.

// ❌ 비용 — 어트리뷰트 직접 수정
ASC->SetNumericAttributeBase(GetStaminaAttribute(), Current - Cost);
// ❌ 회복 — 매 프레임 Tick에서 가산
Stamina += RegenRate * DeltaTime;

동작은 하지만 예측/롤백·리플리케이션·스택·면역(immunity) 등 GAS 인프라를 전부 우회함 → Lyra식 설계와 어긋남.

✅ 정공법: 일회성 비용 = CostGameplayEffectClass, 회복/지속소모 = 주기 GE.

2. 일회성 비용 → CostGameplayEffectClass

어빌리티에 CostGameplayEffectClass만 지정하면 네이티브 경로가 알아서 처리함:

  • CanActivateAbilityCheckCost 가 "자원이 충분한가"를 자동 게이트
  • CommitAbilityCommitAbilityCost 가 비용 GE를 적용

→ C++에선 수동 검사/차감을 삭제하고 무기 타입 같은 고유 게이트만 남김.

// CanActivate: HasStamina(...) 수동검사 제거 → CheckCost가 담당
// Activate: CommitStaminaCost(...) 제거, CommitAbility만 유지(비용+쿨다운 일괄)
if (!CommitAbility(Handle, ActorInfo, ActivationInfo)) { EndAbility(...); return; }

함정: CheckCost는 SetByCaller magnitude를 못 읽음

비용 GE는 고정(Scalable Float) magnitude여야 CheckCost가 사전 평가할 수 있음.
SetByCaller로 비용을 주입하면 발동 게이트가 동작하지 않음 → 어빌리티별 고정 비용 GE를 따로 만듦(GE_Cost_Stamina_Heavy/Blink/Dash).
음수 Additive(-100)면 Stamina + (-100) ≥ 0 검사로 정확히 막힘.

3. 회복/상시 효과 → Infinite + Period GE

자동 회복 = Infinite + Period GE 1개를 자원 컴포넌트가 권한에서 1회 적용, 해제 시 제거.

// 바인딩 시 1회 적용
RegenHandle = ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
// 해제(Uninitialize/EndPlay) 시
ASC->RemoveActiveGameplayEffect(RegenHandle);

Tick 직접 가산 블록은 제거. 값 초기화·클램프는 AttributeSet 생성자 + PostGameplayEffectExecute가 책임지므로 별도 init GE도 불필요.

4. 지속 비용(가드 홀드)은 1회성 Cost로 안 됨

CostGameplayEffectClassCommitAbility 시 1회만 적용됨 → 누르고 있는 동안의 지속 소모엔 부적합.
주기 소모 GE(Infinite+Period) 를 발동 시 적용 / 종료 시 제거하고, 소진 여부만 점검해 어빌리티를 끝냄.

// 발동: 주기 소모 GE 적용 + 점검 타이머 시작
// 점검 타이머: 소모는 GE가 함 → 여기선 소진만 확인
if (!HasStamina(CurrentActorInfo, KINDA_SMALL_NUMBER))
    EndAbility(/*...*/ /*bWasCancelled=*/true);

함정: 상시 켜진 회복 GE와 지속소모가 상쇄됨

회복 GE가 항상 동작하면(회복 50/s) 가드 소모(예 25/s)를 덮어써 가드가 영원히 안 닳음.
해결: ⓐ 소모율 > 회복율, 또는 ⓑ 회복 GE에 Ongoing Tag Requirement: State.Player.Guarding 보유 시 미적용.

5. 곁가지 — 선택은 data-driven, 충전은 기존 경로 재사용

① 버스트 다수결 — 정확 조합 매칭을 버림
"불·물·불 = 특정 행" 같은 정확 매칭은 조합마다 스킬을 만들어야 해 부담. 대신 최다 원소 1개만 보고 그 원소 버스트, 동률/우세없음은 무속성(Element.None)으로 폴백.
데이터는 행에 대표 원소 태그(BurstElement) 하나만 추가 → 코드는 "최다 산출 + 태그 일치 행 찾기"로 단순화, 실행엔진은 그대로 재사용.

② 충전은 평타와 똑같은 이벤트로
강공 명중 충전을 새로 만들지 않고, 평타 콤보 피니셔와 동일하게 SendGameplayEventToActor(ChargeBonusEventTag, Instigator=소유자) 발행.
게이지 컴포넌트가 *"Instigator==본인"* 이벤트만 구독해 충전하므로, 같은 태그를 쏘면 같은 경로로 충전됨. (캐스트당 1회 가드 플래그로 AoE 다중명중 과충전 방지.)

비교 정리

자원 동작 직접 수정 (❌) GE 방식 (✅)
일회성 비용 SetNumericAttributeBase 차감 CostGameplayEffectClass + CheckCost/CommitAbility
자동 회복 Tick 가산 Infinite+Period GE 1회 적용
지속 소모(홀드) Tick 차감 주기 GE + 소진 점검 타이머
발동 게이트 수동 HasStamina CheckCost (단, 고정 magnitude)

핵심 요약

  • 자원은 직접 수정(SetNumericAttributeBase) 말고 GE로: 일회성=CostGameplayEffectClass, 회복/지속=주기 GE.
  • CostGameplayEffectClass만 지정하면 CheckCost(게이트)·CommitAbility(차감)가 자동 → 수동 비용 코드 삭제. 단 CheckCost는 SetByCaller를 못 읽으니 고정 magnitude로.
  • 지속 비용은 1회성 Cost로 안 됨 → 주기 GE + 소진 점검. 상시 회복 GE와 상쇄 주의(소모율>회복율 or Guarding 시 회복 미적용).
  • 선택 로직은 data-driven(BurstElement)로 단순화, 충전은 기존 SendGameplayEventToActor 경로 재사용.