[UE5] 추출 슈터 4-3. 발사와 재장전을 어빌리티로

게시:     수정

카테고리:

태그: , ,

📌 EmploymentProj 4단계 GAS 이관 세 번째 글입니다. 👾 깃허브 · 📚 시리즈 목차 · ← 4-2. TakeDamage를 버리다


문제

3단계 구조는 이랬다.

// AEPCharacter. 재장전 입력은 아예 없었다
void Input_Fire(...)  { CombatComponent->RequestFire(...); }

// UEPCombatComponent
void RequestFire(...);                      // 로컬 코스메틱 + 서버 RPC
UFUNCTION(Server, Reliable) void Server_Fire(...);
UFUNCTION(Server, Reliable) void Server_Reload(...);
float LocalLastFireTime;                    // 클라 FireRate 검증
float LastServerFireTime;                   // 서버 FireRate 검증
// AEPWeapon
UPROPERTY(ReplicatedUsing = OnRep_CurrentAmmo) uint8 CurrentAmmo;
UPROPERTY(Replicated) uint8 MaxAmmo = 30;

// --- 서버 런타임 상태 (복제 X) ---
EEPWeaponState WeaponState;                 // Idle / Firing / Reloading
float LastFireTime;                         // 세 번째 FireRate 검증
FTimerHandle ReloadTimerHandle;

네 가지가 걸렸다.

  • 입력 핸들러가 CombatComponent를 직접 안다. 무기나 스킬이 늘면 Character가 계속 커진다
  • FireRate 검증이 세 군데에 손으로 적혀 있다. 클라, 서버, 그리고 CanFire() 안에 하나 더
  • 타이머가 GA 생명주기와 분리돼 있다. 재장전 중에 죽으면 타이머가 남는다
  • WeaponState가 복제되지 않는다

세 벌짜리 규칙

변수 어디 시계
LocalLastFireTime CombatComponent (클라) GetWorld()->GetTimeSeconds()
LastServerFireTime CombatComponent (서버) GS->GetServerWorldTimeSeconds()
LastFireTime AEPWeapon::CanFire() GetWorld()->GetTimeSeconds()

FireRate 값 자체는 WeaponDef에 있으니 문제가 아니었다. 그 값을 적용하는 코드가 세 벌인 게 문제였다. 변수도 시계도 다른데 간격만 비교하니까 우연히 맞았다.

연사 버프 하나만 붙어도 세 군데를 다 고쳐야 한다. 한 군데를 빠뜨리면 클라가 통과시킨 걸 서버가 거부하고, 그러면 총구 화염만 남는다.

타이머는 아무도 안 껐다

ClearTimer(ReloadTimerHandle)가 프로젝트 전체에 없다. SetTimer만 있다.

void AEPCharacter::Die(AController* Killer)
{
    // 무기를 숨기기만 하고 파괴하지 않는다
    CombatComponent->GetEquippedWeapon()->SetActorHiddenInGame(true);
    CombatComponent->GetEquippedWeapon()->SetActorEnableCollision(false);
    ...
}

재장전 중에 죽으면 타이머가 계속 돈다. 숨겨진 무기에서 FinishReload()가 실행되고 CurrentAmmo = MaxAmmo로 채워진다.

복제 안 되는 상태로 판정했다

마지막이 제일 컸다.

[내 화면]                              [상대 화면]
나: WeaponState = Reloading            나: WeaponState = Idle (복제 안 됨)
   → 발사 차단 O                          → "재장전 중인지" 판정 불가

3단계까지는 서버 CanFire() 체크에만 의존해서 넘어갔다. 클라는 틀린 판단을 하고 서버가 거부하는 구조였다. 동작은 했지만 옳지는 않았다.

그리고 죽은 선언 두 개

이 글을 쓰려고 이관 직전 커밋을 다시 열었다가 찾았다.

Server_Reload()는 부르는 곳이 없었다. 선언도 구현도 있는데 호출이 없다. 재장전 입력 바인딩 자체가 없었고, 실제로 돌던 건 AEPWeapon::Fire() 끝의 자동 재장전뿐이었다.

// EPWeapon.cpp Fire() 마지막 줄
if (CurrentAmmo <= 0) StartReload();   // 이게 전부였다

MaxAmmo는 복제되지 않았다.

UPROPERTY(Replicated) uint8 MaxAmmo = 30;
void AEPWeapon::GetLifetimeReplicatedProps(...) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME_CONDITION(AEPWeapon, CurrentAmmo, COND_OwnerOnly);   // 이 줄뿐
}

Replicated라고 표시해놓고 등록을 안 했다. 클라가 기본값 30을 들고 있어서 우연히 맞았을 뿐이다. 무기가 두 종류가 되는 순간 틀린다.

🐛 선언만 있고 아무 일도 안 일어나는 코드가 이 프로젝트에 반복해서 나온다. 4-4편에서 하나 더 나오는데, 그건 이것보다 훨씬 오래 살아남았다.


1. 입력을 한 경로로

// Before
void AEPCharacter::Input_Fire(const FInputActionValue& Value)
{
    CombatComponent->RequestFire(...);
}

// After
void AEPCharacter::Input_Fire(const FInputActionValue& Value)
{
    if (UAbilitySystemComponent* ASC = GetAbilitySystemComponent())
        ASC->TryActivateAbilitiesByTag(
            FGameplayTagContainer(EmpGameplayTags::TAG_Ability_Item_PrimaryUse));
}

이 세 줄이 이 편에서 제일 중요하다. Character는 이제 “발사”가 뭔지 모른다. 태그 하나만 던진다.

재장전도 태그만 다르고 완전히 같은 모양이다.

void AEPCharacter::Input_Reload(const FInputActionValue& Value)
{
    if (UAbilitySystemComponent* ASC = GetAbilitySystemComponent())
        ASC->TryActivateAbilitiesByTag(
            FGameplayTagContainer(EmpGameplayTags::TAG_Ability_Item_Reload));
}

4-5편에서 만드는 스킬 세 개도 똑같은 세 줄로 붙는다. 그게 이 추상화가 값을 하는 증거다.


2. LocalPredicted

UEPGA_Item_PrimaryUse::UEPGA_Item_PrimaryUse()
{
    NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
    InstancingPolicy   = EGameplayAbilityInstancingPolicy::InstancedPerActor;

    bServerRespectsRemoteAbilityCancellation = false;

    FGameplayTagContainer Tags = GetAssetTags();
    Tags.AddTag(EmpGameplayTags::TAG_Ability_Item_PrimaryUse);
    SetAssetTags(Tags);

    // 이 태그가 붙어 있으면 활성화 자체가 막힌다. GAS가 알아서 검사한다
    ActivationBlockedTags.AddTag(EmpGameplayTags::TAG_State_Dead);
    ActivationBlockedTags.AddTag(EmpGameplayTags::TAG_State_Reloading);
}

🚩 AbilityTags.AddTag(...)는 UE 5.5부터 막혔다. GAS 자료 대부분이 그 형태라 그대로 붙여넣으면 컴파일이 안 된다.

// GameplayAbility.h
UE_DEPRECATED_FORGAME(5.5, "Use GetAssetTags(). This is being made non-mutable,
                            private and renamed to AssetTags in the future.
                            Use SetAssetTags to set defaults (in constructor only).")
FGameplayTagContainer AbilityTags;

읽기는 GetAssetTags(), 쓰기는 SetAssetTags()다. 그리고 쓰기는 생성자에서만 하라고 못 박혀 있다. 런타임에 바꾸면 같은 Spec에서 나온 인스턴스들이 서로 다른 태그를 갖게 된다.

이름이 Ability에서 Asset으로 바뀐 것도 의미가 있다. 이 태그는 “어빌리티가 켜져 있는 동안 붙는 태그”가 아니라 “이 어빌리티 에셋을 식별하는 태그”다. ActivationOwnedTags와 헷갈리기 딱 좋은 자리다.

NetExecutionPolicy 네 가지

정책 실행 위치 이 프로젝트
LocalOnly 클라만 없음
LocalPredicted 클라 즉시 + 서버 검증 발사, 재장전, 스킬
ServerOnly 서버만 GA_Death (4-2편)
ServerInitiated 서버가 시작 후 클라 통보 없음

시뮬레이티드 프록시에서는 GA가 아예 실행되지 않는다. 이게 코스메틱 처리를 갈라놓는 원인이다.

[소유 클라]  GA 실행 O  → 자기 총구 화염을 즉시 본다
[서버]       GA 실행 O  → 실제 판정
[타 클라]    GA 실행 X  → Multicast RPC로 따로 보내야 한다

3. 되돌릴 수 없는 것을 막는 자리

bool UEPGA_Item_PrimaryUse::CanActivateAbility(...) const
{
    if (!Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags))
        return false;

    const AEPCharacter* Char = Cast<AEPCharacter>(ActorInfo->AvatarActor.Get());
    const AEPWeapon* Weapon  = Char ? Char->GetCombatComponent()->GetEquippedWeapon() : nullptr;

    return Char && Weapon && Weapon->CanFire();
}

이걸 ActivateAbility에 넣으면 어떻게 되는지 그림으로 그리면 이렇다.

[CanActivateAbility에서 차단]        [ActivateAbility에서 차단]
클라: 활성화 시도 → 거부              클라: 활성화 → 총구 화염 재생 → 탄약 감소
     아무 일도 안 일어난다                  ↓ 서버가 거부
                                     클라: 탄약은 되돌아온다
                                           총구 화염은 그대로 남는다

🐛 여기서 “롤백”이라는 말을 조심해서 써야 한다. GAS에는 범용 롤백이 없다.

서버가 거부하면 ClientActivateAbilityFailed가 온다. 이 함수가 실제로 하는 일은 셋뿐이다.

FPredictionKeyDelegates::BroadcastRejectedDelegate(PredictionKey);  // ① 예측 GE 제거
Ability->CurrentActivationInfo.SetActivationRejected();             // ② 상태 표시
Ability->K2_EndAbility();                                           // ③ 어빌리티 종료

①이 되돌리는 건 예측으로 적용한 GameplayEffect뿐이다. 탄약 Cost GE가 여기 해당한다. PlayLocalMuzzleEffect가 스폰한 Niagara와 Sound는 GAS가 알지도 못하고, 되돌리는 코드도 없다. 몽타주는 예외적으로 되감기는데, 그건 AbilityTask가 따로 챙겨주기 때문이다.

즉 정확한 서술은 “롤백돼서 이펙트가 났다 사라진다”가 아니라 “수치는 되돌아오고 화면에 뿌린 것은 남는다”다.

그리고 이게 CanActivateAbility로 옮겨야 하는 이유를 더 강하게 만든다. 되돌릴 수 있는 것(탄약) 때문이 아니라 되돌릴 수 없는 것(연출) 때문에 앞에서 막는 것이다.

GAS의 예측은 “틀리면 되돌린다”가 아니라 “되돌릴 수 있는 것만 예측한다”에 가깝다.


4. FireRate를 Duration GE로

수동 시간 비교를 GAS 쿨타임으로 옮겼다.

Content/Data/GAS/GE_FireCooldown:
- DurationPolicy    : HasDuration
- DurationMagnitude : SetByCaller (Data.Cooldown)
- GrantedTags       : Cooldown.Weapon.PrimaryUse

쿨타임 GE는 GrantedTags로 자기 존재를 알린다. GA는 이 태그가 있는지로 쿨타임을 판정한다. 그래서 함수 두 개를 오버라이드해야 한다.

// A. 이 GA의 쿨타임 태그가 뭔지 알려준다
const FGameplayTagContainer* UEPGA_Item_PrimaryUse::GetCooldownTags() const
{
    FGameplayTagContainer* MutableTags = const_cast<FGameplayTagContainer*>(&TempCooldownTags);
    MutableTags->Reset();  // CDO에서 쓰이므로 매번 초기화
    if (const FGameplayTagContainer* ParentTags = Super::GetCooldownTags())
        MutableTags->AppendTags(*ParentTags);
    MutableTags->AppendTags(CooldownTags);
    return MutableTags;
}

// B. 쿨타임을 적용할 때 무기의 FireRate를 Duration으로 주입한다
void UEPGA_Item_PrimaryUse::ApplyCooldown(...) const
{
    UGameplayEffect* CooldownGE = GetCooldownGameplayEffect();
    if (!CooldownGE) return;

    FGameplayEffectSpecHandle SpecHandle =
        MakeOutgoingGameplayEffectSpec(CooldownGE->GetClass(), GetAbilityLevel());
    SpecHandle.Data->DynamicGrantedTags.AppendTags(CooldownTags);

    // 무기 데이터에서 읽는다. 코드에 숫자가 없다
    const AEPCharacter* Char = Cast<AEPCharacter>(ActorInfo->AvatarActor.Get());
    const AEPWeapon* Weapon  = Char ? Char->GetCombatComponent()->GetEquippedWeapon() : nullptr;
    const float Duration     = Weapon ? (1.f / Weapon->WeaponDef->FireRate) : 0.2f;

    SpecHandle.Data->SetSetByCallerMagnitude(EmpGameplayTags::TAG_Data_Cooldown, Duration);
    ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, SpecHandle);
}

얻은 것 세 가지.

  • 연사 속도가 WeaponDefinition 데이터가 됐다. 새 무기는 새 DataAsset이고 코드는 그대로다
  • 서버가 GE로 강제하니 클라가 RPC를 스팸해도 CommitAbility가 막는다
  • showdebug abilitysystem에서 쿨타임이 눈에 보인다. 디버깅이 쉬워졌다

🚩 AEPWeapon::CanFire()에 남아 있던 LastFireTime 체크는 반드시 지워야 한다. GAS 쿨타임과 이중으로 존재하면 쿨타임이 풀렸는데 무기 쪽 타이머가 막는 상황이 생긴다. CanFire()는 이제 탄약 체크만 한다.


5. 서버와 클라가 각자 할 일

void UEPGA_Item_PrimaryUse::ActivateAbility(...)
{
    Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);

    AEPCharacter* Char = Cast<AEPCharacter>(ActorInfo->AvatarActor.Get());
    AEPWeapon* Weapon  = Char ? Char->GetCombatComponent()->GetEquippedWeapon() : nullptr;
    if (!Char || !Weapon)
    {
        EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
        return;
    }

    // CommitAbility = CheckCost + CheckCooldown + 둘 다 적용
    if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
    {
        EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
        return;
    }

    const FVector Origin     = Char->GetCameraComponent()->GetComponentLocation();
    const AGameStateBase* GS = Char->GetWorld()->GetGameState<AGameStateBase>();
    // 3단계에서 정한 규칙 그대로. 서버와 클라가 같은 시계를 쓴다
    const float ClientTime   = GS ? GS->GetServerWorldTimeSeconds()
                                  : Char->GetWorld()->GetTimeSeconds();

    // 서버: 실제 판정
    if (ActorInfo->IsNetAuthority())
    {
        Char->GetCombatComponent()->HandleServerFire(
            Origin, Char->GetControlRotation().Vector(), ClientTime);
    }
    // 클라: 코스메틱 즉시 재생. RTT를 기다리지 않는다
    else
    {
        UEPCombatComponent* Combat = Char->GetCombatComponent();
        if (Combat)
        {
            Combat->PlayLocalMuzzleEffect(Origin);
            if (Weapon->WeaponDef->BallisticType == EEPBallisticType::ProjectileFast)
                Combat->SpawnLocalCosmeticProjectile(Origin, Char->GetControlRotation().Vector());
        }
    }

    EndAbility(Handle, ActorInfo, ActivationInfo, false, false);
}

둘은 다른 일을 한다. 같은 일을 두 번 하는 게 아니다.

  하는 일
클라 총구 화염과 로컬 투사체. 연출만
서버 HandleServerFire → SSR ConfirmHitscan → 데미지. 판정만
공통 CommitAbility. 탄약 Cost GE + 쿨타임 GE

클라는 “맞았다”를 예측하지 않는다. 예측하는 건 “쏠 수 있다”와 “탄약이 준다” 둘뿐이다. 그래서 서버가 거부해도 되돌릴 게 별로 없다. 애초에 되돌릴 수 있는 것만 예측했기 때문이다.

ClientTime이 3단계 SSR과 붙는 지점이다. GS->GetServerWorldTimeSeconds()를 양쪽이 쓴다는 규칙이 GAS로 옮긴 뒤에도 그대로다.


6. 3단계 자산은 하나도 안 버렸다

void UEPCombatComponent::HandleServerFire(
    const FVector& Origin, const FVector& Direction, float ClientFireTime)
{
    if (!EquippedWeapon || !EquippedWeapon->WeaponDef) return;
    AEPCharacter* Owner = GetOwnerCharacter();
    if (!Owner) return;

    // 위치 조작 방지. 3단계에서 만든 검증 그대로
    constexpr float MaxOriginDrift = 200.f;
    if (FVector::DistSquared(Origin, Owner->GetActorLocation()) > FMath::Square(MaxOriginDrift))
        return;

    if (!EquippedWeapon->CanFire()) return;

    switch (EquippedWeapon->WeaponDef->BallisticType)
    {
    case EEPBallisticType::Hitscan:
    default:
        {
            TArray<FVector> PelletDirs;
            EquippedWeapon->Fire(Direction, ClientFireTime, PelletDirs);
            HandleHitscanFire(Owner, Origin, PelletDirs, ClientFireTime);  // → SSR ConfirmHitscan
            break;
        }
    case EEPBallisticType::ProjectileFast:
    case EEPBallisticType::ProjectileSlow:
        { /* ... */ }
    }

    Multicast_PlayMuzzleEffect(MuzzleLoc);   // 시뮬 프록시용. GA로는 도달 못 한다
}
요소 상태
UEPServerSideRewindComponent 전체 무변경
ConfirmHitscan / 리와인드 / 보간 무변경
HandleHitscanFire / HandleProjectileFire 무변경
Origin 200cm 검증 무변경
FireRate 검증 세 벌 제거GE_FireCooldown 하나
Server_Fire / Server_Reload RPC 제거 → GA
RequestFire 제거TryActivateAbilitiesByTag

이 표가 이 시리즈의 메시지다. 지연 보상 같은 어려운 부분은 그대로 두고 호출자만 갈아 끼웠다.

🐛 그런데 이 switch에는 옮기면서 같이 딸려온 문제가 하나 있다.

case EEPBallisticType::Hitscan:default:가 붙어 있다. EEPBallisticType에 값을 하나 추가하면, 예를 들어 근접무기나 수류탄을 넣으면 그게 조용히 히트스캔으로 발사된다. default:가 있어서 -Wswitch 경고도 안 뜬다.

“무변경”이라고 적었지만 정확히는 문제가 있는 채로 무변경이다. 3단계에서는 탄도 종류가 히트스캔뿐이라 안 드러났고, 이관은 호출자만 바꿨으니 그대로 넘어왔다.

2단계와 3단계에서 반복해서 나온 “선언은 했는데 아무 일도 안 일어나는 코드”의 사촌이다. “새 값을 추가했는데 아무도 알려주지 않는 코드”. 근접무기를 넣기 전에 처리할 것으로 적어뒀다.


7. 탄약, CheckCost는 차단하지 않는다

GE_ConsumeAmmo:
- DurationPolicy : Instant
- Modifier       : Ammo / Add / -1

GA Blueprint에서 CostGameplayEffectClass로 지정하면 CommitAbility가 쿨타임과 같이 처리한다. 코드가 필요 없다.

🐛 CheckCost는 차단하지 않는다. Ammo가 0일 때 -1을 적용해도 PreAttributeChange가 0으로 클램핑할 뿐 실패로 처리되지 않는다. 명시적으로 CanFire()에서 Ammo <= 0을 체크해야 발사가 막힌다. 처음에 이걸 몰라서 탄약 0에 계속 발사됐다.

bool AEPWeapon::CanFire() const
{
    if (!WeaponDef) return false;

    if (AEPCharacter* EPOwner = Cast<AEPCharacter>(GetOwner()))
        if (UAbilitySystemComponent* ASC = EPOwner->GetAbilitySystemComponent())
            if (UEPAttributeSet* AS = Cast<UEPAttributeSet>(
                    ASC->GetAttributeSet(UEPAttributeSet::StaticClass())))
                if (AS->GetAmmo() <= 0.f) return false;

    return true;
}

탄약은 무기 Actor의 uint8에서 Attribute로 옮겼다. Health와 완전히 같은 패턴이다.

UPROPERTY(BlueprintReadOnly, Category = "Attribute|Ammo", ReplicatedUsing = OnRep_Ammo)
FGameplayAttributeData Ammo;
ATTRIBUTE_ACCESSORS(UEPAttributeSet, Ammo)

탄약이 float이 됐다. Attribute는 전부 float다. 처음엔 어색했는데, 덕분에 “탄약 소모 50% 감소” 같은 배율 버프가 자연스럽게 붙는다. 표시할 때만 정수로 자르면 된다.

그리고 HP와 탄약이 같은 복제 전략, 같은 델리게이트, 같은 클램핑 규약을 쓰게 됐다. 마지막 편 HUD가 두 값을 동일한 코드로 구독할 수 있게 된 이유가 이것이다.


8. State.Reloading, 코드 한 줄도 안 쓰고 고쳐졌다

이 편에서 제일 마음에 드는 부분이다.

Content/Data/GAS/GE_Reloading:
- DurationPolicy    : HasDuration
- DurationMagnitude : SetByCaller (Data.ReloadDuration)
- GrantedTags       : State.Reloading

왜 Infinite가 아니라 Duration인가. 재장전 시간이 지나면 태그가 자동으로 풀린다. 4-2편의 State.Dead는 계속 유지돼야 해서 Infinite였다. 지속 시간이 정해진 상태는 Duration GE가 타이머 역할까지 겸한다.

그리고 발사 차단은 이미 2번에서 써둔 한 줄이 처리한다.

ActivationBlockedTags.AddTag(EmpGameplayTags::TAG_State_Reloading);

코드를 한 줄도 추가하지 않았는데, 태그가 복제되기 시작하자 다른 클라에서도 발사 차단이 맞게 동작하기 시작했다.

  Before (WeaponState enum) After (State.Reloading 태그)
복제 안 됨 GE를 타고 복제됨
타 클라 쿼리 불가 가능
차단 코드 if (WeaponState == Reloading) return false; 없음
만료 처리 타이머 콜백에서 수동 복구 Duration 만료 시 자동
중복 재장전 방지 별도 체크 같은 태그로 자동

탄약 보충은 GE 하나로

GE_Reload_Ammo:
- DurationPolicy : Instant
- Modifiers:
    Attribute       : EPAttributeSet.Ammo
    ModifierOp      : Override
    MagnitudeCalc   : AttributeBased
        SourceAttribute : EPAttributeSet.MaxAmmo
        AttributeSource : Source
        bSnapshot       : false

“Ammo를 MaxAmmo 값으로 덮어쓴다”를 에셋 하나로 표현했다. 무기마다 MaxAmmo가 달라도 GE는 하나면 된다.

  • Override. Add가 아니라 덮어쓰기다. 남은 탄이 몇 발이든 가득 찬다
  • bSnapshot = false. Spec을 만드는 시점이 아니라 적용하는 시점의 MaxAmmo를 읽는다. 재장전 도중 무기를 바꿔도 맞는 값이 들어간다

9. WaitDelay는 타이머와 뭐가 다른가

void UGA_Item_Reload::ActivateAbility(...)
{
    Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);

    AEPCharacter* Char = Cast<AEPCharacter>(ActorInfo->AvatarActor.Get());
    AEPWeapon* Weapon  = Char ? Char->GetCombatComponent()->GetEquippedWeapon() : nullptr;

    if (!Char || !Weapon || !CommitAbility(Handle, ActorInfo, ActivationInfo))
    {
        CancelAbility(Handle, ActorInfo, ActivationInfo, true);
        return;
    }

    const float ReloadTime = Weapon->WeaponDef->ReloadTime;

    if (GE_ReloadingClass)
    {
        FGameplayEffectSpecHandle SpecHandle =
            MakeOutgoingGameplayEffectSpec(GE_ReloadingClass, GetAbilityLevel());
        SpecHandle.Data->SetSetByCallerMagnitude(
            EmpGameplayTags::TAG_Data_ReloadDuration, ReloadTime);
        ReloadingEffectHandle =
            ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, SpecHandle);
    }

    UAbilityTask_WaitDelay* WaitTask = UAbilityTask_WaitDelay::WaitDelay(this, ReloadTime);
    WaitTask->OnFinish.AddDynamic(this, &UGA_Item_Reload::OnReloadComplete_Task);
    WaitTask->ReadyForActivation();   // C++에서는 필수
}
  SetTimer + 람다 UAbilityTask_WaitDelay
GA 취소 시 타이머가 살아남는다 태스크가 자동 정리
예측 지원 없음 GAS 예측 키와 통합
GA 인스턴스 접근 람다 캡처 필요 GetCurrentAbilitySpecHandle()
주의점   ReadyForActivation() 수동 호출

🐛 ReadyForActivation()을 빼먹으면 콜백이 영원히 안 온다. 에러도 로그도 없다. Blueprint에서는 노드가 알아서 불러주기 때문에 C++로 옮길 때만 생기는 함정이다.

void UGA_Item_Reload::OnReloadComplete_Task()
{
    // 콜백에는 파라미터가 없다. InstancedPerActor라 인스턴스에서 꺼낸다
    const FGameplayAbilitySpecHandle Handle        = GetCurrentAbilitySpecHandle();
    const FGameplayAbilityActorInfo* ActorInfo     = GetCurrentActorInfo();
    const FGameplayAbilityActivationInfo ActivInfo = GetCurrentActivationInfo();

    if (ActorInfo->IsNetAuthority() && GE_ReloadAmmoClass)
        ApplyGameplayEffectToOwner(Handle, ActorInfo, ActivInfo,
            GE_ReloadAmmoClass.GetDefaultObject(), 1.f);

    EndAbility(Handle, ActorInfo, ActivInfo, true, false);
}

취소될 때 태그를 정리하는 것도 직접 해야 한다.

void UGA_Item_Reload::EndAbility(..., bool bReplicateEndAbility, bool bWasCancelled)
{
    if (ReloadingEffectHandle.IsValid())
    {
        ActorInfo->AbilitySystemComponent->RemoveActiveGameplayEffect(ReloadingEffectHandle);
        ReloadingEffectHandle.Invalidate();
    }
    Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
}

빠뜨리면 재장전 중 죽었을 때 GA는 취소되는데 GE_Reloading은 Duration이 다 될 때까지 남는다. 리스폰 후에도 발사가 막힌다.

이 코드에는 authority 가드가 없다. EndAbility는 클라 예측 인스턴스에서도 실행되는데, 거기서 RemoveActiveGameplayEffect를 부르면 경고가 뜬다. 4-5편에서 이 문제를 제대로 겪고 나서 가드를 넣는다. 여기서는 아직 모르고 있었다.


10. 어빌리티 부여, 판단을 한 번 뒤집었다

처음엔 발사 하나뿐이라 단일 핸들로 만들었다.

// 최초 구현
UPROPERTY(EditDefaultsOnly, Category = "GAS")
TSubclassOf<UEPGA_Item_PrimaryUse> PrimaryUseAbilityClass;

FGameplayAbilitySpecHandle GrantedPrimaryUseHandle;

재장전이 붙자마자 문제가 보였다. 같은 패턴을 반복하면 어빌리티가 늘 때마다 멤버가 늘어난다.

// EPWeaponDefinition.h. 배열로 통합
UPROPERTY(EditDefaultsOnly, Category = "GAS")
TArray<TSubclassOf<UGameplayAbility>> WeaponAbilities;

// EPCombatComponent.h
TArray<FGameplayAbilitySpecHandle> GrantedWeaponAbilityHandles;
void UEPCombatComponent::EquipWeapon(AEPWeapon* NewWeapon)
{
    // ... 기존 장착 로직 ...
    if (GetOwner()->HasAuthority() && Owner && NewWeapon->WeaponDef)
        if (UAbilitySystemComponent* ASC = Owner->GetAbilitySystemComponent())
        {
            for (const FGameplayAbilitySpecHandle& Handle : GrantedWeaponAbilityHandles)
                if (Handle.IsValid()) ASC->ClearAbility(Handle);
            GrantedWeaponAbilityHandles.Reset();

            for (const TSubclassOf<UGameplayAbility>& AbilityClass : NewWeapon->WeaponDef->WeaponAbilities)
                if (AbilityClass)
                    GrantedWeaponAbilityHandles.Add(ASC->GiveAbility(FGameplayAbilitySpec(AbilityClass, 1)));
        }
}

Lyra의 UAbilitySet + FAbilitySet_GrantedHandles와 같은 발상이다.

핸들 관리를 빼먹으면 이렇게 된다.

  • ClearAbility 누락 → 무기를 버려도 계속 발사된다
  • 재장착 전 제거 누락 → 어빌리티가 중복 부여돼 한 발에 두 번 판정한다

정직하게 적으면, 어빌리티가 둘뿐일 때는 핸들을 하나 더 추가하는 것으로도 충분하다. 셋 이상이 될 게 확실해질 때 리팩터링하는 게 맞다. 이 프로젝트는 스킬 세 개가 예정돼 있어서 미리 옮겼다.


겪은 문제

죽었는데 재장전이 완료된다

재장전 도중 죽으면 GA_Item_Reload가 계속 살아 있었다. 3초 뒤 시체의 탄약이 채워졌다.

  • 원인: GA_Death가 활성화돼도 다른 GA를 자동으로 끄지 않는다. State.Dead새 활성화만 막는다. 이미 돌고 있는 어빌리티에는 영향이 없다
  • 교훈: ActivationBlockedTags진입 차단이지 중단이 아니다. 진행 중인 어빌리티를 끊으려면 CancelAbilities가 따로 필요하다

말로 하면 당연한데, 태그 하나로 다 되는 것처럼 느껴져서 놓쳤다.

PIE 시작 직후 활성화 실패

게임 시작하자마자 재장전을 누르면 활성화가 실패했다. 몇 초 뒤에는 정상이었다.

  • 원인: 클라 ASC 초기화(OnRep_PlayerStateInitAbilityActorInfo)가 아직 안 끝난 상태에서 입력이 들어왔다
  • 4-1편 함정 표에 적어둔 그 에러(Can't activate LocalOnly or LocalPredicted ability)가 실전에서 나온 경우다
  • 교훈: ASC 초기화는 비결정적 시점에 끝난다

이 사실은 마지막 편 HUD 초기화 타이밍에서 또 문제가 된다.


함정 정리

상황 원인 해결
TryActivateAbilitiesByTag가 GA를 못 찾음 생성자에서 Asset 태그 누락 GetAssetTagsAddTagSetAssetTags
튜토리얼의 AbilityTags.AddTag가 컴파일 안 됨 UE 5.5에서 deprecated SetAssetTags(). 생성자에서만
쿨타임이 무시됨 GetCooldownTags() 미오버라이드 CooldownTags와 GE GrantedTags 일치 확인
이관 후에도 FireRate가 이상함 CanFire()LastFireTime이 GAS와 이중 시간 체크 제거
서버가 거부해도 총구 화염이 남음 차단 조건을 ActivateAbility에 둠 CanActivateAbility로 이전
탄약 0인데 계속 발사됨 CheckCost는 차단하지 않음 CanFire()Ammo <= 0 명시
무기 교체 후에도 발사됨 ClearAbility 누락 UnequipWeapon에서 핸들 제거
한 발에 두 번 판정 재장착 시 중복 부여 부여 전 기존 핸들 확인
타 플레이어 총구 화염이 안 보임 시뮬 프록시에서 GA 미실행 Multicast_PlayMuzzleEffect 유지
WaitDelay 콜백이 안 옴 ReadyForActivation() 미호출 C++에서는 수동 호출
취소했는데 State.Reloading이 남음 EndAbility에서 GE 미제거 RemoveActiveGameplayEffect
재장전이 두 번 발동 ActivationBlockedTagsState.Reloading 누락 자기 자신도 막아야 한다
GE 클래스가 항상 null C++ GA를 직접 할당 Blueprint 서브클래스 (4-2편)

결과

PIE 2인 기준:

  • 발사 → showdebug abilitysystemCooldown.Weapon.PrimaryUse가 FireRate만큼 유지
  • 마우스 연타 → 쿨타임 중에는 활성화 자체가 안 된다. 이펙트도 안 난다
  • 핑 100ms 에뮬에서 발사 → 총구 화염은 즉시, 판정은 3단계 SSR 기준으로 정확
  • 재장전 → 상대 화면의 showdebug abilitysystem에서도 State.Reloading이 보인다
  • 재장전 중 발사, 재장전 중 재장전 → 전부 차단
  • 재장전 중 사망 → 리스폰 후 정상 발사. 태그 잔류 없음

네 번째 줄이 이 편의 핵심 검증이다. 3단계에서 못 하던 것이다.

한계:

  • Ability Batching 미적용. 히트스캔 GA는 활성화부터 종료까지가 한 프레임인데, 지금은 활성화·타겟데이터·종료가 각각 RPC로 나간다. GASDocumentation은 배칭으로 1회로 합치길 권한다. 발사 빈도가 높은 게임이라 적용 가치가 있는데 보류했다
  • 재장전 애니메이션이 없다. WaitDelay 시간만 흐른다. PlayMontageAndWait로 바꾸면 몽타주 종료와 완료 처리를 묶을 수 있다
  • 예비 탄약 개념이 없다. 지금은 무한 탄창이다
  • 발사로 재장전을 캔슬하는 것도 미구현이다

다음 편

무기 판정을 마무리한다. 탄이 어디에 박히는지를 데이터로 만들고, 그걸 눈으로 보려고 탄흔을 붙였다. 그 탄흔이 버그 세 개를 한꺼번에 드러냈다.

그리고 부위별 대미지를 태그로 옮기면서, 3단계 내내 작동한 적이 없던 코드를 발견한다.


참고

  • DOCS/Notes/04/04_GAS_03_PrimaryUse.md, 04_GAS_04_Reload.md. 구현 전체
  • DOCS/Notes/04/04_GAS_04_Reload_STATUS.md. 실제 발생 버그 기록
  • 엔진 AbilitySystemComponent_Abilities.cpp. ClientActivateAbilityFailed
  • 엔진 GameplayAbility.h. AbilityTags deprecation

DevLog 카테고리 내 다른 글 보러가기

댓글남기기