[UE5] 추출 슈터 4-5. 스킬 시스템과 예측 레이스 버그

게시:     수정

카테고리:

태그: , ,

📌 EmploymentProj 4단계 GAS 이관 다섯 번째 글입니다. 👾 깃허브 · 📚 시리즈 목차 · ← 4-4. 탄이 어디에 박히는가


만들 것

스킬 효과 쿨타임 지속 형태
Dash 이동 방향으로 즉시 대시 10초 즉발. 지속 상태 없음
Heal 3초 채널링 후 HP 30 회복. 채널링 중 이동속도 20%, 피격 시 취소 20초 (성공 시만) 채널링. 효과 발동 유지
Shield 5초간 피해 50% 감소 30초 지속 버프. 효과 발동 유지

셋이 전부 다른 형태라는 게 좋았다. 하나만 만들었으면 안 보였을 공통점이 셋을 만들면서 드러났다.


1. N² 문제

처음에는 스킬 상호 잠금을 이렇게 하려고 했다.

// 최초 설계. 폐기
// GA_Skill_Dash 생성자
ActivationBlockedTags.AddTag(TAG_State_Healing);   // 힐 중에는 대시 금지

// GA_Skill_ShieldOn 생성자
ActivationBlockedTags.AddTag(TAG_State_Healing);   // 힐 중에는 실드 금지

스킬을 하나 추가할 때마다 기존 스킬 파일을 전부 열어서 새 태그를 추가해야 한다. 스킬이 5개면 각 파일이 4개 태그를 알아야 한다.

해법은 간단했다. 스킬끼리 서로를 알게 하지 말고, 전부 같은 태그 하나를 보게 한다.

[Before]                          [After]
Dash  ← blocks ← Healing          Dash    ─┐
Dash  ← blocks ← Shielding        Heal    ─┼→ blocked by State.Casting
Heal  ← blocks ← Dashing          Shield  ─┘
Heal  ← blocks ← Shielding             ↑
Shield← blocks ← ...              시전 중인 스킬이 자기 GE로 이 태그를 건다
(스킬 N개면 N(N-1)개 관계)        (스킬이 몇 개든 관계 1개)
// UEPGA_Skill_Base 생성자. 이 두 줄이 모든 스킬에 상속된다
ActivationBlockedTags.AddTag(EmpGameplayTags::TAG_State_Casting);
ActivationBlockedTags.AddTag(EmpGameplayTags::TAG_State_Dead);

새 스킬을 추가할 때 기존 파일을 한 글자도 안 고쳐도 된다. 베이스를 상속하기만 하면 자동으로 잠금 체계에 들어온다.

부수 효과가 하나 있다. 자기 자신 재시전도 자동으로 막힌다. 힐 시전 중에 힐을 또 누르면 자기가 건 State.Casting이 자기를 막는다. 별도 코드가 필요 없다.

이 단계에서 스태미나도 같이 정리했다. 달리기와 점프에 자원 제한을 두지 않기로 하면서 Stamina/MaxStamina Attribute, GE_SprintDrain, GE_StaminaRegen, State.Dashing 태그가 전부 죽은 코드가 됐다. 아무도 부여하지 않는 태그가 코드에 남아 있는 상태였다. 4-4편의 그 패턴이 또 나온 셈이다.


2. 베이스 클래스

모든 스킬이 공유하는 건 셋뿐이다. 시전 시간, 피격 취소 여부, 시전 중 부여할 GE.

UCLASS(Abstract)
class EMPLOYMENTPROJ_API UEPGA_Skill_Base : public UGameplayAbility
{
public:
    // final. 서브클래스가 공용 시전 흐름을 깨뜨리지 못하게 막는다
    virtual void ActivateAbility(...) override final;
    virtual void EndAbility(...) override;

protected:
    UPROPERTY(EditDefaultsOnly, Category = "Cast")
    float CastTime = 0.f;                     // 0이면 즉시시전

    UPROPERTY(EditDefaultsOnly, Category = "Cast")
    bool bInterruptibleOnDamage = false;      // Heal만 true

    UPROPERTY(EditDefaultsOnly, Category = "Cast")
    TSubclassOf<UGameplayEffect> GE_CastingClass;

    // 서브클래스가 채우는 훅
    virtual void OnCastComplete() PURE_VIRTUAL(UEPGA_Skill_Base::OnCastComplete, );
    virtual void OnCastInterrupted() {}
    virtual void ConfigureCastingSpec(FGameplayEffectSpecHandle& SpecHandle) {}

private:
    UFUNCTION() void OnCastTimerComplete();
    UFUNCTION() void OnDamageDuringCast(FGameplayEventData Payload);
    FActiveGameplayEffectHandle CastingEffectHandle;
};

override final이 설계 의도를 코드로 못 박는다. 서브클래스가 ActivateAbility를 다시 구현하면 상호잠금과 취소 처리가 통째로 빠진다. 주석으로 “건드리지 마세요”라고 쓰는 대신 컴파일러가 막게 했다.

void UEPGA_Skill_Base::ActivateAbility(...)
{
    if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
    {
        EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
        return;
    }

    // 즉시시전. 캐스팅 GE와 태그를 아예 적용하지 않는다 (Dash, Shield)
    if (CastTime <= 0.f)
    {
        OnCastComplete();
        EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
        return;
    }

    // 채널링. 시전 GE 부여. 여기에 State.Casting이 들어 있다
    if (GE_CastingClass)
    {
        FGameplayEffectSpecHandle Spec = MakeOutgoingGameplayEffectSpec(GE_CastingClass);
        Spec.Data->SetSetByCallerMagnitude(EmpGameplayTags::TAG_Data_Duration, CastTime);
        ConfigureCastingSpec(Spec);          // 서브클래스가 추가 SetByCaller를 넣는 자리
        CastingEffectHandle = ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, Spec);
    }

    UAbilityTask_WaitDelay* WaitDelay = UAbilityTask_WaitDelay::WaitDelay(this, CastTime);
    WaitDelay->OnFinish.AddDynamic(this, &UEPGA_Skill_Base::OnCastTimerComplete);
    WaitDelay->ReadyForActivation();

    // 피격 취소가 필요한 스킬만 이벤트 태스크를 추가로 건다
    if (bInterruptibleOnDamage)
    {
        UAbilityTask_WaitGameplayEvent* WaitDamage =
            UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(
                this, EmpGameplayTags::TAG_Event_Damaged, nullptr, false, true);
        WaitDamage->EventReceived.AddDynamic(this, &UEPGA_Skill_Base::OnDamageDuringCast);
        WaitDamage->ReadyForActivation();
    }
}

두 태스크가 경쟁하는 구조다. 먼저 발화하는 쪽이 이긴다.

WaitDelay(3초) ────────────────→ OnCastComplete()   → EndAbility(bWasCancelled=false)
WaitGameplayEvent(Event.Damaged) → OnCastInterrupted() → EndAbility(bWasCancelled=true)
void UEPGA_Skill_Base::OnDamageDuringCast(FGameplayEventData Payload)
{
    // OnCastComplete()를 일부러 호출하지 않는다. "완료되지 않은 채 끝남"이 핵심
    OnCastInterrupted();
    EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, true, true);
}

쿨타임이 OnCastComplete에만 들어 있으므로 피격 취소 시 쿨타임이 안 걸린다. 별도 분기 없이 구조로 해결된다.

서브클래스는 얼마나 짧아지나

UEPGA_Skill_Heal::UEPGA_Skill_Heal()
{
    CastTime = 3.f;
    bInterruptibleOnDamage = true;

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

    ActivationBlockedTags.AddTag(EmpGameplayTags::TAG_Cooldown_Skill_Heal);
    // State.Dead / State.Casting은 베이스가 이미 추가했다
}

void UEPGA_Skill_Heal::ConfigureCastingSpec(FGameplayEffectSpecHandle& SpecHandle)
{
    SpecHandle.Data->SetSetByCallerMagnitude(
        EmpGameplayTags::TAG_Data_MoveSpeedMultiplier, HealMoveSpeedMultiplier);
}

void UEPGA_Skill_Heal::OnCastComplete()
{
    if (GE_HealClass) { /* 힐 GE 적용 */ }
    if (GE_HealCooldownClass) { /* 쿨타임 GE 적용 */ }
}

“피격 시 취소”를 구현하는 코드가 Heal에 한 줄도 없다. bInterruptibleOnDamage = true 뿐이다. Dash와 Shield는 CastTime = 0이라 OnCastComplete()만 채우면 끝난다.

취소 이벤트는 어디서 오나

4-2편에서 만든 PostGameplayEffectExecute가 이벤트를 하나 더 발송한다.

[피격]
 → GE_Damage → IncomingDamage
 → PostGameplayEffectExecute
     ├ Health 감소
     ├ HandleGameplayEvent(Event.Death)   ← 4-2편
     └ HandleGameplayEvent(Event.Damaged) ← 여기서 추가
                    ↓
         WaitGameplayEvent 태스크가 수신
                    ↓
         OnCastInterrupted() → 힐 취소

데미지 코드는 “누가 이 이벤트를 듣는지” 모른다. 4-2편에서 “데미지 코드는 이벤트만 던진다”고 적었던 게 실제로 값을 하는 순간이다. 힐 취소를 붙이면서 데미지 쪽은 한 줄도 안 고쳤다.


3. 이동속도 감소, 곱하는 위치가 중요하다

힐 중 이동속도 20%를 Heal에 직접 넣지 않고 어트리뷰트로 만들었다.

UPROPERTY(BlueprintReadOnly, Category = "Attribute|Movement", ReplicatedUsing = OnRep_MoveSpeedMultiplier)
FGameplayAttributeData MoveSpeedMultiplier;
ATTRIBUTE_ACCESSORS(UEPAttributeSet, MoveSpeedMultiplier);

// PreAttributeChange. 0 이하로 떨어져 정지하거나 역주행하는 사고 방지
if (Attribute == GetMoveSpeedMultiplierAttribute())
    NewValue = FMath::Clamp(NewValue, 0.05f, 3.f);
float UEPCharacterMovement::GetMaxSpeed() const
{
    float Base = Super::GetMaxSpeed();
    if (bWantsToSprint && IsMovingOnGround()) Base = SprintSpeed;
    else if (bWantsToAim)                     Base = AimSpeed;
    return Base * MoveSpeedMultiplier;   // Base를 정한 "뒤에" 마지막에 곱한다
}

🐛 분기 이전에 곱하면 힐 중 스프린트가 감속을 무시한다. SprintSpeed가 Base를 덮어써버리기 때문이다. 실제로 겪은 실수다.

// EPCharacter::InitASC. IsLocallyControlled() 가드 "밖"에 둔다
// 서버 시뮬레이션과 소유 클라 예측이 둘 다 GetMaxSpeed()를 실행하므로 양쪽 다 필요하다
if (MoveSpeedMultiplierHandle.IsValid())
    ASC->GetGameplayAttributeValueChangeDelegate(UEPAttributeSet::GetMoveSpeedMultiplierAttribute())
       .Remove(MoveSpeedMultiplierHandle);

MoveSpeedMultiplierHandle =
    ASC->GetGameplayAttributeValueChangeDelegate(UEPAttributeSet::GetMoveSpeedMultiplierAttribute())
       .AddUObject(this, &AEPCharacter::OnMoveSpeedMultiplierChanged);

// 리스폰 등으로 이미 1.0이 아닐 수 있으니 초기값을 즉시 반영한다
if (UEPCharacterMovement* CMC = Cast<UEPCharacterMovement>(GetCharacterMovement()))
    CMC->MoveSpeedMultiplier = ASC->GetNumericAttribute(UEPAttributeSet::GetMoveSpeedMultiplierAttribute());

CMC의 MoveSpeedMultiplier는 따로 복제하지 않는다. 서버와 소유 클라가 각자 자기 ASC의 복제된 어트리뷰트로부터 채우니, 2단계에서 Sprint와 ADS를 CompressedFlags에 실었던 것과 달리 이건 실을 필요가 없다.

GE 쪽은 Modifier 한 줄이다.

항목
Duration Policy Has Duration, SetByCaller(Data.Duration)
GrantedTags State.Healing + State.Casting ← 이 한 줄이 곧 “다른 스킬 잠금” 스위치
Modifier MoveSpeedMultiplier, Multiply, SetByCaller(Data.MoveSpeedMultiplier)

Modifier Op이 Multiply여야 한다. Add로 하면 감속 효과가 두 개 겹칠 때 계산이 무너진다. Multiply면 자동으로 곱연산 누적된다. 0.2 × 0.5 = 0.1이다.

확장성은 “GE를 공유하는 것”이 아니라 “어트리뷰트 + Multiply 연산”에 있다. 나중에 다른 스킬이 자기 GE에 같은 Modifier 한 줄만 추가하면 그대로 재사용된다. 새 어트리뷰트도, CMC 코드 수정도 필요 없다.


겪은 문제: 시전을 다 채웠는데 힐이 자주 무시된다

이 시리즈에서 제일 오래 잡은 버그다.

증상이 이상했다.

  • 시전 게이지는 끝까지 찬다
  • State.CastingState.Healing 태그는 정상 동작한다. 다른 스킬이 제대로 잠긴다
  • 그런데 HP가 안 오른다
  • 그리고 쿨다운도 안 돈다. 바로 다시 쓸 수 있다
  • 확률적이다. 될 때도 있고 안 될 때도 있다

“쿨다운도 같이 안 도는 것”이 결정적 단서였다. HP만 안 올랐다면 GE_Heal 문제겠지만, 쿨다운까지 같이 빠졌다는 건 OnCastComplete() 전체가 서버에서 실행되지 않았다는 뜻이다.

원인

LocalPredicted에서는 클라와 서버가 각자 타이머를 돌린다.

편도 지연 L, 시전 시간 3초라고 하면

t=0        클라: 활성화 → WaitDelay(3) 시작
t=L        서버: 활성화 RPC 도착 → WaitDelay(3) 시작    ← L만큼 늦게 시작
t=3        클라: WaitDelay 완료 → EndAbility → ServerEndAbility RPC 발신
t=3+L      서버: RPC 도착
t=L+3      서버: 자기 WaitDelay 완료

           ★ 3+L 과 L+3 은 같은 시각이다

클라 타이머 완료 시점과 서버 타이머 완료 시점 사이의 지연이 정확히 상쇄된다. 그래서 두 이벤트가 같은 프레임에 도착하고, 프레임 안의 처리 순서로 승패가 갈린다.

[한 프레임 내부]
TickDispatch (프레임 초반)  네트워크 RPC 처리   ← ServerEndAbility가 여기
     ↓
World Tick                  타이머/태스크 발화  ← 서버 WaitDelay가 여기

RPC가 먼저 처리되니 높은 확률로 서버 GA가 강제 종료되고, 그 뒤에 발화하려던 서버의 WaitDelay는 이미 죽은 태스크가 된다. 결과적으로 OnCastComplete()가 통째로 스킵되고 힐과 쿨다운 GE가 전부 미적용된다.

“높은 확률로”이지 항상은 아니라서 확률적으로 보였던 것이다.

3단계에서 리와인드 오차가 “항상 정확히 한 틱”이었던 것도 프레임 내부 순서 문제였다. 그때는 TickDispatch가 월드 시간 전진보다 앞에 있어서였고, 이번엔 RPC 처리가 타이머 발화보다 앞에 있어서다. 같은 프레임 안에서도 순서가 있다는 걸 두 번 배웠다.

해결은 한 줄

// UEPGA_Skill_Base 생성자
bServerRespectsRemoteAbilityCancellation = false;

기본값 true는 “클라가 끝났다고 하면 서버도 끝낸다”는 뜻이다. 예측 어빌리티에서는 이게 거의 항상 틀린 선택이다. 서버는 자기 타이머로 자기 일을 끝내야 한다.

서버에서 클라로 가는 종료 복제는 이 플래그와 무관하게 항상 동작한다. 그래서 피격 인터럽트 경로는 영향받지 않는다. 서버가 취소하면 클라도 취소된다.

정리하면 이렇다.

  • LocalPredicted GA에서 클라 인스턴스와 서버 인스턴스는 별개의 객체다. 각자 타이머를 돌리고 각자 종료한다
  • 클라가 서버보다 먼저 끝나는 건 정상이다. 문제는 그 종료가 서버를 죽이도록 허용한 것이다
  • 증상이 확률적이면 레이스를 의심한다. 그리고 “무엇이 같이 실패했는가”가 범위를 좁혀준다

참고로 GA_DeathGA_Item_PrimaryUse에도 같은 플래그를 이미 넣어뒀었다. 이 버그를 겪고 소급 적용한 게 아니라, 권장을 따랐던 게 여기서 이유를 알게 된 경우다. 남이 하라는 대로 해놓고 왜인지는 모르고 있었던 셈이다.


겪은 문제 (계속)

RemoveActiveGameplayEffect called without Authority

힐을 쓸 때마다 클라 로그에 경고가 쌓였다.

  • 원인: EndAbility클라 예측 인스턴스에서도 실행된다. 거기서 GE를 지우려 하는데, GE 제거는 서버 권한에서만 허용된다
void UEPGA_Skill_Base::EndAbility(..., bool bReplicateEndAbility, bool bWasCancelled)
{
    if (ActorInfo->IsNetAuthority())    // 가드
    {
        if (UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo())
            if (CastingEffectHandle.IsValid())
            {
                ASC->RemoveActiveGameplayEffect(CastingEffectHandle);
                CastingEffectHandle.Invalidate();
            }
    }
    Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
}

4-3편의 GA_Item_Reload에도 같은 패턴이 authority 가드 없이 남아 있다. 같은 잠재 버그라 정직하게 적어둔다.

대시가 서버에서만 전방으로 안 간다

클라에서는 잘 가는데 서버 시뮬레이션은 제자리라 위치가 튀었다.

// Before: const FVector Dir = Char->GetLastMovementInputVector();   // 서버에서 Zero
// After : const FVector Dir = CMC->GetCurrentAcceleration();        // saved move로 복제된다

GetLastMovementInputVector()로컬 전용이다. 서버에서는 Zero를 반환한다.

예측 어빌리티에서 입력 정보를 쓸 때는 “이 값이 서버에도 있는가”를 먼저 확인해야 한다. 2단계에서 CMC saved move 경로를 만들어둔 게 여기서 또 답이 됐다.

지상 대시가 거의 안 나간다

수평으로 발사하면 즉시 착지해서 GroundFrictionBrakingDeceleration이 바로 감속시킨다. Z 부스트로 잠깐 체공시키고 bZOverride = true로 해결했다.

작지만 “물리적으로 맞는 구현”과 “게임에서 원하는 느낌”이 다른 전형적인 사례라 적어둔다.


함정 정리

상황 원인 해결
시전 완료했는데 효과가 자주 무시됨 클라 EndAbility RPC가 서버 타이머를 이기는 레이스 bServerRespectsRemoteAbilityCancellation = false
RemoveActiveGameplayEffect without Authority EndAbility가 클라 인스턴스에서도 실행 ActorInfo->IsNetAuthority() 가드
대시가 서버에서 안 감 GetLastMovementInputVector는 로컬 전용 CMC->GetCurrentAcceleration()
지상 대시가 즉시 감속 착지 후 마찰 Z 부스트 + bZOverride
힐 중 스프린트해도 안 느려짐 Sprint/Aim 분기 이전에 배율을 곱함 Base를 정한 뒤에 곱한다
감속 GE가 겹칠 때 계산이 이상함 Modifier Op이 Add Multiply로 설정
힐 중 힐을 또 눌러도 안 막힘 GE_Healing의 GrantedTags에 State.Casting 누락 이 태그가 실제로 부여돼야 자기 잠금이 동작한다
새 스킬에 잠금이 하나도 안 걸림 UGameplayAbility를 직접 상속 반드시 UEPGA_Skill_Base 상속
피격 취소인데 완료 처리가 됨 OnDamageDuringCast에서 OnCastComplete() 호출 베이스는 OnCastInterrupted()만 부른다
힐로 HP가 100을 넘음 PreAttributeChange의 Health 클램프 제거 4-2편의 클램프 유지

결과

PIE 2인 기준:

  • Dash / Heal / Shield 각각 활성화 → 쿨타임 GE가 양쪽 클라에서 확인됨
  • 힐 시전 중 Dash나 Shield 입력 → 전부 차단
  • 힐 시전 중 힐 재입력 → 차단 (자기 잠금)
  • 힐 시전 중 피격 → 취소되고 쿨타임이 걸리지 않음
  • 힐 시전 중 이동 → 20% 속도. 스프린트를 눌러도 감속 유지
  • 힐 시전 완료 → 10번 중 10번 HP 30 회복 + 쿨다운 시작
  • Shield 발동 → 5초간 피해 50% 감소, State.Shielded 복제
  • 핑 100ms 에뮬에서 위 전부 재확인

마지막 줄이 중요하다. 레이스 버그는 핑이 있어야 재현된다. 로컬 PIE에서 지연이 거의 0일 때는 잘 안 나온다. 그래서 처음에 놓쳤다.

알면서 남긴 것:

  • EPGA_Skill_Base.cpp:85. 피격 중단 경로인데 bWasCancelled = false로 넘긴다. 현재 동작에는 영향이 없지만 의미상 틀렸다
  • EPGA_Skill_ShieldOn.h:31. ShieldCooldown = 50.f인데 기획 스펙은 30초다. 밸런스 테스트 중 바꿔놓고 되돌리지 않았다
  • 시전 애니메이션이 없다. 4-3편의 재장전과 같은 한계다
  • GA_Death가 진행 중인 스킬을 취소하지 않는다. 4-3편에서 겪은 것과 같은 문제다

다음 편

여기까지 만든 Attribute, Tag, GE가 화면에 나타난다. 그리고 시리즈를 마무리하면서 무엇을 지웠고 무엇을 그대로 뒀는지 정리한다.


참고

  • DOCS/Notes/04/04_GAS_07_Skills.md. 구현 전체
  • DOCS/Notes/04/04_GAS_07_Skills_STATUS.md. 실제 버그와 남은 이슈
  • 엔진 AbilitySystemComponent.cpp. IsOwnerActorAuthoritative() 경고 발생 지점

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

댓글남기기