개인프로젝트/AFO_Refactor

[AFO_Refactor #4] GAS 실전 적용 - 데미지/마나/슬로우 GameplayEffect 교체 + 버그 수정

Client Side 2026. 6. 19. 16:32

AFO_Refactor 개발일지 #4

GAS 실전 적용

AFGE_Damage · AFGE_ManaCost · AFGE_Slow · PostGameplayEffectExecute · 버그 수정

🎯 이번 작업 목표

#3에서 GAS 기반 셋업(ASC, AttributeSet, Interface)을 완료했다. 이번엔 실제 전투 코드를 GAS로 교체한다.

기존 교체 후
Health -= Damage 직접 차감 AFGE_Damage GameplayEffect
PS->ConsumeMana() 직접 차감 AFGE_ManaCost GameplayEffect
AFStatusEffectComponent 타이머 AFGE_Slow Duration GameplayEffect
HandleDeath() 직접 호출 PostGameplayEffectExecute 콜백

⚔️ Phase 2 Step 1 — AFGE_Damage

SetByCallerMagnitude 패턴

데미지 수치는 스킬마다 다르므로, GE 클래스에 고정값을 넣는 대신 런타임에 수치를 주입하는 SetByCaller 패턴을 사용했다.

// AFGE_Damage.cpp — Data.Damage 태그로 수치 수신 대기
UAFGE_Damage::UAFGE_Damage()
{
    DurationPolicy = EGameplayEffectDurationType::Instant;

    FSetByCallerFloat SetByCaller;
    SetByCaller.DataTag = FGameplayTag::RequestGameplayTag("Data.Damage");
    ModInfo.ModifierMagnitude = FGameplayEffectModifierMagnitude(SetByCaller);
    Modifiers.Add(ModInfo);
}

// AFAttributeComponent.cpp — 호출 시점에 실제 수치 주입
SpecHandle.Data->SetSetByCallerMagnitude(
    FGameplayTag::RequestGameplayTag("Data.Damage"),
    -FinalDamage  // 음수 = HP 감소
);
ASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());

💡 트러블슈팅: GE 생성자에서 FGameplayEffectModifierMagnitude의 필드에 직접 대입하는 방식이 UE5.5에서 protected라 빌드 실패. FSetByCallerFloat를 생성자로 전달하는 방식으로 수정했다.

🔔 PostGameplayEffectExecute — 사망 처리 연결

GE가 AttributeSet에 적용된 직후 호출되는 콜백이다. HP 클램핑, UI 동기화, 사망 처리를 여기서 담당한다.

void UAFAttributeSet::PostGameplayEffectExecute(
    const FGameplayEffectModCallbackData& Data)
{
    Super::PostGameplayEffectExecute(Data);

    if (Data.EvaluatedData.Attribute == GetHealthAttribute())
    {
        // 1. HP 클램핑
        SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth()));

        // 2. PlayerState UI 동기화
        AAFPlayerState* PS = Cast<AAFPlayerState>(GetOwningActor());
        if (PS) PS->SetHealth(GetHealth(), GetMaxHealth());

        // 3. 사망 처리 — IsDead() 가드로 중복 호출 방지
        if (GetHealth() <= 0.f && PS && !PS->IsDead())
        {
            Character->StartDeath(InstigatorController);
        }
    }
}

트러블슈팅 — HandlePlayerDeath 조기 return 버그

문제 코드

// AFAttributeSet.cpp
if (GetHealth() <= 0.f && PS && !PS->IsDead())
{
    PS->SetDead(true);   // ← 여기서 먼저 true로 설정
    Character->StartDeath(InstigatorController);
    // → HandlePlayerDeath 진입 시 IsDead() == true
    // → if (VictimPS->IsDead()) return;  ← 즉시 return!
    // → 킬 카운트, 리스폰, 킬 로그 전부 실행 안 됨
}

수정 — SetDead(true)를 HandlePlayerDeath에게 맡김

// PostGameplayEffectExecute에서 SetDead(true) 제거
if (GetHealth() <= 0.f && PS && !PS->IsDead())
{
    // SetDead(true) 제거 — HandlePlayerDeath가 처리
    Character->StartDeath(InstigatorController);
}

🔮 Phase 2 Step 3 — AFGE_ManaCost

기존 PS->ConsumeMana()를 GE 기반으로 교체했다. TryConsumeMana() 헬퍼 함수를 추가해서 마나 잔량 체크 → GE 적용을 하나의 함수로 묶었다.

BEFORE

if (!PS->ConsumeMana(SkillEManaCost)) return;

AFTER

bool AAFPlayerCharacter::TryConsumeMana(float Amount)
{
    UAbilitySystemComponent* ASC = GetAbilitySystemComponent();
    if (!ASC) return false;

    const UAFAttributeSet* AS = ASC->GetSet<UAFAttributeSet>();
    if (!AS || AS->GetMana() < Amount) return false;  // 잔량 체크

    FGameplayEffectSpecHandle Spec = ASC->MakeOutgoingSpec(
        UAFGE_ManaCost::StaticClass(), 1.f, Context);
    Spec.Data->SetSetByCallerMagnitude(
        FGameplayTag::RequestGameplayTag("Data.ManaCost"), -Amount);
    ASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
    return true;
}

// 사용처
if (!TryConsumeMana(SkillEManaCost)) return;

🐌 Phase 2 Step 4 — AFGE_Slow

기존 타이머 기반 슬로우를 GE Duration으로 교체했다. Duration을 SetByCaller로 런타임에 전달한다.

UAFGE_Slow::UAFGE_Slow()
{
    DurationPolicy = EGameplayEffectDurationType::HasDuration;

    FSetByCallerFloat DurationCaller;
    DurationCaller.DataTag = FGameplayTag::RequestGameplayTag("Data.SlowDuration");
    DurationMagnitude = FGameplayEffectModifierMagnitude(DurationCaller);

    // Effect.Slowed 태그 부착 — 중복 슬로우 방지
    // UE5.3+ 방식: UTargetTagsGameplayEffectComponent 사용
    UTargetTagsGameplayEffectComponent* TagComponent =
        CreateDefaultSubobject<UTargetTagsGameplayEffectComponent>(TEXT("TargetTagsComponent"));
    GEComponents.Add(TagComponent);
}

💡 트러블슈팅: GE 생성자에서 FindOrAddComponent() 호출 시 크래시 발생. UE 엔진 소스 확인 결과 생성자(IsInConstructor) 내에서 AddComponent를 호출하면 Fatal 조건에 걸린다. CreateDefaultSubobject + GEComponents.Add() 방식으로 수정했다.

🔧 MaxHealth/MaxMana 불일치 문제 해결

GAS 도입 후 발견된 추가 문제였다. UAFAttributeSet 생성자에 고정값(300/1000)이 있는데, 캐릭터별 스탯 DataTable 값이 반영되지 않았다.

// 해결: PS->SetHealth() 호출 시 AttributeSet도 함께 동기화
void AAFPlayerState::SetHealth(float NewHealth, float NewMaxHealth)
{
    // 레거시 변수 업데이트
    CurrentHealth = FMath::Clamp(NewHealth, 0.f, NewMaxHealth);
    MaxHealth = NewMaxHealth;

    // AttributeSet도 함께 동기화
    if (AbilitySystemComponent)
    {
        if (UAFAttributeSet* AS = const_cast<UAFAttributeSet*>(
            AbilitySystemComponent->GetSet<UAFAttributeSet>()))
        {
            AS->InitializeHealthValues(NewMaxHealth);
            AS->SetHealth(FMath::Clamp(NewHealth, 0.f, NewMaxHealth));
        }
    }
}

🐛 추가 버그 수정

AnimInstance 바인딩 타이밍 누락

호스트(클라이언트1) 폰은 월드 시작 직후 스폰되어 BeginPlay() 시점에 GetAnimInstance()가 null일 수 있다. 이 경우 OnMontageEnded 바인딩이 영구 누락되어 스킬 사용 후 이동이 멈추는 버그가 발생했다.

// 수정: AnimInstance null이면 OnAnimInitialized 폴백 바인딩
if (UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance())
{
    AnimInstance->OnMontageEnded.AddDynamic(
        this, &AAFPlayerCharacter::OnAttackMontageEnded);
}
else
{
    GetMesh()->OnAnimInitialized.AddDynamic(
        this, &AAFPlayerCharacter::OnAnimInstanceInitialized);
}

bIsUsingSkill 플래그 오남용

여러 캐릭터 클래스에서 bIsUsingSkill = true를 설정하고 복구 경로가 없어 이동이 영구 잠금되는 버그 3개를 수정했다.

버그 원인 수정
다크나이트 강공격 후 멈춤 강공격 판정에서 bIsUsingSkill = true 설정 해당 라인 제거
늑대인간 Q 후 멈춤 즉발 버프인데 bIsUsingSkill = true, 복구 경로 없음 플래그 제거 + 중복 발동 가드 추가
강공격 종료 시 공통 bIsUsingSkill 체크로 UnlockMovement 건너뜀 강공격 종료 시 bIsUsingSkill = false 강제 리셋

✅ PIE 테스트 결과

✅ HP 감소 → 체력바 갱신 정상

✅ HP 0 → 사망 몽타주 → 리스폰 정상

✅ 킬 스코어 반영 정상

✅ 마나 소모 및 부족 시 스킬 차단 정상

✅ 슬로우 적용/해제 정상

✅ 스킬 사용 후 이동 복원 정상

📌 이번 작업에서 배운 것

상태 플래그 설정 순서가 핵심이다.

HandlePlayerDeathIsDead() 중복 방지 가드가 있는데, GAS 콜백에서 SetDead(true)를 먼저 호출하면 가드에 걸려서 킬 카운트/리스폰/킬 로그가 전부 실행되지 않는다. 어떤 함수가 상태를 변경하는지, 그 순서가 다른 로직에 영향을 주는지 항상 추적해야 한다.

호스트와 클라이언트는 초기화 타이밍이 다르다.

리슨 서버(호스트) 폰은 월드 시작 직후 스폰되어 AnimInstance가 아직 초기화되지 않은 상태일 수 있다. 클라이언트 폰은 늦게 스폰되어 이 문제를 피한다. "클라이언트1에서만 발생하는 버그"의 원인은 대부분 이 타이밍 차이에서 온다.

📌 다음 포스팅

[AFO_Refactor #5] 최적화 — 드로우콜 측정 + Before/After

stat scenerendering으로 드로우콜 측정, 머티리얼 인스턴싱 적용, Tick 제거 Before/After 수치 기록.