오늘의 코드카타
팀 프로젝트 진행 내용
1. 클라에서 스태미나 락아웃이 되지 않는 오류 수정
- 스프린트 동작 과정 중에서 스태미나가 0이 되면 스태미나 리젠이 되서 최대 스태미나의 25%에 도달하지 않으면 다시 스프린트를 실행 못하게 락아웃을 걸었다.
- 패키징 테스트를 진행했을 때, 락아웃되는 기능이 클라에서는 정상적으로 작동하지 않아서 해당 오류를 해결하고자 했다.
기존의 구조
- 기존에는 Sprint Ability에
bIsStaminaLockOut이라는 변수를 선언해서 해당 변수를 통해 락아웃을 걸고, 변수의 값이 true라면 락아웃 검사를 실행하는 방식으로 구현했었다.
- 하지만 이와 같은 방식의 문제점은 해당 변수가 Replicated되고 있지 않다는 점이다.
- 또한 변수를 통해서 관리하는 방식은 상당히 GAS스럽지 않다고 생각해서 해당 구조를 Tag 기반으로 변경하고자 했다.
// AO_GameplayAbility_Sprint.h
UCLASS()
class AO_API UAO_GameplayAbility_Sprint : public UGameplayAbility
{
GENERATED_BODY()
...
private:
bool bIsStaminaLockOut = false;
}
// AO_GameplayAbility_Sprint.cpp
void UAO_GameplayAbility_Sprint::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
...
const UAO_PlayerCharacter_AttributeSet* AttributeSet = Character->GetAbilitySystemComponent()->GetSet<UAO_PlayerCharacter_AttributeSet>();
const float CurrentStamina = AttributeSet->GetStamina();
const float MaxStamina = AttributeSet->GetMaxStamina();
if (bIsStaminaLockOut && CurrentStamina < MaxStamina * RequiredStaminaPercent)
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
if (bIsStaminaLockOut && CurrentStamina >= MaxStamina * RequiredStaminaPercent)
{
bIsStaminaLockOut = false;
}
...
Character->GetAbilitySystemComponent()->GetGameplayAttributeValueChangeDelegate(AttributeSet->GetStaminaAttribute())
.AddUObject(this, &UAO_GameplayAbility_Sprint::OnStaminaChanged);
}
void UAO_GameplayAbility_Sprint::OnStaminaChanged(const FOnAttributeChangeData& Data)
{
if (Data.NewValue <= 0.f)
{
bIsStaminaLockOut = true;
K2_EndAbility();
}
}
변경 방식
- 먼저 Sprint 어빌리티에서 락아웃 태그를 부여하는 방식보다는 Attribute Set에서 Stamina가 0에 도달하면 락아웃 태그를 부여하는 것이 자연스러울 것이라고 생각을 해서 Attribute Set에서 해당 부분을 추가했다.
- 그리고 기존의
OnStaminaChanged()에서 Ability를 종료시키는 로직은 그대로 유지를 했다.
// AO_PlayerCharacter_AttributeSet.cpp
void UAO_PlayerCharacter_AttributeSet::PostGameplayEffectExecute(const struct FGameplayEffectModCallbackData& Data)
{
...
else if (Data.EvaluatedData.Attribute == GetStaminaAttribute())
{
SetStamina(FMath::Clamp(GetStamina(), 0.f, GetMaxStamina()));
HandleStaminaLockout(Data);
}
}
void UAO_PlayerCharacter_AttributeSet::HandleStaminaLockout(const struct FGameplayEffectModCallbackData& Data)
{
UAbilitySystemComponent* ASC = GetOwningAbilitySystemComponentChecked();
AActor* OwningActor = GetOwningActor();
if (!OwningActor || !OwningActor->HasAuthority())
{
return;
}
const float NewStamina = GetStamina();
const float Threshold = GetMaxStamina() * StaminaLockoutPercent;
const FGameplayTag LockoutTag = FGameplayTag::RequestGameplayTag(FName("Status.Lockout.Stamina"));
if (NewStamina <= 0.f)
{
if (!ASC->HasMatchingGameplayTag(LockoutTag))
{
ASC->AddLooseGameplayTag(LockoutTag);
}
}
else if (ASC->HasMatchingGameplayTag(LockoutTag) && NewStamina >= Threshold)
{
ASC->RemoveLooseGameplayTag(LockoutTag);
}
}
- Sprint 어빌리티가 락아웃 태그가 부여되어있으면 블록되도록 추가해줬다.
UAO_GameplayAbility_Sprint::UAO_GameplayAbility_Sprint()
{
...
ActivationBlockedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Status.Lockout.Stamina")));
}
- 하지만 위와 같이 수정을 했음에도 클라에서 정상적으로 동작하지 않았다.
- 로그를 통해 확인해보니 클라에서는
OnStaminaChanged()에서 스태미나가 0으로 찍히는 경우가 한 번도 없었다.
- 그 이유를 보니 Sprint는 LocalPredicted, 즉 예측 구조를 사용하고 있다. 근데 나는 캐릭터에 스태미나가 리젠하는 Effect를 부여했는데, 클라와 서버의 시간 및 동기화 차이로 인해 클라에서는 바로 리젠이 되어서 0이 되는 경우가 없었던 것이다. 따라서 정상적으로 종료시키지 않는 것이었다.
- 따라서 Attribute Set에서 해당 어빌리티를 종료시키게끔 변경했다.
// AO_PlayerCharacter_AttributeSet.cpp
void UAO_PlayerCharacter_AttributeSet::HandleStaminaLockout(const struct FGameplayEffectModCallbackData& Data)
{
...
if (NewStamina <= 0.f)
{
if (!ASC->HasMatchingGameplayTag(LockoutTag))
{
ASC->AddLooseGameplayTag(LockoutTag);
FGameplayTagContainer SprintTag;
SprintTag.AddTag(FGameplayTag::RequestGameplayTag(FName("Ability.Movement.Sprint")));
ASC->CancelAbilities(&SprintTag);
}
}
}
- 또한 다음과 같이 작성을 해도 태그를 부여하고 인식하는 데 걸리는 시간으로 인해 클라에서
ActivationBlockedTags를 인식하지 못하는 경우가 있었다.
- 따라서 Sprint Ability에서 스태미나가 락아웃 퍼센트보다 낮다면 활성화되지 않도록 제한을 했다.
// AO_GameplayAbility_Sprint.cpp
bool UAO_GameplayAbility_Sprint::CanActivateAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags,
const FGameplayTagContainer* TargetTags, FGameplayTagContainer* OptionalRelevantTags) const
{
...
const UAO_PlayerCharacter_AttributeSet* AttributeSet = ActorInfo->AbilitySystemComponent->GetSetChecked<UAO_PlayerCharacter_AttributeSet>();
const float CurrentStamina = AttributeSet->GetStamina();
const float MaxStamina = AttributeSet->GetMaxStamina();
if (CurrentStamina < MaxStamina * AttributeSet->StaminaLockoutPercent)
{
return false;
}
return true;
}
2. 스프린트 약 0.5초동안 스태미나 리젠 막기
- 스프린트 직후에 바로 스태미나가 리젠되는 것이 어색하다는 피드백이 있어서 스프린트가 종료되고 0.5초 후에 리젠이 진행되도록 수정했다.
- 0.5초동안 리젠이 안되는 GameplayEffect를 받아서 Sprint Ability가 종료된 후에 해당 Effect를 실행시켜주는 방식으로 구현했다.
// AO_GameplayAbility_Sprint.h
UCLASS()
class AO_API UAO_GameplayAbility_Sprint : public UGameplayAbility
{
...
protected:
UPROPERTY(EditDefaultsOnly, Category = "Sprint|Stamina")
TSubclassOf<UGameplayEffect> PostSprintNoRegenEffectClass;
}
// AO_GameplayAbility_Sprint.cpp
void UAO_GameplayAbility_Sprint::EndAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
bool bReplicateEndAbility, bool bWasCancelled)
{
...
UAbilitySystemComponent* ASC = ActorInfo->AbilitySystemComponent.Get();
checkf(ASC, TEXT("Failed to get AbilitySystemComponent"));
checkf(PostSprintNoRegenEffectClass, TEXT("PostSprintNoRegenEffectClass is null"));
if (ActorInfo->IsNetAuthority())
{
const FGameplayEffectContextHandle Context = ASC->MakeEffectContext();
const FGameplayEffectSpecHandle SpecHandle = ASC->MakeOutgoingSpec(PostSprintNoRegenEffectClass, 1.f, Context);
if (SpecHandle.IsValid())
{
ASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
}
}
Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
}
- GameplayEffect의 경우에는 아래와 같이 0.5초동안
Status.Debuff.NoStaminaRegen 태그가 부여되도록 설정했다.

3. 파쿠르, 점프 시전 중에 스태미나 변경 막기
- 파쿠르, 점프 도중에 스프린트 키를 떼면 스태미나가 감소하지 않고, 리젠된다는 피드백이 있어서 해당 동작 중에서는 스태미나 감소, 리젠이 발생하지 않도록 변경하고자 했다.
Status.Debuff.NoStaminaChange라는 태그를 새로 생성해서 해당 태그를 파쿠르, 점프 동작에 붙이고자 했다. 이 태그를 가지고 있을 때는 스태미나 리젠과 감소가 적용되지 않게 하고자 했다.
- 파쿠르 Ability가 실행되는 중에
Status.Debuff.NoStaminaChange 태그를 가지도록 ActivationOwnedTags에 추가했다.
- 파쿠르 Ability도 종료된 직후 바로 리젠이 되는 것이 아니라, 0.5초 이후에 스태미나가 감소하도록 Effect를 부여했다.
// AO_GameplayAbility_Traversal.cpp
UAO_GameplayAbility_Traversal::UAO_GameplayAbility_Traversal()
{
...
ActivationOwnedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Status.Debuff.NoStaminaChange")));
}
void UAO_GameplayAbility_Traversal::EndAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
bool bReplicateEndAbility, bool bWasCancelled)
{
...
UAbilitySystemComponent* ASC = ActorInfo->AbilitySystemComponent.Get();
checkf(ASC, TEXT("Failed to get AbilitySystemComponent"));
checkf(PostSprintNoChangeEffectClass, TEXT("PostSprintNoChangeEffectClass is null"));
if (ActorInfo->IsNetAuthority())
{
const FGameplayEffectContextHandle Context = ASC->MakeEffectContext();
const FGameplayEffectSpecHandle SpecHandle = ASC->MakeOutgoingSpec(PostSprintNoChangeEffectClass, 1.f, Context);
if (SpecHandle.IsValid())
{
ASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
}
}
}
GE_PlayerCharacter_NoChangeStamina의 경우에는 아래와 같이 0.5초동안 태그를 부여하는 방식으로 구현했다.

- 스태미나가 리젠하는 Effect와 스프린트 시에 스태미나를 감소하는 Effect에서 TargetTags가
Status.Debuff.NoStaminaRegen를 가지고 있는 경우 동작하지 않도록 Must Not Have Tags 부분에 추가했다.


- 파쿠르 동작 시에는 정상적으로 스태미나가 감소하지 않도록 구현이 됐다.
- 현재 점프의 경우에는
Character 클래스의 Jump() 함수를 그대로 사용하고 있고, Jump Ability로 구현을 하지는 않았는데, GAS로 전환을 해서 관리하고자 했다.
- 파쿠르와 마찬가지로 점프 직후에 스태미나 리젠을 막기 위해 Effect 클래스를 받고 있고, Jump 이후에 착지가 되면
EndAbility를 호출하기 위해서 바인딩할 함수를 선언했다.
// AO_GameplayAbility_Jump.h
UCLASS()
class AO_API UAO_GameplayAbility_Jump : public UGameplayAbility
{
GENERATED_BODY()
public:
UAO_GameplayAbility_Jump();
protected:
virtual bool CanActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayTagContainer* SourceTags = nullptr,
const FGameplayTagContainer* TargetTags = nullptr,
FGameplayTagContainer* OptionalRelevantTags = nullptr) const override;
virtual void ActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData) override;
virtual void EndAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
bool bReplicateEndAbility,
bool bWasCancelled) override;
protected:
UPROPERTY(EditDefaultsOnly, Category = "Traversal|Stamina")
TSubclassOf<UGameplayEffect> PostSprintNoChangeEffectClass;
UFUNCTION()
void OnMovementModeChanged(EMovementMode NewMovementMode);
};
- 생성자에서 Ability의 정책을 설정했고, 해당 동작 과정 중에 Stamina 변화를 막기 위해서
Status.Debuff.NoStaminaChange 태그를 ActivationOwnedTags에 추가했다.
// AO_GameplayAbility_Jump.cpp
UAO_GameplayAbility_Jump::UAO_GameplayAbility_Jump()
{
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
const FGameplayTagContainer TraversalTag(FGameplayTag::RequestGameplayTag(FName("Ability.Movement.Jump")));
SetAssetTags(TraversalTag);
ActivationOwnedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Status.Action.Jump")));
ActivationOwnedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Status.Debuff.NoStaminaChange")));
}
- 점프 가능 여부를
CanActivateAbility에서 검사함으로써 공중에서 점프를 하거나, 점프가 불가능한 상태에서 해당 Ability가 활성화되는 것을 막았다.
// AO_GameplayAbility_Jump.cpp
bool UAO_GameplayAbility_Jump::CanActivateAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags,
const FGameplayTagContainer* TargetTags, FGameplayTagContainer* OptionalRelevantTags) const
{
if (!Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags))
{
return false;
}
if (IsActive())
{
return false;
}
ACharacter* Character = CastChecked<ACharacter>(ActorInfo->AvatarActor.Get());
if (!Character->CanJump())
{
return false;
}
return true;
}
ActivateAbility()에서는 Character 클래스의 Jump를 호출해주는 방식으로 간단하게 구현했다.
- 스태미나 변경이 점프 중에는 없길 바래서 착지를 하면
EndAbility() 호출해주기를 바랬다. Character 클래스의 Jump에는 별도로 점프가 종료된 시점을 알려주는 함수는 없었다. 대신 Landed() 함수가 있었지만, 해당 함수는 점프뿐만 아니라 착지 시점에 불리는 함수로 예측하지 못한 상황이 발생할 수 있을 것으로 예측했다.
UAbilityTask_WaitMovementModeChange라는 어빌리티 태스크를 활용해서 점프 이후에 착지하는 시점을 알고자 했다. 해당 어빌리티 태스크는 캐릭터의 MovementMode가 변경되기를 기다렸다가 호출해주는 어빌리티 태스크이다. Charcter 클래스의 Jump는 MovementMode를 Falling으로 변경하고 착지 시에 Walking으로 변경해주기 때문에 이를 바인딩해서 해당 시점에 EndAbility()를 호출해주는 방식으로 구현했다.
// AO_GameplayAbility_Jump.cpp
void UAO_GameplayAbility_Jump::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
ACharacter* Character = CastChecked<ACharacter>(ActorInfo->AvatarActor.Get());
Character->Jump();
UAbilityTask_WaitMovementModeChange* WaitTask
= UAbilityTask_WaitMovementModeChange::CreateWaitMovementModeChange(this, MOVE_Walking);
WaitTask->OnChange.AddDynamic(this, &UAO_GameplayAbility_Jump::OnMovementModeChanged);
WaitTask->ReadyForActivation();
}
void UAO_GameplayAbility_Jump::OnMovementModeChanged(EMovementMode NewMovementMode)
{
if (IsActive() && NewMovementMode == MOVE_Walking)
{
EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, true, false);
}
}
결과
