대시 무적 + 스태미나 시스템 개편 - 흩어진 자원 로직을 설정 한 곳으로
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;
FStaminaCostRow는 FTableRowBase 상속을 남겨뒀다 — 나중에 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 버그의 뿌리는 입력 태그 == 어빌리티 태그라는 가정. 데이터 값을 짐작하는 대신 그 값에 의존하지 않는 축(발동/자산 태그)으로 옮겨 해결.
'내일배움캠프_Unreal > TIL' 카테고리의 다른 글
| 내일배움캠프_Unreal/TIL - Day 153 (260710) (0) | 2026.07.10 |
|---|---|
| 내일배움캠프_Unreal/TIL - Day 153 (260709) (1) | 2026.07.09 |
| 내일배움캠프_Unreal/TIL - Day 152 (260708) (0) | 2026.07.08 |
| 내일배움캠프_Unreal/TIL - Day 151 (260707) (0) | 2026.07.08 |
| 내일배움캠프_Unreal/TIL - Day 150 (260706) (0) | 2026.07.06 |