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::Instigator는TObjectPtr<const AActor>→.Get()필요.
직접 어트리뷰트 수정을 버리고 GE로 — 비용·회복·지속소모
1. 안티패턴: 어트리뷰트를 직접 깎고/더하기
처음 스태미너는 어빌리티에서 직접 차감하고, 회복은 컴포넌트 Tick에서 직접 가산했음.
// ❌ 비용 — 어트리뷰트 직접 수정
ASC->SetNumericAttributeBase(GetStaminaAttribute(), Current - Cost);
// ❌ 회복 — 매 프레임 Tick에서 가산
Stamina += RegenRate * DeltaTime;
동작은 하지만 예측/롤백·리플리케이션·스택·면역(immunity) 등 GAS 인프라를 전부 우회함 → Lyra식 설계와 어긋남.
✅ 정공법: 일회성 비용 = CostGameplayEffectClass, 회복/지속소모 = 주기 GE.
2. 일회성 비용 → CostGameplayEffectClass
어빌리티에 CostGameplayEffectClass만 지정하면 네이티브 경로가 알아서 처리함:
CanActivateAbility→CheckCost가 "자원이 충분한가"를 자동 게이트CommitAbility→CommitAbilityCost가 비용 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로 안 됨
CostGameplayEffectClass는 CommitAbility 시 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경로 재사용.
'내일배움캠프_Unreal > TIL' 카테고리의 다른 글
| 내일배움캠프_Unreal/TIL - Day 141 (260623) (0) | 2026.06.23 |
|---|---|
| 내일배움캠프_Unreal/TIL - Day 140 (260622) (0) | 2026.06.22 |
| 내일배움캠프_Unreal/TIL - Day 135 (260615) (0) | 2026.06.15 |
| 내일배움캠프_Unreal/TIL - Day 133 (260611) (0) | 2026.06.11 |
| 내일배움캠프_Unreal/TIL - Day 130 (260608) (0) | 2026.06.08 |