언리얼엔진/FPS 프로젝트

[UE5 | FPS] FGameplayEffectContext의 자식 구조체로 데미지 관련 정보 전달

mstone8370 2024. 6. 5. 18:11

 

이 프로젝트는 게임플레이 어빌리티 시스템을 이용해서 멀티 플레이에서도 작동하게 작업하고있다.

따라서 데미지도 게임플레이 어빌리티 시스템을 이용해서 다룬다.

그렇다면 플레이어의 체력을 어트리뷰트 셋에서 관리하고, 데미지는 게임플레이 이펙트를 적용해서 체력 어트리뷰트를 조정하는 방법을 생각할 수 있다.

 

데미지 값은 게임플레이 이펙트의 모디파이어에서 Set by Caller로 값을 설정해서 적용하면 된다.

하지만 최종 데미지를 결정할 때에는 추가적인 정보가 필요하다.

예를 들면 피격된 캐릭터의 스탯과 데미지의 속성에 따라 데미지의 값을 조정해야하는 경우도 있고, FPS의 경우에는 거리가 멀어지면 데미지가 감소하는걸 생각해 볼 수도 있다.

따라서 설정된 데미지 값을 그대로 적용해서 체력을 조정하기보단 피격된 캐릭터가 여러 정보를 받은 뒤에 최종 데미지의 값을 계산해서 체력을 조정하는 방식이 더 낫다.

 

그렇게 하려면 데미지 값으로 체력 어트리뷰트를 직접 수정하기보단 일단 값만 가지고있어야한다.

따라서 임시로 값만 받아둘 목적으로 사용할 어트리뷰트를 생성해서 거기에 Set by Caller로 값을 넣어두면 된다.

이런 어트리뷰트를 메타 어트리뷰트라고 부른다.

 

게임플레이 이펙트가 적용된 주변의 상황같은 여러가지 정보는 FGameplayEffectContext에 담겨서 전달된다.

기본적으로 전달되는 여러 정보들도 유용하게 사용되지만, 게임에 따라 추가적인 정보가 필요할 수 있다.

그럴때에는 FGameplayEffectContext의 자식 구조체를 구현해서 추가 정보를 더 넣으면 된다.

 

이 FPS 프로젝트에서는 데미지를 받을때 어떤 무기로 데미지를 가한건지, 그 무기는 치명타를 가할 수 있는지, 에임 펀치는 어느정도로 적용할지 등의 정보가 추가적으로 필요했기에 이 구조체의 자식 구조체 를 생성해서 사용하기로 했다.

 

 

 


 

 

 

FGameplayEffectContext를 상속받는 구조체를 생성할 때에는 추가적으로 해야할 것이 있다.

  1. 자식 구조체에서 GetScriptStruct(), Duplicate(), NetSerialize() 함수 오버라이드
  2. 자식 구조체를 위한 TStructOpsTypeTraits 구조체 구현
  3. UAbilitySystemGlobals의 서브 클래스를 추가해서 AllocGameplayEffectContext() 함수 오버라이드
  4. DefaultGame.ini에서 AbilitySystemGlobalsClassName을 새로 만든 UAbilitySystemGlobals의 서브 클래스로 지정

 


 

1. 구조체 함수 오버라이드

새로 만든 게임플레이 이펙트 컨텍스트의 이름을 FMyGameplayEffectContext라고 했다면 아래와 같이 구현하면 된다.

USTRUCT(BlueprintType)
struct FMyGameplayEffectContext : public FGameplayEffectContext
{
    GENERATED_BODY()

    virtual UScriptStruct* GetScriptStruct() const
    {
        return FMyGameplayEffectContext::StaticStruct();
    }

    virtual FMyGameplayEffectContext* Duplicate() const
    {
        FMyGameplayEffectContext* NewContext = new FMyGameplayEffectContext();
        *NewContext = *this;
        if (GetHitResult())
        {
            // Does a deep copy of the hit result
            NewContext->AddHitResult(*GetHitResult(), true);
        }
        return NewContext;
    }

    virtual bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess);
}

이 구조체에 필요한 변수를 추가하면 된다.

 

NetSerialize 구현과 관련된 정보는 이전에 작성된 글을 보면 된다.

https://mstone8370.tistory.com/34

 

[UE5] USTRUCT의 NetSerialize

언리얼 엔진은 RPC나 프로퍼티 리플리케이션으로 클라이언트의 로컬 프로퍼티를 업데이트 할 수 있다. 프로퍼티를 업데이트 할 때 트래픽과 대역폭을 줄이기 위한 최적화 방법들이 있는데 그 중

mstone8370.tistory.com

 

2. TStructOpsTypeTraits 구조체 구현

위에서 작성한 FMyGameplayEffectContext 아래에 구현하면 된다.

template<>
struct TStructOpsTypeTraits< FMyGameplayEffectContext > : public TStructOpsTypeTraitsBase2< FMyGameplayEffectContext >
{
	enum
	{
		WithNetSerializer = true,
		WithCopy = true		// Necessary so that TSharedPtr<FHitResult> Data is copied around
	};
};

 

여기에 추가 가능한 enum 옵션 목록은 아래와 같다.

필요한 경우에는 추가하면 되겠지만, 그러지 않은 경우에는 FGameplayEffectContext에 사용된 대로 적으면 된다.

// UObject/Class.h

/** type traits to cover the custom aspects of a script struct **/
template <class CPPSTRUCT>
struct TStructOpsTypeTraitsBase2
{
    enum
    {
        WithZeroConstructor            = false,                         // struct can be constructed as a valid object by filling its memory footprint with zeroes.
        WithNoInitConstructor          = false,                         // struct has a constructor which takes an EForceInit parameter which will force the constructor to perform initialization, where the default constructor performs 'uninitialization'.
        WithNoDestructor               = false,                         // struct will not have its destructor called when it is destroyed.
        WithCopy                       = !TIsPODType<CPPSTRUCT>::Value, // struct can be copied via its copy assignment operator.
        WithIdenticalViaEquality       = false,                         // struct can be compared via its operator==.  This should be mutually exclusive with WithIdentical.
        WithIdentical                  = false,                         // struct can be compared via an Identical(const T* Other, uint32 PortFlags) function.  This should be mutually exclusive with WithIdenticalViaEquality.
        WithExportTextItem             = false,                         // struct has an ExportTextItem function used to serialize its state into a string.
        WithImportTextItem             = false,                         // struct has an ImportTextItem function used to deserialize a string into an object of that class.
        WithAddStructReferencedObjects = false,                         // struct has an AddStructReferencedObjects function which allows it to add references to the garbage collector.
        WithSerializer                 = false,                         // struct has a Serialize function for serializing its state to an FArchive.
        WithStructuredSerializer       = false,                         // struct has a Serialize function for serializing its state to an FStructuredArchive.
        WithPostSerialize              = false,                         // struct has a PostSerialize function which is called after it is serialized
        WithNetSerializer              = false,                         // struct has a NetSerialize function for serializing its state to an FArchive used for network replication.
        WithNetDeltaSerializer         = false,                         // struct has a NetDeltaSerialize function for serializing differences in state from a previous NetSerialize operation.
        WithSerializeFromMismatchedTag = false,                         // struct has a SerializeFromMismatchedTag function for converting from other property tags.
        WithStructuredSerializeFromMismatchedTag = false,               // struct has an FStructuredArchive-based SerializeFromMismatchedTag function for converting from other property tags.
        WithPostScriptConstruct        = false,                         // struct has a PostScriptConstruct function which is called after it is constructed in blueprints
        WithNetSharedSerialization     = false,                         // struct has a NetSerialize function that does not require the package map to serialize its state.
        WithGetPreloadDependencies     = false,                         // struct has a GetPreloadDependencies function to return all objects that will be Preload()ed when the struct is serialized at load time.
        WithPureVirtual                = false,                         // struct has PURE_VIRTUAL functions and cannot be constructed when CHECK_PUREVIRTUALS is true
        WithFindInnerPropertyInstance  = false,                         // struct has a FindInnerPropertyInstance function that can provide an FProperty and data pointer when given a property FName
        WithCanEditChange              = false,                         // struct has an editor-only CanEditChange function that can conditionally make child properties read-only in the details panel (same idea as UObject::CanEditChange)
    };

    static constexpr EPropertyObjectReferenceType WithSerializerObjectReferences = EPropertyObjectReferenceType::Conservative; // struct's Serialize method(s) may serialize object references of these types - default Conservative means unknown and object reference collector archives should serialize this struct 
};

 

3. UAbilitySystemGlobals의 서브 클래스

AbilitySystemComponent에서 이펙트 컨텍스트를 생성할때 사용되는 함수를 오버라이드한다.

// .h

UCLASS()
class NL_GAS_API UMyAbilitySystemGlobals : public UAbilitySystemGlobals
{
    GENERATED_BODY()

    // UAbilitySystemComponent::MakeEffectContext 함수의 내부에선 아래의 함수로 FGameplayEffectContext 생성함.
    // 이 함수를 override해서 FGameplayEffectContext를 상속받은 FMyGameplayEffectContext를 리턴하도록 변경하면 모든 곳에 적용됨.
    virtual FGameplayEffectContext* AllocGameplayEffectContext() const override;
};




// .cpp

FGameplayEffectContext* UMyAbilitySystemGlobals::AllocGameplayEffectContext() const
{
    return new FMyGameplayEffectContext();
}

 

4. DefaultGame.ini

프로젝트 폴더의 Config 폴더 내부에있는 DefaultGame.ini 파일을 수정해서 AbilitySystemGlobals 클래스를 직접 지정해준다.

MODULE_NAME에는 프로젝트의 모듈 이름을 적어야한다.

[/Script/GameplayAbilities.AbilitySystemGlobals]
AbilitySystemGlobalsClassName="/Script/MODULE_NAME.MyAbilitySystemGlobals"

 

이렇게 작성한 뒤에 에디터에서 게임플레이 큐 관련 경고가 뜨면 게임플레이 큐 노티파이를 모아놓은 폴더 경로를 지정해주면 된다.

[/Script/GameplayAbilities.AbilitySystemGlobals]
AbilitySystemGlobalsClassName="/Script/MODULE_NAME.MyAbilitySystemGlobals"
GameplayCueNotifyPaths=게임플레이 큐 노티파이 경로

 

 

 


 

 

 

위 방법대로 게임플레이 이펙트 컨텍스트를 상속받아서 필요한 정보를 추가했다.

TSharedPtr<FGameplayTag> DamageType;

UPROPERTY()
bool bCanCriticalHit = false;

UPROPERTY()
bool bIsRadialDamage = false;

UPROPERTY()
FVector RadialDamageOrigin = FVector::ZeroVector;

UPROPERTY()
float RadialDamageInnerRadius = 0.f;

UPROPERTY()
float RadialDamageOuterRadius = 0.f;

UPROPERTY()
float KnockbackMagnitude = 0.f;

 

피격된 방향을 UI에 표시하기 위해 공격한 위치와 관련된 정보도 필요했는데, 이건 게임플레이 이펙트 컨텍스트에 포함되어있는 FHitResult를 사용했다.

피격됐을때 에임 펀치도 필요한데 데미지 타입을 통해 데이터 에셋에서 필요한 값을 찾는 방법을 사용했다.

 

이런 정보들을 게임플레이 어빌리티에도 가지고있다가 데미지를 가해야하는 상황에 게임플레이 이펙트 컨텍스트를 작성해서 적용한다.

그러면 그 정보는 게임플레이 이펙트가 적용되는 캐릭터의 어트리뷰트 셋에서 접근 가능하다.

 

어트리뷰트 셋의 PostGameplayEffectExecute 함수는 게임플레이 이펙트가 적용된 후에 호출되는 함수다.

이 함수의 파라미터인 FGameplayEffectModCallbackData 구조체를 통해 게임플레이 이펙트와 관련된 정보에 접근할 수 있다.

이 함수를 오버라이드해서 상황에 맞게 최종 데미지를 결정하고 그걸 체력 어트리뷰트에 적용한다.

게임플레이 이펙트는 데미지 값을 임시로 저장하는 메타 어트리뷰트 값을 설정하고, 이펙트가 적용된 후에는 이 함수가 호출되고, 함수의 파라미터를 통해 게임플레이 이펙트 컨텍스트에도 접근 가능하니 최종 데미지 값을 결정하기에 좋은 곳이다.

데미지 계산이 끝났다면 사용했던 메타 어트리뷰트의 값을 0으로 초기화해서 누적되지 않도록 하는걸 잊지 말아야한다.

 

아래 코드는 지금까지 진행된 데미지를 계산하고 적용하는 코드다.

대략적인 진행 방식만 참고하면 될듯한다.

void UNLAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
    FEffectContextParams Params;
    SetEffectContextParams(Data, Params);

    FNLGameplayEffectContext* NLContext = static_cast<FNLGameplayEffectContext*>(Params.ContextHandle.Get());
    ANLPlayerController* SourceNLPC = Cast<ANLPlayerController>(Params.SourceController);
    ANLPlayerController* TargetNLPC = Cast<ANLPlayerController>(Params.TargetController);

    if (Data.EvaluatedData.Attribute == GetIncomingDamageAttribute())
    {
        float LocalIncomingDamage = GetIncomingDamage();
        SetIncomingDamage(0.f);
        LocalIncomingDamage = FMath::Floor(LocalIncomingDamage);

        FGameplayTag DamageType = FGameplayTag();
        if (NLContext->DamageType.Get())
        {
            DamageType = *NLContext->DamageType.Get();
        }

        FVector DamageOrigin = FVector::ZeroVector;
        if (NLContext->HasOrigin())
        {
            DamageOrigin = NLContext->GetOrigin();
        }
        else
        {
            if (SourceNLPC)
            {
                DamageOrigin = SourceNLPC->GetPawn()->GetActorLocation();
            }
            else if (TargetNLPC)
            {
                DamageOrigin = TargetNLPC->GetPawn()->GetActorLocation();
            }
        }

        bool bIsCriticalHit = false;
        if (NLContext->bCanCriticalHit)
        {
            if (NLContext->GetHitResult()->GetComponent())
            {
                if (UHitboxComponent* Hitbox = Cast<UHitboxComponent>(NLContext->GetHitResult()->GetComponent()))
                {
                    bIsCriticalHit = Hitbox->IsWeakHitbox();
                    if (bIsCriticalHit)
                    {
                        LocalIncomingDamage *= Hitbox->GetCriticalHitDamageMultiplier();
                    }
                }
            }
        }

        SetHealth(FMath::Max(GetHealth() - LocalIncomingDamage, 0.f));

        if (Params.SourceAvatarActor != Params.TargetAvatarActor && SourceNLPC)
        {
            SourceNLPC->OnCausedDamage(LocalIncomingDamage, bIsCriticalHit, Params.TargetAvatarActor);
        }

        if (TargetNLPC)
        {
            TargetNLPC->OnTakenDamage(NLContext->GetHitResult(), DamageOrigin, bIsCriticalHit, DamageType);
        }
    }
}

FEffectContextParams는 게임플레이 이펙트와 관련된 정보에 편하게 접근할 수 있게 관련 값들을 구해서 모아놓은 구조체다.

함수의 파라미터인 FGameplayEffectModCallbackData를 통해 필요한 값에 접근하는건 번거롭고 반복되는 작업이므로, 미리 구해두고 그 값을 사용하는게 낫다.

 

데미지 계산이 끝나고 체력을 조정했으면 각 플레이어 컨트롤러에 데미지가 적용됐음을 알리고, 상황에 맞게 클라이언트 RPC를 호출해서 그 결과를 클라이언트에서도 확인할 수 있게 했다.

결과를 UI에 표시해줘야하기 때문에 클라이언트의 HUD에 접근할 수 있는 플레이어 컨트롤러에서 다룬다.

 

이렇게 전달된 정보를 이용해서 아래 영상처럼 데미지 표시와 에임 펀치를 적용했다.

https://youtu.be/g9guwMhMMvQ