[UE5] 추출 슈터 4-2. TakeDamage를 버리다
카테고리: DevLog
📌 EmploymentProj 4단계 GAS 이관 두 번째 글입니다. 👾 깃허브 · 📚 시리즈 목차 · ← 4-1. GAS 도입: ASC를 어디에 둘 것인가
문제
3단계까지는 이랬다.
// EPCharacter.h
UPROPERTY(ReplicatedUsing = OnRep_HP) int32 HP = 100;
int32 MaxHP = 100;
FORCEINLINE bool IsDead() const { return HP <= 0; }
virtual float TakeDamage(...) override;
void Die(AController* Killer);
UFUNCTION(NetMulticast, Reliable) void Multicast_Die();
// EPCharacter.cpp
DOREPLIFETIME_CONDITION(AEPCharacter, HP, COND_OwnerOnly);
// EPCombatComponent.cpp. 히트 확정 후
UGameplayStatics::ApplyPointDamage(HitChar, FinalDamage, ...);
언리얼 기본 TakeDamage는 맞는 쪽이 계산까지 다 한다.
지금은 괜찮다. 그런데 방어구, 부위 배율, 실드가 붙기 시작하면 TakeDamage 하나가 계속 커진다.
정리하면 세 가지다.
- HP 감소, 히트 리액트, 킬 확인음, 사망 판정이
TakeDamage한 함수에 줄줄이 들어 있다 - HP가
int32다. 소수점이 잘린다 - 사망 상태를 다른 클라는 알 방법이 없다
int32가 잘라먹는다
HP = FMath::Clamp(HP - DamageAmount, 0.f, static_cast<float>(MaxHP));
DamageAmount는 float다. 우변은 float로 계산되는데 HP가 int32라 대입하면서 절삭된다.
부위 배율이 붙는 순간 26.25 같은 값이 나오는데, 그게 26으로 들어간다.
부위 배율도 실드 감산도 소수를 낳는다. HP를 정수로 잡은 것만으로 확장이 막힌 셈이다.
GAS의 FGameplayAttributeData는 float다. 이관하면서 이 문제가 같이 사라진다.
다른 클라는 남의 HP를 모른다
COND_OwnerOnly다. 내 HP는 나한테만 온다. 남의 HP는 다른 클라에서 기본값 100 그대로다.
정보 은폐 자체는 의도한 것이고 지금도 유지한다. 문제는 그 결과다.
FORCEINLINE bool IsDead() const { return HP <= 0; }
이 함수는 서버와 소유 클라에서만 맞는다. 다른 클라에서 부르면 항상 false다.
그래서 남이 죽은 걸 알리려면 Multicast_Die() RPC를 따로 쏴야 했다.
RPC는 그 순간 접속해 있고 릴러번트한 클라에만 간다. 늦게 들어오거나 잠깐 릴러번시가 끊긴 클라는 사망 사실을 영영 못 받는다. “상태”를 RPC로 흉내 낸 구조다.
4단계에서는 State.Dead 태그가 이 자리를 대신한다. 태그는 상태라서 나중에 붙는 클라도 받는다.
1. 메타 어트리뷰트, Health를 직접 깎지 않는다
이 편의 핵심이다. GE는 Health가 아니라 IncomingDamage를 건드린다.
[직접 방식] [메타 어트리뷰트 방식]
GE → Health -= 30 GE → IncomingDamage += 30
↓
PostGameplayEffectExecute
├ 부위 배율 적용 (4-4편)
├ 실드 50% 감산 (4-5편)
├ (향후) 방어구 감산
↓
Health -= 최종값
IncomingDamage = 0 ← 즉시 초기화
직접 방식은 감산 로직을 넣을 자리가 GE를 만드는 쪽, 즉 공격자밖에 없다. 그러면 방어구를 추가할 때 모든 공격 경로를 고쳐야 한다. 총, 근접, 폭발, 낙하 데미지 전부.
메타 방식은 감산 로직이 맞는 쪽 AttributeSet 한 곳에 모인다.
// EPAttributeSet.h. 복제하지 않는다
UPROPERTY(BlueprintReadOnly, Category = "Attribute|Meta")
FGameplayAttributeData IncomingDamage;
ATTRIBUTE_ACCESSORS(UEPAttributeSet, IncomingDamage)
복제하지 않는 이유는 단순하다. 서버에서 생겼다가 같은 프레임에 0으로 초기화되고 사라진다. 클라가 볼 필요도 없고, 볼 수도 없는 값이다.
2. PreAttributeChange와 PostGameplayEffectExecute
둘을 헷갈리면 버그가 난다. 역할이 완전히 다르다.
PreAttributeChange |
PostGameplayEffectExecute |
|
|---|---|---|
| 시점 | 값이 바뀌기 직전 | Instant GE가 BaseValue를 바꾼 직후 |
| 경로 | GE, SetXxx(), 초기화 등 전부 |
Instant GE 만 |
| 할 일 | 클램핑만 | 게임플레이 로직 |
| 하면 안 되는 것 | 이벤트 발송, 상태 변경 |
// 클램핑 전용. 여기에 게임 로직을 넣으면 예상 못 한 경로에서 터진다
void UEPAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
Super::PreAttributeChange(Attribute, NewValue);
if (Attribute == GetHealthAttribute())
NewValue = FMath::Clamp(NewValue, 0.f, GetMaxHealth());
if (Attribute == GetMaxHealthAttribute())
NewValue = FMath::Max(NewValue, 1.f);
}
데미지를 여기서 처리할 수 없는 이유가 명확하다.
PreAttributeChange는 NewValue 하나만 받는다. 누가 왜 바꾸는지를 모른다.
“누가 때렸나”를 알아야 킬 크레딧을 주는데, 여기서는 알 방법이 없다.
이 Health 상한 클램프가 4-5편에서 힐이 100을 넘지 않게 하는 역할을 그대로 한다. 별도 코드를 안 짜도 된다.
3. 데미지에서 사망까지
void UEPAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);
// 이 콜백은 항상 "이 AttributeSet 소유자에게 GE가 적용됐을 때"만 불린다
AEPCharacter* TargetCharacter = Cast<AEPCharacter>(GetOwningActor());
// 공격자 추출. 킬 크레딧용
UAbilitySystemComponent* SourceASC =
Data.EffectSpec.GetContext().GetOriginalInstigatorAbilitySystemComponent();
AActor* SourceActor = nullptr;
if (SourceASC && SourceASC->AbilityActorInfo.IsValid()
&& SourceASC->AbilityActorInfo->AvatarActor.IsValid())
{
SourceActor = SourceASC->AbilityActorInfo->AvatarActor.Get();
}
if (Data.EvaluatedData.Attribute == GetIncomingDamageAttribute())
{
const float Damage = GetIncomingDamage();
SetIncomingDamage(0.f); // 즉시 초기화. 안 하면 다음 피격에 누적된다
if (Damage > 0.f)
{
const bool bWasAlive = GetHealth() > 0.f;
const float NewHealth = FMath::Max(GetHealth() - Damage, 0.f);
SetHealth(NewHealth);
if (bWasAlive && NewHealth <= 0.f)
{
UAbilitySystemComponent* TargetASC = GetOwningAbilitySystemComponent();
if (TargetASC && !TargetASC->HasMatchingGameplayTag(TAG_State_Dead))
{
FGameplayEventData Payload;
Payload.Instigator = SourceActor;
TargetASC->HandleGameplayEvent(TAG_Event_Death, &Payload);
}
}
}
}
}
PostGameplayEffectExecute는 FGameplayEffectModCallbackData로 소스, 타겟, Spec, Context를 전부 받는다.
그래서 여기서만 킬 크레딧을 줄 수 있다.
중복 방어가 두 겹인 이유
연사 무기는 한 프레임에 여러 발이 들어온다. AK 기준으로 초당 열 발이 넘는다.
bWasAlive: 이미 HP가 0인 대상에 대한 추가 데미지 차단HasMatchingGameplayTag(TAG_State_Dead): 사망 이벤트 중복 발송 차단
하나만 있으면 오버킬 상황에서 GA_Death가 두 번 돈다. 래그돌이 두 번 걸리고 킬 카운트가 2가 된다.
4. GE_Damage, 값은 코드가 넣는다
Content/Data/GAS/GE_Damage:
- DurationPolicy : Instant
- Modifiers:
Attribute : EPAttributeSet.IncomingDamage
ModifierOp : Add
MagnitudeCalc : SetByCaller
DataTag : Data.Damage
무기마다 데미지가 다르고 부위 배율까지 곱해지니 에셋에 숫자를 박을 수 없다. 에셋은 “IncomingDamage에 Data.Damage만큼 더한다”는 형태만 정의하고, 값은 코드가 넣는다.
void UEPCombatComponent::ApplyGEDamage(
AActor* Target, AActor* Instigator,
TSubclassOf<UGameplayEffect> GEClass, float FinalDamage)
{
if (!Target || !GEClass) return;
IAbilitySystemInterface* TargetIF = Cast<IAbilitySystemInterface>(Target);
UAbilitySystemComponent* TargetASC = TargetIF ? TargetIF->GetAbilitySystemComponent() : nullptr;
IAbilitySystemInterface* InstigatorIF = Cast<IAbilitySystemInterface>(Instigator);
UAbilitySystemComponent* InstigatorASC = InstigatorIF ? InstigatorIF->GetAbilitySystemComponent() : nullptr;
if (!TargetASC || !InstigatorASC) return;
FGameplayEffectContextHandle Context = InstigatorASC->MakeEffectContext();
Context.AddInstigator(Instigator, Instigator);
FGameplayEffectSpecHandle Spec = InstigatorASC->MakeOutgoingSpec(GEClass, 1.f, Context);
if (!Spec.IsValid()) return;
// GameplayTag 버전 SetByCaller. FName 버전은 쓰지 않는다
Spec.Data->SetSetByCallerMagnitude(EmpGameplayTags::TAG_Data_Damage, FinalDamage);
InstigatorASC->ApplyGameplayEffectSpecToTarget(*Spec.Data, TargetASC);
}
SetByCaller는 반드시 GameplayTag 버전을 쓴다.
FName 오버로드도 있지만 오타가 런타임까지 가고 자동완성도 없다.
태그 버전은 4-1에서 만든 네이티브 태그를 그대로 쓰니 컴파일 타임에 걸린다.
ASC를 얻는 방법도 하나로 정했다.
// 채택
Cast<IAbilitySystemInterface>(Actor)->GetAbilitySystemComponent()
// 안 씀. Blueprint 래퍼라 같은 일을 하면서 한 단계 더 거친다
UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Actor)
교체는 한 줄이었다.
// 기존
UGameplayStatics::ApplyPointDamage(HitChar, FinalDamage, ...);
// 변경 후
ApplyGEDamage(HitChar, OwnerChar, GE_DamageClass, FinalDamage);
3단계에서 만든 ConfirmHitscan은 한 줄도 안 바뀌었다.
어려운 부분은 그대로 두고 그 뒤 한 줄만 갈아 끼운 셈이다.
5. State.Dead는 Infinite GE로만 복제된다
시리즈를 관통하는 주제가 처음 나오는 지점이다.
ActivationOwnedTags (GA가 켜져 있는 동안 자동 부여)
→ 소유 클라와 서버에서만 보인다
→ 다른 클라에서는 없는 것과 같다
GE GrantedTags (GE가 붙어 있는 동안 부여)
→ GE가 복제되니 태그도 따라간다
GA_Death의 ActivationOwnedTags에 State.Dead를 넣는 게 자연스러워 보인다.
그런데 그러면 다른 플레이어가 “쟤 죽었나”를 판정할 수 없다.
Content/Data/GAS/GE_State_Dead:
- DurationPolicy : Infinite
- GrantedTags : State.Dead
이 판단은 4-3편에서 State.Reloading에 그대로 반복되고, 4-5편의 State.Casting까지 이어진다.
기준 한 문장: 다른 클라가 쿼리해야 하는 상태인가. 그렇다면 GE로 만든다. 아니면
ActivationOwnedTags로 충분하다.
6. GA_Death, 아무도 직접 호출하지 않는다
UEPGA_Death::UEPGA_Death()
{
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::ServerOnly;
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
bServerRespectsRemoteAbilityCancellation = false;
// Event.Death가 발생하면 자동으로 활성화된다
FAbilityTriggerData Trigger;
Trigger.TriggerTag = EmpGameplayTags::TAG_Event_Death;
Trigger.TriggerSource = EGameplayAbilityTriggerSource::GameplayEvent;
AbilityTriggers.Add(Trigger);
}
ServerOnly인 이유는 사망이 예측할 대상이 아니기 때문이다. 클라가 먼저 죽여봐야 서버가 부정하면 되살아나는 그림이 나온다.
bServerRespectsRemoteAbilityCancellation = false는 클라가 “내 사망 어빌리티 끝났다”고 해도 서버가 무시한다는 뜻이다.
이 플래그는 4-5편에서 훨씬 아픈 방식으로 다시 등장한다. 여기서는 GASDocumentation 권장을 그대로 따랐다.
void UEPGA_Death::ActivateAbility(...)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
// State.Dead를 Infinite GE로 부여해 복제를 보장한다
if (GE_StateDeadClass)
ApplyGameplayEffectToOwner(Handle, ActorInfo, ActivationInfo,
GE_StateDeadClass.GetDefaultObject(), 1.f);
if (AEPCharacter* Char = Cast<AEPCharacter>(ActorInfo->AvatarActor.Get()))
{
// 래그돌 위에 무기가 떠 있는 현상 방지
if (UEPCombatComponent* CC = Char->GetCombatComponent())
if (AEPWeapon* Weapon = CC->GetEquippedWeapon())
{
Weapon->SetActorHiddenInGame(true);
Weapon->SetActorEnableCollision(false);
}
// 킬 크레딧
const AActor* Killer = TriggerEventData ? TriggerEventData->Instigator.Get() : nullptr;
AController* KillerController = Killer ? Killer->GetInstigatorController() : nullptr;
if (AEPGameMode* GM = Char->GetWorld()->GetAuthGameMode<AEPGameMode>())
GM->OnPlayerKilled(KillerController, Char->GetController());
Char->Multicast_Die(); // 래그돌
if (AController* Controller = Char->GetController()) // 죽어서도 입력이 들어오는 것 방지
Controller->UnPossess();
}
EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
}
뭐가 달라졌나
| Before | After | |
|---|---|---|
| 사망 판정 | TakeDamage 안에서 HP 체크 |
PostGEExecute에서 이벤트 발송 |
| 사망 처리 | Die() 직접 호출 |
Event.Death → GA가 알아서 활성화 |
| 결합도 | 데미지 코드가 GameMode·무기·컨트롤러를 다 안다 | 데미지 코드는 이벤트만 던진다 |
세 번째 줄이 값을 하는 순간이 4-5편에 나온다. 채널링 힐을 피격으로 끊어야 하는데, 데미지 코드는 한 줄도 안 고치고 이벤트를 하나 더 던지는 것으로 끝났다.
7. Blueprint 서브클래스가 필요한 이유
// EPCharacter.h
UPROPERTY(EditDefaultsOnly, Category = "GAS")
TArray<TSubclassOf<UGameplayAbility>> DefaultAbilities;
// PossessedBy 서버측
for (const TSubclassOf<UGameplayAbility>& AbilityClass : DefaultAbilities)
if (AbilityClass)
ASC->GiveAbility(FGameplayAbilitySpec(AbilityClass, 1));
🚩
UEPGA_Death::StaticClass()를 직접 넣으면 안 된다.GE_StateDeadClass가EditDefaultsOnly라서 C++ 클래스의 CDO에서는 항상 null이다. Blueprint 서브클래스를 만들어 에디터에서 GE를 할당한 뒤 그 클래스를 참조해야 한다.
이 패턴은 이후 모든 GA에 반복된다. “C++로 GA를 만들고, Blueprint 서브클래스에서 에셋을 주입한다”가 이 프로젝트의 표준이 됐다.
리스폰해도 ASC는 PlayerState에 살아 있으니 재부여가 필요 없다. 4-1에서 내린 배치 결정이 여기서 이득으로 돌아온다.
8. OnRep_HP가 사라진 자리
HP UPROPERTY를 지우면 OnRep_HP 경로도 없어진다. Attribute 변경 델리게이트로 갈아 끼운다.
// EPCharacter.cpp. InitASC() 내부
// IsLocallyControlled() 가드 필수. 서버에서는 모든 Character의 InitASC가 호출된다
if (IsLocallyControlled())
{
ASC->GetGameplayAttributeValueChangeDelegate(UEPAttributeSet::GetHealthAttribute())
.AddUObject(this, &AEPCharacter::OnHealthChanged);
}
역할을 두 곳으로 나눴다.
| 등록 위치 | 용도 |
|---|---|
AEPPlayerState::BeginPlay |
서버 로직 |
AEPCharacter::InitASC |
로컬 UI 갱신만 |
한 곳에 몰아넣고 조건 분기하면 서버와 클라 실행 경로가 섞여서 나중에 디버깅이 어려워진다. 이 구조가 마지막 편 HUD의 기반이 된다.
전체 흐름
[SSR::ConfirmHitscan] 히트 확정
→ ApplyGEDamage(Target, Instigator, GE_Damage, FinalDamage)
→ SetSetByCallerMagnitude(Data.Damage, FinalDamage)
→ ApplyGameplayEffectSpecToTarget
→ AttributeSet::PostGameplayEffectExecute
→ IncomingDamage 소모 → Health 감소
→ Health <= 0 → HandleGameplayEvent(Event.Death)
→ GA_Death::ActivateAbility (ServerOnly, 트리거 자동 활성화)
→ GE_State_Dead (Infinite) → State.Dead 복제
→ 무기 숨김 → 킬 크레딧 → Multicast_Die() → UnPossess()
→ EndAbility
첫 줄이 3단계고 나머지가 4단계다. 그 경계에서 바뀐 건 한 줄이다.
함정 정리
| 상황 | 원인 | 해결 |
|---|---|---|
| GE를 적용해도 Health가 안 줄어듦 | Data.Damage SetByCaller 미주입 |
SetSetByCallerMagnitude 확인 |
| 사망 이벤트가 두 번 발생 | 한 프레임에 여러 발 명중 | bWasAlive + State.Dead 이중 체크 |
State.Dead가 다른 클라에서 안 보임 |
ActivationOwnedTags는 복제 안 됨 |
Infinite GE GrantedTags |
GA_Death가 활성화 안 됨 |
GA 부여 누락 | PossessedBy 서버측 GiveAbility |
GE_StateDeadClass가 항상 null |
C++ 클래스를 직접 넣음 | Blueprint 서브클래스 |
ApplyGEDamage가 클라에서 실행됨 |
호출 경로 추가 시 가드 누락 | HasAuthority() 가드 |
결과
PIE 2인 기준:
- 피격 →
showdebug abilitysystem에서 Health 감소 - Health 0 →
State.Dead태그가 양쪽 클라 모두에서 보인다 - 래그돌, 무기 숨김,
UnPossess동작 - 연사로 오버킬 →
GA_Death가 한 번만 활성화
한계:
IncomingDamage에 부위 배율이 아직 적용되지 않는다. 배율은 여전히HandleHitscanFire에서 곱해서 넘긴다. 4-4편에서 태그 기반으로 바뀐다.- 방어구는 미구현이다. GE Context에 HitZone 태그를 실어 보내
PostGEExecute에서 감산하는 방향으로 설계만 해뒀다 - 데미지 숫자나 피격 방향 인디케이터는 GameplayCue로 붙일 자리가 열려 있다
다음 편
Server_Fire와 Server_Reload RPC를 지운다.
그 자리에 어빌리티가 들어오고, “막을 거면 예측 전에 막는다”는 원칙이 나온다.
참고
DOCS/Notes/04/04_GAS_02_DamagePipeline.md. 구현 전체DOCS/Notes/04/04_GAS_00_Reference.md4장. 메타 어트리뷰트 패턴
댓글남기기