광역 공격 & Cost & Cooldown
GAS에 대한 마지막 포스팅이다.
지금까지 언급한 GAS 클래스들을 이용하여 광역 공격 스킬을 구현하고 해당 스킬을 사용하는데 필요한 코스트와 해당 스킬에 쿨다운을 걸 수 있는 방법을 구현할 것이다.
기본적인 흐름은 다음과 같다.
- 캐릭터가 무기를 획득하면 스킬을 사용할 수 있는 GA를 부여한다.
- 마우스 우클릭 입력을 추가하고 해당 입력이 들어오면 앞서 부여했던 스킬 GA를 실행한다.
- GA에서는 스킬 애니메이션을 실행할 AT를 생성한 뒤 실행 요청을 한다.
- Montage를 통해 애니메이션이 실행되면 Event와 태그를 통해 공격 판정 GA를 실행한다.
- 공격 판정 GA는 스킬에 적중한 액터를 판별할 AT를 생성한 뒤 실행 요청을 한다.
- 판정 AT에서는 TA를 만들어 공격 판정 데이터를 만든다.
- 데이터가 만들어지면 적중한 액터에게 VFX를 만들 수 있는 GC를 실행한다.
캐릭터 입력 & GA 부여 (1 ~ 2)
캐릭터에 스킬을 사용할 수 있는 입력을 추가하는 부분은 다음과 같이 구현할 수 있다.
//.h
class PROJECT_API AGASCharacter : public ACharacter, public IAbilitySystemInterface
{
...
protected:
void SetupGASInputComponent();
void GASInputPressed(int32 InputId);
...
void EquipWeapon(const FGameplayEventData* EventData);
void UnequipWeapon(const FGameplayEventData* EventData);
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UInputAction> SkillAction;
UPROPERTY(EditAnywhere, Category = GAS)
TSubclassOf<UGameplayAbility> SkillAbilityClass;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Animation)
TObjectPtr<class UAnimMontage> SkillActionMontage;
...
};
//.cpp
void AGASCharacter::SetupGASInputComponent()
{
if (IsValid(ASC) && IsValid(InputComponent))
{
UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(InputComponent);
...
EnhancedInputComponent->BindAction(SkillAction, ETriggerEvent::Triggered, this, &AGASCharacter::GASInputPressed, 2);
}
}
void AGASCharacter::GASInputPressed(int32 InputId)
{
FGameplayAbilitySpec* Spec = ASC->FindAbilitySpecFromInputID(InputId);
if (Spec)
{
Spec->InputPressed = true;
if (Spec->IsActive())
{
ASC->AbilitySpecInputPressed(*Spec);
}
else
{
ASC->TryActivateAbility(Spec->Handle);
}
}
}
void AGASCharacter::EquipWeapon(const FGameplayEventData* EventData)
{
if (Weapon)
{
Weapon->SetSkeletalMesh(WeaponMesh);
FGameplayAbilitySpec NewSkillSpec(SkillAbilityClass);
NewSkillSpec.InputID = 2;
if (!ASC->FindAbilitySpecFromClass(SkillAbilityClass))
{
ASC->GiveAbility(NewSkillSpec);
}
const float CurrentAttackRange = ASC->GetNumericAttributeBase(UCharacterAttributeSet::GetAttackRangeAttribute());
const float CurrentAttackRate = ASC->GetNumericAttributeBase(UCharacterAttributeSet::GetAttackRateAttribute());
ASC->SetNumericAttributeBase(UCharacterAttributeSet::GetAttackRangeAttribute(), CurrentAttackRange + WeaponRange);
ASC->SetNumericAttributeBase(UCharacterAttributeSet::GetAttackRateAttribute(), CurrentAttackRate + WeaponAttackRate);
}
}
void AGASCharacter::UnequipWeapon(const FGameplayEventData* EventData)
{
if (Weapon)
{
Weapon->SetSkeletalMesh(nullptr);
const float CurrentAttackRange = ASC->GetNumericAttributeBase(UCharacterAttributeSet::GetAttackRangeAttribute());
const float CurrentAttackRate = ASC->GetNumericAttributeBase(UCharacterAttributeSet::GetAttackRateAttribute());
ASC->SetNumericAttributeBase(UCharacterAttributeSet::GetAttackRangeAttribute(), CurrentAttackRange - WeaponRange);
ASC->SetNumericAttributeBase(UCharacterAttributeSet::GetAttackRateAttribute(), CurrentAttackRate - WeaponAttackRate);
FGameplayAbilitySpec* SkillAbilitySpec = ASC->FindAbilitySpecFromClass(SkillAbilityClass);
if (SkillAbilitySpec)
{
ASC->ClearAbility(SkillAbilitySpec->Handle);
}
}
}
무기를 장착하면 EquipWeapon함수가 실행되고 이 부분에서 미리 설정해 놓은 SkillAbilityClass를 통해 FGameplayAbilitySpec을 만들어 ASC에 등록한다.
UnequipWeapon은 반대라고 생각하면 된다.
또한, SkillAction을 바인딩하여 마우스 우클릭이 입력으로 들어오면 스킬을 발동할 GA를 실행한다.
이는 InputID를 맞춰 놓아야 올바르게 동작한다.
스킬 발동 & 애니메이션 (3)
스킬 GA가 발동하면 GA에서는 Montage를 실행할 AT를 만든다.
이후, Callback을 설정하고 실행 요청을 한다.
//.h
class PROJECT_API UGA_Skill : public UGameplayAbility
{
GENERATED_BODY()
public:
UGA_Skill();
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:
UFUNCTION()
void OnCompleteCallback();
UFUNCTION()
void OnInterruptedCallback();
protected:
UPROPERTY()
TObjectPtr<class UAnimMontage> ActiveSkillActionMontage;
};
//.cpp
void UGA_Skill::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
//Create AT
AGASCharacter* TargetCharacter = Cast<AGASCharacter>(ActorInfo->AvatarActor.Get());
if (!TargetCharacter) return;
ActiveSkillActionMontage = TargetCharacter->GetSkillActionMontage();
if (!ActiveSkillActionMontage) return;
TargetCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_None);
UAbilityTask_PlayMontageAndWait* PlayMontageTask = UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(this, TEXT("SkillMontage"), ActiveSkillActionMontage, 1.f);
PlayMontageTask->OnCompleted.AddDynamic(this, &UGA_Skill::OnCompleteCallback);
PlayMontageTask->OnCancelled.AddDynamic(this, &UGA_Skill::OnInterruptedCallback);
PlayMontageTask->ReadyForActivation();
}
void UGA_Skill::EndAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, bool bReplicateEndAbility, bool bWasCancelled)
{
AGASCharacter* TargetCharacter = Cast<AGASCharacter>(ActorInfo->AvatarActor.Get());
if (TargetCharacter)
{
TargetCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
}
Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
}
void UGA_Skill::OnCompleteCallback()
{
bool bReplicateEndAbility = true;
bool bWasCancelled = false;
EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, bReplicateEndAbility, bWasCancelled);
}
void UGA_Skill::OnInterruptedCallback()
{
bool bReplicateEndAbility = true;
bool bWasCancelled = true;
EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, bReplicateEndAbility, bWasCancelled);
}
스킬 GA에서는 크게 어려운 부분이 없다.
하지만, 이후에 나올 cost와 cooldown 관련하여 한 가지 주의 사항이 있다.
이는 잠시 후 다루겠다.
Animation 실행 & Event를 통한 판정 GA 실행 (4~5)
Montage의 AnimNotify를 이전에 만들어 놓은 AttackHitCheck으로 설정한 뒤, 스킬 판정과 일반 공격 판정에 대한 구분을 위해 새로운 태그를 만들어 설정해 준다.
해당 AnimNotify에서는 UAbilitySystemBlueprintLibrary를 통해 Event를 발생시키는 일을 한다.
void UAnimNotify_GASAttackHitCheck::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference& EventReference)
{
Super::Notify(MeshComp, Animation, EventReference);
if (MeshComp)
{
AActor* OwnerActor = MeshComp->GetOwner();
if (OwnerActor)
{
FGameplayEventData PayloadData;
PayloadData.EventMagnitude = ComboAttackLevel;
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(OwnerActor, TriggerGameplayTag, PayloadData);
}
}
}
이를 통해 TriggerGameplayTag로 설정되어 있는 태그를 통해 트리거가 설정되어 있는 GA를 실행시킨다.
GA_SkillHitCheck를 실행하지만 이는 일반 공격 판정을 수행하는 클래스와 같은 부모를 같기 때문에 C++에서 이에 대해 처리를 해주어야 한다.
//.h
...
UPROPERTY(EditAnywhere, category = "GAS")
TSubclassOf<class AABTA_Trace> TargetActorClass;
...
//.cpp
void UGA_AttackHitCheck::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
CurrentLevel = TriggerEventData->EventMagnitude;
UABAT_Trace* AttackTraceTask = UABAT_Trace::CreateTask(this, TargetActorClass);
AttackTraceTask->OnComplete.AddDynamic(this, &UGA_AttackHitCheck::OnTraceResultCallback);
AttackTraceTask->ReadyForActivation();
}
TargetActorClass라는 변수를 통해 설정된 TA를 실행할 수 있게 변경하여 Skill과 일반 공격에서 서로 다른 TA를 생성할 수 있게 변경해 주었다.
스킬 판정 TA (6 ~ 7)
스킬은 다수의 적에게 피해를 입혀야 한다.
일반 공격에서는 SweepSingleByChannel를 통해 하나의 액터만 trace 하여 데이터를 만들었지만 이번에는 OverlapMultiByChannel를 통해 다수의 액터를 검출하면 된다.
//.cpp
FGameplayAbilityTargetDataHandle ATA_SphereMultiTrace::MakeTargetData() const
{
ACharacter* Character = CastChecked<ACharacter>(SourceActor);
UAbilitySystemComponent* ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(SourceActor);
if (!ASC) return FGameplayAbilityTargetDataHandle();
const UCharacterAttributeSet* AttrubuteSet = ASC->GetSet<UCharacterAttributeSet>();
if (!AttrubuteSet) return FGameplayAbilityTargetDataHandle();
TArray<FOverlapResult> Overlaps;
const float SkillRaduis = 800.f;
FVector Origin = Character->GetActorLocation();
FCollisionQueryParams Params(SCENE_QUERY_STAT(ATA_SphereMultiTrace), false, Character);
GetWorld()->OverlapMultiByChannel(Overlaps, Origin, FQuat::Identity, 채널, FCollisionShape::MakeSphere(SkillRaduis), Params);
TArray<TWeakObjectPtr<AActor>> HitActors;
for (const FOverlapResult& Overlap : Overlaps)
{
AActor* HitActor = Overlap.OverlapObjectHandle.FetchActor<AActor>();
if (HitActor && !HitActors.Contains(HitActor))
{
HitActors.Add(HitActor);
}
}
FGameplayAbilityTargetData_ActorArray* ActorsData = new FGameplayAbilityTargetData_ActorArray();
ActorsData->SetActors(HitActors);
return FGameplayAbilityTargetDataHandle(ActorsData);
}
반환할 값에 Trace에서 얻은 액터들을 담아 전달하면 되는데 데이터 타입을 잘 맞추어 반환값을 준비하면 된다.
데이터가 준비되었다면 Delegate를 통해 Callback을 실행해 주면 된다.
TargetDataReadyDelegate.Broadcast(DataHandle);
실행되는 Callback에서는 일반 공격과 스킬 공격 모두 처리해야 하지만, 반환되는 데이터에 따라 구분할 수 있다.
일반 공격에서는 단 하나의 액터만 판정하여 HitResult를 담아 반환하였고 스킬에서는 다수의 액터를 판정하여 여러 개의 액터를 담은 배열을 반환하였기 때문에 이를 이용해 로직을 분리하였다.
void UGA_AttackHitCheck::OnTraceResultCallback(const FGameplayAbilityTargetDataHandle& TargetDataHandle)
{
if (UAbilitySystemBlueprintLibrary::TargetDataHasHitResult(TargetDataHandle, 0))
{
// 일반 공격
...
}
else if(UAbilitySystemBlueprintLibrary::TargetDataHasActor(TargetDataHandle, 0))
{
UAbilitySystemComponent* SourceASC = GetAbilitySystemComponentFromActorInfo_Checked();
FGameplayEffectSpecHandle EffectSpecHandle = MakeOutgoingGameplayEffectSpec(AttackDamageEffect, CurrentLevel);
if (EffectSpecHandle.IsValid())
{
ApplyGameplayEffectSpecToTarget(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, EffectSpecHandle, TargetDataHandle);
FGameplayEffectContextHandle CueContextHandle = UAbilitySystemBlueprintLibrary::GetEffectContext(EffectSpecHandle);
CueContextHandle.AddActors(TargetDataHandle.Data[0].Get()->GetActors());
FGameplayCueParameters CueParam;
CueParam.EffectContext = CueContextHandle;
SourceASC->ExecuteGameplayCue(ABTAG_GAMEPLAYCUE_CHARACTER_ATTACKHIT, CueParam);
}
}
bool bReplicateEndAbility = true;
bool bWasCancelled = false;
EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, bReplicateEndAbility, bWasCancelled);
}
스킬 공격이 성공하여 다수의 액터가 반환되었다면 해당 액터들에게 대미지를 입히며 GC를 실행하는 부분이다.
스킬 관련 AttributeSet
현재 스킬의 사거리나 GE에 적용하는 피해량 등이 하드코딩되어 있다.
이를 개선하기 위해 스킬과 관련된 데이터를 관리하는 AttributeSet을 하나 만들고 추가적으로 cost나 cooldown을 적용할 수 있게 해 보자.
우선, AttributeSet을 상속받아 SkillAttributeSet을 만들자.
//.h
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)
class PROJECT_API UCharacterSkillAttributeSet : public UAttributeSet
{
GENERATED_BODY()
public:
UCharacterSkillAttributeSet();
ATTRIBUTE_ACCESSORS(UCharacterSkillAttributeSet, SkillRange);
ATTRIBUTE_ACCESSORS(UCharacterSkillAttributeSet, MaxSkillRange);
ATTRIBUTE_ACCESSORS(UCharacterSkillAttributeSet, SkillAttackRate);
ATTRIBUTE_ACCESSORS(UCharacterSkillAttributeSet, MaxSkillAttackRate);
ATTRIBUTE_ACCESSORS(UCharacterSkillAttributeSet, SkillEnergy);
ATTRIBUTE_ACCESSORS(UCharacterSkillAttributeSet, MaxSkillEnergy);
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override;
protected:
UPROPERTY(BlueprintReadOnly, Category = Attack, meta = (AllowPrivateAccess = true))
FGameplayAttributeData SkillRange;
UPROPERTY(BlueprintReadOnly, Category = Attack, meta = (AllowPrivateAccess = true))
FGameplayAttributeData MaxSkillRange;
UPROPERTY(BlueprintReadOnly, Category = Attack, meta = (AllowPrivateAccess = true))
FGameplayAttributeData SkillAttackRate;
UPROPERTY(BlueprintReadOnly, Category = Attack, meta = (AllowPrivateAccess = true))
FGameplayAttributeData MaxSkillAttackRate;
UPROPERTY(BlueprintReadOnly, Category = Attack, meta = (AllowPrivateAccess = true))
FGameplayAttributeData SkillEnergy;
UPROPERTY(BlueprintReadOnly, Category = Attack, meta = (AllowPrivateAccess = true))
FGameplayAttributeData MaxSkillEnergy;
};
//.cpp
UCharacterSkillAttributeSet::UABCharacterSkillAttributeSet() :
SkillRange(800.f),
MaxSkillRange(1200.f),
SkillAttackRate(150.f),
MaxSkillAttackRate(300.f),
SkillEnergy(100.f),
MaxSkillEnergy(100.f)
{
}
void UCharacterSkillAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
Super::PreAttributeChange(Attribute, NewValue);
if (Attribute == GetSkillRangeAttribute())
{
NewValue = FMath::Clamp(NewValue, .1f, GetMaxSkillRange());
}
else if (Attribute == GetSkillAttackRateAttribute())
{
NewValue = FMath::Clamp(NewValue, .1f, GetMaxSkillAttackRate());
}
}
AttribuetSet에서 관리하는 데이터는 다음과 같다.
- 스킬 사거리
- 스킬 공격력
- 스킬 에너지(마나, 기력 등등..)
또한, 각 데이터는 최댓값을 가진다.
이러한 데이터를 관리하기 쉽게 매크로를 이용하였다.
이제 이 AttributeSet을 PlayerState에 붙이면 된다.
ASC와 일반적인 Attribute 역시 PlayerState에서 관리하기 때문이다.
//.h
...
protected:
UPROPERTY(EditAnywhere, Category = GAS)
TObjectPtr<class UAbilitySystemComponent> ASC;
UPROPERTY()
TObjectPtr<class UABCharacterAttributeSet> AttributeSet;
UPROPERTY()
TObjectPtr<class UABCharacterSkillAttributeSet> SkillAttributeSet;
...
//.cpp
AGASPlayerState::AGASPlayerState()
{
ASC = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("ASC"));
AttributeSet = CreateDefaultSubobject<UCharacterAttributeSet>(TEXT("AttributeSet"));
SkillAttributeSet = CreateDefaultSubobject<UCharacterSkillAttributeSet>(TEXT("SkillAttributeSet"));
}
그리고 TA에서 SkillAttributeSet에서 사거리를 받아와서 설정해 주면 된다.
//.cpp
FGameplayAbilityTargetDataHandle ATA_SphereMultiTrace::MakeTargetData() const
{
...
const UABCharacterSkillAttributeSet* SkillAttributeSet = ASC->GetSet<UABCharacterSkillAttributeSet>();
if (!SkillAttributeSet) return FGameplayAbilityTargetDataHandle();
TArray<FOverlapResult> Overlaps;
const float SkillRaduis = SkillAttributeSet->GetSkillRange();
...
}
GE에서는 AttribuetBase로 설정해주면 된다.
Cost & Cooldown
스킬 사용 GA를 보면 코스트와 쿨다운을 설정할 수 있다.
이 두 가지 모두 GE를 통해 설정할 수 있는데, 코스트에서는 어떠한 값을 얼마나 변경할 것인지에 대한 명세가 필요하다.
SkillAttributeSet에 설정한 SkillEnergy를 30만큼 빼주게 설정하였다.
쿨다운 같은 경우에는 자동으로 GA의 사용을 막아주지는 않는다.
쿨다운 GE에서 설정한 시간만큼 태그를 붙여준다고 이해하면 쉽다.
그리고 쿨다운 GE는 HasDuration일 때만 동작한다.
이렇게 설정하면 스킬 GA가 실행되는 순간 쿨다운 GE도 같이 실행되며 설정한 태그를 붙이고 시간이 지나면 제거한다.
스킬 GA에서 쿨다운 태그가 있다면 실행을 할 수 없게 만들면 된다.
UE5.3만 그러는지는 모르겠지만, 이렇게만 설정하면 코스트와 쿨다운이 동작하지 않는다.
두 옵션은 모두 GA가 Commit이 되어야 실행되기 때문이다.
따라서, GA가 Activate 될 때 Commit을 명시적으로 해주어야 한다.
//GA_Skill.cpp
void UGA_Skill::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
...
CommitAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo);
}
거리에 따른 Damage계산
스킬에 따라 복잡한 대미지 계산이 필요할 수 있다.
이를 담당하여 처리하는 클래스를 UGameplayEffectExecutionCalculation이라고 한다.
UGameplayEffectExecutionCalculation의 Execute함수에서 계산 결과를 반환하면 GE에서 이를 통해 적용할 값을 정할 수 있다.
지금의 경우는 거리가 멀어지면 더 낮은 대미지를 받게 만들었다.
//.cpp
void USkillDamageExecutionCalc::Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const
{
Super::Execute_Implementation(ExecutionParams, OutExecutionOutput);
UAbilitySystemComponent* SourceASC = ExecutionParams.GetSourceAbilitySystemComponent();
UAbilitySystemComponent* TargetASC = ExecutionParams.GetTargetAbilitySystemComponent();
if (SourceASC && TargetASC)
{
AActor* SourceActor = SourceASC->GetAvatarActor();
AActor* TargetActor = TargetASC->GetAvatarActor();
if (SourceActor && TargetActor)
{
const float MaxDamageRange = SourceASC->GetNumericAttributeBase(UABCharacterSkillAttributeSet::GetSkillRangeAttribute());
const float MaxDamage = SourceASC->GetNumericAttributeBase(UABCharacterSkillAttributeSet::GetSkillAttackRateAttribute());
const float Distance = FMath::Clamp(SourceActor->GetDistanceTo(TargetActor), 0.f, MaxDamageRange);
const float InvDamageRatio = 1.f - Distance / MaxDamageRange;
float Damage = InvDamageRatio * MaxDamage;
OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(UCharacterAttributeSet::GetDamageAttribute(), EGameplayModOp::Additive, Damage));
}
}
}
이를 대미지를 부여하는 GE에 설정하면 된다.