내일배움캠프_Unreal/TIL

내일배움캠프_Unreal/TIL - Day 154 (260713)

하임 2026. 7. 13. 21:13

대시 무적 + 스태미나 시스템 개편 - 흩어진 자원 로직을 설정 한 곳으로


1. 대시 무적 — 새 시스템 대신 패리 윈도우 패턴을 빌려왔다

회피에 무적을 넣는 방식은 여러 가지였는데, 이 프로젝트엔 이미 패리 윈도우가 같은 골격을 쓰고 있었다: AnimNotifyState가 몽타주 구간에서 활성 어빌리티의 hook을 부르고, 그 어빌리티가 GE로 상태 태그를 여닫는 구조. 무적도 결국 "몽타주의 특정 프레임 구간 동안만 상태 태그 부여"라 이 패턴을 그대로 빌려왔다.

그래서 베이스 어빌리티에 패리 훅과 동형으로 무적 훅을 두고, GA_Dash가 override해 무적 GE를 여닫게 했다. 신규 AnimNotifyState_IFrameWindow는 특정 어빌리티를 몰라도 되게 활성 어빌리티에 hook만 라우팅한다.

무적 판정 자체는 데미지 파이프라인의 단일 진입점(HandleIncomingDamage_Defense)에 한 조건만 추가하면 끝이었다. 환경 데미지 반환 직후에 두니, 낙하·화상(DoT)은 예외로 남기고 나머지 전투 피해만 무시하게 됐다.

// 환경(낙하 등)은 이미 위에서 반환됨
// 무적 중엔 전투 피해 전부 무시(데미지 0 → BroadcastHitEvent 미호출로 넉백도 자동 스킵). DoT(화상)는 예외.
if (TargetASC->HasMatchingGameplayTag(RetrieveGameplayTags::State_Player_Invincible)
    && !SpecTags.HasTag(RetrieveGameplayTags::GameplayEvent_Attack_HitSuccess_Burn))
{
    return 0.f;
}

데미지를 0으로 만들면 넉백까지 공짜로 꺼지는 게 좋았다 — 넉백은 FinalDamage > 0 경로에서만 걸리기 때문. 대신 EndAbility에서 무조건 무적 GE를 닫도록 해뒀다. i-frame 도중 다른 액션에 캔슬되면 무적이 영구히 남을 수 있어서다.

void UGA_Dash::EndAbility(...)
{
    // 어떤 종료 경로에서도 무적 GE 제거(i-frame 도중 캔슬 시 무적 영구 잔류 방지).
    CloseNotifyIFrameWindow();
    ...
}

교훈: "결과가 같은 견고한 패턴이 이미 있으면 새로 짜지 말고 빌려온다." 무적은 결국 패리 윈도우와 같은 모양이었고, 판정도 방어 로직이 모이는 한 곳에 한 줄이면 됐다.


2. 스태미나 비용 GE를 다 지웠다 — 네이티브 GE 하나 + 설정 맵

원래 스태미나 소모는 어빌리티마다 GE_Cost_Stamina_100, GE_Cost_Stamina_50, GE_StaminaDrain_Guard, GE_StaminaRestore_Parry … 이런 GE 에셋이 흩어져 있었다. 값 하나 바꾸려면 에셋을 일일이 열어야 해서, 밸런싱을 몇 번 하다 보니 이건 못 버티겠다 싶었다.

그래서 소모 로직을 베이스 어빌리티로 끌어올렸다. 각 어빌리티는 자기 StaminaCostTag 하나만 들고, 베이스가 CheckCost/ApplyCost를 override해 설정 맵에서 비용을 조회한다. 태그가 비면 그냥 무료다.

void URetrieveGameplayAbility::ApplyCost(...) const
{
    Super::ApplyCost(Handle, ActorInfo, ActivationInfo);
    FStaminaCostRow Row;
    if (GetStaminaCostRow(Row) && Row.ActivationCost > 0.f)
        ApplyStaminaDelta(Handle, ActorInfo, ActivationInfo, -Row.ActivationCost);
}

실제 스태미나 증감에 쓰는 GE도 에셋으로 두지 않고 네이티브 클래스로 만들었다. Instant + SetByCaller 모디파이어 하나라, 생성자에서 다 짤 수 있었다. 이걸로 소모·회복·가드 드레인·자연 회복이 전부 이 GE 하나를 재사용하고, 에디터에서 GE 에셋을 만들 필요가 사라졌다.

URetrieveStaminaCostEffect::URetrieveStaminaCostEffect()
{
    DurationPolicy = EGameplayEffectDurationType::Instant;
    FSetByCallerFloat SetByCaller;
    SetByCaller.DataTag = RetrieveGameplayTags::Data_Cost_Stamina;   // 음수=소모, 양수=회복

    FGameplayModifierInfo ModInfo;
    ModInfo.Attribute = UCombatAttributeSet::GetStaminaAttribute();
    ModInfo.ModifierOp = EGameplayModOp::Additive;
    ModInfo.ModifierMagnitude = FGameplayEffectModifierMagnitude(SetByCaller);
    Modifiers.Add(ModInfo);
}

덤으로 죽은 코드도 걷어냈다. 회복이 코드 주도로 바뀌면서 StaminaRegenRate 어트리뷰트는 아무도 안 읽는 상태가 됐고(예전엔 회복 GE가 참조했을 수도 있는데 그마저 불투명했다), FCharacterStats의 스태미나 필드와 Data.Init.Stamina* 태그도 같이 제거했다. 스태미나는 적이 안 쓰는 플레이어 전용 개념이라 공용 캐릭터 스탯에 있던 게 애초에 어색했다.


3. DT로 갈까 하다가 결국 설정 내장 맵으로

처음엔 소모값을 DataTable(DT_StaminaCost)에 넣었다. 그런데 소모하는 액션이 4~5개뿐이라 DT의 장점(수백 행 스프레드시트, CSV 임포트)이 하나도 안 살고, 행 키를 태그 문자열("Ability.Player.Dash")로 잡다 보니 오타에 취약했다.

DataAsset(TMap)도 후보였지만, 결국 URetrieveStaminaSettings(DeveloperSettings)에 맵을 직접 넣는 쪽으로 갔다. 태그 피커가 붙어 오타가 사라지고, 만들 에셋이 없어지고, 튜닝 위치가 Project Settings 한 화면으로 모인다. 이 프로젝트의 WeaponSocketSettings가 이미 config TMap<FGameplayTag, FStruct> 패턴을 쓰고 있어서 직렬화도 검증된 길이었다.

UPROPERTY(Config, EditAnywhere, Category = "Stamina|Cost", meta = (Categories = "Ability", ForceInlineRow))
TMap<FGameplayTag, FStaminaCostRow> StaminaCosts;

FStaminaCostRowFTableRowBase 상속을 남겨뒀다 — 나중에 DT로 되돌릴 여지. 규모가 커지면 그때 옮기면 된다.


4. "전투 중에만" 판정을 스탠스 태그로 짰다가 두 번 데였다

전투 중일 때만 질주가 스태미나를 먹게 하고 싶었다. 마침 State.Player.Combat 태그가 있길래 그걸 조건으로 걸었다. 그런데 두 가지 증상이 나왔다.

  • 적이 없는데 제자리에서 공격키만 눌러도 직후 질주에 스태미나가 닳았다.
  • 몬스터에게 추격당하는 중인데도 잠깐만 닳고 이후엔 비전투처럼 계속 회복됐다.

원인을 따라가 보니 State.Player.Combat은 교전 상태가 아니라 발검 스탠스였다. CombatStanceComponent가 공격 입력에도 켜고(→ 제자리 공격 증상), 적 포착 시 1회만 켠 뒤 3.5초 디케이라 추격이 이어져도 꺼졌다(→ 추격 중 끊김). "진짜 교전"과는 다른 신호였던 것.

그런데 추격 BGM을 켜는 RetrieveMusicSubsystem이 이미 진짜 교전을 정확히 추적하고 있었다 — 나를 포착한 몬스터 집합을 들고, 0.5초 폴링으로 죽거나 추적을 포기한 놈을 걷어내며, 집합이 비면 전투 종료로 본다. 그래서 판정을 이 신호로 갈아끼웠다.

const URetrieveMusicSubsystem* MusicSubsystem = World->GetSubsystem<URetrieveMusicSubsystem>();
const bool bInCombat = MusicSubsystem && MusicSubsystem->IsCombatActive();

같은 김에 회복 규칙도 전투/탐험으로 갈랐다. 소모 후 회복 지연은 전투 중에만 걸고, 비전투는 지연 없이 더 빠른 속도로 회복하게 했다. 전투 중엔 자원 관리가 팽팽하고, 탐험 중엔 스태미나 신경 안 쓰고 돌아다니는 게 맞다고 봤다.

const float RegenDelay = bInCombat ? Settings->RegenDelaySeconds : 0.f;              // 지연은 전투 중에만
const float RegenRate  = bInCombat ? Settings->RegenPerSecond : Settings->OutOfCombatRegenPerSecond;

가드 지속 드레인도 똑같이 전투 중에만 닳게 맞췄다.

교훈: 이름이 Combat이라고 교전 상태인 건 아니었다. "이 태그가 정확히 언제 켜지고 꺼지는가"를 확인 안 하고 의미만 보고 쓴 게 실수였다. 필요한 신호(진짜 교전)는 엉뚱하게도 오디오 서브시스템이 이미 정확히 들고 있었다.


5. 상호배타 — Guard는 되는데 BowAim만 안 됐던 이유

달리기와 가드/조준을 동시에 못 하게, 나중에 누른 쪽이 이기도록 만들었다. 처음엔 입력 핸들러에서 처리했다: 달리기를 누르면 가드/조준을 취소하고, 가드/조준을 누르면 달리기를 끄고 — 이때 입력 태그를 Ability.Player.Guard / Ability.Player.BowAim으로 비교했다.

Guard는 잘 됐는데 BowAim만 Sprint와 완전히 무관하게 놀았다. 코드가 두 태그에 대해 완전히 대칭인데 하나만 안 되니, 차이는 태그 값밖에 없었다. 파고 보니 BowAim의 입력 바인딩 태그가 Ability.Player.BowAim이 아니었다(입력 태그는 데이터 에셋이라 코드엔 안 드러난다). 그래서 if (InputTag == Ability_Player_BowAim)이 영영 안 걸렸고, 홀드 입력 해제도 대상을 못 찾았다.

입력 태그를 알아내 맞추는 대신, 입력 태그 의존을 아예 없앴다. "가드/조준이 달리기를 취소" 방향을 각 어빌리티의 ActivateAbility로 옮긴 것. 실제로 발동됐다는 건 무기 조건(CanActivate)을 이미 통과했다는 뜻이라, 무기가 안 맞으면 달리기가 유지되는 것까지 공짜로 따라왔다.

// GA_Guard / GA_BowAim :: ActivateAbility (Commit 직후)
if (UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo())
    ASC->SetLooseGameplayTagCount(RetrieveGameplayTags::State_Player_Sprinting, 0);

반대로 "달리기가 가드/조준 취소" 방향은 자산 태그로 처리했다. CancelAbilities는 원래 자산 태그 기반이라 문제없었고, 홀드 입력 해제만 입력 태그 대신 자산 태그로 찾게 바꿨다. 이게 없으면 홀드형(WhileInputActive)이라 키를 계속 눌러둔 채 취소되면 다음 프레임에 바로 재발동돼 버린다(자동 재개).

void URetrieveAbilitySystemComponent::ClearInputHeldForAbilityWithTag(const FGameplayTag& AbilityAssetTag)
{
    for (const FGameplayAbilitySpec& Spec : GetActivatableAbilities())
        if (Spec.Ability && Spec.Ability->GetAssetTags().HasTag(AbilityAssetTag))
            InputHeldSpecHandles.Remove(Spec.Handle);   // WhileInputActive 재시도 목록에서 제거 → 자동 재개 차단
}

교훈: 입력 태그 == 어빌리티 태그라고 가정한 게 화근이었다. Guard가 우연히 그 가정을 만족해서 버그가 한동안 안 보였다. 데이터에 있는 값을 코드에서 짐작하지 말고, 아예 그 값에 의존하지 않는 축(여기선 어빌리티 발동/자산 태그)으로 옮기는 게 답이었다.


비교 정리

구분 ❌ 이전 / 처음 시도 ✅ 이번 구현
대시 무적 없음 패리 윈도우 패턴 재사용(ANS→hook→GE), 데미지 단일 진입점서 판정
스태미나 소모값 어빌리티별 GE_Cost 에셋 분산 설정 맵 1곳 + 네이티브 GE 하나(에디터 에셋 불필요)
튜닝 저장소 DataTable(문자열 키·오타 취약) DeveloperSettings 내장 맵(태그 피커)
전투 판정 State.Player.Combat(발검 스탠스, 오판) MusicSubsystem::IsCombatActive()(진짜 교전)
회복 항상 동일 전투=지연+느림 / 탐험=즉시+빠름
상호배타 입력 태그 비교(BowAim 먹통) 어빌리티 발동/자산 태그 기반(입력 태그 비의존)

핵심 요약

  • 무적은 패리 윈도우 패턴을 그대로 빌려 ANS→hook→GE로 배선하고, 판정은 방어 로직이 모이는 단일 진입점에 한 줄. 데미지 0이면 넉백도 자동으로 꺼진다.
  • 흩어진 소모 GE 에셋을 지우고 베이스 CheckCost/ApplyCost + 네이티브 GE + 설정 맵으로 통합. 밸런싱이 Project Settings 한 곳에서 끝난다.
  • 저장소는 규모(액션 4~5개)와 태그 키 특성 때문에 DT보다 DeveloperSettings 내장 맵이 나았다. FTableRowBase는 남겨 회귀 여지만 확보.
  • State.Player.Combat은 교전이 아니라 발검 스탠스였다 — 태그 이름만 보고 쓰면 안 되고, 필요한 "진짜 교전" 신호는 이미 오디오 서브시스템(IsCombatActive)에 있었다.
  • 상호배타 BowAim 버그의 뿌리는 입력 태그 == 어빌리티 태그라는 가정. 데이터 값을 짐작하는 대신 그 값에 의존하지 않는 축(발동/자산 태그)으로 옮겨 해결.