Gameplay Ability System Course Project - Development Blog

Hello everyone!

My name is Devin Sherry and I am a Senior Gameplay Designer at CD Projekt Red working on the next generation Witcher series. I’ve also worked at People Can Fly on the Outriders and Outriders: Worldslayer expansion titles, in addition to co-authoring a few textbooks about Unreal Engine 4 & 5. Lastly, I have a history of making YouTube tutorials about Blueprint/C++ related topics, which will be linked below; however, I haven’t had the time to focus on this aspect of my career. But enough about me, let’s talk about the purpose of this discussion:

I am working on a side project that I am calling the GAS Course Project, a small game example built from scratch using the Gameplay Ability System plugin in my efforts to continue my own learning and game development journey, but to also hopefully help others learn along the way. I have been working on this project for about an hour a day, a few times a week starting from the beginning of 2023, and I am nowhere near being finished :smiley: however, I wanted to start a forum discussion regarding the project, in a form of blog posts, talking about what I am working on, gathering feedback, showing bits of code and game features in hopes to help others, but to also help myself improve. My goal, by the end of the project, is to have an example project available for free on the Unreal Engine marketplace for everyone to use as a base, or working example, of features related to the Gameplay Ability System plugin. In addition, I want to provide robust documentation, both in the forms of these forum posts but also documentation similarly formatted as Tranek’s GAS Documentation - Big shout out to Tranek because their documentation is priceless. Ideally I would then make a tutorial course series breaking everything down and remaking things from scratch; whether or not this course would be free, let alone exist in the first place, is still unknown.

This initial entry in the discussion is to introduce everyone to both myself, and to the project and show-case one game feature as a kick-off point. To finish introductions, here are my socials and links to short videos (both on Tiktok and Youtube) showcasing some parts of the project. Also, I try to stream my work Monday, Tuesday, Thursday at around 7pm (GMT+1) if anyone is interested, a follow there would be appreciated, and I also have a Discord server for those interested in joining.

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Now, let’s talk about something that I hope will interest you, Wait Target Data:


What is Wait Target Data

Classes to research on your own:

AGameplayAbilityTargetActor
UAbilityTask_WaitTargetData
AGameplayAbilityWorldReticle

Wait Target Data is a latent ability task that allows users to obtain Target Data using any form of overlap detection needed to do so. The task allows for (EGameplayTargetingConfirmation) Instant, User Confirmed, Custom and Custom Multi returning of the data; however, my experience is only with Instant and User Confirmed methods. You can find more information about them in GameplayAbilityTargetTypes.h

UENUM(BlueprintType)
namespace EGameplayTargetingConfirmation
{
	/** Describes how the targeting information is confirmed */
	enum Type : int
	{
		/** The targeting happens instantly without special logic or user input deciding when to 'fire' */
		Instant,

		/** The targeting happens when the user confirms the targeting */
		UserConfirmed,

		/** The GameplayTargeting Ability is responsible for deciding when the targeting data is ready. Not supported by all TargetingActors */
		Custom,

		/** The GameplayTargeting Ability is responsible for deciding when the targeting data is ready. Not supported by all TargetingActors. Should not destroy upon data production */
		CustomMulti,
	};
}

For instant:

When using the Confirmation Type Instant, the task will automatically call the method:

/** Outside code is saying 'stop and just give me what you have.' Returns true if the ability accepts this and can be forgotten. */

UFUNCTION()
virtual void ConfirmTargeting();

This will return the target data immediately, depending on your implementation of the function, here is what I do:

void AGASCourseTargetActor_CameraTrace::ConfirmTargeting()
{
	check(ShouldProduceTargetData());
	if (SourceActor)
	{
		const FVector Origin = PerformTrace(SourceActor).Location;
		FGameplayAbilityTargetDataHandle Handle = MakeTargetData(PerformOverlap(Origin), Origin);
		TargetDataReadyDelegate.Broadcast(Handle);
	}
}

NOTE: I don’t use the Instant confirmation type for my aim cast ability, and instead use User Confirmed!


For User Confirmed:

In order to setup the input needed for User Confirmed, you need to do the following when setting up the Input Configuration:

			if(UGASCourseAbilitySystemComponent* MyASC = CastChecked<UGASCourseAbilitySystemComponent>( GetAbilitySystemComponent()))
			{
				EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_ConfirmTargetData, ETriggerEvent::Triggered, MyASC, &UGASCourseAbilitySystemComponent::LocalInputConfirm);
				EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_CancelTargetData, ETriggerEvent::Triggered, MyASC, &UGASCourseAbilitySystemComponent::LocalInputCancel);
			}

The assumption here for those following is that you are using Enhanced Input to bind ability activation to keyboard/mouse/gamepad button presses. Don’t worry, my next blog post will be about how to set this up!


The base class GameplayAbilityTargetActor has the method:
void AGameplayAbilityTargetActor::BindToConfirmCancelInputs() and this method is reponsible for finding and adding those call-backs shown above.

When confirming or cancelling the Wait Target Data task, the following functions are called respectively:

void AGASCourseTargetActor_CameraTrace::ConfirmTargetingAndContinue()
{
	check(ShouldProduceTargetData());
	if (SourceActor)
	{
		const FVector Origin = PerformTrace(SourceActor).Location;
		FGameplayAbilityTargetDataHandle Handle = MakeTargetData(PerformOverlap(Origin), Origin);
		TargetDataReadyDelegate.Broadcast(Handle);
	}
}

void AGASCourseTargetActor_CameraTrace::CancelTargeting()
{
	Super::CancelTargeting();
}

The Ability Task is more or less a wrapper to the behavior implemented in the chosen GameplayAbilityTargetActor class; by default there a few options:

GameplayAbilityTargetActor_ActorPlacement

Allows to spawn a preview actor, using the trace data hit result used from the PerformTrace() method:

//Might want to override this function to allow for a radius check against the ground, possibly including a height check. Or might want to do it in ground trace.
//FHitResult AGameplayAbilityTargetActor_ActorPlacement::PerformTrace(AActor* InSourceActor) const

GameplayAbilityTargetActor_GroundTrace

This is meant to trace for and detect the ground in front of the player, within X unit Range. The trace is performed via the method FHitResult AGameplayAbilityTargetActor_GroundTrace::PerformTrace(AActor* InSourceActor) and takes advantage of the helper method void AGameplayAbilityTargetActor_Trace::AimWithPlayerController(const AActor* InSourceActor, FCollisionQueryParams Params, const FVector& TraceStart, FVector& OutTraceEnd, bool bIgnorePitch) const

FHitResult AGameplayAbilityTargetActor_GroundTrace::PerformTrace(AActor* InSourceActor)
{
	bool bTraceComplex = false;

	FCollisionQueryParams Params(SCENE_QUERY_STAT(AGameplayAbilityTargetActor_GroundTrace), bTraceComplex);
	Params.bReturnPhysicalMaterial = true;
	Params.AddIgnoredActor(InSourceActor);

	FVector TraceStart = StartLocation.GetTargetingTransform().GetLocation();// InSourceActor->GetActorLocation();
	FVector TraceEnd;
	AimWithPlayerController(InSourceActor, Params, TraceStart, TraceEnd);		//Effective on server and launching client only

	// ------------------------------------------------------

	FHitResult ReturnHitResult;
	//Use a line trace initially to see where the player is actually pointing
	LineTraceWithFilter(ReturnHitResult, InSourceActor->GetWorld(), Filter, TraceStart, TraceEnd, TraceProfile.Name, Params);
	//Default to end of trace line if we don't hit anything.
	if (!ReturnHitResult.bBlockingHit)
	{
		ReturnHitResult.Location = TraceEnd;
	}

	//Second trace, straight down. Consider using InSourceActor->GetWorld()->NavigationSystem->ProjectPointToNavigation() instead of just going straight down in the case of movement abilities (flag/bool).
	TraceStart = ReturnHitResult.Location - (TraceEnd - TraceStart).GetSafeNormal();		//Pull back very slightly to avoid scraping down walls
	TraceEnd = TraceStart;
	TraceStart.Z += CollisionHeightOffset;
	TraceEnd.Z -= 99999.0f;
	LineTraceWithFilter(ReturnHitResult, InSourceActor->GetWorld(), Filter, TraceStart, TraceEnd, TraceProfile.Name, Params);
	//if (!ReturnHitResult.bBlockingHit) then our endpoint may be off the map. Hopefully this is only possible in debug maps.

	bLastTraceWasGood = true;		//So far, we're good. If we need a ground spot and can't find one, we'll come back.

	//Use collision shape to find a valid ground spot, if appropriate
	if (CollisionShape.ShapeType != ECollisionShape::Line)
	{
		ReturnHitResult.Location.Z += CollisionHeightOffset;		//Rise up out of the ground
		TraceStart = InSourceActor->GetActorLocation();
		TraceEnd = ReturnHitResult.Location;
		TraceStart.Z += CollisionHeightOffset;
		bLastTraceWasGood = AdjustCollisionResultForShape(TraceStart, TraceEnd, Params, ReturnHitResult);
		if (bLastTraceWasGood)
		{
			ReturnHitResult.Location.Z -= CollisionHeightOffset;	//Undo the artificial height adjustment
		}
	}

	if (AGameplayAbilityWorldReticle* LocalReticleActor = ReticleActor.Get())
	{
		LocalReticleActor->SetIsTargetValid(bLastTraceWasGood);
		LocalReticleActor->SetActorLocation(ReturnHitResult.Location);
	}

	// Reset the trace start so the target data uses the correct origin
	ReturnHitResult.TraceStart = StartLocation.GetTargetingTransform().GetLocation();

	return ReturnHitResult;
}

GameplayAbilityTargetActor_Radius

This is meant to perform a sphere overlap trace around the ability owner, or whichever data you place in the Start Location parameter of the task; most times you would use the method MakeTargetDataInfoFromOwnerActor().

image

GameplayAbilityTargetActor_SingleLine

This is meant to perform a single line trace using vectors calculated from the method void AGameplayAbilityTargetActor_Trace::AimWithPlayerController(const AActor* InSourceActor, FCollisionQueryParams Params, const FVector& TraceStart, FVector& OutTraceEnd, bool bIgnorePitch) const

Lyra uses similar mechanisms when performing weapon traces.

NOTE: This works well for first/third person perspectives, but with top-down and isometric viewpoints, custom solutions need to be made (at least from my experience when developing my custom GameplayAbilityTargetActor classes)

void AGameplayAbilityTargetActor_Trace::AimWithPlayerController(const AActor* InSourceActor, FCollisionQueryParams Params, const FVector& TraceStart, FVector& OutTraceEnd, bool bIgnorePitch) const
{
	if (!OwningAbility) // Server and launching client only
	{
		return;
	}

	APlayerController* PC = OwningAbility->GetCurrentActorInfo()->PlayerController.Get();
	check(PC);

	FVector ViewStart;
	FRotator ViewRot;
	PC->GetPlayerViewPoint(ViewStart, ViewRot);

	const FVector ViewDir = ViewRot.Vector();
	FVector ViewEnd = ViewStart + (ViewDir * MaxRange);

	ClipCameraRayToAbilityRange(ViewStart, ViewDir, TraceStart, MaxRange, ViewEnd);

	FHitResult HitResult;
	LineTraceWithFilter(HitResult, InSourceActor->GetWorld(), Filter, ViewStart, ViewEnd, TraceProfile.Name, Params);

	const bool bUseTraceResult = HitResult.bBlockingHit && (FVector::DistSquared(TraceStart, HitResult.Location) <= (MaxRange * MaxRange));

	const FVector AdjustedEnd = (bUseTraceResult) ? HitResult.Location : ViewEnd;

	FVector AdjustedAimDir = (AdjustedEnd - TraceStart).GetSafeNormal();
	if (AdjustedAimDir.IsZero())
	{
		AdjustedAimDir = ViewDir;
	}

	if (!bTraceAffectsAimPitch && bUseTraceResult)
	{
		FVector OriginalAimDir = (ViewEnd - TraceStart).GetSafeNormal();

		if (!OriginalAimDir.IsZero())
		{
			// Convert to angles and use original pitch
			const FRotator OriginalAimRot = OriginalAimDir.Rotation();

			FRotator AdjustedAimRot = AdjustedAimDir.Rotation();
			AdjustedAimRot.Pitch = OriginalAimRot.Pitch;

			AdjustedAimDir = AdjustedAimRot.Vector();
		}
	}

	OutTraceEnd = TraceStart + (AdjustedAimDir * MaxRange);
}

Now that you have the target data, what can you do with it?

There are alot of helper functions that you can utilize from the returned Gameplay Ability Target Data Handle Structure of the Wait Target Data task; here are just a few:

Target Data Has Hit Result

Returns whether or not the target data has a valid hit result.

Get All Actors from Target Data

Returns the array of actors found from the Wait Target Data task. This is dependent on how you handle filtering:

Get Hit Result from Target Data

Returns the hit result, if one exists, of the wait target data. This can be useful for single line traces for impacts of weapons, for example.

Does Target Data Contain Actor

Returns whether or not the target data contains a specific actor; can be useful if you need to do something special in conditions of capturing a specific actor.

Apply Gameplay Effect To Target

Allows you to apply a gameplay effect to all actors found in the target data; I use this to apply damage to all valid targets.

In my use case, I take the target data and apply a random integer value as damage to the hit actors. My overall damage pipeline is still a work in progress, but I needed to put some together as a proof of concept.


Below are the C++ classes, and example images from Blueprint on how I implement the Wait Target Data task for my project:

NOTE: Since my project is a top-down/isometric point & click example, I needed to create my own GameplayAbilityTargetActor class so that I could properly trace from the camera to the ground (with its own unique TargetTraceChannel) and draw a cylindrical overlap from the hit location.

Features


Custom Debug Drawing

In order to help me debug the traces, I added two different traces, a line trace to show me the impact normal of the hit trace from the camera to the ground, and a cylinder overlap draw to show me the radius and bounds of my overlap detection. Here is the code found in the method FHitResult AGASCourseTargetActor_CameraTrace::PerformTrace(AActor* InSourceActor)

#if ENABLE_DRAW_DEBUG
	if (bDebug)
	{
		const FVector CylinderHeight = (ReturnHitResult.Normal * CollisionHeight);
		DrawDebugCylinder(ThisWorld, TraceEnd, TraceEnd + CylinderHeight, CollisionRadius, 10, FColor::Red, false, 1.0f, 0, 2.0f);
		DrawDebugLine(GetWorld(), ReturnHitResult.Location, ReturnHitResult.Location + (ReturnHitResult.Normal * 500.0f), FColor::Blue, true);
	}
#endif


Custom Character Outline

To help players know that an actor within the radius of the marker are indeed valid and will receive damage (or any other effect) on confirmation, I have added a custom character outline that I enable/disable on tick, check it out in the method: void AGASCourseTargetActor_CameraTrace::Tick(float DeltaSeconds)

Here are some references for those interested in the topic of creating this outline via post process:

Tom Looman - Multi-color Outline Post Process in Unreal Engine

BYC - [Outline Effect]

Matt Aspland - How To Highlight An Object With An Outline In Unreal Engine 5 (Tutorial)

I use a customm struct to initialize my outline data:

image

For the future, I’d like to move this out to some project settings so that you can parameterize the outline color either based on class, or even team association (Hostile, Neutral, Friendly).

void AGASCourseTargetActor_CameraTrace::DrawTargetOutline(TArray<TWeakObjectPtr<AActor>> InHitActors,
	TArray<TWeakObjectPtr<AActor>> InLatestHitActors)
{
	if(TargetOutlineData.CharacterClassToOutline == nullptr)
	{
		return;
	}
	
	for(const TWeakObjectPtr<AActor>& Actor : InHitActors)
	{
		if(Actor->IsA(TargetOutlineData.CharacterClassToOutline))
		{
			const AGASCourseCharacter* Character = Cast<AGASCourseCharacter>(Actor);
			if(USkeletalMeshComponent* Mesh = Character->GetComponentByClass<USkeletalMeshComponent>())
			{
				Mesh->SetRenderCustomDepth(false);
				Mesh->SetCustomDepthStencilValue(0);
			}
		}
	}
	
	for(const TWeakObjectPtr<AActor>& Actor : InLatestHitActors)
	{
		if(Actor->IsA(TargetOutlineData.CharacterClassToOutline))
		{
			const AGASCourseCharacter* Character = Cast<AGASCourseCharacter>(Actor);
			if(USkeletalMeshComponent* Mesh = Character->GetComponentByClass<USkeletalMeshComponent>())
			{
				Mesh->SetRenderCustomDepth(true);
				Mesh->SetCustomDepthStencilValue(2);
			}
		}
	}
}

void AGASCourseTargetActor_CameraTrace::ClearTargetOutline(TArray<TWeakObjectPtr<AActor>> InHitActors)
{
	if(TargetOutlineData.CharacterClassToOutline == nullptr)
	{
		return;
	}
	
	for(const TWeakObjectPtr<AActor>& Actor : InHitActors)
	{
		if(Actor->IsA(TargetOutlineData.CharacterClassToOutline))
		{
			const AGASCourseCharacter* Character = Cast<AGASCourseCharacter>(Actor);
			if(USkeletalMeshComponent* Mesh = Character->GetComponentByClass<USkeletalMeshComponent>())
			{
				Mesh->SetRenderCustomDepth(false);
				Mesh->SetCustomDepthStencilValue(0);
			}
		}
	}
}


Custom Reticle

The last thing that I needed was a custom reticle so that players can visualize the radius of the aim cast spell. This reticle is a custom decal material that gets drawn via the AGameplayAbilityWorldReticle class. I use the WorldReticleParameters to adjust the scale of the actor:

You can find where I initialize the world reticle in the method FHitResult AGASCourseTargetActor_CameraTrace::PerformTrace(AActor* InSourceActor)

	if (AGameplayAbilityWorldReticle* LocalReticleActor = ReticleActor.Get())
	{
		LocalReticleActor->SetIsTargetValid(bLastTraceWasGood);
		LocalReticleActor->SetActorLocation(ReturnHitResult.Location);
		LocalReticleActor->SetActorScale3D(ReticleParams.AOEScale);
		FRotator LocalReticleRot = ReturnHitResult.Normal.Rotation();
		LocalReticleActor->SetActorRotation(LocalReticleRot);
	}

	return ReturnHitResult;

Here is the reference I used to create the material:

Segmented Circle Material in Unreal Engine 4


To wrap up this blog post, here are the final results with all the C++ code I wrote and images of the BP implementation of my Aim Cast ability.

Example videos:

Blueprints


C++:

GASCourseTargetActor_Trace.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "GASCourseCharacter.h"
#include "Abilities/GameplayAbilityTargetActor_Trace.h"
#include "GASCourseTargetActor_Trace.generated.h"

/**
 * 
 */

USTRUCT(Blueprintable)
struct FTargetingOutline
{
	GENERATED_BODY()

public:
	
	UPROPERTY(EditAnywhere,BlueprintReadWrite, Category = "GASCourse|Targeting|Outline")
	bool bEnableTargetingOutline;
	
	UPROPERTY(EditAnywhere,BlueprintReadWrite, Category = "GASCourse|Targeting|Outline")
	FLinearColor OutlineColor;
	
	UPROPERTY(EditAnywhere,BlueprintReadWrite, Category = "GASCourse|Targeting|Outline")
	TSubclassOf<AGASCourseCharacter> CharacterClassToOutline;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "GASCourse|Targeting|Outline")
	UMaterialInterface* OutlineMaterial;

	FTargetingOutline()
	{
		bEnableTargetingOutline = true;
		OutlineColor = FColor::Red;
		CharacterClassToOutline = nullptr;
		OutlineMaterial = nullptr;
	}
	
};
UCLASS(Abstract, Blueprintable, notplaceable, config=Game, HideCategories=Trace)
class GASCOURSE_API AGASCourseTargetActor_Trace : public AGameplayAbilityTargetActor
{
	GENERATED_UCLASS_BODY()
	
public:
	
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

	virtual void CancelTargeting() override;

	virtual void ConfirmTargeting() override;

	/** Traces as normal, but will manually filter all hit actors */
	static void LineTraceWithFilter(FHitResult& OutHitResult, const UWorld* World, const FGameplayTargetDataFilterHandle FilterHandle, const FVector& Start, const FVector& End, ECollisionChannel CollisionChannel, const FCollisionQueryParams Params);

	/** Sweeps as normal, but will manually filter all hit actors */
	static void SweepWithFilter(FHitResult& OutHitResult, const UWorld* World, const FGameplayTargetDataFilterHandle FilterHandle, const FVector& Start, const FVector& End, const FQuat& Rotation, const FCollisionShape CollisionShape, FName ProfileName, const FCollisionQueryParams Params);

	void AimWithPlayerController(const AActor* InSourceActor, FCollisionQueryParams Params, const FVector& TraceStart, FVector& OutTraceEnd, bool bIgnorePitch = false) const;

	static bool ClipCameraRayToAbilityRange(FVector CameraLocation, FVector CameraDirection, FVector AbilityCenter, float AbilityRange, FVector& ClippedPosition);

	virtual void StartTargeting(UGameplayAbility* Ability) override;

	virtual void ConfirmTargetingAndContinue() override;

	virtual void Tick(float DeltaSeconds) override;

	virtual void ShowMouseCursor(bool bShowCursor);

	//Range of the required trace to hit the ground/landscepe
	float MaxRange;

	//Profile FName to detect the ground
	UPROPERTY(BlueprintReadOnly, meta = (ExposeOnSpawn = true), Category = Targeting)
	TEnumAsByte<ECollisionChannel> TraceChannel;

	TArray<TWeakObjectPtr<AActor>> ActorsToOutline;

protected:
	
	virtual FHitResult PerformTrace(AActor* InSourceActor) PURE_VIRTUAL(AGameplayAbilityTargetActor_Trace, return FHitResult(););

	FGameplayAbilityTargetDataHandle MakeTargetData(const FHitResult& HitResult) const;

	TWeakObjectPtr<AGameplayAbilityWorldReticle> ReticleActor;

	virtual void UpdateLooseGameplayTagsDuringTargeting(FGameplayTag InGameplayTag, int32 InCount);

	virtual void DrawTargetOutline(TArray<TWeakObjectPtr<AActor> > InHitActors,  TArray<TWeakObjectPtr<AActor>> InLatestHitActors);
	virtual void ClearTargetOutline(TArray<TWeakObjectPtr<AActor> > InHitActors);

protected:

	FGameplayTagContainer DefaultTargetingTagContainer;
	
};

GASCourseTargetActor_Trace.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/GameplayAbilitySystem/Tasks/AbilityTargetActor/GASCourseTargetActor_Trace.h"
#include "AbilitySystemComponent.h"
#include "Game/Character/NPC/GASCourseNPC_Base.h"
#include "Game/Character/Player/GASCoursePlayerController.h"
#include "Abilities/GameplayAbility.h"
#include "Game/GameplayAbilitySystem/GASCourseNativeGameplayTags.h"

AGASCourseTargetActor_Trace::AGASCourseTargetActor_Trace(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
	PrimaryActorTick.bCanEverTick = true;
	PrimaryActorTick.TickGroup = TG_PostUpdateWork;

	//Initialize these variables to our needs for tracing.
	MaxRange = 999999.0f;
}

void AGASCourseTargetActor_Trace::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	if (ReticleActor.IsValid())
	{
		ReticleActor.Get()->Destroy();
	}

	Super::EndPlay(EndPlayReason);
}

void AGASCourseTargetActor_Trace::LineTraceWithFilter(FHitResult& OutHitResult, const UWorld* World,
	const FGameplayTargetDataFilterHandle FilterHandle, const FVector& Start, const FVector& End, ECollisionChannel CollisionChannel,
	const FCollisionQueryParams Params)
{
	check(World);

	TArray<FHitResult> HitResults;
	//World->LineTraceMultiByProfile(HitResults, Start, End, ProfileName, Params);
	World->LineTraceMultiByChannel(HitResults, Start, End, CollisionChannel, Params);

	OutHitResult.TraceStart = Start;
	OutHitResult.TraceEnd = End;

	for (int32 HitIdx = 0; HitIdx < HitResults.Num(); ++HitIdx)
	{
		const FHitResult& Hit = HitResults[HitIdx];

		if (!Hit.HitObjectHandle.IsValid() || FilterHandle.FilterPassesForActor(Hit.HitObjectHandle.FetchActor()))
		{
			OutHitResult = Hit;
			OutHitResult.bBlockingHit = true; // treat it as a blocking hit
			return;
		}
	}
}

void AGASCourseTargetActor_Trace::SweepWithFilter(FHitResult& OutHitResult, const UWorld* World,
	const FGameplayTargetDataFilterHandle FilterHandle, const FVector& Start, const FVector& End, const FQuat& Rotation,
	const FCollisionShape CollisionShape, FName ProfileName, const FCollisionQueryParams Params)
{
	check(World);

	TArray<FHitResult> HitResults;
	World->SweepMultiByProfile(HitResults, Start, End, Rotation, ProfileName, CollisionShape, Params);

	OutHitResult.TraceStart = Start;
	OutHitResult.TraceEnd = End;

	for (int32 HitIdx = 0; HitIdx < HitResults.Num(); ++HitIdx)
	{
		const FHitResult& Hit = HitResults[HitIdx];

		if (!Hit.HitObjectHandle.IsValid() || FilterHandle.FilterPassesForActor(Hit.HitObjectHandle.FetchActor()))
		{
			OutHitResult = Hit;
			OutHitResult.bBlockingHit = true; // treat it as a blocking hit
			return;
		}
	}
}

void AGASCourseTargetActor_Trace::AimWithPlayerController(const AActor* InSourceActor, FCollisionQueryParams Params,
	const FVector& TraceStart, FVector& OutTraceEnd, bool bIgnorePitch) const
{
	if (!OwningAbility) // Server and launching client only
		{
		return;
		}

	APlayerController* PC = OwningAbility->GetCurrentActorInfo()->PlayerController.Get();
	check(PC);

	FVector ViewStart;
	FRotator ViewRot;
	PC->GetPlayerViewPoint(ViewStart, ViewRot);

	const FVector ViewDir = ViewRot.Vector();
	FVector ViewEnd = ViewStart + (ViewDir * MaxRange);

	ClipCameraRayToAbilityRange(ViewStart, ViewDir, TraceStart, MaxRange, ViewEnd);

	FHitResult HitResult;
	LineTraceWithFilter(HitResult, InSourceActor->GetWorld(), Filter, ViewStart, ViewEnd, TraceChannel, Params);

	const bool bUseTraceResult = HitResult.bBlockingHit && (FVector::DistSquared(TraceStart, HitResult.Location) <= (MaxRange * MaxRange));

	const FVector AdjustedEnd = (bUseTraceResult) ? HitResult.Location : ViewEnd;

	FVector AdjustedAimDir = (AdjustedEnd - TraceStart).GetSafeNormal();
	if (AdjustedAimDir.IsZero())
	{
		AdjustedAimDir = ViewDir;
	}

	if (bUseTraceResult)
	{
		FVector OriginalAimDir = (ViewEnd - TraceStart).GetSafeNormal();

		if (!OriginalAimDir.IsZero())
		{
			// Convert to angles and use original pitch
			const FRotator OriginalAimRot = OriginalAimDir.Rotation();

			FRotator AdjustedAimRot = AdjustedAimDir.Rotation();
			AdjustedAimRot.Pitch = OriginalAimRot.Pitch;

			AdjustedAimDir = AdjustedAimRot.Vector();
		}
	}

	OutTraceEnd = TraceStart + (AdjustedAimDir * MaxRange);
}

bool AGASCourseTargetActor_Trace::ClipCameraRayToAbilityRange(FVector CameraLocation, FVector CameraDirection,
	FVector AbilityCenter, float AbilityRange, FVector& ClippedPosition)
{
	FVector CameraToCenter = AbilityCenter - CameraLocation;
	float DotToCenter = FVector::DotProduct(CameraToCenter, CameraDirection);
	if (DotToCenter >= 0)		//If this fails, we're pointed away from the center, but we might be inside the sphere and able to find a good exit point.
		{
		float DistanceSquared = CameraToCenter.SizeSquared() - (DotToCenter * DotToCenter);
		float RadiusSquared = (AbilityRange * AbilityRange);
		if (DistanceSquared <= RadiusSquared)
		{
			float DistanceFromCamera = FMath::Sqrt(RadiusSquared - DistanceSquared);
			float DistanceAlongRay = DotToCenter + DistanceFromCamera;						//Subtracting instead of adding will get the other intersection point
			ClippedPosition = CameraLocation + (DistanceAlongRay * CameraDirection);		//Cam aim point clipped to range sphere
			return true;
		}
		}
	return false;
}

void AGASCourseTargetActor_Trace::Tick(float DeltaSeconds)
{
	// very temp - do a mostly hardcoded trace from the source actor
	if (SourceActor && SourceActor->GetLocalRole() != ENetRole::ROLE_SimulatedProxy)
	{
		FHitResult HitResult = PerformTrace(SourceActor);
		FVector EndPoint = HitResult.Component.IsValid() ? HitResult.ImpactPoint : HitResult.TraceEnd;

#if ENABLE_DRAW_DEBUG
		if (bDebug)
		{
			DrawDebugLine(GetWorld(), SourceActor->GetActorLocation(), EndPoint, FColor::Green, false);
			DrawDebugSphere(GetWorld(), EndPoint, 16, 10, FColor::Green, false);
		}
#endif // ENABLE_DRAW_DEBUG

		SetActorLocationAndRotation(EndPoint, SourceActor->GetActorRotation());
	}
}

void AGASCourseTargetActor_Trace::ShowMouseCursor(bool bShowCursor)
{
	if(AGASCoursePlayerController* SourcePC = Cast<AGASCoursePlayerController>(OwningAbility->GetCurrentActorInfo()->PlayerController.Get()))
	{
		SourcePC->bShowMouseCursor = bShowCursor;
	}
}

FGameplayAbilityTargetDataHandle AGASCourseTargetActor_Trace::MakeTargetData(const FHitResult& HitResult) const
{
	/** Note: This will be cleaned up by the FGameplayAbilityTargetDataHandle (via an internal TSharedPtr) */
	return StartLocation.MakeTargetDataHandleFromHitResult(OwningAbility, HitResult);
}

void AGASCourseTargetActor_Trace::DrawTargetOutline(TArray<TWeakObjectPtr<AActor>> InHitActors, TArray<TWeakObjectPtr<AActor>> InLatestHitActors)
{

}

void AGASCourseTargetActor_Trace::ClearTargetOutline(TArray<TWeakObjectPtr<AActor>> InHitActors)
{

}

void AGASCourseTargetActor_Trace::StartTargeting(UGameplayAbility* InAbility)
{
	Super::StartTargeting(InAbility);
	SourceActor = InAbility->GetCurrentActorInfo()->AvatarActor.Get();
	UpdateLooseGameplayTagsDuringTargeting(Status_Block_PointClickMovementInput, 1);
	UpdateLooseGameplayTagsDuringTargeting(Status_Gameplay_Targeting, 1);
	ShowMouseCursor(false);
	
	if (ReticleClass)
	{
		AGameplayAbilityWorldReticle* SpawnedReticleActor = GetWorld()->SpawnActor<AGameplayAbilityWorldReticle>(ReticleClass, GetActorLocation(), GetActorRotation());
		if (SpawnedReticleActor)
		{
			SpawnedReticleActor->InitializeReticle(this, PrimaryPC, ReticleParams);
			ReticleActor = SpawnedReticleActor;

			// This is to catch cases of playing on a listen server where we are using a replicated reticle actor.
			// (In a client controlled player, this would only run on the client and therefor never replicate. If it runs
			// on a listen server, the reticle actor may replicate. We want consistancy between client/listen server players.
			// Just saying 'make the reticle actor non replicated' isnt a good answer, since we want to mix and match reticle
			// actors and there may be other targeting types that want to replicate the same reticle actor class).
			if (!ShouldProduceTargetDataOnServer)
			{
				SpawnedReticleActor->SetReplicates(false);
			}
		}
	}

}

void AGASCourseTargetActor_Trace::ConfirmTargetingAndContinue()
{
	Super::ConfirmTargetingAndContinue();
	UpdateLooseGameplayTagsDuringTargeting(Status_Block_PointClickMovementInput, 0);
	UpdateLooseGameplayTagsDuringTargeting(Status_Gameplay_Targeting, 0);
	ShowMouseCursor(true);
	ClearTargetOutline(ActorsToOutline);
}

void AGASCourseTargetActor_Trace::CancelTargeting()
{
	Super::CancelTargeting();
	UpdateLooseGameplayTagsDuringTargeting(Status_Block_PointClickMovementInput, 0);
	UpdateLooseGameplayTagsDuringTargeting(Status_Gameplay_Targeting, 0);
	ShowMouseCursor(true);
	ClearTargetOutline(ActorsToOutline);
}

void AGASCourseTargetActor_Trace::ConfirmTargeting()
{
	Super::ConfirmTargeting();
	Super::ConfirmTargetingAndContinue();
	UpdateLooseGameplayTagsDuringTargeting(Status_Block_PointClickMovementInput, 0);
	UpdateLooseGameplayTagsDuringTargeting(Status_Gameplay_Targeting, 0);
	ShowMouseCursor(true);
	ClearTargetOutline(ActorsToOutline);
}

void AGASCourseTargetActor_Trace::UpdateLooseGameplayTagsDuringTargeting(FGameplayTag InGameplayTag, int32 InCount)
{
	if(UAbilitySystemComponent* ASC = OwningAbility->GetCurrentActorInfo()->AbilitySystemComponent.Get())
	{
		ASC->SetLooseGameplayTagCount(InGameplayTag, InCount);
	}
}

GASCourseTargetActor_CameraTrace.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "GASCourseTargetActor_Trace.h"
#include "GASCourseTargetActor_CameraTrace.generated.h"

class UGameplayAbility;

/**
 * 
 */

UCLASS(Blueprintable)
class GASCOURSE_API AGASCourseTargetActor_CameraTrace : public AGASCourseTargetActor_Trace
{
	GENERATED_UCLASS_BODY()

public:	

	virtual void StartTargeting(UGameplayAbility* InAbility) override;
	virtual void ConfirmTargetingAndContinue() override;
	virtual void CancelTargeting();
	virtual void Tick(float DeltaSeconds) override;

	/** Radius for a sphere or capsule. */
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ExposeOnSpawn = true), Category = Targeting)
	float CollisionRadius;

	/** Height for a capsule. Implicitly indicates a capsule is desired if this is greater than zero. */
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ExposeOnSpawn = true), Category = Targeting)
	float CollisionHeight;
	
	/** Trace Channel to check for*/
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ExposeOnSpawn = true), Category = Targeting)
	TArray<TEnumAsByte<ECollisionChannel>> QueryChannels;
	
	/** Trace Channel to check for*/
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ExposeOnSpawn = true), Category = Targeting)
	FTargetingOutline TargetOutlineData;

protected:
	virtual FHitResult PerformTrace(AActor* InSourceActor) override;

	virtual bool IsConfirmTargetingAllowed() override;

	TArray<TWeakObjectPtr<AActor> >	PerformOverlap(const FVector& Origin);
	
	bool OverlapMultiByObjectTypes(TArray<TWeakObjectPtr<AActor>>& OutHitActors, const FVector& Pos, const FQuat& Rot, const FCollisionShape& OverlapCollisionShape,
		const FCollisionQueryParams& Params = FCollisionQueryParams::DefaultQueryParam) const;
	
	FGameplayAbilityTargetDataHandle MakeTargetData(const TArray<TWeakObjectPtr<AActor>>& Actors, const FVector& Origin) const;

	void DrawTargetOutline(TArray<TWeakObjectPtr<AActor>> InHitActors, TArray<TWeakObjectPtr<AActor>> InLatestHitActors) override;
	void ClearTargetOutline(TArray<TWeakObjectPtr<AActor>> InHitActors) override;
	
protected:	
	bool bLastTraceWasGood;
};

GASCourseTargetActor_CameraTrace.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/GameplayAbilitySystem/Tasks/AbilityTargetActor/GASCourseTargetActor_CameraTrace.h"
#include "Abilities/GameplayAbility.h"

AGASCourseTargetActor_CameraTrace::AGASCourseTargetActor_CameraTrace(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
	CollisionRadius = 50.0f;
	CollisionHeight = 50.0f;
}

void AGASCourseTargetActor_CameraTrace::StartTargeting(UGameplayAbility* InAbility)
{
	Super::StartTargeting(InAbility);
}

void AGASCourseTargetActor_CameraTrace::ConfirmTargetingAndContinue()
{
	check(ShouldProduceTargetData());
	if (SourceActor)
	{
		const FVector Origin = PerformTrace(SourceActor).Location;
		FGameplayAbilityTargetDataHandle Handle = MakeTargetData(PerformOverlap(Origin), Origin);
		TargetDataReadyDelegate.Broadcast(Handle);
	}
}

void AGASCourseTargetActor_CameraTrace::CancelTargeting()
{
	Super::CancelTargeting();
}

void AGASCourseTargetActor_CameraTrace::Tick(float DeltaSeconds)
{
	Super::Tick(DeltaSeconds);
	if (SourceActor && SourceActor->GetLocalRole() != ENetRole::ROLE_SimulatedProxy)
	{
		const FHitResult HitResult = PerformTrace(SourceActor);
		const FVector EndPoint = HitResult.Component.IsValid() ? HitResult.ImpactPoint : HitResult.TraceEnd;

		if(TargetOutlineData.bEnableTargetingOutline)
		{
			DrawTargetOutline(ActorsToOutline, PerformOverlap(EndPoint));
			ActorsToOutline = PerformOverlap(EndPoint);
		}
	}
}

FHitResult AGASCourseTargetActor_CameraTrace::PerformTrace(AActor* InSourceActor)
{
	bool bTraceComplex = false;

	FCollisionQueryParams Params(SCENE_QUERY_STAT(AGASCourseTargetActor_CameraTrace), bTraceComplex);
	Params.bReturnPhysicalMaterial = true;
	Params.AddIgnoredActor(InSourceActor);
	UWorld *ThisWorld = GetWorld();
	FHitResult ReturnHitResult;
	
	APlayerController* PC = OwningAbility->GetCurrentActorInfo()->PlayerController.Get();
	check(PC);

	FVector MousePositionToWorldLocation;
	FVector MousePositionToWorldDirection;

	FVector TraceStart;
	FVector TraceEnd;

	bLastTraceWasGood = false;
	
	if(PC->DeprojectMousePositionToWorld(MousePositionToWorldLocation, MousePositionToWorldDirection))
	{
		TraceStart = MousePositionToWorldLocation;
		TraceEnd = TraceStart + MousePositionToWorldDirection * MaxRange;
	}
	
	LineTraceWithFilter(ReturnHitResult, InSourceActor->GetWorld(), Filter, TraceStart, TraceEnd, TraceChannel, Params);
	//Default to end of trace line if we don't hit anything.
	if (ReturnHitResult.bBlockingHit)
	{
		TraceEnd = ReturnHitResult.Location;
		bLastTraceWasGood = true;
	}

#if ENABLE_DRAW_DEBUG
	if (bDebug)
	{
		const FVector CylinderHeight = (ReturnHitResult.Normal * CollisionHeight);
		DrawDebugCylinder(ThisWorld, TraceEnd, TraceEnd + CylinderHeight, CollisionRadius, 10, FColor::Red, false, 1.0f, 0, 2.0f);
		DrawDebugLine(GetWorld(), ReturnHitResult.Location, ReturnHitResult.Location + (ReturnHitResult.Normal * 500.0f), FColor::Blue, true);
	}
#endif	
	
	if (AGameplayAbilityWorldReticle* LocalReticleActor = ReticleActor.Get())
	{
		LocalReticleActor->SetIsTargetValid(bLastTraceWasGood);
		LocalReticleActor->SetActorLocation(ReturnHitResult.Location);
		LocalReticleActor->SetActorScale3D(ReticleParams.AOEScale);
		FRotator LocalReticleRot = ReturnHitResult.Normal.Rotation();
		LocalReticleActor->SetActorRotation(LocalReticleRot);
	}

	return ReturnHitResult;
}

bool AGASCourseTargetActor_CameraTrace::IsConfirmTargetingAllowed()
{
	return bLastTraceWasGood;
}

TArray<TWeakObjectPtr<AActor>> AGASCourseTargetActor_CameraTrace::PerformOverlap(const FVector& Origin)
{
	constexpr bool bTraceComplex = false;
	
	FCollisionQueryParams Params(SCENE_QUERY_STAT(RadiusTargetingOverlap), bTraceComplex);
	Params.bReturnPhysicalMaterial = false;
	
	TArray<TWeakObjectPtr<AActor>>	HitActors;
	if(QueryChannels.Num() == 0)
	{
		return HitActors;
	}
	
	OverlapMultiByObjectTypes(HitActors, Origin, FQuat::Identity, FCollisionShape::MakeSphere(CollisionRadius), Params);
	return HitActors;
}

bool AGASCourseTargetActor_CameraTrace::OverlapMultiByObjectTypes(TArray<TWeakObjectPtr<AActor>>& OutHitActors, const FVector& Pos,
                                                                  const FQuat& Rot, const FCollisionShape& OverlapCollisionShape, const FCollisionQueryParams& Params) const
{
	TArray<FOverlapResult> Overlaps;
	bool bTraceSuccessful = false;
	
	if(QueryChannels.Num() == 0)
	{
		return bTraceSuccessful;
	}
	
	for(const ECollisionChannel QueryChannel : QueryChannels)
	{
		SourceActor->GetWorld()->OverlapMultiByObjectType(Overlaps, Pos, Rot, FCollisionObjectQueryParams(QueryChannel), OverlapCollisionShape, Params);
		for(int32 i = 0; i < Overlaps.Num(); ++i)
		{
			//Should this check to see if these pawns are in the AimTarget list?
			AActor* HitActor = Overlaps[i].OverlapObjectHandle.FetchActor<AActor>();
			if (HitActor && !OutHitActors.Contains(HitActor) && Filter.FilterPassesForActor(HitActor))
			{
				OutHitActors.Add(HitActor);
			}
		}
	}

	return bTraceSuccessful = OutHitActors.Num() > 0 ? true : false;
}

FGameplayAbilityTargetDataHandle AGASCourseTargetActor_CameraTrace::MakeTargetData(
	const TArray<TWeakObjectPtr<AActor>>& Actors, const FVector& Origin) const
{
	if (OwningAbility)
	{
		/** Use the source location instead of the literal origin */
		return StartLocation.MakeTargetDataHandleFromActors(Actors, false);
	}

	return FGameplayAbilityTargetDataHandle();
}

void AGASCourseTargetActor_CameraTrace::DrawTargetOutline(TArray<TWeakObjectPtr<AActor>> InHitActors,
	TArray<TWeakObjectPtr<AActor>> InLatestHitActors)
{
	if(TargetOutlineData.CharacterClassToOutline == nullptr)
	{
		return;
	}
	
	for(const TWeakObjectPtr<AActor>& Actor : InHitActors)
	{
		if(Actor->IsA(TargetOutlineData.CharacterClassToOutline))
		{
			const AGASCourseCharacter* Character = Cast<AGASCourseCharacter>(Actor);
			if(USkeletalMeshComponent* Mesh = Character->GetComponentByClass<USkeletalMeshComponent>())
			{
				Mesh->SetRenderCustomDepth(false);
				Mesh->SetCustomDepthStencilValue(0);
			}
		}
	}
	
	for(const TWeakObjectPtr<AActor>& Actor : InLatestHitActors)
	{
		if(Actor->IsA(TargetOutlineData.CharacterClassToOutline))
		{
			const AGASCourseCharacter* Character = Cast<AGASCourseCharacter>(Actor);
			if(USkeletalMeshComponent* Mesh = Character->GetComponentByClass<USkeletalMeshComponent>())
			{
				Mesh->SetRenderCustomDepth(true);
				Mesh->SetCustomDepthStencilValue(2);
			}
		}
	}
}

void AGASCourseTargetActor_CameraTrace::ClearTargetOutline(TArray<TWeakObjectPtr<AActor>> InHitActors)
{
	if(TargetOutlineData.CharacterClassToOutline == nullptr)
	{
		return;
	}
	
	for(const TWeakObjectPtr<AActor>& Actor : InHitActors)
	{
		if(Actor->IsA(TargetOutlineData.CharacterClassToOutline))
		{
			const AGASCourseCharacter* Character = Cast<AGASCourseCharacter>(Actor);
			if(USkeletalMeshComponent* Mesh = Character->GetComponentByClass<USkeletalMeshComponent>())
			{
				Mesh->SetRenderCustomDepth(false);
				Mesh->SetCustomDepthStencilValue(0);
			}
		}
	}
}

References:

Umbral Studios

NanceDevDiaries

LYRA


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

Gameplay Ability System & Enhanced Input Setup

19 Likes

Very cool effect and some super useful code. :+1:

General question given what you’ve worked on - do big dev houses actually use blueprints for gameplay stuff in AAA games?

Outside of things where blueprints are nearly required, like animations, I cannot imagine using blueprints extensively for a game on complexity with Witcher or Skyrim. Can you even even diff blueprint check ins? Also trying to write up any coherent function data flow, framework architecture, or use-case diagram seems incredibly more difficult?

With just C++ code you can look at the versioning system log, see a check-in like “updated the ability targeting code”, then without even opening the engine or Visual Studio, just diff the file and see every single bit of logic they changed. The ability to do that seems imperative to avoiding a bug-ridden mess of spaghetti code.

1 Like

The short answer to if big dev house use blueprints for gameplay stuff is that yes they do.

There is the problem of diffing blueprint check-ins, however you can do historical diffs of blueprints in editor to view changes and try to spot any potential errors that a submit may have caused. Additionally, there is Data-Only BP Merging on the UE roadmap which could be useful, but doesn’t fully solve the problem: https://portal.productboard.com/epicgames/1-unreal-engine-public-roadmap/c/1282-data-only-bp-merging.

The primary use case for Blueprints is fast iteration that is super useful for prototyping mechanics and features that would then either be thrown away and re-written in C++ during production or it can be moved to C++ piece by piece, or refactored to be more performant through code. Since Blueprints are more accessible, non-technical people can be involved in the prototyping process and allow coders to focus on more framework related topics that these prototypes could then be injected into. Whether or not most logic remains in Blueprints is really up to how they are maintained and the requirements of the project.

One of the biggest weaknesses with Blueprints that I have experienced is the lack of ‘Blueprint Reviews’ that can help reduce bad practices and bugs; but these type of reviews aren’t very common. Code Reviews are more of an industry standard and its hard to move that practice to Blueprints simply due to the quick & dirty approach that they offer.

Outriders and Outriders: Worldslayer shipped with some of the gameplay handled in Blueprints, mostly abilities and complicated perks/bonuses that required logic. All-in-all, the overall structure of the gameplay, its framework, was in code, and then the individual abilities were done in Blueprint; with exceptions to either complex logic that needed to be done in C++ for performance or if logic could be shared between abilities.

3 Likes

Ahh ok great thanks for the info. :+1:

1 Like

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Today we are going to talk about the Enhanced Input System and how I use it with the Gameplay Ability System plugin. For my project, I am handling input mostly from C++, but I will try to include some information relative to Blueprints. There is alot of good information in the Unreal Engine documentation, so I will take a good chunk of detail from there, so please check it out yourself for even more info.


What is Enhanced Input

Classes to research on your own:

UEnhancedInput
UInputAction
UInputMappingContext

Additional Reading:

https://docs.unrealengine.com/5.0/en-US/enhanced-input-in-unreal-engine/


The Enhanced Input System is Unreal Engine’s solution to complex input handling that give’s developers control on how input is mapped, and hooked into their gameplay logic. Before Enhanced Input there were simple input bindings for Action and Axis mappings that are now deprecated, but you can still find them under Project Settings->Engine->Input:

Action Mappings: Action mappings are representative of a binary input, like press and release of a button; optional chorded logic was present in the form of check-boxes for keys such as Shift, CTRL, ALT, and CMD.

Axis Mappings: Axis mappings are representative of sum of the values of the key’s state in a specific frame, meaning for a binary input action like a button press, when its held down the the axis value is 1, and when its released it is back to 0. However, for inputs such as Mouse X or Y, its motion returns a interpolated value based on the movement of mouse in the specified direction.

You can read more about them here:

https://www.unrealengine.com/en-US/blog/input-action-and-axis-mappings-in-ue4


The Enhanced Input System is comprised of two main classes:

Input Actions

Input Actions are the connection between the system and your project’s code. You can create an Input Action by right-clicking in the Context Browser , expanding the Input option, and choosing Input Action . To trigger an Input Action, you must include it in an Input Mapping Context, and add that Input Mapping Context to the local player’s Enhanced Input Local Player Subsystem .” (Epic Games)

Input Mapping Contexts

"Input Mapping Contexts describe the rules for triggering one or more Input Actions. The basic structure of an Input Mapping Context is a hierarchy with a list of Input Actions at the top level. Under the Input Action level is a list of user inputs that can trigger each Input Action, such as keys, buttons, and movement axes.

The bottom level contains a list of Input Triggers and Input Modifiers for each user input, which you can use to determine how an input’s raw value is filtered or processed, and what restrictions it must meet in order to drive the Input Action at the top of its hierarchy.

Any input can have multiple Input Modifiers and Input Triggers. These will be evaluated in the order in which they appear in the list you create; this is particularly important for Input Modifiers, which use the output of each step as the input for the next." (Epic Games)

For the sake of brevity, you can read more about triggers and modifiers here. The cool thing about input triggers and modifiers is that you can extend these classes

Input Triggers determine whether user input, after passing through an optional list of Input Modifiers, should activate the corresponding Input Action within its Input Mapping Context. Most Input Triggers analyze the input itself, checking for minimum actuation values and validating patterns like short taps, prolonged holds, or the typical “press” or “release” events. The one exception to this rule is the “Chorded Action” Input Trigger, which is only triggered with another Input Action. By default, any user activity on an input will trigger on every tick.

There are three types of Input Triggers:

  • Explicit types cause the input to succeed if the Input Trigger succeeds.
  • Implicit types cause the input to succeed only if the Input Trigger and all other Implicit type Input Triggers succeed.
  • Blocker types cause the input to fail if the Input Trigger succeeds.

(Epic Games)

image

You can extend the UInputTrigger class in Blueprints in order to override the Update State function:


Input Modifiers are pre-processors that alter the raw input values that UE receives before sending them on to Input Triggers. The Enhanced Input Plugin has a variety of Input Modifiers to perform tasks like changing the order of axes, implementing “dead zones”, and converting axial input to world space.

Input Modifiers are useful for applying sensitivity settings, smoothing input over multiple frames, or changing how input behaves based on the state of the player. Because you have access to the UPlayerInput class when making your own modifier, you can access the owning Player Controller and get any game state you want. (Epic Games)

You can extend the UInputModifier class in Blueprints in order to override the Modify Raw function:


Depending on how your project is set up will dictate when you call the AddMappingContext function of the UEnhancedInputLocalPlayerSubsystem to apply your mapping contexts. Since my project is intended to support multiplayer, I call the following code in both the PossessedBy and OnRep_PlayerState functions:

			if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
			{
				Subsystem->AddMappingContext(DefaultMappingContextKBM, 0);
				Subsystem->AddMappingContext(DefaultMappingContextGamepad, 0);
			}

Here is the Blueprint equivalent of adding a mapping context:


Enhanced Input Debugging

You can use the console command ShowDebug EnhancedInput to show more details about the currently applied mapping contexts.


Now, how do we hook in our Enhanced Input actions and contexts to the Gameplay Ability System? This is done mainly through a custom data asset called UGASCourseInputConfig, which is heavily influenced by the ULyraInputConfig class:

GASCourseInputConfig.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "Engine/DataAsset.h"
#include "GameplayTagContainer.h"
#include "GASCourseInputConfig.generated.h"

class UInputAction;
struct FGameplayTag;

/**
 * Struct used to map an input action to a gameplay input tag.
 */
USTRUCT(BlueprintType)
struct FTaggedInputAction
{
	GENERATED_BODY()

public:

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	TObjectPtr<const UInputAction> InputAction = nullptr;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Meta = (Categories = "InputTag"))
	FGameplayTag InputTag;
};

USTRUCT(BlueprintType)
struct FTaggedAbilityAction
{
	GENERATED_BODY()

public:

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	TObjectPtr<const UInputAction> InputAction = nullptr;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Meta = (Categories = "InputTag"))
	FGameplayTag InputTag;
};


UCLASS()
class GASCOURSE_API UGASCourseInputConfig : public UDataAsset
{
	GENERATED_BODY()

public:
	// Returns the first Input Action associated with a given tag.
	const UInputAction* FindInputActionForTag(const FGameplayTag& InputTag) const;

public:
	// List of input actions used by the owner. These input actions are mapped to a gameplay tag and must be manually bound.
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Meta = (TitleProperty = "InputAction"))
	TArray<FTaggedInputAction> TaggedInputActions;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Meta = (TitleProperty = "InputAction"))
	TArray<FTaggedAbilityAction> TaggedAbilityActions;
};

GASCourseInputConfig.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/Input/GASCourseInputConfig.h"
#include "GameplayTagContainer.h"
#include "InputAction.h"

const UInputAction* UGASCourseInputConfig::FindInputActionForTag(const FGameplayTag& InputTag) const
{
	for (const FTaggedInputAction& TaggedInputAction : TaggedInputActions)
	{
		if (TaggedInputAction.InputAction && TaggedInputAction.InputTag == InputTag)
		{
			return TaggedInputAction.InputAction;
		}
	}

	return nullptr;
}

Within the Input Configuation, there are two main concepts. The first is of Tagged Input Actions and the other is Tagged Ability Actions:

Tagged Input Actions are for native input actions that the characters’ in my game can perform; these are actions such as movement, point and click, camera movement/rotation, camera zoom, confirm/cancel targeting, etc. These are native actions that are built in code rather than linked to an ability to run the logic. There is also the concept of Native Gameplay Tags, that you can learn more about here: UE Tip: Declare & Define Native Gameplay Tags

Tagged Ability Actions are input actions that are linked to granted abilities that are mapped to the same input tag via the UGASCourseGameplayAbilitySet, which is also heavily influenced by Lyra’s ULyraAbilitySet.

UGASCourseInputConfig


UGASCourseGameplayAbilitySet

Note: We will talk more in-depth about ability sets and how we grant abilities in a future blog post!


We can use Native Gameplay Tags to provide us with a mechanism to populate the Gameplay Tags and label these tags in code so that we can reference them in other classes. We will be referencing some of these native gameplay tags when we finally bind A fantastic reference can be found here by Kaos Spectrum, who is also very helpful in the Unreal Source Discord server under the gameplay-ability-system channel.

GASCourseNativeGameplayTags.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "GameplayTagContainer.h"
#include "NativeGameplayTags.h"

class UGameplayTagsManager;

/**
 * Singleton containing native gameplay tags.
 */

UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_Move);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_PointClickMovement);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_Look_Stick);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_Jump);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_WeaponPrimaryFire);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_Crouch);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_CameraZoom);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_AbilityOne);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_AbilityTwo);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_AbilityThree);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_EquipmentAbilityOne)
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_EquipmentAbilityTwo)
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_ConfirmTargetData);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_CancelTargetData);


UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_MoveCamera);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_RecenterCamera);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_RotateCameraAxis);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(InputTag_RotateCamera);

UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_Crouching);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_Falling);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_IsMoving);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_Block_MovementInput);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_Block_AbilityInput);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_Gameplay_Targeting);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_Block_PointClickMovementInput);
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_Death);

struct FGASCourseNativeGameplayTags
{
	
	static const FGASCourseNativeGameplayTags& Get() { return GameplayTags; }

private:

	static FGASCourseNativeGameplayTags GameplayTags;
};

GASCourseNativeGameplayTags.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/GameplayAbilitySystem/GASCourseNativeGameplayTags.h"
#include "NativeGameplayTags.h"

UE_DEFINE_GAMEPLAY_TAG(InputTag_Move, "Input.NativeAction.Move")
UE_DEFINE_GAMEPLAY_TAG(InputTag_PointClickMovement, "Input.NativeAction.PointClickMovement")
UE_DEFINE_GAMEPLAY_TAG(InputTag_Look_Stick, "Input.NativeAction.GamepadLook")
UE_DEFINE_GAMEPLAY_TAG(InputTag_Jump, "Input.NativeAction.Jump")
UE_DEFINE_GAMEPLAY_TAG(InputTag_WeaponPrimaryFire, "Input.NativeAction.PrimaryWeaponFire")
UE_DEFINE_GAMEPLAY_TAG(InputTag_Crouch, "Input.NativeAction.Crouch")
UE_DEFINE_GAMEPLAY_TAG(InputTag_CameraZoom, "Input.NativeAction.CameraZoom")
UE_DEFINE_GAMEPLAY_TAG(InputTag_AbilityOne, "Input.NativeAction.Ability.One")
UE_DEFINE_GAMEPLAY_TAG(InputTag_AbilityTwo, "Input.NativeAction.Ability.Two")
UE_DEFINE_GAMEPLAY_TAG(InputTag_AbilityThree, "Input.NativeAction.Ability.Three")
UE_DEFINE_GAMEPLAY_TAG(InputTag_EquipmentAbilityOne, "Input.NativeAction.Ability.Equipment.One")
UE_DEFINE_GAMEPLAY_TAG(InputTag_EquipmentAbilityTwo, "Input.NativeAction.Ability.Equipment.Two")
UE_DEFINE_GAMEPLAY_TAG(InputTag_ConfirmTargetData, "Input.NativeAction.ConfirmTargeting")
UE_DEFINE_GAMEPLAY_TAG(InputTag_CancelTargetData, "Input.NativeAction.CancelTargeting")

UE_DEFINE_GAMEPLAY_TAG(InputTag_MoveCamera, "Input.NativeAction.MoveCamera")
UE_DEFINE_GAMEPLAY_TAG(InputTag_RecenterCamera, "Input.NativeAction.RecenterCamera")
UE_DEFINE_GAMEPLAY_TAG(InputTag_RotateCamera, "Input.NativeAction.RotateCamera")
UE_DEFINE_GAMEPLAY_TAG(InputTag_RotateCameraAxis, "Input.NativeAction.RotateCamera.Axis")

UE_DEFINE_GAMEPLAY_TAG(Status_Crouching, "Status.Crouching")
UE_DEFINE_GAMEPLAY_TAG(Status_Falling, "Status.Falling")
UE_DEFINE_GAMEPLAY_TAG(Status_IsMoving, "Status.IsMoving")
UE_DEFINE_GAMEPLAY_TAG(Status_Block_PointClickMovementInput, "Status.Block.Input.PointClickMovement")
UE_DEFINE_GAMEPLAY_TAG(Status_Gameplay_Targeting, "Status.Gameplay.Targeting")
UE_DEFINE_GAMEPLAY_TAG(Status_Block_MovementInput, "Status.Block.Input.Movement")
UE_DEFINE_GAMEPLAY_TAG(Status_Block_AbilityInput, "Status.Block.Input.AbilityActivation")
UE_DEFINE_GAMEPLAY_TAG(Status_Death, "Status.Death")

FGASCourseNativeGameplayTags FGASCourseNativeGameplayTags::GameplayTags;

The last piece of the puzzle is the GASCourseEnhancedInputComponent class, derived from UEnhancedInputComponent that contains helper functions that allow me to bind these input actions by tag and by input actions.

GASCourseEnhancedInputComponent.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "EnhancedInputComponent.h"
#include "InputAction.h"
#include "GASCourseInputConfig.h"
#include "GameplayTagContainer.h"
#include "Misc/AssertionMacros.h"
#include "HAL/Platform.h"
#include "GASCourseEnhancedInputComponent.generated.h"

class UEnhancedInputLocalPlayerSubsystem;
class UInputAction;
class UObject;

/**
 * 
 */
UCLASS(Config = Input)
class GASCOURSE_API UGASCourseEnhancedInputComponent : public UEnhancedInputComponent
{
	GENERATED_BODY()
	
public:

	template<class UserClass, typename FuncType>
	void BindActionByTag(const UGASCourseInputConfig* InputConfig, const FGameplayTag& InputTag, ETriggerEvent TriggerEvent, UserClass* Object, FuncType Func);
	
	template<class UserClass, typename PressedFuncType,typename ReleasedFuncType>
	void BindAbilityActions(const UGASCourseInputConfig* InputConfig, UserClass* Object, PressedFuncType PressedFunc, ReleasedFuncType ReleasedFunc, TArray<uint32>& BindHandles);
};

template<class UserClass, typename FuncType>
void UGASCourseEnhancedInputComponent::BindActionByTag(const UGASCourseInputConfig* InputConfig, const FGameplayTag& InputTag, ETriggerEvent TriggerEvent, UserClass* Object, FuncType Func)
{
	check(InputConfig);
	if (const UInputAction* IA = InputConfig->FindInputActionForTag(InputTag))
	{
		BindAction(IA, TriggerEvent, Object, Func);
	}
}

template<class UserClass, typename PressedFuncType, typename ReleasedFuncType>
void UGASCourseEnhancedInputComponent::BindAbilityActions(const UGASCourseInputConfig* InputConfig, UserClass* Object, PressedFuncType PressedFunc, ReleasedFuncType ReleasedFunc,
	TArray<uint32>& BindHandles)
{
	check(InputConfig);
	for (const FTaggedAbilityAction& Action : InputConfig->TaggedAbilityActions)
	{
		if(Action.InputAction && Action.InputTag.IsValid())
		{
			if(PressedFunc)
			{
				BindHandles.Add(BindAction(Action.InputAction, ETriggerEvent::Triggered, Object, PressedFunc, Action.InputTag).GetHandle());
			}

			if(ReleasedFunc)
			{
				BindHandles.Add(BindAction(Action.InputAction, ETriggerEvent::Completed, Object, ReleasedFunc, Action.InputTag).GetHandle());
			}
		}
	}
}

Note: You must remember, if you are using custom classes for either UEnhancedPlayerInput or UEnhancedInputComponent, you must override the default classes under Project Settings->Engine->Input:


Now that we have our ability set granting our abilities, our input config mapping both tagged native input actions and tagged ability input actions, and our input mapping context linking input actions to specific mouse/keyboard/gamepad bindings, we can now setup the input component in C++. The full AGASCoursePlayerCharacter class will be posted at the end:

In the following code, I am showing how we bind both the native actions and the ability actions; there are some legacy bindings that are marked to be removed so please ignore those examples.

//////////////////////////////////////////////////////////////////////////
// Input
void AGASCoursePlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);

	// Set up action bindings
	if (UGASCourseEnhancedInputComponent* EnhancedInputComponent = CastChecked<UGASCourseEnhancedInputComponent>(PlayerInputComponent))
	{
		check(EnhancedInputComponent);
		
		if(InputConfig)
		{
			check(InputConfig);
			//Jumping - TODO: Remove this
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Jump, ETriggerEvent::Triggered, this, &ThisClass::Jump);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Jump, ETriggerEvent::Completed, this, &ThisClass::StopJumping);

			//Moving - TODO: Remove this
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Move, ETriggerEvent::Triggered, this, &ThisClass::Move);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Move, ETriggerEvent::Completed, this, &ThisClass::StopMove);
			
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_PointClickMovement, ETriggerEvent::Triggered, this, &ThisClass::PointClickMovement);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_PointClickMovement, ETriggerEvent::Started, this, &ThisClass::PointClickMovementStarted);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_PointClickMovement, ETriggerEvent::Canceled, this, &ThisClass::PointClickMovementCompleted);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_PointClickMovement, ETriggerEvent::Completed, this, &ThisClass::PointClickMovementCompleted);

			if(UGASCourseAbilitySystemComponent* MyASC = CastChecked<UGASCourseAbilitySystemComponent>( GetAbilitySystemComponent()))
			{
				EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_ConfirmTargetData, ETriggerEvent::Triggered, MyASC, &UGASCourseAbilitySystemComponent::LocalInputConfirm);
				EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_CancelTargetData, ETriggerEvent::Triggered, MyASC, &UGASCourseAbilitySystemComponent::LocalInputCancel);
			}

			//Looking
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Look_Stick, ETriggerEvent::Triggered, this, &ThisClass::Look);

			//Camera Controls
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_MoveCamera, ETriggerEvent::Triggered, this, &ThisClass::Input_MoveCamera);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_MoveCamera,ETriggerEvent::Completed, this, &ThisClass::Input_MoveCameraCompleted);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_RecenterCamera, ETriggerEvent::Triggered, this, &ThisClass::Input_RecenterCamera);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_RotateCameraAxis, ETriggerEvent::Triggered, this, &ThisClass::Input_RotateCameraAxis);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_RotateCamera,ETriggerEvent::Completed, this, &ThisClass::Input_RotateCameraCompleted);

			//Crouching
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Crouch, ETriggerEvent::Triggered, this, &ThisClass::Input_Crouch);

			//Camera Zoom
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_CameraZoom,ETriggerEvent::Triggered, this, &ThisClass::Input_CameraZoom);

			TArray<uint32> BindHandles;
			EnhancedInputComponent->BindAbilityActions(InputConfig, this, &ThisClass::Input_AbilityInputTagPressed, &ThisClass::Input_AbilityInputTagReleased, /*out*/ BindHandles);
		}
	}
}

The BindAbilityActions function references two function signatures; Input_AbilityInputTagPressed and Input)AbilityTagReleased:

void AGASCoursePlayerCharacter::Input_AbilityInputTagPressed(FGameplayTag InputTag)
{
	if(UGASCourseAbilitySystemComponent* ASC = GetAbilitySystemComponent())
	{
		if(ASC->HasMatchingGameplayTag(Status_Block_AbilityInput))
		{
			return;
		}
		ASC->AbilityInputTagPressed(InputTag);
	}
}

void AGASCoursePlayerCharacter::Input_AbilityInputTagReleased(FGameplayTag InputTag)
{
	if(UGASCourseAbilitySystemComponent* ASC = GetAbilitySystemComponent())
	{
		if(ASC->HasMatchingGameplayTag(Status_Block_AbilityInput))
		{
			return;
		}
		ASC->AbilityInputTagReleased(InputTag);
	}
}

These two functions then call the Ability System Component equivalent functions for check for the current activatable abilities that are linked via our Ability Set to specific input tags. When found, these abilities are added to the InputPressedSpecHandles/InputReleasedSpecHandles respectively, and are added/removed from the InputHeldSpecHandles.

void UGASCourseAbilitySystemComponent::AbilityInputTagPressed(const FGameplayTag& InputTag)
{
	if (InputTag.IsValid())
	{
		for (const FGameplayAbilitySpec& AbilitySpec : ActivatableAbilities.Items)
		{
			if (AbilitySpec.Ability && (AbilitySpec.DynamicAbilityTags.HasTagExact(InputTag)))
			{
				InputPressedSpecHandles.AddUnique(AbilitySpec.Handle);
				InputHeldSpecHandles.AddUnique(AbilitySpec.Handle);
			}
		}
	}
}

void UGASCourseAbilitySystemComponent::AbilityInputTagReleased(const FGameplayTag& InputTag)
{
	if (InputTag.IsValid())
	{
		for (const FGameplayAbilitySpec& AbilitySpec : ActivatableAbilities.Items)
		{
			if (AbilitySpec.Ability && (AbilitySpec.DynamicAbilityTags.HasTagExact(InputTag)))
			{
				InputReleasedSpecHandles.AddUnique(AbilitySpec.Handle);
				InputHeldSpecHandles.Remove(AbilitySpec.Handle);
			}
		}
	}
}

Here is where you can see when the Dynamic Ability Tags are updated in the GASCourseGameplayAbilitySet class:

// Grant the gameplay abilities.
	for (int32 AbilityIndex = 0; AbilityIndex < GrantedGameplayAbilities.Num(); ++AbilityIndex)
	{
		const FGASCourseAbilitySet_GameplayAbility& AbilityToGrant = GrantedGameplayAbilities[AbilityIndex];

		if (!IsValid(AbilityToGrant.Ability))
		{
			continue;
		}

		UGASCourseGameplayAbility* AbilityCDO = AbilityToGrant.Ability->GetDefaultObject<UGASCourseGameplayAbility>();

		FGameplayAbilitySpec AbilitySpec(AbilityCDO, AbilityToGrant.AbilityLevel);
		AbilitySpec.SourceObject = SourceObject;
		AbilitySpec.DynamicAbilityTags.AddTag(AbilityToGrant.InputTag);

		const FGameplayAbilitySpecHandle AbilitySpecHandle = ASC->GiveAbility(AbilitySpec);

		if (OutGrantedHandles)
		{
			OutGrantedHandles->AddAbilitySpecHandle(AbilitySpecHandle);
		}
	}

Finally, from inside the UGASCourseAbilitySystemComponent class, we have the ProcessAbilityInput function that is called from

void AGASCoursePlayerController::PostProcessInput(const float DeltaTime, const bool bGamePaused)

which is called on tick.

void UGASCourseAbilitySystemComponent::ProcessAbilityInput(float DeltaTime, bool bGamePaused)
{
	if (HasMatchingGameplayTag(Status_Block_AbilityInput))
	{
		ClearAbilityInput();
		return;
	}

	static TArray<FGameplayAbilitySpecHandle> AbilitiesToActivate;
	AbilitiesToActivate.Reset();

	//
	// Process all abilities that activate when the input is held.
	//
	for (const FGameplayAbilitySpecHandle& SpecHandle : InputHeldSpecHandles)
	{
		if (const FGameplayAbilitySpec* AbilitySpec = FindAbilitySpecFromHandle(SpecHandle))
		{
			if (AbilitySpec->Ability && !AbilitySpec->IsActive())
			{
				const UGASCourseGameplayAbility* AbilityCDO = CastChecked<UGASCourseGameplayAbility>(AbilitySpec->Ability);

				if (AbilityCDO->GetActivationPolicy() == EGASCourseAbilityActivationPolicy::WhileInputActive)
				{
					AbilitiesToActivate.AddUnique(AbilitySpec->Handle);
				}
			}
		}
	}

	//
	// Process all abilities that had their input pressed this frame.
	//
	for (const FGameplayAbilitySpecHandle& SpecHandle : InputPressedSpecHandles)
	{
		if (FGameplayAbilitySpec* AbilitySpec = FindAbilitySpecFromHandle(SpecHandle))
		{
			if (AbilitySpec->Ability)
			{
				AbilitySpec->InputPressed = true;

				if (AbilitySpec->IsActive())
				{
					// Ability is active so pass along the input event.
					AbilitySpecInputPressed(*AbilitySpec);
				}
				else
				{
					const UGASCourseGameplayAbility* AbilityCDO = CastChecked<UGASCourseGameplayAbility>(AbilitySpec->Ability);

					if (AbilityCDO->GetActivationPolicy() == EGASCourseAbilityActivationPolicy::OnInputTriggered)
					{
						AbilitiesToActivate.AddUnique(AbilitySpec->Handle);
					}
				}
			}
		}
	}

	//
	// Try to activate all the abilities that are from presses and holds.
	// We do it all at once so that held inputs don't activate the ability
	// and then also send a input event to the ability because of the press.
	//
	for (const FGameplayAbilitySpecHandle& AbilitySpecHandle : AbilitiesToActivate)
	{
		TryActivateAbility(AbilitySpecHandle);
	}

	//
	// Process all abilities that had their input released this frame.
	//
	for (const FGameplayAbilitySpecHandle& SpecHandle : InputReleasedSpecHandles)
	{
		if (FGameplayAbilitySpec* AbilitySpec = FindAbilitySpecFromHandle(SpecHandle))
		{
			if (AbilitySpec->Ability)
			{
				AbilitySpec->InputPressed = false;

				if (AbilitySpec->IsActive())
				{
					// Ability is active so pass along the input event.
					AbilitySpecInputReleased(*AbilitySpec);
				}
			}
		}
	}

	//
	// Clear the cached ability handles.
	//
	InputPressedSpecHandles.Reset();
	InputReleasedSpecHandles.Reset();
}
void UGASCourseAbilitySystemComponent::AbilitySpecInputPressed(FGameplayAbilitySpec& Spec)
{
	Super::AbilitySpecInputPressed(Spec);
	if(Spec.IsActive())
	{
		InvokeReplicatedEvent(EAbilityGenericReplicatedEvent::InputPressed, Spec.Handle,
			Spec.ActivationInfo.GetActivationPredictionKey());
	}
}

void UGASCourseAbilitySystemComponent::AbilitySpecInputReleased(FGameplayAbilitySpec& Spec)
{
	Super::AbilitySpecInputReleased(Spec);
	if(Spec.IsActive())
	{
		InvokeReplicatedEvent(EAbilityGenericReplicatedEvent::InputReleased, Spec.Handle,
			Spec.ActivationInfo.GetActivationPredictionKey());
	}
}

The final step is now the AbilitySpecInputPressed and AbilitySpecInputReleased functions are called which dictate how the ability is activated.


Here are the full player and character classes. In future blog posts, I will post the full classes for my player controller, and ability system component.

GASCoursePlayerCharacter.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "Game/Input/GASCourseInputConfig.h"
#include "GASCourse/GASCourseCharacter.h"
#include "Components/TimelineComponent.h"
#include "Tasks/Task.h"
#include "InputAction.h"
#include "GASCoursePlayerCharacter.generated.h"

/**
 * 
 */
UCLASS()
class GASCOURSE_API AGASCoursePlayerCharacter : public AGASCourseCharacter
{
	GENERATED_BODY()

	/** MappingContext */
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, meta = (AllowPrivateAccess = "true"))
	class UInputMappingContext* DefaultMappingContextKBM;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, meta = (AllowPrivateAccess = "true"))
	class UInputMappingContext* DefaultMappingContextGamepad;
	
	/** Camera boom positioning the camera behind the character */
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true"))
	class USpringArmComponent* CameraBoom;

	/** Follow camera */
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true"))
	class UCameraComponent* FollowCamera;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float MaxCameraBoomDistance = 500.0f;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float MinCameraBoomDistance = 250.0f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float CameraZoomDistanceStep = 10.0f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float CameraMovementSpeedMin = 30.0f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float CameraMovementSpeedMax = 30.0f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float CameraMaxVectorDistance = 3000.0f;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	UCurveFloat* RecenterCameraCurve;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	UCurveFloat* MoveCameraCurve;
	
	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	UCurveFloat* RotateCameraCurve;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float RecenterCameraInterpSpeed = 0.1f;
	
	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float MoveCameraInterpSpeed = 0.1f;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float MinCameraPitchAngle = -10.0f;
	
	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float MaxCameraPitchAngle = 40.0f;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float CameraRotationSpeedMultiplier = 1.0f;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float RotateCameraInterpSpeed = 1.0f;
	
	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float EdgePanningSpeedMin = 30.0f;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float EdgePanningSpeedMax = 30.0f;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	UInputAction* EnableRotateCameraAxis;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float CameraTargetOffsetZDownTraceLength = -5000.0f;

	UPROPERTY(EditAnywhere,BlueprintReadOnly, Category = "GASCourse|Camera Settings", meta = (AllowPrivateAccess = "true"))
	float CameraTargetOffsetZDownTraceRadius = 30.0f;

public:

	AGASCoursePlayerCharacter(const FObjectInitializer& ObjectInitializer);

	/** Returns CameraBoom subobject **/
	FORCEINLINE class USpringArmComponent* GetCameraBoom() const { return CameraBoom; }
	/** Returns FollowCamera subobject **/
	FORCEINLINE class UCameraComponent* GetFollowCamera() const { return FollowCamera; }

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UGASCourseInputConfig> InputConfig;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "GASCourse|Animation")
	TSubclassOf<UAnimInstance> UnArmedAnimLayer;

	void UpdateCharacterAnimLayer(TSubclassOf<UAnimInstance> NewAnimLayer) const;

protected:

	virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

	//Add GASCourseAbilitySystemComponent on PossessedBy
	virtual void PossessedBy(AController* NewController) override;

	virtual void OnRep_PlayerState() override;
	virtual void OnRep_Controller() override;

	virtual void BeginPlay() override;
	virtual void Tick(float DeltaSeconds) override;
	
	void Input_AbilityInputTagPressed(FGameplayTag InputTag);
	void Input_AbilityInputTagReleased(FGameplayTag InputTag);

	void Move(const FInputActionValue& Value) override;

	void Input_CameraZoom(const FInputActionInstance& InputActionInstance);
	void Input_MoveCamera(const FInputActionInstance& InputActionInstance);
	void Input_MoveCameraCompleted(const FInputActionInstance& InputActionInstance);
	void UpdateCameraBoomTargetOffset(const FVector& InCameraBoomTargetOffset) const;
	void Input_RecenterCamera(const FInputActionInstance& InputActionInstance);
	void Input_RotateCameraAxis(const FInputActionInstance& InputActionInstance);
	void Input_RotateCameraCompleted(const FInputActionInstance& InputActionInstance);

	/** Called for left-click based movement */
	void PointClickMovement(const FInputActionValue& Value);
	void PointClickMovementStarted(const FInputActionValue& Value);
	void PointClickMovementCompleted(const FInputActionInstance& InputActionInstance);

	void MoveToMouseHitResultLocation();

	UFUNCTION()
	void RecenterCameraBoomTargetOffset();

	UFUNCTION()
	void RecenterCameraBoomTimelineFinished();

	UFUNCTION()
	void UpdateCameraMovementSpeed();

	UFUNCTION()
	void UpdateCameraMovementSpeedTimelineFinished();

	UFUNCTION()
	void UpdateCameraRotationSpeed();

	UFUNCTION()
	void UpdateCameraRotationSpeedTimelineFinished();

	void CameraEdgePanning();

	UFUNCTION()
	void SetMousePositionToScreenCenter();

	UFUNCTION()
	void UpdateCameraTargetOffsetZ();

	UFUNCTION()
	float GetEdgePanningSpeedBasedOnZoomDistance() const;

	UFUNCTION()
	float GetCameraMovementSpeedBasedOnZoomDistance() const;

	UFUNCTION()
	void OnMovementUpdated(float DeltaSeconds, FVector OldLocation, FVector OldVelocity);

public:

	UE::Tasks::TTask<FVector> MultithreadTask;
	FVector GetWorldDirection(const FVector& CachedDirection) const;

	UE::Tasks::TTask<FHitResult> HitResultMultithreadTask;

private:

	FTimeline ResetCameraOffsetTimeline;
	FTimeline MoveCameraTimeline;
	FTimeline RotateCameraTimeline;
	void InitializeCamera();

	void OnWindowFocusChanged(bool bIsInFocus);
	bool bIsWindowFocused;

	float CurrentCameraMovementSpeed;
	bool bCameraSpeedTimelineFinished;
	bool bCameraSpeedTimelineActivated;

	float CurrentCameraRotationSpeed;
	bool bCameraRotationTimelineFinished;
	bool bCameraRotationTimelineActivated;
	
};

GASCoursePlayerCharacter.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/Character/Player/GASCoursePlayerCharacter.h"
#include "Game/Character/Player/GASCoursePlayerState.h"
#include "Game/Input/GASCourseEnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"
#include "Blueprint/WidgetLayoutLibrary.h"
#include "Components/TimelineComponent.h"
#include "Game/Character/Player/GASCoursePlayerController.h"
#include "Game/GameplayAbilitySystem/GASCourseNativeGameplayTags.h"
#include "GameFramework/SpringArmComponent.h"
#include "Kismet/KismetMathLibrary.h"
#include "Kismet/KismetSystemLibrary.h"
#include "Camera/CameraComponent.h"

#if WITH_EDITOR
#include "Editor/EditorEngine.h"
#include "UnrealEd.h"
#endif

//////////////////////////////////////////////////////////////////////////
// AGASCourseCharacter

AGASCoursePlayerCharacter::AGASCoursePlayerCharacter(const FObjectInitializer& ObjectInitializer) :
Super(ObjectInitializer)
{

	// Create a camera boom (pulls in towards the player if there is a collision)
	CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
	CameraBoom->SetupAttachment(RootComponent);
	CameraBoom->TargetArmLength = 400.0f; // The camera follows at this distance behind the character	
	CameraBoom->bUsePawnControlRotation = false; // Rotate the arm based on the controller
	CameraBoom->bInheritPitch = false;
	CameraBoom->bInheritRoll = false;
	CameraBoom->bInheritYaw = false;
	CameraBoom->bDoCollisionTest = false;

	// Create a follow camera
	FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
	FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName); // Attach the camera to the end of the boom and let the boom adjust to match the controller orientation
	FollowCamera->bUsePawnControlRotation = false; // Camera does not rotate relative to arm
	
	//Set camera boom arm length and socket offset Z to default max camera boom distance.
	CameraBoom->TargetArmLength = MaxCameraBoomDistance;
	CameraBoom->SocketOffset.Z = MaxCameraBoomDistance;
}

void AGASCoursePlayerCharacter::UpdateCharacterAnimLayer(TSubclassOf<UAnimInstance> NewAnimLayer) const
{
	if(NewAnimLayer)
	{
		GetMesh()->LinkAnimClassLayers(NewAnimLayer);
	}
}

void AGASCoursePlayerCharacter::InitializeCamera()
{
	GetCameraBoom()->TargetArmLength = MaxCameraBoomDistance/2.0f;
	GetCameraBoom()->SocketOffset = FVector(0.0f,0.0f, MaxCameraBoomDistance/2.0f);
}

void AGASCoursePlayerCharacter::OnWindowFocusChanged(bool bIsInFocus)
{
	bIsWindowFocused = bIsInFocus;
	SetMousePositionToScreenCenter();
}

void AGASCoursePlayerCharacter::UpdateCameraMovementSpeed()
{
	const float TimelineValue = MoveCameraTimeline.GetPlaybackPosition();
	const float CurveFloatValue = MoveCameraCurve->GetFloatValue(TimelineValue);
	CurrentCameraMovementSpeed = (FMath::FInterpTo(0.0f, GetCameraMovementSpeedBasedOnZoomDistance(), CurveFloatValue, MoveCameraInterpSpeed));
}

void AGASCoursePlayerCharacter::UpdateCameraMovementSpeedTimelineFinished()
{
	bCameraSpeedTimelineFinished = true;
}

void AGASCoursePlayerCharacter::UpdateCameraRotationSpeed()
{
	const float TimelineValue = RotateCameraTimeline.GetPlaybackPosition();
	const float CurveFloatValue =RotateCameraCurve->GetFloatValue(TimelineValue);

	CurrentCameraRotationSpeed = (FMath::FInterpTo(CurrentCameraRotationSpeed, CameraRotationSpeedMultiplier, CurveFloatValue, RotateCameraInterpSpeed));
}

void AGASCoursePlayerCharacter::UpdateCameraRotationSpeedTimelineFinished()
{
}

//////////////////////////////////////////////////////////////////////////
// Input
void AGASCoursePlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);

	// Set up action bindings
	if (UGASCourseEnhancedInputComponent* EnhancedInputComponent = CastChecked<UGASCourseEnhancedInputComponent>(PlayerInputComponent))
	{
		check(EnhancedInputComponent);
		
		if(InputConfig)
		{
			check(InputConfig);
			//Jumping - TODO: Remove this
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Jump, ETriggerEvent::Triggered, this, &ThisClass::Jump);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Jump, ETriggerEvent::Completed, this, &ThisClass::StopJumping);

			//Moving - TODO: Remove this
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Move, ETriggerEvent::Triggered, this, &ThisClass::Move);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Move, ETriggerEvent::Completed, this, &ThisClass::StopMove);
			
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_PointClickMovement, ETriggerEvent::Triggered, this, &ThisClass::PointClickMovement);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_PointClickMovement, ETriggerEvent::Started, this, &ThisClass::PointClickMovementStarted);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_PointClickMovement, ETriggerEvent::Canceled, this, &ThisClass::PointClickMovementCompleted);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_PointClickMovement, ETriggerEvent::Completed, this, &ThisClass::PointClickMovementCompleted);

			if(UGASCourseAbilitySystemComponent* MyASC = CastChecked<UGASCourseAbilitySystemComponent>( GetAbilitySystemComponent()))
			{
				EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_ConfirmTargetData, ETriggerEvent::Triggered, MyASC, &UGASCourseAbilitySystemComponent::LocalInputConfirm);
				EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_CancelTargetData, ETriggerEvent::Triggered, MyASC, &UGASCourseAbilitySystemComponent::LocalInputCancel);
			}

			//Looking
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Look_Stick, ETriggerEvent::Triggered, this, &ThisClass::Look);

			//Camera Controls
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_MoveCamera, ETriggerEvent::Triggered, this, &ThisClass::Input_MoveCamera);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_MoveCamera,ETriggerEvent::Completed, this, &ThisClass::Input_MoveCameraCompleted);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_RecenterCamera, ETriggerEvent::Triggered, this, &ThisClass::Input_RecenterCamera);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_RotateCameraAxis, ETriggerEvent::Triggered, this, &ThisClass::Input_RotateCameraAxis);
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_RotateCamera,ETriggerEvent::Completed, this, &ThisClass::Input_RotateCameraCompleted);

			//Crouching
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_Crouch, ETriggerEvent::Triggered, this, &ThisClass::Input_Crouch);

			//Camera Zoom
			EnhancedInputComponent->BindActionByTag(InputConfig, InputTag_CameraZoom,ETriggerEvent::Triggered, this, &ThisClass::Input_CameraZoom);

			TArray<uint32> BindHandles;
			EnhancedInputComponent->BindAbilityActions(InputConfig, this, &ThisClass::Input_AbilityInputTagPressed, &ThisClass::Input_AbilityInputTagReleased, /*out*/ BindHandles);
		}
	}
}

void AGASCoursePlayerCharacter::PossessedBy(AController* NewController)
{
	Super::PossessedBy(NewController);
	
	if(AGASCoursePlayerState* PS = GetPlayerState<AGASCoursePlayerState>())
	{
		AbilitySystemComponent = Cast<UGASCourseAbilitySystemComponent>(PS->GetAbilitySystemComponent());
		PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);
		InitializeAbilitySystem(AbilitySystemComponent);

		if (const APlayerController* PlayerController = Cast<APlayerController>(Controller))
		{
			if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
			{
				Subsystem->AddMappingContext(DefaultMappingContextKBM, 0);
				Subsystem->AddMappingContext(DefaultMappingContextGamepad, 0);
			}
		}
	}

	UpdateCharacterAnimLayer(UnArmedAnimLayer);
	InitializeCamera();
	OnCharacterMovementUpdated.AddDynamic(this, &ThisClass::OnMovementUpdated);
}

void AGASCoursePlayerCharacter::OnRep_PlayerState()
{
	Super::OnRep_PlayerState();
	
	if(AGASCoursePlayerState* PS = GetPlayerState<AGASCoursePlayerState>())
	{
		AbilitySystemComponent = Cast<UGASCourseAbilitySystemComponent>(PS->GetAbilitySystemComponent());
		PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);
		InitializeAbilitySystem(AbilitySystemComponent);

		if (const APlayerController* PlayerController = Cast<APlayerController>(Controller))
		{
			if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
			{
				Subsystem->AddMappingContext(DefaultMappingContextKBM, 0);
				Subsystem->AddMappingContext(DefaultMappingContextGamepad, 0);
			}
		}

		UpdateCharacterAnimLayer(UnArmedAnimLayer);
		InitializeCamera();
	}
}

void AGASCoursePlayerCharacter::OnRep_Controller()
{
	Super::OnRep_Controller();

	UpdateCharacterAnimLayer(UnArmedAnimLayer);
	
	// Needed in case the PC wasn't valid when we Init-ed the ASC.
	if (const AGASCoursePlayerState* PS = GetPlayerState<AGASCoursePlayerState>())
	{
		PS->GetAbilitySystemComponent()->RefreshAbilityActorInfo();
	}
	OnCharacterMovementUpdated.AddDynamic(this, &ThisClass::OnMovementUpdated);
}

void AGASCoursePlayerCharacter::BeginPlay()
{
	Super::BeginPlay();

	if(RecenterCameraCurve)
	{
		FOnTimelineFloat TimelineCallback;
		FOnTimelineEvent TimelineFinishedFunc;
		TimelineFinishedFunc.BindUFunction(this,FName("RecenterCameraBoomTimelineFinished"));
		ResetCameraOffsetTimeline.SetTimelineFinishedFunc(TimelineFinishedFunc);
		TimelineCallback.BindUFunction(this, FName("RecenterCameraBoomTargetOffset"));
		ResetCameraOffsetTimeline.AddInterpFloat(RecenterCameraCurve, TimelineCallback);
	}

	if(MoveCameraCurve)
	{
		FOnTimelineFloat TimelineCallback;
		FOnTimelineEvent TimelineFinishedFunc;
		TimelineFinishedFunc.BindUFunction(this, FName("UpdateCameraMovementSpeedTimelineFinished"));
		MoveCameraTimeline.SetTimelineFinishedFunc(TimelineFinishedFunc);
		TimelineCallback.BindUFunction(this, FName("UpdateCameraMovementSpeed"));
		MoveCameraTimeline.AddInterpFloat(MoveCameraCurve, TimelineCallback);
	}

	if(RotateCameraCurve)
	{
		FOnTimelineFloat TimelineCallback;
		FOnTimelineEvent TimelineFinishedFunc;
		TimelineFinishedFunc.BindUFunction(this, FName("UpdateCameraRotationSpeedTimelineFinished"));
		RotateCameraTimeline.SetTimelineFinishedFunc(TimelineFinishedFunc);
		TimelineCallback.BindUFunction(this, FName("UpdateCameraRotationSpeed"));
		RotateCameraTimeline.AddInterpFloat(RotateCameraCurve, TimelineCallback);
	}

	FSlateApplication::Get().OnApplicationActivationStateChanged().AddUObject(this, &ThisClass::OnWindowFocusChanged);
}

void AGASCoursePlayerCharacter::Tick(float DeltaSeconds)
{
	Super::Tick(DeltaSeconds);
	ResetCameraOffsetTimeline.TickTimeline(DeltaSeconds);
	MoveCameraTimeline.TickTimeline(DeltaSeconds);
	RotateCameraTimeline.TickTimeline(DeltaSeconds);

	CameraEdgePanning();
}

void AGASCoursePlayerCharacter::Input_AbilityInputTagPressed(FGameplayTag InputTag)
{
	if(UGASCourseAbilitySystemComponent* ASC = GetAbilitySystemComponent())
	{
		if(ASC->HasMatchingGameplayTag(Status_Block_AbilityInput))
		{
			return;
		}
		ASC->AbilityInputTagPressed(InputTag);
	}
}

void AGASCoursePlayerCharacter::Input_AbilityInputTagReleased(FGameplayTag InputTag)
{
	if(UGASCourseAbilitySystemComponent* ASC = GetAbilitySystemComponent())
	{
		if(ASC->HasMatchingGameplayTag(Status_Block_AbilityInput))
		{
			return;
		}
		ASC->AbilityInputTagReleased(InputTag);
	}
}

void AGASCoursePlayerCharacter::Move(const FInputActionValue& Value)
{
	// input is a Vector2D
	const FVector2D MovementVector = Value.Get<FVector2D>();

	if (Controller != nullptr)
	{
		if (UGASCourseAbilitySystemComponent* GASCourseASC = GetAbilitySystemComponent())
		{
			//Block any type of movement if character has tag Status.MovementInputBlocked
			if(GASCourseASC->HasMatchingGameplayTag(Status_Block_MovementInput))
			{
				return;
			}
			if(MovementVector.Length() > 0.0f)
			{
				GASCourseASC->SetLooseGameplayTagCount(Status_IsMoving, 1);
			}
			// find out which way is forward
			const FRotator Rotation = GetCameraBoom()->GetRelativeRotation();
			const FRotator YawRotation(0, Rotation.Yaw, 0);

			// get forward vector
			const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
	
			// get right vector 
			const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);

			// add movement 
			AddMovementInput(ForwardDirection, MovementVector.Y);
			AddMovementInput(RightDirection, MovementVector.X);
		}
	}
}

void AGASCoursePlayerCharacter::Input_CameraZoom(const FInputActionInstance& InputActionInstance)
{
	const float AxisValue = InputActionInstance.GetValue().Get<float>();
	
	if(USpringArmComponent* CameraRef = GetCameraBoom())
	{
		const float Step = CameraZoomDistanceStep * AxisValue;
		const float CurrentTargetArmLength = FMath::Clamp((CameraRef->TargetArmLength - Step),
		MinCameraBoomDistance, MaxCameraBoomDistance);
					
		CameraRef->TargetArmLength = CurrentTargetArmLength;
		CameraRef->SocketOffset.Z = CurrentTargetArmLength;
	}
}

void AGASCoursePlayerCharacter::Input_MoveCamera(const FInputActionInstance& InputActionInstance)
{
	const FVector2d CameraMovement =  InputActionInstance.GetValue().Get<FVector2D>();
	if(CameraMovement.Length() >= 0.35f && ResetCameraOffsetTimeline.IsPlaying())
	{
		ResetCameraOffsetTimeline.Stop();
	}

	if(CameraMovement.Length() > 0.0f)
	{
		UpdateCameraTargetOffsetZ();
	}

	if(!MoveCameraTimeline.IsPlaying() && !bCameraSpeedTimelineFinished)
	{
		if(GetCameraBoom()->IsAttachedTo(RootComponent))
		{
			FDetachmentTransformRules DetachmentRules = FDetachmentTransformRules::KeepWorldTransform;
			DetachmentRules.RotationRule = EDetachmentRule::KeepRelative;
			GetCameraBoom()->DetachFromComponent(DetachmentRules);
		}
		MoveCameraTimeline.PlayFromStart();
	}

	const FVector RotatedVector = GetCameraBoom()->GetRelativeRotation().RotateVector(FVector(CameraMovement.Y, CameraMovement.X, 0.0f));
	UpdateCameraBoomTargetOffset(RotatedVector);
}

void AGASCoursePlayerCharacter::Input_MoveCameraCompleted(const FInputActionInstance& InputActionInstance)
{
	MoveCameraTimeline.Stop();
	bCameraSpeedTimelineFinished = false;
	CurrentCameraMovementSpeed = 0.0f;
}

void AGASCoursePlayerCharacter::UpdateCameraBoomTargetOffset(const FVector& InCameraBoomTargetOffset) const
{
	const FVector NewTargetOffset = GetCameraBoom()->TargetOffset + (InCameraBoomTargetOffset.GetSafeNormal2D() * CurrentCameraMovementSpeed);
	GetCameraBoom()->TargetOffset = FVector(NewTargetOffset.X, NewTargetOffset.Y, GetCameraBoom()->TargetOffset.Z).GetClampedToSize(-CameraMaxVectorDistance, CameraMaxVectorDistance);
}

void AGASCoursePlayerCharacter::Input_RecenterCamera(const FInputActionInstance& InputActionInstance)
{
	ResetCameraOffsetTimeline.PlayFromStart();
}

void AGASCoursePlayerCharacter::Input_RotateCameraAxis(const FInputActionInstance& InputActionInstance)
{
	if(!RotateCameraTimeline.IsPlaying())
	{
		RotateCameraTimeline.PlayFromStart();
	}
	GetCameraBoom()->bEnableCameraRotationLag = false;
	const FVector2d CameraRotation = InputActionInstance.GetValue().Get<FVector2D>();
	const float CameraRotationX = CameraRotation.X * CurrentCameraRotationSpeed;
	const float CameraRotationY = CameraRotation.Y * CurrentCameraRotationSpeed;
	
	const FRotator NewCameraRelativeRotation = FRotator(FMath::ClampAngle((GetCameraBoom()->GetRelativeRotation().Pitch + CameraRotationX), MinCameraPitchAngle, MaxCameraPitchAngle),
		GetCameraBoom()->GetRelativeRotation().Yaw + CameraRotationY, 0.0f);
	
	GetCameraBoom()->SetRelativeRotation(NewCameraRelativeRotation);
}

void AGASCoursePlayerCharacter::Input_RotateCameraCompleted(const FInputActionInstance& InputActionInstance)
{
	RotateCameraTimeline.Stop();
	CurrentCameraRotationSpeed = 0.0f;
	GetCameraBoom()->bEnableCameraRotationLag = true;
	SetMousePositionToScreenCenter();
}

void AGASCoursePlayerCharacter::PointClickMovement(const FInputActionValue& Value)
{
	if(GetAbilitySystemComponent()->HasMatchingGameplayTag(Status_Block_PointClickMovementInput))
	{
		return;
	}
	MoveToMouseHitResultLocation();
}

void AGASCoursePlayerCharacter::PointClickMovementStarted(const FInputActionValue& Value)
{
	if(GetAbilitySystemComponent()->HasMatchingGameplayTag(Status_Block_PointClickMovementInput))
	{
		return;
	}
	if(AGASCoursePlayerController* PC = Cast<AGASCoursePlayerController>(Controller))
	{
		PC->StopMovement();
	}
}

void AGASCoursePlayerCharacter::PointClickMovementCompleted(const FInputActionInstance& InputActionInstance)
{
	if(GetAbilitySystemComponent()->HasMatchingGameplayTag(Status_Block_PointClickMovementInput))
	{
		return;
	}
	
	if(AGASCoursePlayerController* PC = Cast<AGASCoursePlayerController>(Controller))
	{
		UAIBlueprintHelperLibrary::SimpleMoveToLocation(PC, PC->GetCachedDestination());
	}
}

void AGASCoursePlayerCharacter::MoveToMouseHitResultLocation()
{
	if(AGASCoursePlayerController* PC = Cast<AGASCoursePlayerController>(Controller))
	{
		SCOPED_NAMED_EVENT(AGASCourseCharacter_PointClickMovement, FColor::Blue);
		if(PC)
		{
			FHitResult HitResultUnderCursor;
			if(PC->GetHitResultUnderCursor(ECC_Visibility, true, HitResultUnderCursor))
			{
				PC->SetCachedDestination(HitResultUnderCursor.Location);
				MultithreadTask = UE::Tasks::Launch(UE_SOURCE_LOCATION, [this]
				{
					if(const AGASCoursePlayerController* InPC = Cast<AGASCoursePlayerController>(Controller))
					{
						return GetWorldDirection(InPC->GetCachedDestination());
					}
					return FVector::ZeroVector;
				});

				const FVector WorldDirection = MultithreadTask.GetResult();
				AddMovementInput(WorldDirection, 1.0f, false);
				
				if(MultithreadTask.IsCompleted())
				{
					MultithreadTask = {};
				}
			}
		}
	}
	MultithreadTask = {};
}

FVector AGASCoursePlayerCharacter::GetWorldDirection(const FVector& CachedDirection) const
{
	const FVector WorldDirection = UKismetMathLibrary::GetDirectionUnitVector(GetActorLocation(), CachedDirection);
	return WorldDirection;
}

void AGASCoursePlayerCharacter::RecenterCameraBoomTargetOffset()
{
	const float TimelineValue = ResetCameraOffsetTimeline.GetPlaybackPosition();
	const float CurveFloatValue = RecenterCameraCurve->GetFloatValue(TimelineValue);
	const FVector CurrentCameraTargetOffset = GetCameraBoom()->TargetOffset;
	const FVector CurrentCameraLocation = GetCameraBoom()->GetComponentLocation();
	
	GetCameraBoom()->TargetOffset = (FMath::VInterpTo(CurrentCameraTargetOffset, FVector(0.0f), CurveFloatValue, RecenterCameraInterpSpeed));
	GetCameraBoom()->SetWorldLocation(FMath::VInterpTo(CurrentCameraLocation, GetActorLocation(), CurveFloatValue, RecenterCameraInterpSpeed));
}

void AGASCoursePlayerCharacter::RecenterCameraBoomTimelineFinished()
{
	GetCameraBoom()->TargetOffset = FVector(0.0f);
	if(!GetCameraBoom()->IsAttachedTo(RootComponent))
	{
		FAttachmentTransformRules AttachmentRules = FAttachmentTransformRules::KeepWorldTransform;
		AttachmentRules.RotationRule = EAttachmentRule::KeepRelative;
		AttachmentRules.LocationRule = EAttachmentRule::SnapToTarget;
		GetCameraBoom()->AttachToComponent(GetRootComponent(), AttachmentRules);
	}
}

void AGASCoursePlayerCharacter::CameraEdgePanning()
{
	SCOPED_NAMED_EVENT(AGASCourseCharacter_CameraEdgePanning, FColor::Red);
	bool bIsEnableRotateCameraAxis = false;
	bIsWindowFocused = true;

	//TODO: Move this check somewhere else?
	if(InputComponent)
	{
		if(EnableRotateCameraAxis)
		{
			if (UGASCourseEnhancedInputComponent* EnhancedInputComponent = CastChecked<UGASCourseEnhancedInputComponent>(InputComponent))
			{
				check(EnhancedInputComponent);
				const FEnhancedInputActionValueBinding* EnableRotateAxisBinding = &EnhancedInputComponent->BindActionValue(EnableRotateCameraAxis);
				bIsEnableRotateCameraAxis = EnableRotateAxisBinding->GetValue().Get<bool>();
			}
		}
	}

#if WITH_EDITOR
	const FViewport* EditorViewport = GEditor->GetPIEViewport();
	bIsWindowFocused = EditorViewport->HasMouseCapture();
#endif

	if(!GetWorld()->IsPlayInEditor())
	{
		const UGameViewportClient* GameViewport = GetWorld()->GetGameViewport();
		bIsWindowFocused = GameViewport->Viewport->HasMouseCapture();
	}
	
	const FVector2d MousePositionbyDPI = UWidgetLayoutLibrary::GetMousePositionOnViewport(this);
	const FVector2d ViewportScale2D = FVector2d(UWidgetLayoutLibrary::GetViewportScale(this));
	const FVector2d ViewportSize = UWidgetLayoutLibrary::GetViewportSize(this);
	const FVector2d MultipliedMousePosition = MousePositionbyDPI * ViewportScale2D;
		
	const float MappedNormalizedRangeX = UKismetMathLibrary::MapRangeClamped(UKismetMathLibrary::NormalizeToRange(MultipliedMousePosition.X, (ViewportSize.X * .01f), (ViewportSize.X * 0.99f)),
	0.0f, 1.0f, -1.0f, 1.0f);

	const float MappedNormalizedRangeY = UKismetMathLibrary::MapRangeClamped(UKismetMathLibrary::NormalizeToRange(MultipliedMousePosition.Y, (ViewportSize.Y * .01f), (ViewportSize.Y * 0.99f)),
	0.0f, 1.0f, 1.0f, -1.0f);
	
	if(FMath::Abs(MappedNormalizedRangeX) == 1 || FMath::Abs(MappedNormalizedRangeY) == 1)
	{
		if(!bIsEnableRotateCameraAxis && bIsWindowFocused)
		{
			const FVector OffsetDirection = GetCameraBoom()->GetRelativeRotation().RotateVector(FVector(MappedNormalizedRangeY, MappedNormalizedRangeX, 0.0f)).GetSafeNormal2D();
			const FVector NewTargetOffset = GetCameraBoom()->TargetOffset + (OffsetDirection * GetEdgePanningSpeedBasedOnZoomDistance());
			GetCameraBoom()->TargetOffset = NewTargetOffset.GetClampedToSize(-CameraMaxVectorDistance, CameraMaxVectorDistance);

			if(!MoveCameraTimeline.IsPlaying() && !bCameraSpeedTimelineFinished && !bCameraSpeedTimelineActivated)
			{
				MoveCameraTimeline.PlayFromStart();
				bCameraSpeedTimelineActivated = true;
			}

			if(GetCameraBoom()->IsAttachedTo(RootComponent))
			{
				FDetachmentTransformRules DetachmentRules = FDetachmentTransformRules::KeepWorldTransform;
				DetachmentRules.RotationRule = EDetachmentRule::KeepRelative;
				GetCameraBoom()->DetachFromComponent(DetachmentRules);
			}

			UpdateCameraTargetOffsetZ();
		}
	}
	else
	{
		if(bCameraSpeedTimelineActivated)
		{
			MoveCameraTimeline.Stop();
			bCameraSpeedTimelineFinished = false;
			bCameraSpeedTimelineActivated = false;
			CurrentCameraMovementSpeed = 0.0f;
		}

	}
}

void AGASCoursePlayerCharacter::SetMousePositionToScreenCenter()
{
	if (APlayerController* PC = Cast<APlayerController>(GetController()))
	{
		if(const ULocalPlayer* LP = PC->GetLocalPlayer())
		{
			if(UGameViewportClient* GVC = LP->ViewportClient)
			{
				FViewport* VP = GVC->Viewport;
				if(VP)
				{
					FVector2D ViewportSize;
					GVC->GetViewportSize(ViewportSize);
					const int32 X = static_cast<int32>(ViewportSize.X * 0.5f);
					const int32 Y = static_cast<int32>(ViewportSize.Y * 0.5f);

					VP->SetMouse(X, Y);
				}
			}
		}
	}
}

void AGASCoursePlayerCharacter::UpdateCameraTargetOffsetZ()
{
	if(const UWorld* World = GetWorld())
	{
		const FVector CameraBoomLocation = GetCameraBoom()->GetComponentLocation() + GetCameraBoom()->TargetOffset;
#if WITH_EDITOR
		DrawDebugSphere(GetWorld(), CameraBoomLocation, 15.f, 8, FColor::Blue);
		DrawDebugLine(GetWorld(), CameraBoomLocation, CameraBoomLocation + (GetActorUpVector() * 1000.0f), FColor::Red);
#endif

		if(GetCameraBoom()->IsAttachedTo(RootComponent))
		{
			return;
		}
		
		SCOPED_NAMED_EVENT(AGASCourseCharacter_UpdateCameraTargetOffsetZMultithread, FColor::Blue)
		HitResultMultithreadTask = UE::Tasks::Launch(UE_SOURCE_LOCATION, [this]
		{
			const UWorld* World = GetWorld();
			const FVector TraceStart = GetCameraBoom()->GetComponentLocation() + GetCameraBoom()->TargetOffset;
			const FVector TraceEnd = TraceStart + (GetActorUpVector() * CameraTargetOffsetZDownTraceLength);
			TArray<AActor*> ActorsToIgnore;
			ActorsToIgnore.Add(this);
			FHitResult OutHitResult;
			UKismetSystemLibrary::SphereTraceSingle(World, TraceStart, TraceEnd, CameraTargetOffsetZDownTraceRadius, UEngineTypes::ConvertToTraceType(ECC_Camera), true,
			ActorsToIgnore, EDrawDebugTrace::ForOneFrame, OutHitResult, true);
			return OutHitResult;
		});
		
		const FHitResult MultiThreadHitResult = HitResultMultithreadTask.GetResult();
		const float HitLocationZ = MultiThreadHitResult.ImpactPoint.Z;
		const float CharacterMeshLocationZ = GetMesh()->GetComponentLocation().Z;
		
		const float ZDifference = (HitLocationZ - CharacterMeshLocationZ) - GetCameraBoom()->TargetOffset.Z;
		GetCameraBoom()->TargetOffset.Z += ZDifference;
		
		if(HitResultMultithreadTask.IsCompleted())
		{
			HitResultMultithreadTask = {};
		}
	}
	HitResultMultithreadTask = {};
}

float AGASCoursePlayerCharacter::GetEdgePanningSpeedBasedOnZoomDistance() const
{
	return UKismetMathLibrary::MapRangeClamped(GetCameraBoom()->TargetArmLength, MinCameraBoomDistance, MaxCameraBoomDistance, EdgePanningSpeedMin, EdgePanningSpeedMax);
}

float AGASCoursePlayerCharacter::GetCameraMovementSpeedBasedOnZoomDistance() const
{
	return UKismetMathLibrary::MapRangeClamped(GetCameraBoom()->TargetArmLength, MinCameraBoomDistance, MaxCameraBoomDistance, CameraMovementSpeedMin, CameraMovementSpeedMax);
}

void AGASCoursePlayerCharacter::OnMovementUpdated(float DeltaSeconds, FVector OldLocation, FVector OldVelocity)
{
	const float MovementHeightDelta = GetActorLocation().Z - OldLocation.Z;
	const FVector CombinedCameraBoomLocation = GetCameraBoom()->TargetOffset + GetCameraBoom()->GetComponentLocation();
	if(GetCameraBoom()->IsAttachedTo(RootComponent))
	{
		return;
	}

	if((MovementHeightDelta > 0.0f && GetMesh()->GetComponentLocation().Z > CombinedCameraBoomLocation.Z) || (MovementHeightDelta < 0.0f && CombinedCameraBoomLocation.Z > GetMesh()->GetComponentLocation().Z))
	{
		GetCameraBoom()->TargetOffset.Z += MovementHeightDelta;
	}
}

GASCourseCharacter.h

// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "AbilitySystemInterface.h"
#include "GameFramework/Character.h"
#include "InputActionValue.h"
#include "Game/GameplayAbilitySystem/GASCourseAbilitySystemComponent.h"
#include "Game/GameplayAbilitySystem/GASAbilityTagRelationshipMapping.h"
#include "Game/GameplayAbilitySystem/AttributeSets/GASCourseCharBaseAttributeSet.h"
#include "Game/GameplayAbilitySystem/GameplayTagResponseTable/GASCourseStatusEffectTable.h"
#include "GASCourseCharacter.generated.h"

class UGASCourseGameplayAbilitySet;

USTRUCT(BlueprintType)
struct GASCOURSE_API FReplicationProxyVarList
{
	GENERATED_BODY()

public:

	FReplicationProxyVarList() :
		GameplayTagsBitMask(0),
		AttributeOne(0.0f),
		AttributeTwo(0.0f)
	{
		
	}

	void Copy(uint8 inGameplayTagsBitMask, float inAttributeOne, float inAttributeTwo)
	{
		GameplayTagsBitMask = inGameplayTagsBitMask;
		AttributeOne = inAttributeOne;
		AttributeTwo = inAttributeTwo;
	}
	
public:

	UPROPERTY()
	uint8 GameplayTagsBitMask;
	
	UPROPERTY()
	float AttributeOne;
	
	UPROPERTY()
	float AttributeTwo;
};


UCLASS(config=Game)
class AGASCourseCharacter : public ACharacter, public IAbilitySystemInterface, public IGCAbilitySystemReplicationProxyInterface
{
	GENERATED_BODY()


public:
	
	AGASCourseCharacter(const class FObjectInitializer& ObjectInitializer);
	
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
	
	FReplicationProxyVarList& Call_GetReplicationProxyVarList_Mutable();

protected:

	/** Called for movement input */
	virtual void Move(const FInputActionValue& Value);
	void StopMove(const FInputActionValue& Value);
	
	/** Called for looking input */
	void Look(const FInputActionValue& Value);

	/** Called for crouch input */
	void Input_Crouch(const FInputActionValue& Value);

	//Override these functions to add loose gameplay tag for status.crouching
	virtual void OnStartCrouch(float HalfHeightAdjust, float ScaledHalfHeightAdjust) override;
	virtual void OnEndCrouch(float HalfHeightAdjust, float ScaledHalfHeightAdjust) override;

	//Override these functions in order to jump while crouched, if movement component allows for it.
	virtual bool CanJumpInternal_Implementation() const override;
	virtual void Jump() override;
	
	UFUNCTION()
	void OnRep_ReplicationVarList();
	
	UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_ReplicationVarList)
	FReplicationProxyVarList ReplicationVarList;

	
protected:
	
	// To add mapping context
	virtual void BeginPlay() override;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Getter=GetAbilitySystemComponent)
	UGASCourseAbilitySystemComponent* AbilitySystemComponent = nullptr;

	void InitializeAbilitySystem(UGASCourseAbilitySystemComponent* InASC);
		
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Abilities")
	TObjectPtr<UGASCourseGameplayAbilitySet> DefaultAbilitySet;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Abilities")
	TObjectPtr<UGASAbilityTagRelationshipMapping> AbilityTagRelationshipMapping;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Abilities")
	TObjectPtr<UGASCourseStatusEffectTable> GameplayStatusEffectTable;

public:
	
	virtual UGASCourseAbilitySystemComponent* GetAbilitySystemComponent() const override;
	
	UFUNCTION(BlueprintCallable, Category = "GASCourse|Character|Attributes")
	float GetCrouchSpeed() const;

	UFUNCTION(BlueprintCallable, Category = "GASCiyrse|Character|Attributes")
	float GetJumpZVelocityOverride() const;
	
	UFUNCTION()
	FORCEINLINE UGASCourseGameplayAbilitySet* GetDefaultAbilitySet() const
	{
		return (DefaultAbilitySet) ? DefaultAbilitySet : nullptr;
	}
	
	UFUNCTION()
	FORCEINLINE UGASAbilityTagRelationshipMapping* GetAbilityTagRelationshipMapping() const
	{
		return (AbilityTagRelationshipMapping) ? AbilityTagRelationshipMapping : nullptr;
	}

	UFUNCTION()
	FORCEINLINE UGASCourseStatusEffectTable* GetGameplayStatusEffectTable() const
	{
		return (GameplayStatusEffectTable) ? GameplayStatusEffectTable : nullptr;
	}
	
protected:

	UFUNCTION()
	virtual void Call_OnRep_ReplicatedAnimMontage() override;
	
	UPROPERTY(ReplicatedUsing = Call_OnRep_ReplicatedAnimMontage)
	FGameplayAbilityRepAnimMontage RepAnimMontageInfo;
	
	virtual void ForceReplication() override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCueExecuted_FromSpec(const FGameplayEffectSpecForRPC Spec, FPredictionKey PredictionKey) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCueExecuted(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayEffectContextHandle EffectContext) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCuesExecuted(const FGameplayTagContainer GameplayCueTags, FPredictionKey PredictionKey, FGameplayEffectContextHandle EffectContext) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCueExecuted_WithParams(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayCueParameters GameplayCueParameters) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCuesExecuted_WithParams(const FGameplayTagContainer GameplayCueTags, FPredictionKey PredictionKey, FGameplayCueParameters GameplayCueParameters) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCueAdded(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayEffectContextHandle EffectContext) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCueAdded_WithParams(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayCueParameters Parameters) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCueAddedAndWhileActive_FromSpec(const FGameplayEffectSpecForRPC& Spec, FPredictionKey PredictionKey) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCueAddedAndWhileActive_WithParams(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayCueParameters GameplayCueParameters) override;

	UFUNCTION(NetMulticast, unreliable)
	virtual void NetMulticast_InvokeGameplayCuesAddedAndWhileActive_WithParams(const FGameplayTagContainer GameplayCueTags, FPredictionKey PredictionKey, FGameplayCueParameters GameplayCueParameters) override;
	  
	virtual FGameplayAbilityRepAnimMontage& Call_GetRepAnimMontageInfo_Mutable() override;
	
};

GASCourseCharacter.cpp

// Copyright Epic Games, Inc. All Rights Reserved.

#include "GASCourseCharacter.h"
#include "Camera/CameraComponent.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/Controller.h"
#include "GameFramework/SpringArmComponent.h"
#include "Game/GameplayAbilitySystem/GASCourseGameplayAbilitySet.h"
#include "Game/GameplayAbilitySystem/GASCourseNativeGameplayTags.h"
#include "Game/Character/Components/GASCourseMovementComponent.h"
#include "Net/UnrealNetwork.h"


//////////////////////////////////////////////////////////////////////////
// AGASCourseCharacter

AGASCourseCharacter::AGASCourseCharacter(const class FObjectInitializer& ObjectInitializer) :
	Super(ObjectInitializer.SetDefaultSubobjectClass<UGASCourseMovementComponent>(ACharacter::CharacterMovementComponentName))
{
	SetReplicates(true);
	
	// Set size for collision capsule
	GetCapsuleComponent()->InitCapsuleSize(42.f, 96.0f);
		
	// Don't rotate when the controller rotates. Let that just affect the camera.
	bUseControllerRotationPitch = false;
	bUseControllerRotationYaw = false;
	bUseControllerRotationRoll = false;

	// Configure character movement
	GetCharacterMovement()->bOrientRotationToMovement = true; // Character moves in the direction of input...	
	GetCharacterMovement()->RotationRate = FRotator(0.0f, 500.0f, 0.0f); // ...at this rotation rate

	// Note: For faster iteration times these variables, and many more, can be tweaked in the Character Blueprint
	// instead of recompiling to adjust them

	//JumpZVelocity is overridden by the base attribute set value, JumpZVelocityOverride.
	GetCharacterMovement()->JumpZVelocity = 700.f;
	//MaxWalkSpeed is overridden by the base attribute set value, MovementSpeed.
	GetCharacterMovement()->MaxWalkSpeed = 500.f;
	
	GetCharacterMovement()->AirControl = 0.35f;
	GetCharacterMovement()->MinAnalogWalkSpeed = 20.f;
	GetCharacterMovement()->BrakingDecelerationWalking = 2000.f;

	GetCharacterMovement()->bConstrainToPlane = true;
	GetCharacterMovement()->bSnapToPlaneAtStart = true;

	// Note: The skeletal mesh and anim blueprint references on the Mesh component (inherited from Character) 
	// are set in the derived blueprint asset named ThirdPersonCharacter (to avoid direct content references in C++)

	//Initialize AbilitySystemComponent
	AbilitySystemComponent = CreateDefaultSubobject<UGASCourseAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
	AbilitySystemComponent->SetIsReplicated(true);

	// Activate ticking in order to update the cursor every frame.
	PrimaryActorTick.bCanEverTick = true;
	PrimaryActorTick.bStartWithTickEnabled = true;
	
}

void AGASCourseCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	DOREPLIFETIME(AGASCourseCharacter, ReplicationVarList);
	DOREPLIFETIME(AGASCourseCharacter, RepAnimMontageInfo);
}

void AGASCourseCharacter::BeginPlay()
{
	// Call the base class  
	Super::BeginPlay();
}

void AGASCourseCharacter::InitializeAbilitySystem(UGASCourseAbilitySystemComponent* InASC)
{
	if(GetLocalRole() != ROLE_Authority || !InASC)
	{
		return;
	}
	if(DefaultAbilitySet)
	{
		DefaultAbilitySet->GiveToAbilitySystem(InASC, nullptr);
	}

	if(AbilityTagRelationshipMapping)
	{
		InASC->SetTagRelationshipMapping(AbilityTagRelationshipMapping);
	}

	if(GameplayStatusEffectTable)
	{
		InASC->SetGameplayEffectStatusTable(GameplayStatusEffectTable);
	}

}

UGASCourseAbilitySystemComponent* AGASCourseCharacter::GetAbilitySystemComponent() const
{
	return AbilitySystemComponent;
}

float AGASCourseCharacter::GetCrouchSpeed() const
{
	if (const UGASCourseCharBaseAttributeSet* BaseAttributeSet = GetAbilitySystemComponent()->GetSetChecked<UGASCourseCharBaseAttributeSet>())
	{
		return BaseAttributeSet->GetCrouchSpeed();
	}
	UE_LOG(LogTemp, Warning, TEXT("NO VALID ATTRIBUTE SET FOUND"));
	return 0.0f;
}

float AGASCourseCharacter::GetJumpZVelocityOverride() const
{
	if (const UGASCourseCharBaseAttributeSet* BaseAttributeSet = GetAbilitySystemComponent()->GetSetChecked<UGASCourseCharBaseAttributeSet>())
	{
		return BaseAttributeSet->GetJumpZVelocityOverride();
	}
	UE_LOG(LogTemp, Warning, TEXT("NO VALID ATTRIBUTE SET FOUND"));
	return 0.0f;
}

void AGASCourseCharacter::Move(const FInputActionValue& Value)
{
	// input is a Vector2D
	const FVector2D MovementVector = Value.Get<FVector2D>();

	if (Controller != nullptr)
	{
		if (UGASCourseAbilitySystemComponent* GASCourseASC = GetAbilitySystemComponent())
		{
			//Block any type of movement if character has tag Status.MovementInputBlocked
			if(GASCourseASC->HasMatchingGameplayTag(Status_Block_MovementInput))
			{
				return;
			}
			if(MovementVector.Length() > 0.0f)
			{
				GASCourseASC->SetLooseGameplayTagCount(Status_IsMoving, 1);
			}
			// find out which way is forward
			const FRotator Rotation = Controller->GetControlRotation(); //TODO: Maybe breaks something?
			const FRotator YawRotation(0, Rotation.Yaw, 0);

			// get forward vector
			const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
	
			// get right vector 
			const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);

			// add movement 
			AddMovementInput(ForwardDirection, MovementVector.Y);
			AddMovementInput(RightDirection, MovementVector.X);
		}
	}
}

void AGASCourseCharacter::StopMove(const FInputActionValue& Value)
{
	// input is a Vector2D
	const FVector2D MovementVector = Value.Get<FVector2D>();

	if (Controller != nullptr)
	{
		if (UGASCourseAbilitySystemComponent* GASCourseASC = GetAbilitySystemComponent())
		{
			//Block any type of movement if character has tag Status.MovementInputBlocked
			if(GASCourseASC->HasMatchingGameplayTag(Status_Block_MovementInput))
			{
				return;
			}
			if(FMath::IsNearlyZero(MovementVector.Length()))
			{
				GASCourseASC->SetLooseGameplayTagCount(Status_IsMoving, 0);
			}
		}
	}
}

void AGASCourseCharacter::Look(const FInputActionValue& Value)
{
	// input is a Vector2D
	const FVector2D LookAxisVector = Value.Get<FVector2D>();

	if (Controller != nullptr)
	{
		// add yaw and pitch input to controller
		AddControllerYawInput(LookAxisVector.X);
		AddControllerPitchInput(LookAxisVector.Y);
	}
}

void AGASCourseCharacter::Input_Crouch(const FInputActionValue& Value)
{
	const UGASCourseAbilitySystemComponent* GASCourseASC = GetAbilitySystemComponent();
	//Block any type of movement if character has tag Status.MovementInputBlocked
	if(GASCourseASC->HasMatchingGameplayTag(Status_Block_MovementInput))
	{
		return;
	}
	const UCharacterMovementComponent* GASCharacterMovementComponent = CastChecked<UCharacterMovementComponent>(GetCharacterMovement());
	
	if (bIsCrouched || GASCharacterMovementComponent->bWantsToCrouch)
	{
		UnCrouch();
	}
	else if (GASCharacterMovementComponent->IsMovingOnGround())
	{
		Crouch();
	}
}

void AGASCourseCharacter::OnStartCrouch(float HalfHeightAdjust, float ScaledHalfHeightAdjust)
{
	UGASCourseAbilitySystemComponent* GASCourseASC = GetAbilitySystemComponent();
	//Block any type of movement if character has tag Status.MovementInputBlocked
	if(GASCourseASC->HasMatchingGameplayTag(Status_Block_MovementInput))
	{
		return;
	}
	
	GASCourseASC->SetLooseGameplayTagCount(Status_Crouching, 1);
	Super::OnStartCrouch(HalfHeightAdjust, ScaledHalfHeightAdjust);
}

void AGASCourseCharacter::OnEndCrouch(float HalfHeightAdjust, float ScaledHalfHeightAdjust)
{
	UGASCourseAbilitySystemComponent* GASCourseASC = GetAbilitySystemComponent();
	//Block any type of movement if character has tag Status.MovementInputBlocked
	if(GASCourseASC->HasMatchingGameplayTag(Status_Block_MovementInput))
	{
		return;
	}
	
	GASCourseASC->SetLooseGameplayTagCount(Status_Crouching, 0);
	Super::OnEndCrouch(HalfHeightAdjust, ScaledHalfHeightAdjust);
}

bool AGASCourseCharacter::CanJumpInternal_Implementation() const
{
	const UGASCourseAbilitySystemComponent* GASCourseASC = GetAbilitySystemComponent();
	if(GASCourseASC->HasMatchingGameplayTag(Status_Block_MovementInput))
	{
		return false;
	}
	const UGASCourseMovementComponent* GASCharacterMovementComponent = CastChecked<UGASCourseMovementComponent>(GetCharacterMovement());
	if(GASCharacterMovementComponent->bAllowJumpFromCrouch)
	{
		return JumpIsAllowedInternal();
	}
	
	return Super::CanJumpInternal_Implementation();
}

void AGASCourseCharacter::Jump()
{
	const UGASCourseMovementComponent* GASCharacterMovementComponent = CastChecked<UGASCourseMovementComponent>(GetCharacterMovement());
	if(GASCharacterMovementComponent->bAllowJumpFromCrouch)
	{
		UnCrouch();
	}
	
	Super::Jump();
}


FReplicationProxyVarList& AGASCourseCharacter::Call_GetReplicationProxyVarList_Mutable()
{
	MARK_PROPERTY_DIRTY_FROM_NAME(AGASCourseCharacter, ReplicationVarList, this);
	return ReplicationVarList;
}

void AGASCourseCharacter::OnRep_ReplicationVarList()
{
	
	UGASCourseAbilitySystemComponent* ASC = GetAbilitySystemComponent();
	if (ASC)
	{
		// Update ASC client version of RepAnimMontageInfo
		ASC->SetNumericAttributeBase(UGASCourseCharBaseAttributeSet::GetMovementSpeedAttribute(), ReplicationVarList.AttributeOne);
		ASC->SetNumericAttributeBase(UGASCourseCharBaseAttributeSet::GetCrouchSpeedAttribute(), ReplicationVarList.AttributeTwo);

		// Here you should add the tags to the simulated proxies ie.:
		if(bool bFirstTagExists = ((ReplicationVarList.GameplayTagsBitMask & 0x01) != 0))
		{
			ASC->AddLooseGameplayTag(FGameplayTag::RequestGameplayTag("Data.Sample"));
		}
		else
		{
			ASC->RemoveLooseGameplayTag(FGameplayTag::RequestGameplayTag("Data.Sample"));
		}
	}

}

void AGASCourseCharacter::ForceReplication()
{
	ForceNetUpdate();
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCueExecuted_FromSpec_Implementation(const FGameplayEffectSpecForRPC Spec, FPredictionKey PredictionKey)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(Spec, EGameplayCueEvent::Executed);
	}
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCueExecuted_Implementation(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayEffectContextHandle EffectContext)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::Executed, EffectContext);
	}
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCuesExecuted_Implementation(const FGameplayTagContainer GameplayCueTags, FPredictionKey PredictionKey, FGameplayEffectContextHandle EffectContext)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		for (const FGameplayTag& GameplayCueTag : GameplayCueTags)
		{
			GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::Executed, EffectContext);
		}
	}
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCueExecuted_WithParams_Implementation(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayCueParameters GameplayCueParameters)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::Executed, GameplayCueParameters);
	}
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCuesExecuted_WithParams_Implementation(const FGameplayTagContainer GameplayCueTags, FPredictionKey PredictionKey, FGameplayCueParameters GameplayCueParameters)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		for (const FGameplayTag& GameplayCueTag : GameplayCueTags)
		{
			GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::Executed, GameplayCueParameters);
		}
	}
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCueAdded_Implementation(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayEffectContextHandle EffectContext)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::OnActive, EffectContext);
	}
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCueAdded_WithParams_Implementation(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayCueParameters Parameters)
{
	// If server generated prediction key and auto proxy, skip this message. 
	// This is an RPC from mixed replication mode code, we will get the "real" message from our OnRep on the autonomous proxy
	// See UAbilitySystemComponent::AddGameplayCue_Internal for more info.
	
	bool bIsMixedReplicationFromServer = (GetAbilitySystemComponent()->ReplicationMode == EGameplayEffectReplicationMode::Mixed && PredictionKey.IsServerInitiatedKey() && IsLocallyControlled());

	if (HasAuthority() || (PredictionKey.IsLocalClientKey() == false && !bIsMixedReplicationFromServer))
	{
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::OnActive, Parameters);
	}
}


void AGASCourseCharacter::NetMulticast_InvokeGameplayCueAddedAndWhileActive_FromSpec_Implementation(const FGameplayEffectSpecForRPC& Spec, FPredictionKey PredictionKey)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(Spec, EGameplayCueEvent::OnActive);
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(Spec, EGameplayCueEvent::WhileActive);
	}
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCueAddedAndWhileActive_WithParams_Implementation(const FGameplayTag GameplayCueTag, FPredictionKey PredictionKey, FGameplayCueParameters GameplayCueParameters)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::OnActive, GameplayCueParameters);
		GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::WhileActive, GameplayCueParameters);
	}
}

void AGASCourseCharacter::NetMulticast_InvokeGameplayCuesAddedAndWhileActive_WithParams_Implementation(const FGameplayTagContainer GameplayCueTags, FPredictionKey PredictionKey, FGameplayCueParameters GameplayCueParameters)
{
	if (HasAuthority() || PredictionKey.IsLocalClientKey() == false)
	{
		for (const FGameplayTag& GameplayCueTag : GameplayCueTags)
		{
			GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::OnActive, GameplayCueParameters);
			GetAbilitySystemComponent()->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::WhileActive, GameplayCueParameters);
		}
	}
}

FGameplayAbilityRepAnimMontage& AGASCourseCharacter::Call_GetRepAnimMontageInfo_Mutable()
{
	MARK_PROPERTY_DIRTY_FROM_NAME(AGASCourseCharacter, RepAnimMontageInfo, this);
	return RepAnimMontageInfo;
}

void AGASCourseCharacter::Call_OnRep_ReplicatedAnimMontage()
{
	UGASCourseAbilitySystemComponent* ASC = GetAbilitySystemComponent();
	if (ASC)
	{
		// Update ASC client version of RepAnimMontageInfo
		ASC->SetRepAnimMontageInfoAccessor(RepAnimMontageInfo);
		// Call OnRep of AnimMontageInfo
		ASC->ReplicatedAnimMontageOnRepAccesor();
	}
}

To wrap up this blog post, here are the final results showcasing the current native & ability tagged input actions in the project so far:


References:

./X157 Dev Notes

# Enhanced Input - What you need to know

Let’s play MR. Singh

LYRA


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

Tag Relationship Mapping

3 Likes

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos


Today we are going to talk about Ability Gameplay Tag Relationship Mappings and how I use it for my GAS Course Project. In short, an Ability Gameplay Tag Relationship Mapping asset is a way to dictate how different types of abilities can interact with each other, by means of blocking or cancelling one another, as well as adding required and block tags for each ability from one central location.


What is an Ability Gameplay Tag Relationship Mapping

Additional Reading:

Abilities in Lyra

In order to understand what the Ability Gameplay Tag Relationship Mapping is, we first must discuss the primary tag container that exists within Gameplay Abilities in GAS.

Ability Tags - Abillity Tags are a labeling of this ability that can then be referenced by other aspects of GAS. For example, when using the function, Try Activate Abilities by Tag, are referring to the Ability Tags of the ability(abilities) to activate. A common misconception that I have seen is developers thinking these are tags that get applied to the owning character of the ability, but that is wrong; this is what the Activation Owned Tags represent.

Cancel Abilities with Tag - These are Ability Tags (referenced above) that should be cancelled when this ability is activated. I use this to block and cancel my hit reaction abilities when the death ability is activated, in order to prevent further reactions from happening.

Block Abilities with Tag - These are Ability Tags (referenced above) that should be blocked while this ability is active. I use this to block other ‘action’ type of abilities while the aim-cast type ability is active.

Activation Owned Tags - These are the gameplay tags that are applied to the owner’s ability system component while the ability is active. This is good to have some labeled gameplay tag present on the ability system component in case you need to query on the component. In the case of my aim cast ability, I use this to apply a status tag to block point & click movement.

Activation Required Tags - These are gameplay tags that are required to be present on the ability system component attempting to activate the ability.

Activation Blocked Tags - These are gameplay tags that, if present on the ability system component attempting to activate the ability, will prevent the ability from being active. I use this to prevent my hit reaction ability from being activated if the ability system component has the death gameplay tags (Reaction.Death|Status.Death).

[Note: The following set of gameplay tag containers I have not yet used in any projects I have worked on, but perhaps you’ll find a need for them]

When triggering an ability via a Gameplay Event, the optional payload data includes gameplay tag containers for both Instigator and Target tags, instigator correlating to Source and target relating to Target.

Source Required Tags - Tags in the Source Required Tags container must be present in the Instigator Tags container of the Send Gameplay Event function payload in order for the ability to activate.


Source Block Tags - Tags in the Source Blocked Tags container cannot be present in the Instigator Tags container of the Send Gameplay Event function payload in order for the ability to activate.

Target Required Tags - Tags in the Target Required Tags container must be present in the Target Tags container of the Send Gameplay Event function payload in order for the ability to activate.


Target Block Tags - Tags in the Target Blocked Tags container cannot be present in the Target Tags container of the Send Gameplay Event function payload in order for the ability to activate.

The Ability Gameplay Tag Relationship Mapping was developed for Lyra, by Epic Games, to allow broader control over how abilities interact with each other. Here is a snippet of what their TagRelationships_ShooterHero mapping looks like:


Below is the current setup of my Ability Gameplay Tag Relationship Mapping

The typical naming/hierarchal convention for Ability Tags that I try to follow come from the examples provided by Lyra. For the GAS Course Project, at the time of this writing, are set up in 3 primary categories:

Ability.Type.Action - These are for abilities that are active via input actions of the player, and will be present in the abilities UI of the example project. Sub-categories are the following:

Ability.Type.Action.AimCast | .Duration | .Instant

Ability.Type.Passive - These are for abilities that are not activated by input, and are activated passively by gameplay elements, such as gameplay status (burn, freeze, etc.)

Ability.Type.Reaction - These are for abilities that are not activated by input, and are activated passively by gameplay elements, as reactions; such as the following:

Ability.Type.Reaction.Death | .Hit

NOTE: It may be better to move reactions to under the Passive action type category, but for now, this is fine for my needs.


Now that we understand what these gameplay tag containers do inside of the Gameplay Ability class, we can now talk a bit about what the Ability Gameplay Tag Relationship Mapping data asset does. In short, it allows for developers to map all their ability tags, and how they interact with one another, in one data asset; however, it only covers the following interactions:

Ability Tags to Block - This is the same as the Block Abilities with Tag container found inside of gameplay abilities.

Ability Tags to Cancel - This is the same as the Cancel Abilities with Tag container found inside of gameplay abilities.

Activation Required Tags - This is the same as the Activation Required Tags container found inside of gameplay abilities.

Activation Blocked Tags - This is the same as the Activation Blocked Tags container found inside of gameplay abilities.

The idea is to have a more hollistic approach of how these abilities interact with each other, rather than having these interactions exist in isolation within each gameplay ability. It gives designers the bigger picture, instead of hiding this within each ability, and can be super useful for fast iteration, if you set your ability tag hierarchy in a way that is flexible and scalable.


Here is my AbilityTagRelationshipMapping data asset class:

GASAbilityTagRelationshipMapping.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "Engine/DataAsset.h"
#include "Containers/Array.h"
#include "GameplayTagContainer.h"
#include "GASAbilityTagRelationshipMapping.generated.h"

class UObject;

/** Struct that defines the relationship between different ability tags */
USTRUCT()
struct FGASCourseAbilityTagRelationship
{
	GENERATED_BODY()

	/** The tag that this container relationship is about. Single tag, but abilities can have multiple of these */
	UPROPERTY(EditAnywhere, Category = Ability, meta = (Categories = "Gameplay.Action"))
	FGameplayTag AbilityTag;

	/** The other ability tags that will be blocked by any ability using this tag */
	UPROPERTY(EditAnywhere, Category = Ability)
	FGameplayTagContainer AbilityTagsToBlock;

	/** The other ability tags that will be canceled by any ability using this tag */
	UPROPERTY(EditAnywhere, Category = Ability)
	FGameplayTagContainer AbilityTagsToCancel;

	/** If an ability has the tag, this is implicitly added to the activation required tags of the ability */
	UPROPERTY(EditAnywhere, Category = Ability)
	FGameplayTagContainer ActivationRequiredTags;

	/** If an ability has the tag, this is implicitly added to the activation blocked tags of the ability */
	UPROPERTY(EditAnywhere, Category = Ability)
	FGameplayTagContainer ActivationBlockedTags;
};

/** Mapping of how ability tags block or cancel other abilities */
UCLASS()
class UGASAbilityTagRelationshipMapping : public UDataAsset
{
	GENERATED_BODY()

private:
	/** The list of relationships between different gameplay tags (which ones block or cancel others) */
	UPROPERTY(EditAnywhere, Category = Ability, meta=(TitleProperty="AbilityTag"))
	TArray<FGASCourseAbilityTagRelationship> AbilityTagRelationships;

public:
	/** Given a set of ability tags, parse the tag relationship and fill out tags to block and cancel */
	void GetAbilityTagsToBlockAndCancel(const FGameplayTagContainer& AbilityTags, FGameplayTagContainer* OutTagsToBlock, FGameplayTagContainer* OutTagsToCancel) const;

	/** Given a set of ability tags, add additional required and blocking tags */
	void GetRequiredAndBlockedActivationTags(const FGameplayTagContainer& AbilityTags, FGameplayTagContainer* OutActivationRequired, FGameplayTagContainer* OutActivationBlocked) const;

	/** Returns true if the specified ability tags are canceled by the passed in action tag */
	bool IsAbilityCancelledByTag(const FGameplayTagContainer& AbilityTags, const FGameplayTag& ActionTag) const;
	
};

GASAbilityTagRelationshipMapping.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/GameplayAbilitySystem/GASAbilityTagRelationshipMapping.h"

void UGASAbilityTagRelationshipMapping::GetAbilityTagsToBlockAndCancel(const FGameplayTagContainer& AbilityTags,
	FGameplayTagContainer* OutTagsToBlock, FGameplayTagContainer* OutTagsToCancel) const
{
	// Simple iteration for now
	for (int32 i = 0; i < AbilityTagRelationships.Num(); i++)
	{
		const FGASCourseAbilityTagRelationship& Tags = AbilityTagRelationships[i];
		if (AbilityTags.HasTag(Tags.AbilityTag))
		{
			if (OutTagsToBlock)
			{
				OutTagsToBlock->AppendTags(Tags.AbilityTagsToBlock);
			}
			if (OutTagsToCancel)
			{
				OutTagsToCancel->AppendTags(Tags.AbilityTagsToCancel);
			}
		}
	}
}

void UGASAbilityTagRelationshipMapping::GetRequiredAndBlockedActivationTags(const FGameplayTagContainer& AbilityTags,
	FGameplayTagContainer* OutActivationRequired, FGameplayTagContainer* OutActivationBlocked) const
{
	// Simple iteration for now
	for (int32 i = 0; i < AbilityTagRelationships.Num(); i++)
	{
		const FGASCourseAbilityTagRelationship& Tags = AbilityTagRelationships[i];
		if (AbilityTags.HasTag(Tags.AbilityTag))
		{
			if (OutActivationRequired)
			{
				OutActivationRequired->AppendTags(Tags.ActivationRequiredTags);
			}
			if (OutActivationBlocked)
			{
				OutActivationBlocked->AppendTags(Tags.ActivationBlockedTags);
			}
		}
	}
}

bool UGASAbilityTagRelationshipMapping::IsAbilityCancelledByTag(const FGameplayTagContainer& AbilityTags,
	const FGameplayTag& ActionTag) const
{
	// Simple iteration for now
	for (int32 i = 0; i < AbilityTagRelationships.Num(); i++)
	{
		const FGASCourseAbilityTagRelationship& Tags = AbilityTagRelationships[i];

		if (Tags.AbilityTag == ActionTag && Tags.AbilityTagsToCancel.HasAny(AbilityTags))
		{
			return true;
		}
	}

	return false;
}

Next, you will need to create a variable for the tag relationship mapping inside of your ability system component and override the function,

void GetAdditionalActivationTagRequirements(const FGameplayTagContainer& AbilityTags, FGameplayTagContainer& OutActivationRequired, FGameplayTagContainer& OutActivationBlocked) const;|

This handles the block and cancel tags container and ensures we use what is set inside of the tag relationship mapping:

GASCourseAbilitySystemComponent.h

/** Looks at ability tags and gathers additional required and blocking tags */
void GetAdditionalActivationTagRequirements(const FGameplayTagContainer& AbilityTags, FGameplayTagContainer& OutActivationRequired, FGameplayTagContainer& OutActivationBlocked) const;|

// If set, this table is used to look up tag relationships for activate and cancel
UPROPERTY()
TObjectPtr<UGASAbilityTagRelationshipMapping> AbilityTagRelationshipMapping;

GASCourseAbilitySystemComponent.cpp

void UGASCourseAbilitySystemComponent::GetAdditionalActivationTagRequirements(const FGameplayTagContainer& AbilityTags,
                                                                              FGameplayTagContainer& OutActivationRequired, FGameplayTagContainer& OutActivationBlocked) const
{
	if (AbilityTagRelationshipMapping)
	{
		AbilityTagRelationshipMapping->GetRequiredAndBlockedActivationTags(AbilityTags, &OutActivationRequired, &OutActivationBlocked);
	}
}

In your Gameplay Ability class, you will need to override the function,

bool UGASCourseGameplayAbility::DoesAbilitySatisfyTagRequirements(const UAbilitySystemComponent& AbilitySystemComponent,
const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags,
FGameplayTagContainer* OptionalRelevantTags) const

This handles the activation required and activation blocked tags container and ensures we use what is set inside of the tag relationship mapping:

bool UGASCourseGameplayAbility::DoesAbilitySatisfyTagRequirements(const UAbilitySystemComponent& AbilitySystemComponent,
	const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags,
	FGameplayTagContainer* OptionalRelevantTags) const
{
	// Specialized version to handle death exclusion and AbilityTags expansion via ASC

	bool bBlocked = false;
	bool bMissing = false;

	const UAbilitySystemGlobals& AbilitySystemGlobals = UAbilitySystemGlobals::Get();
	const FGameplayTag& BlockedTag = AbilitySystemGlobals.ActivateFailTagsBlockedTag;
	const FGameplayTag& MissingTag = AbilitySystemGlobals.ActivateFailTagsMissingTag;

	// Check if any of this ability's tags are currently blocked
	if (AbilitySystemComponent.AreAbilityTagsBlocked(AbilityTags))
	{
		bBlocked = true;
	}

	const UGASCourseAbilitySystemComponent* GASCourseASC = Cast<UGASCourseAbilitySystemComponent>(&AbilitySystemComponent);
	static FGameplayTagContainer AllRequiredTags;
	static FGameplayTagContainer AllBlockedTags;

	AllRequiredTags = ActivationRequiredTags;
	AllBlockedTags = ActivationBlockedTags;

	// Expand our ability tags to add additional required/blocked tags
	if (GASCourseASC)
	{
		GASCourseASC->GetAdditionalActivationTagRequirements(AbilityTags, AllRequiredTags, AllBlockedTags);
	}

	// Check to see the required/blocked tags for this ability
	if (AllBlockedTags.Num() || AllRequiredTags.Num())
	{
		static FGameplayTagContainer AbilitySystemComponentTags;
		
		AbilitySystemComponentTags.Reset();
		AbilitySystemComponent.GetOwnedGameplayTags(AbilitySystemComponentTags);

		if (AbilitySystemComponentTags.HasAny(AllBlockedTags))
		{
			if (OptionalRelevantTags && AbilitySystemComponentTags.HasTag(Status_Death))
			{
				// If player is dead and was rejected due to blocking tags, give that feedback
			}

			bBlocked = true;
		}

		if (!AbilitySystemComponentTags.HasAll(AllRequiredTags))
		{
			bMissing = true;
		}
	}

	if (SourceTags != nullptr)
	{
		if (SourceBlockedTags.Num() || SourceRequiredTags.Num())
		{
			if (SourceTags->HasAny(SourceBlockedTags))
			{
				bBlocked = true;
			}

			if (!SourceTags->HasAll(SourceRequiredTags))
			{
				bMissing = true;
			}
		}
	}

	if (TargetTags != nullptr)
	{
		if (TargetBlockedTags.Num() || TargetRequiredTags.Num())
		{
			if (TargetTags->HasAny(TargetBlockedTags))
			{
				bBlocked = true;
			}

			if (!TargetTags->HasAll(TargetRequiredTags))
			{
				bMissing = true;
			}
		}
	}

	if (bBlocked)
	{
		if (OptionalRelevantTags && BlockedTag.IsValid())
		{
			OptionalRelevantTags->AddTag(BlockedTag);
		}
		return false;
	}
	if (bMissing)
	{
		if (OptionalRelevantTags && MissingTag.IsValid())
		{
			OptionalRelevantTags->AddTag(MissingTag);
		}
		return false;
	}

	return true;
}

References:

Scalesculptor - Ability Tag Relationship Maps in Lyra

Kaos Spectrum - Gameplay Tag Relationships


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

Gameplay Ability Cooldown

3 Likes

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Today we are going to talk about Ability Cooldowns, including showing how to hook in remaining cooldown duration time into the ability UI. For my project, each activatable ability via enhanced input has a cooldown gameplay effect that controls for how long after activation an ability can be activated again. As always, I cannot recommend enough Tranek’s documentation on GAS, and you can find their in-depth documentation of Cooldowns linked here.


What is an Ability Cooldown?

An ability cooldown is a mechanism that can be used within the Gameplay Ability System to prevent abilities from be activated for a specified duration after initial activation. This is a common role-playing game mechanic to prevent ability spamming, and as a path-way for character progression; i.e., shorter cool-downs at higher player level or skill-tree perks to reduce cooldowns.

Classes to research on your own:

UGameplayAbility

Additional Reading:

Adding a Global Cooldown Using GAS

Dynamic Cooldowns

Kaos Spectrum - Adjusting Durations/Cooldowns of Active Gameplay Effects


When trying to activate an ability, the Gameplay Ability class has built in checks to verify that the ability in question can be activated. These two checks include Ability Cost and Ability Cooldown. At the time of this writing, I do not use Ability Cost; however, this is a mechanism I will be using in the future of this project. In short, Ability Cost can represent the amount of mana, or any other gameplay related resource, required to activate an ability.

The functions in question are:

bool UGameplayAbility::CheckCooldown(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, OUT FGameplayTagContainer* OptionalRelevantTags) const
{
	const FGameplayTagContainer* CooldownTags = GetCooldownTags();
	if (CooldownTags)
	{
		if (CooldownTags->Num() > 0)
		{
			UAbilitySystemComponent* const AbilitySystemComponent = ActorInfo->AbilitySystemComponent.Get();
			check(AbilitySystemComponent != nullptr);
			if (AbilitySystemComponent->HasAnyMatchingGameplayTags(*CooldownTags))
			{
				const FGameplayTag& CooldownTag = UAbilitySystemGlobals::Get().ActivateFailCooldownTag;

				if (OptionalRelevantTags && CooldownTag.IsValid())
				{
					OptionalRelevantTags->AddTag(CooldownTag);
				}

				return false;
			}
		}
	}
	return true;
}
bool UGameplayAbility::CheckCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, OUT FGameplayTagContainer* OptionalRelevantTags) const
{
	UGameplayEffect* CostGE = GetCostGameplayEffect();
	if (CostGE)
	{
		UAbilitySystemComponent* const AbilitySystemComponent = ActorInfo->AbilitySystemComponent.Get();
		check(AbilitySystemComponent != nullptr);
		if (!AbilitySystemComponent->CanApplyAttributeModifiers(CostGE, GetAbilityLevel(Handle, ActorInfo), MakeEffectContext(Handle, ActorInfo)))
		{
			const FGameplayTag& CostTag = UAbilitySystemGlobals::Get().ActivateFailCostTag;

			if (OptionalRelevantTags && CostTag.IsValid())
			{
				OptionalRelevantTags->AddTag(CostTag);
			}
			return false;
		}
	}
	return true;
}

Note: There are console commands that can be used to ignore both cooldowns and costs. AbilitySystem.IgnoreCooldowns | AbilitySystem.IgnoreCosts

These commands are checked via

AbilitySystemGlobals.ShouldIgnoreCooldowns()

and

AbilitySystemGlobals.ShouldIgnoreCosts()

respectively.


What is a Cooldown Gameplay Effect?

Gameplay Abilities have a parameter, Cooldown Gameplay Effect class. If this is left empty, it is assumed that the ability does not have a cooldown.

How to make a Cooldown Gameplay Effect?

There are two aspects of a Cooldown Gameplay Effect that are required to be present in order to work within a Gameplay Ability. To be clear, this is just an ordinary UGameplayEffect class; not a special cooldown gameplay effect class.

Duration - A cooldown requires a duration that represents the time between ability activations. A duration policy of instant will be treated as if there is no cooldown at all, and an infinite policy, at least in the context of my project, completely breaks the ability and it cannot be activated again.

Target Tags - A required component of the Gameplay Effect is the Target Tags Gameplay Effect Component, which is used to grant a gameplay tag to the owning ability system component, and is required in order for the cool-down effect to be recognized. Please again refer to the Check Cooldown function and its requirement of CooldownTags:

bool UGameplayAbility::CheckCooldown(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, OUT FGameplayTagContainer* OptionalRelevantTags) const
{
	const FGameplayTagContainer* CooldownTags = GetCooldownTags();
	if (CooldownTags)
	{
		if (CooldownTags->Num() > 0)
		{
			UAbilitySystemComponent* const AbilitySystemComponent = ActorInfo->AbilitySystemComponent.Get();
			check(AbilitySystemComponent != nullptr);
			if (AbilitySystemComponent->HasAnyMatchingGameplayTags(*CooldownTags))
			{
				const FGameplayTag& CooldownTag = UAbilitySystemGlobals::Get().ActivateFailCooldownTag;

				if (OptionalRelevantTags && CooldownTag.IsValid())
				{
					OptionalRelevantTags->AddTag(CooldownTag);
				}

				return false;
			}
		}
	}
	return true;
}

Depending on how you organize your project, and more specifically, your abilities you may want to only create a one global cooldown effect that you update its duration at runtime when activating your ability; rather than creating unique cooldowns effects for each ability. Tranek’s documentation goes over this here: Cooldown Gameplay Effect

In past projects, and most likely in the future of this one, there were attributes used to control both global cooldown modifiers and ability group/type cooldown modifiers; for example, cooldown modifier attributes for shield based abilities. In this case, it would be useful to use a Custom Calculation Class to modify the incoming duration value passed in via attributes that may affect them.


The following are snippets of code that are relevant for when an ability is activated and both ability cooldown & ability cost are checked in order for the ability to activate successfully:

void UGameplayAbility::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
	if (TriggerEventData && bHasBlueprintActivateFromEvent)
	{
		// A Blueprinted ActivateAbility function must call CommitAbility somewhere in its execution chain.
		K2_ActivateAbilityFromEvent(*TriggerEventData);
	}
	else if (bHasBlueprintActivate)
	{
		// A Blueprinted ActivateAbility function must call CommitAbility somewhere in its execution chain.
		K2_ActivateAbility();
	}
	else if (bHasBlueprintActivateFromEvent)
	{
		UE_LOG(LogAbilitySystem, Warning, TEXT("Ability %s expects event data but none is being supplied. Use 'Activate Ability' instead of 'Activate Ability From Event' in the Blueprint."), *GetName());
		constexpr bool bReplicateEndAbility = false;
		constexpr bool bWasCancelled = true;
		EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
	}
	else
	{
		// Native child classes should override ActivateAbility and call CommitAbility.
		// CommitAbility is used to do one last check for spending resources.
		// Previous versions of this function called CommitAbility but that prevents the callers
		// from knowing the result. Your override should call it and check the result.
		// Here is some starter code:
		
		//	if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
		//	{			
		//		constexpr bool bReplicateEndAbility = true;
		//		constexpr bool bWasCancelled = true;
		//		EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
		//	}
	}
}
bool UGameplayAbility::CommitAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, OUT FGameplayTagContainer* OptionalRelevantTags)
{
	// Last chance to fail (maybe we no longer have resources to commit since we after we started this ability activation)
	if (!CommitCheck(Handle, ActorInfo, ActivationInfo, OptionalRelevantTags))
	{
		return false;
	}

	CommitExecute(Handle, ActorInfo, ActivationInfo);

	// Fixme: Should we always call this or only if it is implemented? A noop may not hurt but could be bad for perf (storing a HasBlueprintCommit per instance isn't good either)
	K2_CommitExecute();

	// Broadcast this commitment
	ActorInfo->AbilitySystemComponent->NotifyAbilityCommit(this);

	return true;
}
bool UGameplayAbility::CommitCheck(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, OUT FGameplayTagContainer* OptionalRelevantTags)
{
	/**
	 *	Checks if we can (still) commit this ability. There are some subtleties here.
	 *		-An ability can start activating, play an animation, wait for a user confirmation/target data, and then actually commit
	 *		-Commit = spend resources/cooldowns. It's possible the source has changed state since it started activation, so a commit may fail.
	 *		-We don't want to just call CanActivateAbility() since right now that also checks things like input inhibition.
	 *			-E.g., its possible the act of starting your ability makes it no longer activatable (CanActivateAbility() may be false if called here).
	 */

	const bool bValidHandle = Handle.IsValid();
	const bool bValidActorInfoPieces = (ActorInfo && (ActorInfo->AbilitySystemComponent != nullptr));
	const bool bValidSpecFound = bValidActorInfoPieces && (ActorInfo->AbilitySystemComponent->FindAbilitySpecFromHandle(Handle) != nullptr);

	// Ensure that the ability spec is even valid before trying to process the commit
	if (!bValidHandle || !bValidActorInfoPieces || !bValidSpecFound)
	{
		ABILITY_LOG(Warning, TEXT("UGameplayAbility::CommitCheck provided an invalid handle or actor info or couldn't find ability spec: %s Handle Valid: %d ActorInfo Valid: %d Spec Not Found: %d"), *GetName(), bValidHandle, bValidActorInfoPieces, bValidSpecFound);
		return false;
	}

	UAbilitySystemGlobals& AbilitySystemGlobals = UAbilitySystemGlobals::Get();

	if (!AbilitySystemGlobals.ShouldIgnoreCooldowns() && !CheckCooldown(Handle, ActorInfo, OptionalRelevantTags))
	{
		return false;
	}

	if (!AbilitySystemGlobals.ShouldIgnoreCosts() && !CheckCost(Handle, ActorInfo, OptionalRelevantTags))
	{
		return false;
	}

	return true;
}
void UGameplayAbility::CommitExecute(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo)
{
	ApplyCooldown(Handle, ActorInfo, ActivationInfo);

	ApplyCost(Handle, ActorInfo, ActivationInfo);
}
void UGameplayAbility::ApplyCooldown(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo) const
{
	UGameplayEffect* CooldownGE = GetCooldownGameplayEffect();
	if (CooldownGE)
	{
		ApplyGameplayEffectToOwner(Handle, ActorInfo, ActivationInfo, CooldownGE, GetAbilityLevel(Handle, ActorInfo));
	}
}

Ability Cooldown UI

In order to get the Ability UI to update based on remaining cooldown after ability activation, I created an ability task that monitors when the corresponding ability cooldown tag is added and then will get the remaining time of the cooldown effect at a specified interval.

This task is based on the code provide in Tranek’s documentation: Get the Cooldown Gameplay Effect’s Remaining Time

Note: I use the same task for checking my Duration-based abilities, and updating their UI; however, I will not be going into details about that aspect.

In general, this task works for my needs, but I don’t like having to specify an interval value of 0.025f in order to get a smooth progression of the UI progress bar, and its something I would want to possibly revisit in the future.

Here is the code:

AbilityTask_WaitForDurationEffectChange.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "Kismet/BlueprintAsyncActionBase.h"
#include "AbilitySystemComponent.h"
#include "GameplayTagContainer.h"
#include "AbilityTask_WaitForDurationEffectChange.generated.h"


DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnDurationChanged, FGameplayTag, DurationTag, float, TimeRemaining, float, Duration);

/**
 * 
 */

UCLASS(Abstract)
class GASCOURSE_API UAbilityTask_WaitForDurationEffectChange: public UBlueprintAsyncActionBase
{

	GENERATED_BODY()
	
public:

	UFUNCTION(BlueprintCallable)
	void EndTask();
	
	UPROPERTY(BlueprintAssignable)
	FOnDurationChanged OnDurationBegin;

	UPROPERTY(BlueprintAssignable)
	FOnDurationChanged OnDurationEnd;

	UPROPERTY(BlueprintAssignable)
	FOnDurationChanged OnDurationTimeUpdated;

protected:

	UPROPERTY()
	UAbilitySystemComponent* ASC;

	FGameplayTagContainer DurationTags;
	float DurationInterval = 0.1f;
	bool bUseServerCooldown;
	const UObject* WorldContext;

	/**
	 * This method is invoked when a gameplay effect is added to the target ability system component (ASC).
	 * It retrieves the asset tags and granted tags from the applied gameplay effect specification (SpecApplied)
	 * and checks if any of them match the duration tags (DurationTagArray) specified.
	 * If a match is found, it determines the remaining time and total duration for the duration tag, and broadcasts
	 * the OnDurationBegin event with the duration tag, time remaining, and duration as parameters.
	 *
	 * Here are the possible scenarios for broadcasting the OnDurationBegin event:
	 * - If the owner of the ASC is the server, it broadcasts the event with the duration tag, time remaining, and duration as parameters.
	 * - If bUseServerCooldown is false and the applied effect is not replicated, it broadcasts the event with the duration tag, time remaining, and duration as parameters.
	 * - If bUseServerCooldown is true and the applied effect is not replicated, it broadcasts the event with the duration tag, time remaining, and duration as parameters.
	 * - If bUseServerCooldown is true and the applied effect is replicated, it broadcasts the event with the duration tag, -1.0f, and -1.0f as parameters.
	 *
	 * Additionally, if the WorldContext is not null, it sets a timer that calls the OnDurationUpdate method periodically
	 * with a specified frequency (DurationInterval).
	 *
	 * @param InTargetASC The target ability system component to which the gameplay effect is added.
	 * @param InSpecApplied The gameplay effect specification that was applied.
	 * @param ActiveHandle The handle of the active gameplay effect.
	 */
	void OnActiveGameplayEffectAddedCallback(UAbilitySystemComponent* InTargetASC, const FGameplayEffectSpec& InSpecApplied, FActiveGameplayEffectHandle ActiveHandle);

	/**
	 * \brief Notifies when the duration tag of a duration effect has changed.
	 *
	 * This method is called when the duration tag of a duration effect has changed to a new count.
	 * If the new count is zero, it means that the duration has ended.
	 *
	 * \param InDurationTag The gameplay tag of the duration effect.
	 * \param InNewCount The new count of the duration effect.
	 */
	void DurationTagChanged(const FGameplayTag InDurationTag, int32 InNewCount);

	/**
	 * Retrieves the remaining time and total duration of the longest active cooldown with the specified tags.
	 *
	 * @param InDurationTags The tags used to identify the cooldowns.
	 * @param TimeRemaining (out) The remaining time of the longest active cooldown.
	 * @param InDuration (out) The total duration of the longest active cooldown.
	 *
	 * @return True if a cooldown with the specified tags is found, false otherwise.
	 */
	bool GetCooldownRemainingForTag(const FGameplayTagContainer& InDurationTags, float& TimeRemaining, float& InDuration) const;

	/**
	 * @brief The OnDurationUpdate method is called when the duration of a particular effect is updated.
	 *
	 * This method calculates the time remaining and total duration of the effect and broadcasts these values
	 * using the OnDurationTimeUpdated event.
	 *
	 * @see UAbilityTask_WaitForDurationEffectChange::OnDurationTimeUpdated
	 */
	UFUNCTION()
	void OnDurationUpdate();

private:

	
	FTimerHandle DurationTimeUpdateTimerHandle;
};

UCLASS(BlueprintType, meta = (ExposedAsyncProxy = AsyncTask))
class GASCOURSE_API UAbilityTask_WaitOnDurationChange : public UAbilityTask_WaitForDurationEffectChange
{
	GENERATED_BODY()
	
public:
	
	/**
	 * WaitOnDurationChange is a static method that creates and returns an instance of UAbilityTask_WaitOnDurationChange.
	 * This task listens for changes in the duration of active gameplay effects with the specified duration tags.
	 *
	 * @param InAbilitySystemComponent The ability system component to listen for duration changes on.
	 * @param InDurationTags The gameplay tag container specifying which duration tags to listen for changes on.
	 * @param InDurationInterval The interval (in seconds) at which to check for duration changes.
	 * @param bInUseServerCooldown Determines if server cooldown should be used for duration changes.
	 * @return A new instance of UAbilityTask_WaitOnDurationChange that is set up to listen for duration changes.
	 */
	UFUNCTION(BlueprintCallable, Category="GASCourse|Ability|Tasks", meta=(BlueprintInternalUseOnly = "true"))
	static UAbilityTask_WaitOnDurationChange* WaitOnDurationChange(UAbilitySystemComponent* InAbilitySystemComponent,FGameplayTagContainer InDurationTags, float InDurationInterval=0.05f, bool bInUseServerCooldown=true);
	
};

UCLASS(BlueprintType, meta = (ExposedAsyncProxy = AsyncTask))
class GASCOURSE_API UAbilityTask_WaitOnCooldownChange : public UAbilityTask_WaitForDurationEffectChange
{
	GENERATED_BODY()
	
public:
	/**
	 * Waits for the cooldown status of ability to change.
	 *
	 * @param InAbilitySystemComponent The ability system component to wait on cooldown change.
	 * @param InCooldownTags The cooldown tags to listen for changes.
	 * @param InDurationInterval The duration interval to check for cooldown changes.
	 * @param bInUseServerCooldown Determines if server cooldown should be used.
	 * @return The instance of UAbilityTask_WaitOnCooldownChange.
	 */
	UFUNCTION(BlueprintCallable, Category="GASCourse|Ability|Tasks", meta=(BlueprintInternalUseOnly = "true"))
	static UAbilityTask_WaitOnCooldownChange* WaitOnCooldownChange(UAbilitySystemComponent* InAbilitySystemComponent,FGameplayTagContainer InCooldownTags, float InDurationInterval = 1.0f, bool bInUseServerCooldown=true);
	
};

AbilityTaskWaitForDurationEffectChange.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/GameplayAbilitySystem/Tasks/AbilityTask_WaitForDurationEffectChange.h"

UAbilityTask_WaitOnDurationChange* UAbilityTask_WaitOnDurationChange::WaitOnDurationChange(UAbilitySystemComponent* InAbilitySystemComponent,FGameplayTagContainer InDurationTags, float InDurationInterval, bool bInUseServerCooldown)
{
	UAbilityTask_WaitOnDurationChange* MyObj = NewObject<UAbilityTask_WaitOnDurationChange>();
	MyObj->WorldContext = GEngine->GetWorldFromContextObjectChecked(InAbilitySystemComponent);
	MyObj->ASC = InAbilitySystemComponent;
	MyObj->DurationTags = InDurationTags;
	MyObj->DurationInterval = InDurationInterval;
	MyObj->bUseServerCooldown = bInUseServerCooldown;


	if(!IsValid(InAbilitySystemComponent) || InDurationTags.Num() < 1)
	{
		MyObj->EndTask();
		return nullptr;
	}

	InAbilitySystemComponent->OnActiveGameplayEffectAddedDelegateToSelf.AddUObject(MyObj, &UAbilityTask_WaitForDurationEffectChange::OnActiveGameplayEffectAddedCallback);

	TArray<FGameplayTag> DurationTagArray;
	InDurationTags.GetGameplayTagArray(DurationTagArray);

	for(const FGameplayTag DurationTag : DurationTagArray)
	{
		InAbilitySystemComponent->RegisterGameplayTagEvent(DurationTag, EGameplayTagEventType::NewOrRemoved).AddUObject(MyObj, &UAbilityTask_WaitForDurationEffectChange::DurationTagChanged);
	}
	
	return MyObj;
}

UAbilityTask_WaitOnCooldownChange* UAbilityTask_WaitOnCooldownChange::WaitOnCooldownChange(
	UAbilitySystemComponent* InAbilitySystemComponent, FGameplayTagContainer InCooldownTags, float InDurationInterval,
	bool bInUseServerCooldown)
{
	UAbilityTask_WaitOnCooldownChange* MyObj = NewObject<UAbilityTask_WaitOnCooldownChange>();
	MyObj->WorldContext = GEngine->GetWorldFromContextObjectChecked(InAbilitySystemComponent);
	MyObj->ASC = InAbilitySystemComponent;
	MyObj->DurationTags = InCooldownTags;
	MyObj->DurationInterval = InDurationInterval;
	MyObj->bUseServerCooldown = bInUseServerCooldown;


	if(!IsValid(InAbilitySystemComponent) || InCooldownTags.Num() < 1)
	{
		MyObj->EndTask();
		return nullptr;
	}

	InAbilitySystemComponent->OnActiveGameplayEffectAddedDelegateToSelf.AddUObject(MyObj, &UAbilityTask_WaitForDurationEffectChange::OnActiveGameplayEffectAddedCallback);

	TArray<FGameplayTag> DurationTagArray;
	InCooldownTags.GetGameplayTagArray(DurationTagArray);

	for(const FGameplayTag DurationTag : DurationTagArray)
	{
		InAbilitySystemComponent->RegisterGameplayTagEvent(DurationTag, EGameplayTagEventType::NewOrRemoved).AddUObject(MyObj, &UAbilityTask_WaitForDurationEffectChange::DurationTagChanged);
	}
	
	return MyObj;
}

void UAbilityTask_WaitForDurationEffectChange::EndTask()
{
	if(IsValid(ASC))
	{
		ASC->OnActiveGameplayEffectAddedDelegateToSelf.RemoveAll(this);

		TArray<FGameplayTag> DurationTagArray;
		DurationTags.GetGameplayTagArray(DurationTagArray);

		for(const FGameplayTag DurationTag :DurationTagArray)
		{
			ASC->RegisterGameplayTagEvent(DurationTag, EGameplayTagEventType::NewOrRemoved).RemoveAll(this);
		}
	}

	SetReadyToDestroy();
	MarkAsGarbage();
}

void UAbilityTask_WaitForDurationEffectChange::OnActiveGameplayEffectAddedCallback(UAbilitySystemComponent* InTargetASC,
	const FGameplayEffectSpec& InSpecApplied, FActiveGameplayEffectHandle ActiveHandle)
{
	FGameplayTagContainer AssetTags;
	InSpecApplied.GetAllAssetTags(AssetTags);

	FGameplayTagContainer GrantedTags;
	InSpecApplied.GetAllGrantedTags(GrantedTags);

	TArray<FGameplayTag> DurationTagArray;
	DurationTags.GetGameplayTagArray(DurationTagArray);

	for(FGameplayTag DurationTag : DurationTagArray)
	{
		if(AssetTags.HasTagExact(DurationTag) || GrantedTags.HasTagExact(DurationTag))
		{
			float TimeRemaining = 0.0f;
			float Duration = 0.0f;

			const FGameplayTagContainer DurationTagContainer(GrantedTags.GetByIndex(0));
			GetCooldownRemainingForTag(DurationTagContainer, TimeRemaining, Duration);

			if (ASC->GetOwnerRole() == ROLE_Authority)
			{
				// Player is Server
				OnDurationBegin.Broadcast(DurationTag, TimeRemaining, Duration);
			}
			else if (!bUseServerCooldown && InSpecApplied.GetContext().GetAbilityInstance_NotReplicated())
			{
				// Client using predicted cooldown
				OnDurationBegin.Broadcast(DurationTag, TimeRemaining, Duration);
			}
			else if (bUseServerCooldown && InSpecApplied.GetContext().GetAbilityInstance_NotReplicated() == nullptr)
			{
				// Client using Server's cooldown. This is Server's corrective cooldown GE.
				OnDurationBegin.Broadcast(DurationTag, TimeRemaining, Duration);
			}
			else if (bUseServerCooldown && InSpecApplied.GetContext().GetAbilityInstance_NotReplicated())
			{
				// Client using Server's cooldown but this is predicted cooldown GE.
				// This can be useful to gray out abilities until Server's cooldown comes in.
				OnDurationBegin.Broadcast(DurationTag, -1.0f, -1.0f);
			}
			
			if(WorldContext)
			{
				WorldContext->GetWorld()->GetTimerManager().SetTimer(DurationTimeUpdateTimerHandle, this, &UAbilityTask_WaitForDurationEffectChange::OnDurationUpdate, DurationInterval, true);
			}

		}
	}
}

void UAbilityTask_WaitForDurationEffectChange::DurationTagChanged(const FGameplayTag InDurationTag, int32 InNewCount)
{
	if(InNewCount == 0)
	{
		OnDurationEnd.Broadcast(InDurationTag, -1.0f, -1.0f);
		if(WorldContext)
		{
			WorldContext->GetWorld()->GetTimerManager().ClearTimer(DurationTimeUpdateTimerHandle);
			WorldContext->GetWorld()->GetTimerManager().ClearAllTimersForObject(this);
		}
	}
}

bool UAbilityTask_WaitForDurationEffectChange::GetCooldownRemainingForTag(const FGameplayTagContainer& InDurationTags,
	float& TimeRemaining, float& InDuration) const
{
	if(IsValid(ASC) && InDurationTags.Num() > 0)
	{
		TimeRemaining = 0.0f;
		InDuration = 0.0f;

		FGameplayEffectQuery const Query = FGameplayEffectQuery::MakeQuery_MatchAnyOwningTags(InDurationTags);
		TArray< TPair<float, float> > DurationAndTimeRemaining = ASC->GetActiveEffectsTimeRemainingAndDuration(Query);
		if(DurationAndTimeRemaining.Num() > 0)
		{
			int32 BestIndex = 0;
			float LongestTime = DurationAndTimeRemaining[0].Key;
			for(int32 Index = 1; Index < DurationAndTimeRemaining.Num(); ++Index)
			{
				if(DurationAndTimeRemaining[Index].Key >LongestTime)
				{
					LongestTime = DurationAndTimeRemaining[Index].Key;
					BestIndex = Index;
				}
			}

			TimeRemaining = DurationAndTimeRemaining[BestIndex].Key;
			InDuration = DurationAndTimeRemaining[BestIndex].Value;

			return true;
		}
	}

	return false;
}

void UAbilityTask_WaitForDurationEffectChange::OnDurationUpdate()
{
	float TimeRemaining = 0.0f;
	float Duration = 0.0f;
	GetCooldownRemainingForTag(DurationTags, TimeRemaining, Duration);
	OnDurationTimeUpdated.Broadcast(DurationTags.GetByIndex(0), TimeRemaining, Duration);
}

Here is the Blueprint logic:

Finally, here is the current results:


References:

Mahadi - Unreal Engine 5 Ability Cooldown And Smooth Progress Bar

Ryan Laley - Unreal Engine 5 Tutorial - Action RPG Part 8: Cooldown UI

Reddit - u/lexical-decoherence - How to setup costs/cooldowns for abilities in gameplay ability system?


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile: I will be taking a break from making additional posts in this topic for the holidays, but I’ll be back in a few weeks with more!


Next Blog Post Topic:

Attribute Sets & Initialization

1 Like

Devin, thanks for sharing all this useful information as always! Hope everything’s well on your side of things. Happy holidays!

1 Like

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Today we are going to talk about a topic that I had just recently learned about in the context of using GAS and Wait Target Data in multiplayer, which is how to properly replicate local/client target data to the server when executing an ability locally. I know that on the last entry of this ongoing blog/thread I said we would talk about Attributes/Attribute Sets & Initialization, but I felt to cover this instead since I have discussed Wait Target Data as my first entry of this thread.


To begin, let’s reiterate over the original problem we needed to solve with abilities using Wait Target Data in a multiplayer context. When using any sort of targeting, more specifically Target Data with GAS, there is the question of what needs to be done locally on the client and what needs to be replicated back to the server in order for the ability to execute correctly. For my GAS Course project, I need the following only to happen on the local client/player side:

  1. The targeting reticle showing the radius of the ability’s targeting effect.
  2. The red outline to appear on the valid targets within that radius.
  3. The damage numbers to appear when targets receive damage.

In regards to Wait Target Data, only the first two points are relevant because the damage numbers are UI that is handled on the local player side anyway. In regards to what needs to be replicated to the server:

  1. The targets that the client found and received from the target data.
  2. A validation of said targets to ensure no cheating.
  3. Whether or not the client confirmed or cancelled targeting.

At the current stage of my project, I am not yet worried about cheating, but there are functions included in my logic that can be used as the designated points of server-side verification. For now, all the server needs to know is that the client either confirm or cancelled the targeting, and if confirming, retrieving the targets found in order to apply damage and clean up the ability as it is ended.


Additional Reading:

The knowledge learned of this topic came from the ability class, ULyraGameplayAbility_RangedWeapon, which is how Lyra handles client-side target data replication to the server when using ranged weapons. Additionally, much of the boilerplate code is either from default GAS or pulled directly from Lyra, with only some modifications made for the purposes of my project.

ULyraGameplayAbility_RangedWeapon


To begin, when we activate our ability, we will want to check if the ability Is Locally Controlled, and if so, run the Wait Target Data task:

Now that we are running the Wait Target Data task, how do we inform the Server when we either confirm or cancel the targeting? The answer lies in the following inside of the UAbilityTask_WaitTargetData class:

AbilityTask_WaitTargetData.h:

UFUNCTION()
virtual void OnTargetDataReadyCallback(const FGameplayAbilityTargetDataHandle& Data);

UFUNCTION()
virtual void OnTargetDataCancelledCallback(const FGameplayAbilityTargetDataHandle& Data);

AbilityTask_WaitTargetData.cpp:

/** The TargetActor we spawned locally has called back with valid target data */
void UAbilityTask_WaitTargetData::OnTargetDataReadyCallback(const FGameplayAbilityTargetDataHandle& Data)
{
	UAbilitySystemComponent* ASC = AbilitySystemComponent.Get();
	if (!Ability || !ASC)
	{
		return;
	}

	FScopedPredictionWindowScopedPrediction(ASC, ShouldReplicateDataToServer());
	
	const FGameplayAbilityActorInfo* Info = Ability->GetCurrentActorInfo();
	if (IsPredictingClient())
	{
		if (!TargetActor->ShouldProduceTargetDataOnServer)
		{
			FGameplayTag ApplicationTag; // Fixme: where would this be useful?
			ASC->CallServerSetReplicatedTargetData(GetAbilitySpecHandle(), GetActivationPredictionKey(), Data, ApplicationTag, ASC->ScopedPredictionKey);
		}
		else if (ConfirmationType == EGameplayTargetingConfirmation::UserConfirmed)
		{
			// We aren't going to send the target data, but we will send a generic confirmed message.
			ASC->ServerSetReplicatedEvent(EAbilityGenericReplicatedEvent::GenericConfirm, GetAbilitySpecHandle(), GetActivationPredictionKey(), ASC->ScopedPredictionKey);
		}
	}

	if (ShouldBroadcastAbilityTaskDelegates())
	{
		ValidData.Broadcast(Data);
	}

	if (ConfirmationType != EGameplayTargetingConfirmation::CustomMulti)
	{
		EndTask();
	}
}
/** The TargetActor we spawned locally has called back with a cancel event (they still include the 'last/best' targetdata but the consumer of this may want to discard it) */
void UAbilityTask_WaitTargetData::OnTargetDataCancelledCallback(const FGameplayAbilityTargetDataHandle& Data)
{
	UAbilitySystemComponent* ASC = AbilitySystemComponent.Get();
	if (!ASC)
	{
		return;
	}

	FScopedPredictionWindow ScopedPrediction(ASC, IsPredictingClient());

	if (IsPredictingClient())
	{
		if (!TargetActor->ShouldProduceTargetDataOnServer)
		{
			ASC->ServerSetReplicatedTargetDataCancelled(GetAbilitySpecHandle(), GetActivationPredictionKey(), ASC->ScopedPredictionKey );
		}
		else
		{
			// We aren't going to send the target data, but we will send a generic confirmed message.
			ASC->ServerSetReplicatedEvent(EAbilityGenericReplicatedEvent::GenericCancel, GetAbilitySpecHandle(), GetActivationPredictionKey(), ASC->ScopedPredictionKey);
		}
	}
	Cancelled.Broadcast(Data);
	EndTask();
}

AbilitySystemComponent.h

/** Replicates targeting data to the server */
UFUNCTION(Server, reliable, WithValidation)
void ServerSetReplicatedTargetData(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey, const FGameplayAbilityTargetDataHandle& ReplicatedTargetDataHandle, FGameplayTag ApplicationTag, FPredictionKey CurrentPredictionKey);

/** Replicates to the server that targeting has been cancelled */
UFUNCTION(Server, reliable, WithValidation)
void ServerSetReplicatedTargetDataCancelled(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey, FPredictionKey CurrentPredictionKey);

/** Returns TargetDataSet delegate for a given Ability/PredictionKey pair */
FAbilityTargetDataSetDelegate& AbilityTargetDataSetDelegate(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey);

/** Returns TargetData Cancelled delegate for a given Ability/PredictionKey pair */
FSimpleMulticastDelegate& AbilityTargetDataCancelledDelegate(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey);

void CallServerSetReplicatedTargetData(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey, const FGameplayAbilityTargetDataHandle& ReplicatedTargetDataHandle, FGameplayTag ApplicationTag, FPredictionKey CurrentPredictionKey);

/** Consumes cached TargetData from client (only TargetData) */
void ConsumeClientReplicatedTargetData(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey);

AbilitySystemComponent_Abilities.cpp

	void UAbilitySystemComponent::ServerSetReplicatedTargetData_Implementation(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey, const FGameplayAbilityTargetDataHandle& ReplicatedTargetDataHandle, FGameplayTag ApplicationTag, FPredictionKey CurrentPredictionKey)
{
	FScopedPredictionWindow ScopedPrediction(this, CurrentPredictionKey);

	// Always adds to cache to store the new data
	TSharedRef<FAbilityReplicatedDataCache> ReplicatedData = AbilityTargetDataMap.FindOrAdd(FGameplayAbilitySpecHandleAndPredictionKey(AbilityHandle, AbilityOriginalPredictionKey));

	if (ReplicatedData->TargetData.Num() > 0)
	{
		FGameplayAbilitySpec* Spec = FindAbilitySpecFromHandle(AbilityHandle);
		if (Spec && Spec->Ability)
		{
			// Can happen under normal circumstances if ServerForceClientTargetData is hit
			ABILITY_LOG(Display, TEXT("Ability %s is overriding pending replicated target data."), *Spec->Ability->GetName());
		}
	}

	ReplicatedData->TargetData = ReplicatedTargetDataHandle;
	ReplicatedData->ApplicationTag = ApplicationTag;
	ReplicatedData->bTargetConfirmed = true;
	ReplicatedData->bTargetCancelled = false;
	ReplicatedData->PredictionKey = CurrentPredictionKey;

	ReplicatedData->TargetSetDelegate.Broadcast(ReplicatedTargetDataHandle, ReplicatedData->ApplicationTag);
}

bool UAbilitySystemComponent::ServerSetReplicatedTargetData_Validate(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey, const FGameplayAbilityTargetDataHandle& ReplicatedTargetDataHandle, FGameplayTag ApplicationTag, FPredictionKey CurrentPredictionKey)
{
	// check the data coming from the client to ensure it's valid
	for (const TSharedPtr<FGameplayAbilityTargetData>& TgtData : ReplicatedTargetDataHandle.Data)
	{
		if (!ensure(TgtData.IsValid()))
		{
			return false;
		}
	}

	return true;
}

void UAbilitySystemComponent::ServerSetReplicatedTargetDataCancelled_Implementation(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey, FPredictionKey CurrentPredictionKey)
{
	FScopedPredictionWindow ScopedPrediction(this, CurrentPredictionKey);

	// Always adds to cache to store the new data
	TSharedRef<FAbilityReplicatedDataCache> ReplicatedData = AbilityTargetDataMap.FindOrAdd(FGameplayAbilitySpecHandleAndPredictionKey(AbilityHandle, AbilityOriginalPredictionKey));

	ReplicatedData->Reset();
	ReplicatedData->bTargetCancelled = true;
	ReplicatedData->PredictionKey = CurrentPredictionKey;
	ReplicatedData->TargetCancelledDelegate.Broadcast();
}

bool UAbilitySystemComponent::ServerSetReplicatedTargetDataCancelled_Validate(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey, FPredictionKey CurrentPredictionKey)
{
	return true;
}

FAbilityTargetDataSetDelegate& UAbilitySystemComponent::AbilityTargetDataSetDelegate(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey)
{
	return AbilityTargetDataMap.FindOrAdd(FGameplayAbilitySpecHandleAndPredictionKey(AbilityHandle, AbilityOriginalPredictionKey))->TargetSetDelegate;
}

FSimpleMulticastDelegate& UAbilitySystemComponent::AbilityTargetDataCancelledDelegate(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey)
{
	return AbilityTargetDataMap.FindOrAdd(FGameplayAbilitySpecHandleAndPredictionKey(AbilityHandle, AbilityOriginalPredictionKey))->TargetCancelledDelegate;
}

void UAbilitySystemComponent::ConsumeClientReplicatedTargetData(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey)
{
	TSharedPtr<FAbilityReplicatedDataCache> CachedData = AbilityTargetDataMap.Find(FGameplayAbilitySpecHandleAndPredictionKey(AbilityHandle, AbilityOriginalPredictionKey));
	if (CachedData.IsValid())
	{
		CachedData->TargetData.Clear();
		CachedData->bTargetConfirmed = false;
		CachedData->bTargetCancelled = false;
	}
}

void UAbilitySystemComponent::CallServerSetReplicatedTargetData(FGameplayAbilitySpecHandle AbilityHandle, FPredictionKey AbilityOriginalPredictionKey, const FGameplayAbilityTargetDataHandle& ReplicatedTargetDataHandle, FGameplayTag ApplicationTag, FPredictionKey CurrentPredictionKey)
{
	UE_CLOG(AbilitySystemLogServerRPCBatching, LogAbilitySystem, Display, TEXT("::CallServerSetReplicatedTargetData %s %s %s %s %s"), 
		*AbilityHandle.ToString(), *AbilityOriginalPredictionKey.ToString(), ReplicatedTargetDataHandle.IsValid(0) ? *ReplicatedTargetDataHandle.Get(0)->ToString() : TEXT("NULL"), *ApplicationTag.ToString(), *CurrentPredictionKey.ToString());

	/** Queue this call up if we are in  a batch window, otherwise just push it through now */
	if (FServerAbilityRPCBatch* ExistingBatchData = LocalServerAbilityRPCBatchData.FindByKey(AbilityHandle))
	{
		if (!ExistingBatchData->Started)
		{
			// A batch window was setup but we didn't see the normal try activate -> target data -> end path. So let this unbatched rpc through.
			FGameplayAbilitySpec* Spec = FindAbilitySpecFromHandle(AbilityHandle);
			UE_CLOG(AbilitySystemLogServerRPCBatching, LogAbilitySystem, Display, TEXT("::CallServerSetReplicatedTargetData called for ability (%s) when CallServerTryActivateAbility has not been called"), Spec ? *GetNameSafe(Spec->Ability) : TEXT("INVALID"));
			ServerSetReplicatedTargetData(AbilityHandle, AbilityOriginalPredictionKey, ReplicatedTargetDataHandle, ApplicationTag, CurrentPredictionKey);
			return;
		}

		if (ExistingBatchData->PredictionKey.IsValidKey() == false)
		{
			FGameplayAbilitySpec* Spec = FindAbilitySpecFromHandle(AbilityHandle);
			ABILITY_LOG(Warning, TEXT("::CallServerSetReplicatedTargetData called for ability (%s) when the prediction key is not valid."), Spec ? *GetNameSafe(Spec->Ability) : TEXT("INVALID"));
		}


		ExistingBatchData->TargetData = ReplicatedTargetDataHandle;
	}
	else
	{
		ServerSetReplicatedTargetData(AbilityHandle, AbilityOriginalPredictionKey, ReplicatedTargetDataHandle, ApplicationTag, CurrentPredictionKey);
	}

}

GameplayAbilityTargetActor.h

/** Replicated target data was received from a client. Possibly sanitize/verify. return true if data is good and we should broadcast it as valid data. */
virtual bool OnReplicatedTargetDataReceived(FGameplayAbilityTargetDataHandle& Data) const;

GameplayAbilityTargetActor.cpp

bool AGameplayAbilityTargetActor::OnReplicatedTargetDataReceived(FGameplayAbilityTargetDataHandle& Data) const
{
	return true;
}

Now that we know a bit about what functionality is provided to us by the gameplay ability system, let’s take a look at what I do to utilize it and properly replicate the client-side target data to the server:

To start, on ActivateAbility() I do the following, though the initial checkfor ActorInfo->IsNetAuthority() my be redundant since we also check IsLocallyControlled in blueprint on ActivateAbility:

void UGASCourseAimcastGameplayAbility::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
	const FGameplayEventData* TriggerEventData)
{

	if(ActorInfo->IsNetAuthority() || HasAuthorityOrPredictionKey(ActorInfo, &ActivationInfo))
	{
		// Bind target data callback
		UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
		check(MyAbilityComponent);
		OnTargetDataReadyCallbackDelegateHandle = MyAbilityComponent->AbilityTargetDataSetDelegate(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey()).AddUObject(this, &ThisClass::OnTargetDataReadyCallback);
		MyAbilityComponent->SetUserAbilityActivationInhibited(true);
	}
	
	Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
}

The most important line being:

OnTargetDataReadyCallbackDelegateHandle = MyAbilityComponent->AbilityTargetDataSetDelegate(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey()).AddUObject(this, &ThisClass::OnTargetDataReadyCallback);

This is where I am binding my ability function, OnTargetDataReadyCallback to the AbilityTargetDataSetDelegate; which means, when the target data is set, I will get my function called. The OnTargetDataReadyCallback looks like this:

/**

  • @brief Callback function invoked when the aim cast target data is ready to be processed.
  • This function is called when the aim cast target data is ready to be processed. It takes in the aim cast target data handle and the application tag.
  • @param InData The target data handle containing the aim cast target data.
  • @param ApplicationTag The tag of the ability application that caused the target data to be ready.
    */
    void OnTargetDataReadyCallback(const FGameplayAbilityTargetDataHandle& InData, FGameplayTag ApplicationTag);
void UGASCourseAimcastGameplayAbility::OnTargetDataReadyCallback(const FGameplayAbilityTargetDataHandle& InData,
	FGameplayTag ApplicationTag)
{
	UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
	check(MyAbilityComponent);

	if (const FGameplayAbilitySpec* AbilitySpec = MyAbilityComponent->FindAbilitySpecFromHandle(CurrentSpecHandle))
	{
		FScopedPredictionWindow	ScopedPrediction(MyAbilityComponent);

		// Take ownership of the target data to make sure no callbacks into game code invalidate it out from under us
		FGameplayAbilityTargetDataHandle LocalTargetDataHandle(MoveTemp(const_cast<FGameplayAbilityTargetDataHandle&>(InData)));

		const bool bShouldNotifyServer = CurrentActorInfo->IsLocallyControlled() && !CurrentActorInfo->IsNetAuthority();
		if (bShouldNotifyServer)
		{
			MyAbilityComponent->CallServerSetReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey(), LocalTargetDataHandle, ApplicationTag, MyAbilityComponent->ScopedPredictionKey);
		}

		const bool bIsTargetDataValid = true;

#if WITH_SERVER_CODE
		if (AController* Controller = GetControllerFromActorInfo())
		{
			if (Controller->GetLocalRole() == ROLE_Authority)
			{
				//TODO: Confirm target data somehow?
			}
		}

#endif //WITH_SERVER_CODE

		if(bIsTargetDataValid)
		{
			OnAimCastTargetDataReady(LocalTargetDataHandle);
		}
		else
		{
			K2_EndAbility();
		}
	}

	// We've processed the data
	MyAbilityComponent->ConsumeClientReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey());
	MyAbilityComponent->SetUserAbilityActivationInhibited(false);
}

It is also within the OnTargetDataReadyCallback that I do the following:

  • I have a WITH_SERVER_CODE check to handle whether or not the data is valid; as mentioned earlier, this doesn’t do anything just yet but this is the place where you could validate the target data by the server.
  • I call a blueprint implementable event, OnAimCastTargetDataReady, that I use in my ability blueprint to handle applying damage to the targets returned by the LocalTargetDataHandle. Below is the what the signature for the OnAimCastTargetDataReady event looks like; I also show OnAimCastTargetDataCancelled.

/**

  • @brief Event called when the aim cast target data is ready.
  • This event is blueprint implementable, allowing developers to define their own functionality in blueprint.
  • It is called when the aim cast target data is ready to be processed.
  • @param TargetData The target data handle containing the aim cast target data.
    */
    UFUNCTION(BlueprintImplementableEvent)
    void OnAimCastTargetDataReady(const FGameplayAbilityTargetDataHandle& TargetData);

/**

  • @brief Event called when the aim cast target data is cancelled.
  • This event is blueprint implementable, allowing developers to define their own functionality in blueprint.
  • It is called when the aim cast target data is cancelled.
    */
    UFUNCTION(BlueprintImplementableEvent)
    void OnAimCastTargetDataCancelled();

To also show what the target data cancelled workflow looks like, here is the OnTargetDataCancelledCallback function:

/**

  • @brief Callback function invoked when the aim cast target data is cancelled.
  • This function is called when the aim cast target data is cancelled. It takes in the target data handle as the parameter.
  • @param Data The handle containing the aim cast target data that was cancelled.
    */
    void OnTargetDataCancelledCallback(const FGameplayAbilityTargetDataHandle& Data);

The OnTargetDataCancelledCallback function right now looks almost identical to the OnTargetDataReadyCallback but only worries about clearing client replicated target data, ending the ability, and calling the blueprint implementable event OnAimCastTargetDataCancelled().

void UGASCourseAimcastGameplayAbility::OnTargetDataCancelledCallback(const FGameplayAbilityTargetDataHandle& Data)
{
	UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
	check(MyAbilityComponent);

	if(bSendTargetDataToServerOnCancelled)
	{
		if (const FGameplayAbilitySpec* AbilitySpec = MyAbilityComponent->FindAbilitySpecFromHandle(CurrentSpecHandle))
		{
			FScopedPredictionWindow	ScopedPrediction(MyAbilityComponent);

			// Take ownership of the target data to make sure no callbacks into game code invalidate it out from under us
			const FGameplayAbilityTargetDataHandle LocalTargetDataHandle(MoveTemp(const_cast<FGameplayAbilityTargetDataHandle&>(Data)));
			
			if (CurrentActorInfo->IsLocallyControlled() && !CurrentActorInfo->IsNetAuthority())
			{
				MyAbilityComponent->CallServerSetReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey(), LocalTargetDataHandle, FGameplayTag::EmptyTag, MyAbilityComponent->ScopedPredictionKey);
			}

			const bool bIsTargetDataValid = true;

#if WITH_SERVER_CODE
			if (AController* Controller = GetControllerFromActorInfo())
			{
				if (Controller->GetLocalRole() == ROLE_Authority)
				{
					//TODO: Confirm target data somehow?
				}
			}

#endif //WITH_SERVER_CODE
		}
	}
	else
	{
		OnAimCastTargetDataCancelled();
	}
	// We've processed the data
	MyAbilityComponent->ConsumeClientReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey());
	MyAbilityComponent->SetUserAbilityActivationInhibited(false);

	if(bEndAbilityOnTargetDataCancelled)
	{
		K2_EndAbility();
	}
}

This is what these blueprint implementable events look like inside of my aimcast ability blueprint:

Now, inside of my target actor base class, I have the following functions to use as a means to send confirmation or cancel decisions back to the calling/owning ability:

virtual void SendTargetDataBacktoServer(const FGameplayAbilityTargetDataHandle& InData, FGameplayTag ApplicationTag);

virtual void SendCancelledTargetDataBackToServer(const FGameplayAbilityTargetDataHandle& InData);

From my extended AGASCourseTargetActor_CameraTrace class, I override these functions and add the following logic:

void AGASCourseTargetActor_CameraTrace::SendTargetDataBacktoServer(const FGameplayAbilityTargetDataHandle& InData,
	FGameplayTag ApplicationTag)
{
	if(bHasDataBeenSentToServer)
	{
		return;
	}
	
	if(UGASCourseAimcastGameplayAbility* LocalAbility = Cast<UGASCourseAimcastGameplayAbility>(OwningAbility))
	{
		LocalAbility->OnTargetDataReadyCallback(InData, ApplicationTag);
		bHasDataBeenSentToServer = true;
	}
	Super::SendTargetDataBacktoServer(InData, ApplicationTag);
}

void AGASCourseTargetActor_CameraTrace::SendCancelledTargetDataBackToServer(
	const FGameplayAbilityTargetDataHandle& InData)
{
	if(bHasDataBeenSentToServer)
	{
		return;
	}
	
	if(UGASCourseAimcastGameplayAbility* LocalAbility = Cast<UGASCourseAimcastGameplayAbility>(OwningAbility))
	{
		LocalAbility->OnTargetDataCancelledCallback(InData);
		bHasDataBeenSentToServer = true;
	}
	Super::SendCancelledTargetDataBackToServer(InData);
}

These functions are what are responsible for notifying the server of whether we confirmed or cancelled our targeting, and to handle the approach replication from client to server, as well as calling the necessary delegates back to the ability performing the targeting. We call these functions inside of our child targeting class, AGASCourseTargetActor_CameraTrace, in the ConfirmTargeting() and CancelTargeting() functions respectively.

void AGASCourseTargetActor_CameraTrace::ConfirmTargeting()
{
	check(ShouldProduceTargetData());
	if (SourceActor)
	{
		const FVector Origin = PerformTrace(SourceActor).Location;
		const FGameplayAbilityTargetDataHandle Handle = MakeTargetData(PerformOverlap(Origin), Origin);
		TargetDataReadyDelegate.Broadcast(Handle);
		SendTargetDataBacktoServer(Handle, FGameplayTag());
		Super::ConfirmTargeting();
	}
}

void AGASCourseTargetActor_CameraTrace::CancelTargeting()
{
	if(SourceActor)
	{
		const FVector Origin = PerformTrace(SourceActor).Location;
		const FGameplayAbilityTargetDataHandle Handle = MakeTargetData(PerformOverlap(Origin), Origin);
		SendCancelledTargetDataBackToServer(Handle);
		Super::CancelTargeting();
	}
}

Here is the final result:


References:

LYRA

Sending Gameplay Ability Data From Client to Server - x157


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

Attribute Sets & Initialization

3 Likes

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Today we are going to talk about Attributes/Attribute Sets, how to initialize attributes to default values, and how attributes are modified through Gameplay Effects. There are two methods for attribute initialization, and we will cover both in this blog post along with much more. As always, I highly recommend reading through Tranek’s documentation found here: GitHub - tranek/GASDocumentation: My understanding of Unreal Engine 5's GameplayAbilitySystem plugin with a simple multiplayer sample project..


What are Attributes and Attribute Sets?

Classes to research on your own:

UAttributeSet: https://docs.unrealengine.com/5.3/en-US/API/Plugins/GameplayAbilities/UAttributeSet/
FGameplayAttributeData: https://docs.unrealengine.com/5.3/en-US/API/Plugins/GameplayAbilities/FGameplayAttributeData/
UGameplayEffectExecutionCalculation: https://docs.unrealengine.com/4.27/en-US/API/Plugins/GameplayAbilities/UGameplayEffectExecutionCalculat-/

Additional Reading:

https://docs.unrealengine.com/5.0/en-US/gameplay-attributes-and-attribute-sets-for-the-gameplay-ability-system-in-unreal-engine/
https://docs.unrealengine.com/5.3/en-US/gameplay-effects-for-the-gameplay-ability-system-in-unreal-engine/


Attributes are scalable float values defined by the FGameplayAttributeData struct that allows developers to modify these values through mechanisms such as Gameplay Effects and Gameplay Effect Execution Calculation classes. What these attributes represent is up to the developer and the design of their game, however common attributes are values of character health/max health, mana/max mana, stamina/max stamina, etc. Essentially any gameplay oriented aspect of an actor that can be represented by a numeric value. Attributes are defined by Attribute Sets, which we will talk a bit about later.


BaseValue vs CurrentValue

It is recommended that attributes only be modified by gameplay effects so that the ability system component can predict the changes. More information on prediction can be found in Tranek’s documentation as well: GitHub - tranek/GASDocumentation: My understanding of Unreal Engine 5's GameplayAbilitySystem plugin with a simple multiplayer sample project.. Gameplay Effects have different duration policies that will dictate whether an attributes’ base value or its current value will be modified by the effect. The difference between these two values is:

Base Value: A permanent change to the attribute value, such as when leveling a character and permanently changing attributes such as Max Health applied via a Gameplay Effect using an Instant duration policy

Current Value: Current Value is the representation of the Base Value that is modified by temporary Gameplay Effects; effects with infinite or duration based duration policies.

Attributes can be given minimum and maximum values to clamp to, when defined using data tables and the struct FAttributeMetaData; however it is recommended that for maximum values that can be changed through gameplay, such as leveling, these values should have their own attribute representation. In my project, I use attributes for maximum values like Max Health.


AdjustAttributeForMaxChange

An interesting concept that I found in my research is the adjustment of attributes based on changes done to their Max value attribute in order to preserve percentages between changes. As an example, let’s say that the NPC dummy character has a default Max Health value of 100.0f and a Current Health value of 50.0f; meaning they have 50% of health. What were to happen to their Current Health attribute when the Max Health attribute of the NPC were to increase by 100.0f? Without the AdjustAttributeForMaxChange, their Current Health would remain at 50.0f while their Max Health increases from 100.0f to 200.0f, causing the percentage to go from 50% to 25%. With AdjustAttributeForMaxChange, we can preserve that 50% by having the Current Health value increase to 100.0f when the Max Health is increased to 200.0f. Here is the code for this function:

// Helper function to proportionally adjust the value of an attribute when it's associated max attribute changes.
// (i.e. When MaxHealth increases, Health increases by an amount that maintains the same percentage as before)
void AdjustAttributeForMaxChange(FGameplayAttributeData& AffectedAttribute, const FGameplayAttributeData& MaxAttribute,
		float NewMaxValue, const FGameplayAttribute& AffectedAttributeProperty);
void UGASCourseAttributeSet::AdjustAttributeForMaxChange(FGameplayAttributeData& AffectedAttribute,
	const FGameplayAttributeData& MaxAttribute, float NewMaxValue, const FGameplayAttribute& AffectedAttributeProperty)
{
	UAbilitySystemComponent* AbilityComp = GetOwningAbilitySystemComponent();
	const float CurrentMaxValue = MaxAttribute.GetCurrentValue();
	if (!FMath::IsNearlyEqual(CurrentMaxValue, NewMaxValue) && AbilityComp)
	{
		// Change current value to maintain the current Val / Max percent
		const float CurrentValue = AffectedAttribute.GetCurrentValue();
		float NewDelta = (CurrentMaxValue > 0.f) ? (CurrentValue * NewMaxValue / CurrentMaxValue) - CurrentValue : NewMaxValue;

		AbilityComp->ApplyModToAttributeUnsafe(AffectedAttributeProperty, EGameplayModOp::Additive, NewDelta);
	}
}

For my GAS Course Project, this function is called during PreAttributeChange():

void UGASCourseHealthAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
	Super::PreAttributeChange(Attribute, NewValue);

	if(Attribute == GetMaxHealthAttribute())
	{
		AdjustAttributeForMaxChange(CurrentHealth, MaxHealth, NewValue, GetCurrentHealthAttribute());
	}

	if(Attribute == GetCurrentHealthAttribute())
	{
		NewValue = FMath::Clamp<float>(NewValue, 0.0f, MaxHealth.GetCurrentValue());
	}
}

Here is a before & after example:

AdjutsAttributeForMaxChangeBefore
AdjutsAttributeForMaxChangeAfter


Meta Attributes

Meta Attributes are attributes for temporary values intended to interact with actual attributes. The most common example I have seen, and that I am using this for, is damage. Instead of Gameplay Effects directly modifying Health attributes, we can use a temporary damage attribute value that can be modified via other attributes that act as buffs/debuffs. Here is an example use-case:

  • The player character applies 10 fire damage onto a target.
  • The target has an attribute called IncomingFireDamageModifer set to 1.5x
  • The player character has an attribute called AllDamageModifier set to 2.0x
  • When applying the 10 fire damage, we can create a custom formula that takes into consideration the above attributes to augment the damage value before actually reducing the Current Health Attribute.
  • Damage = BaseDamage * (IncomingFireDamageModifer + AllDamageModifier) OR Damage = 10.0f * (1.5f + 2.0f) OR Damage = 10.0f * 3.5f || Damage = 35.0f.

I have not yet introduced damage modifier attributes to my project, but it is in my TODO list; however my execution class does have the logic needed to make the necessary modifications when the time comes. Here is what my GASCourseDamageExecution class looks like:

GASCourseDamageExecution.h

UCLASS()
class UGASCourseDamageExecution : public UGameplayEffectExecutionCalculation
{
	GENERATED_BODY()

public:
	
	UGASCourseDamageExecution();

	/** \brief Executes the custom gameplay effect implementation.
	 *
	 * This method is invoked when a gameplay effect with a custom execution is applied. It calculates and applies the damage to the target actor,
	 * taking into account any damage modifiers and tags. It also broadcasts the damage dealt event to the target and source ability system components.
	 *
	 * \param ExecutionParams The parameters for the execution of the gameplay effect.
	 * \param OutExecutionOutput The output data of the execution of the gameplay effect.
	 */
	virtual void Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const override;
	
};

GASCourseDamageExecution.cpp

struct GASCourseDamageStatics
{
	DECLARE_ATTRIBUTE_CAPTUREDEF(IncomingDamage);

	GASCourseDamageStatics()
	{
		DEFINE_ATTRIBUTE_CAPTUREDEF(UGASCourseHealthAttributeSet, IncomingDamage, Source, true);
	}
};

static const GASCourseDamageStatics& DamageStatics()
{
	static GASCourseDamageStatics DStatics;
	return DStatics;
} 

UGASCourseDamageExecution::UGASCourseDamageExecution()
{
	RelevantAttributesToCapture.Add(DamageStatics().IncomingDamageDef);
}

void UGASCourseDamageExecution::Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams,
	FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const
{
	
	UGASCourseAbilitySystemComponent* TargetAbilitySystemComponent = Cast<UGASCourseAbilitySystemComponent>(ExecutionParams.GetTargetAbilitySystemComponent());
	UGASCourseAbilitySystemComponent* SourceAbilitySystemComponent = Cast<UGASCourseAbilitySystemComponent>(ExecutionParams.GetSourceAbilitySystemComponent());

	AActor* SourceActor = SourceAbilitySystemComponent ? SourceAbilitySystemComponent->GetAvatarActor() : nullptr;
	AActor* TargetActor = TargetAbilitySystemComponent ? TargetAbilitySystemComponent->GetAvatarActor() : nullptr;

	const FGameplayEffectSpec& Spec = ExecutionParams.GetOwningSpec();

	// Gather the tags from the source and target as that can affect which buffs should be used
	const FGameplayTagContainer* SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
	const FGameplayTagContainer* TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();

	FAggregatorEvaluateParameters EvaluationParameters;
	EvaluationParameters.SourceTags = SourceTags;
	EvaluationParameters.TargetTags = TargetTags;
	
	float Damage = 0.0f;
	ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().IncomingDamageDef, EvaluationParameters, Damage);

	// Add SetByCaller damage if it exists
	Damage += FMath::Max<float>(Spec.GetSetByCallerMagnitude(Data_IncomingDamage, false, -1.0f), 0.0f);

	float UnmitigatedDamage = Damage; // Can multiply any damage boosters here
	
	float MitigatedDamage = UnmitigatedDamage;

	if (MitigatedDamage > 0.f)
	{
		// Set the Target's damage meta attribute
		OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(DamageStatics().IncomingDamageProperty, EGameplayModOp::Additive, MitigatedDamage));
	}

	// Broadcast damages to Target ASC & SourceASC
	if (TargetAbilitySystemComponent && SourceAbilitySystemComponent)
	{
		FGameplayEventData DamageDealtPayload;
		DamageDealtPayload.Instigator = SourceAbilitySystemComponent->GetAvatarActor();
		DamageDealtPayload.Target = TargetAbilitySystemComponent->GetAvatarActor();
		DamageDealtPayload.EventMagnitude = MitigatedDamage;
		DamageDealtPayload.ContextHandle = Spec.GetContext();
		DamageDealtPayload.InstigatorTags = Spec.DynamicGrantedTags;
		if(Spec.GetContext().GetHitResult())
		{
			FHitResult HitResultFromContext = *Spec.GetContext().GetHitResult();
			DamageDealtPayload.TargetData = UAbilitySystemBlueprintLibrary::AbilityTargetDataFromHitResult(HitResultFromContext); 
		}

		if(TargetAbilitySystemComponent->HasMatchingGameplayTag(FGameplayTag::RequestGameplayTag(FName("Status.Death"))))
		{
			return;
		}
		
		SourceAbilitySystemComponent->HandleGameplayEvent(FGameplayTag::RequestGameplayTag(FName("Event.Gameplay.OnDamageDealt")), &DamageDealtPayload);
		TargetAbilitySystemComponent->HandleGameplayEvent(FGameplayTag::RequestGameplayTag(FName("Event.Gameplay.OnDamageReceived")), &DamageDealtPayload);

		//TODO: Instead of sending event, pass in status effect tag into gameplay status table
		TargetAbilitySystemComponent->ApplyGameplayStatusEffect(TargetAbilitySystemComponent, SourceAbilitySystemComponent, Spec.DynamicGrantedTags);
	}
}

Responding to Attribute Changes

A very important aspect of attributes is being able to listen to their changes and have other systems, such as UI, respond to those changes. The Gameplay Ability System, by default, comes with the Ability Async task Wait for Attribute Changed (UAbilityTask_WaitAttributeChange) that allows you to wait for changes to the specified attribute. When the event is received, you are returned the Attribute being modified, the New Value of the attribute, and the Old Value. In my GAS Course Project, I use this task to monitor the attribute changes for both CurrentHealth and MaxHealth of the NPC Dummy character in order to update the health bar UI when receiving damage, healing, or changes to the MaxHealth attribute.

In the following example, the dummy NPC character has a passive healing effect applied.


HealthModification


PreAttributeChange

As the name suggests, this function is called before the attribute is changed. This is for only changes made to the Current Value of the attribute, via Duration based Gameplay Effects. For my project, I use this to call AdjustAttributeForMaxChange in order to adjust the Current Health to match the change. I will need to evaluate whether this adjustment should be made before or after the attribute change, but for now this is where I am handling it.

/**
 *	Called just before any modification happens to an attribute. This is lower level than PreAttributeModify/PostAttribute modify.
 *	There is no additional context provided here since anything can trigger this. Executed effects, duration based effects, effects being removed, immunity being applied, stacking rules changing, etc.
 *	This function is meant to enforce things like "Health = Clamp(Health, 0, MaxHealth)" and NOT things like "trigger this extra thing if damage is applied, etc".
 *	
 *	NewValue is a mutable reference so you are able to clamp the newly applied value as well.
 */
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) { }
	
void UGASCourseHealthAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
	Super::PreAttributeChange(Attribute, NewValue);

	if(Attribute == GetMaxHealthAttribute())
	{
		AdjustAttributeForMaxChange(CurrentHealth, MaxHealth, NewValue, GetCurrentHealthAttribute());
	}

	if(Attribute == GetCurrentHealthAttribute())
	{
		NewValue = FMath::Clamp<float>(NewValue, 0.0f, MaxHealth.GetCurrentValue());
	}
}

PostAttributeChange

This function gets called after the current attribute value has been changed. I am not sure what sort of logic should be placed here, so any information anyone has on this would be super useful.

/** Called just after any modification happens to an attribute. */
virtual void PostAttributeChange(const FGameplayAttribute& Attribute, float OldValue, float NewValue) { }

void UGASCourseHealthAttributeSet::PostAttributeChange(const FGameplayAttribute& Attribute, float OldValue,
	float NewValue)
{
	Super::PostAttributeChange(Attribute, OldValue, NewValue);
}

PreAttributeBaseChange

This is the equivalent to PreAttributeChange but for the Base Value of an Attribute which is modified by Instant Gameplay Effects. I am still figuring out whether or not the AdjustAttributeForMaxChange should be in both PreAttributeChange & PreAttributeBaseChange or in the PostAttributeChange/PostAttributeBaseChange functions.

/**
 *	This is called just before any modification happens to an attribute's base value when an attribute aggregator exists.
 *	This function should enforce clamping (presuming you wish to clamp the base value along with the final value in PreAttributeChange)
 *	This function should NOT invoke gameplay related events or callbacks. Do those in PreAttributeChange() which will be called prior to the
 *	final value of the attribute actually changing.
 */
virtual void PreAttributeBaseChange(const FGameplayAttribute& Attribute, float& NewValue) const { }

PostAttributeBaseChange

This function gets called after the base attribute value has been changed. I am not sure what sort of logic should be placed here, so any information anyone has on this would be super useful.

/** Called just after any modification happens to an attribute's base value when an attribute aggregator exists. */
virtual void PostAttributeBaseChange(const FGameplayAttribute& Attribute, float OldValue, float NewValue) const { }

PreGameplayEffectExecute

This function gets called before the Gameplay Effect is executed. I am not sure what sort of logic should be placed here, so any information anyone has on this would be super useful.

/**
 *	Called just before modifying the value of an attribute. AttributeSet can make additional modifications here. Return true to continue, or false to throw out the modification.
 *	Note this is only called during an 'execute'. E.g., a modification to the 'base value' of an attribute. It is not called during an application of a GameplayEffect, such as a 5 ssecond +10 movement speed buff.
*/	
virtual bool PreGameplayEffectExecute(struct FGameplayEffectModCallbackData &Data) { return true; }

PostGameplayEffectExecute

The PostGameplayEffectExecute function is called after the Gameplay Effect is executed and I use to to evaluate whether or not the data being evaluated involved my IncomingDamage attribute, and if so, send a Gameplay Event to the Owning Ability System Component to inform them of the owners’ death.

/**
 *	Called just before a GameplayEffect is executed to modify the base value of an attribute. No more changes can be made.
 *	Note this is only called during an 'execute'. E.g., a modification to the 'base value' of an attribute. It is not called during an application of a GameplayEffect, such as a 5 ssecond +10 movement speed buff.
 */
virtual void PostGameplayEffectExecute(const struct FGameplayEffectModCallbackData &Data) { }

void UGASCourseHealthAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
	Super::PostGameplayEffectExecute(Data);

	if(Data.EvaluatedData.Attribute == GetIncomingDamageAttribute())
	{
		const float LocalDamage = GetIncomingDamage();
		SetIncomingDamage(0.0f);
		SetCurrentHealth(GetCurrentHealth() - LocalDamage);

		if(GetCurrentHealth() <= 0.0f && !GetOwningAbilitySystemComponent()->HasMatchingGameplayTag(Status_Death))
		{
			FGameplayEventData OnDeathPayload;
			OnDeathPayload.Instigator = Data.EffectSpec.GetContext().GetOriginalInstigator();
			OnDeathPayload.Target = GetOwningActor();
			OnDeathPayload.ContextHandle = Data.EffectSpec.GetContext();
			OnDeathPayload.EventMagnitude = LocalDamage;
			GetOwningAbilitySystemComponent()->HandleGameplayEvent(Event_OnDeath, &OnDeathPayload);
		}
	}
	else if (Data.EvaluatedData.Attribute == GetCurrentHealthAttribute())
	{
		// Handle other health changes.
		// Health loss should go through Damage.
		SetCurrentHealth(FMath::Clamp(GetCurrentHealth(), 0.0f, GetMaxHealth()));
	}
}

Mod Evaluation Channels

By default, when two or more Gameplay Effect modifiers of the same operator are applied to the same attribute, those modifiers are summed together to provide the final modification to the attribute. Here is the final formula applied for all modifiers:

((CurrentValue + Additive) * Multiplicitive) / Division

Mod Evaluation Channels allow designers a bit more control over which modifiers are summed together and which modifiers amplify each other. Modifiers using the same channel are combined using the default calculation described above, summed together, while modifiers of different channels will amplify each other. Here is an example:

When applying the following Gameplay Effects using the same evaluation channel, Weapons, here is the result:

  1. GE_ModChannelTest_01

  2. GE_ModChannelTest_02

Here is a break-down of what is happening with the calcuation to the attribute, OneAttribute in the above example:

  • By default, GE_ModChannelTest_01 is applied to the player character, multipling 1.5f to the base attribute value 1.0f of OneAttribute.
  • After 3 seconds, GE_ModChannelTest_02 adds 2.0f to the OneAttribute.
  • The final result is 4.5f. [1.0f (Base Value) * 1.5f (GE_ModChannelTest_01) + 1.0f (Base Value) + 2.0f (GE_ModChannelTest_02)] OR [(1.0f * 1.5f) + (1.0f + 2.0f)] OR [1.5f + 3.0f]

ModChannel_Example01

Now what happens if we make GE_ModChannelTest_02 use a different evaluation channel, such as Equipment:

Here is a break-down of what is happening with the calcuation to the attribute, OneAttribute in the above example:

  • By default, GE_ModChannelTest_01 is applied to the player character, multipling 1.5f to the base attribute value 1.0f of OneAttribute.
  • After 3 seconds, GE_ModChannelTest_02 adds 2.0f to the OneAttribute, but now using the Equipment evaluation channel.
  • The final result is 3.5f. [1.0f (Base Value) * 1.5f (GE_ModChannelTest_01) + 2.0f (GE_ModChannelTest_02)] OR [(1.0f * 1.5f) + 2.0f] OR [1.5f + 2.0f]

ModChannel_Example02

Mod Evaluation Channels can be added in the DefaultGame.ini. Below is the example I have in place for my project, which is based off of the example provided here: GAS: Customize modifier aggregation with mod evaluation channels | Tutorial

[/Script/GameplayAbilities.AbilitySystemGlobals]

;Set up mod evaluation channels

bAllowGameplayModEvaluationChannels=True
GameplayModEvaluationChannelAliases[0]=Weapons
GameplayModEvaluationChannelAliases[1]=Equipment
GameplayModEvaluationChannelAliases[2]=Abilities
GameplayModEvaluationChannelAliases[3]=Difficulty
GameplayModEvaluationChannelAliases[4]=WorldEvents
GameplayModEvaluationChannelAliases[5]=GameMode

After adding these lines into the ini file, you will see these options appear when adding modifiers to your Gameplay Effects:

NOTE: Mod Evaluation Channels only appear for Gameplay Effects that use either Infinite or Duration based duration policies; Instant Gameplay Effects are not supported because modifiers are permanently affecting the Base Value and other modifiers won’t impact that change.


Attribute Sets

The Gameplay Ability System allows you to create as many Attribute Sets as you want, so it is up to developer whether or not to have one large monolithic Attribute Set that contains all attributes, or create separate Attribute Sets based on categories such as Health related attributes. For my GAS Course Project, I prefer creating separate Attribute Sets for different groups of Attributes so that I can allow opt-in functionality when it comes to granting Attributes to my actors. For example, the player character may require an Attribute Set for movement speeds while a static actor type, such as a tower in a MOBA game, would not need these types of Attributes, but both would require Health Attributes.


Adding Attribute Sets via Ability Sets

In my GAS Course Project, I use Ability Sets to grant Attribute Sets to my characters. Below is the code used to grant the Attribute Sets:

GASCourseGameplayAbilitySet.h

public:

void AddAttributeSet(UGASCourseAttributeSet* Set);

// Grants the ability set to the specified ability system component.
// The returned handles can be used later to take away anything that was granted.
void GiveToAbilitySystem(UGASCourseAbilitySystemComponent* ASC, FGASCourseAbilitySet_GrantedHandles* OutGrantedHandles, UObject* SourceObject = nullptr) const;

GASCourseGameplayAbilitySet.cpp

void FGASCourseAbilitySet_GrantedHandles::AddAttributeSet(UGASCourseAttributeSet* Set)
{
	GrantedAttributeSets.Add(Set);
}

void UGASCourseGameplayAbilitySet::GiveToAbilitySystem(UGASCourseAbilitySystemComponent* ASC, FGASCourseAbilitySet_GrantedHandles* OutGrantedHandles, UObject* SourceObject) const
{
	check(ASC);

	if (!ASC->IsOwnerActorAuthoritative())
	{
		// Must be authoritative to give or take ability sets.
		return;
	}
	
	// Grant the attribute sets.
	for (int32 SetIndex = 0; SetIndex < GrantedAttributes.Num(); ++SetIndex)
	{
		const FGASCourseAbilitySet_AttributeSet& SetToGrant = GrantedAttributes[SetIndex];

		if (!IsValid(SetToGrant.AttributeSet))
		{
			continue;
		}

		UGASCourseAttributeSet* NewSet = NewObject<UGASCourseAttributeSet>(ASC->GetOwner(), SetToGrant.AttributeSet);
		ASC->AddAttributeSetSubobject(NewSet);

		if (OutGrantedHandles)
		{
			OutGrantedHandles->AddAttributeSet(NewSet);
		}
	}

	// Grant the gameplay abilities.
	for (int32 AbilityIndex = 0; AbilityIndex < GrantedGameplayAbilities.Num(); ++AbilityIndex)
	{
		const FGASCourseAbilitySet_GameplayAbility& AbilityToGrant = GrantedGameplayAbilities[AbilityIndex];

		if (!IsValid(AbilityToGrant.Ability))
		{
			continue;
		}

		UGASCourseGameplayAbility* AbilityCDO = AbilityToGrant.Ability->GetDefaultObject<UGASCourseGameplayAbility>();

		FGameplayAbilitySpec AbilitySpec(AbilityCDO, AbilityToGrant.AbilityLevel);
		AbilitySpec.SourceObject = SourceObject;
		AbilitySpec.DynamicAbilityTags.AddTag(AbilityToGrant.InputTag);

		const FGameplayAbilitySpecHandle AbilitySpecHandle = ASC->GiveAbility(AbilitySpec);

		if (OutGrantedHandles)
		{
			OutGrantedHandles->AddAbilitySpecHandle(AbilitySpecHandle);
		}
	}

	// Grant the gameplay effects.
	for (int32 EffectIndex = 0; EffectIndex < GrantedGameplayEffects.Num(); ++EffectIndex)
	{
		const FGASCourseAbilitySet_GameplayEffect& EffectToGrant = GrantedGameplayEffects[EffectIndex];

		if (!IsValid(EffectToGrant.GameplayEffect))
		{
			continue;
		}

		const UGameplayEffect* GameplayEffect = EffectToGrant.GameplayEffect->GetDefaultObject<UGameplayEffect>();
		const FActiveGameplayEffectHandle GameplayEffectHandle = ASC->ApplyGameplayEffectToSelf(GameplayEffect, EffectToGrant.EffectLevel, ASC->MakeEffectContext());

		if (OutGrantedHandles)
		{
			OutGrantedHandles->AddGameplayEffectHandle(GameplayEffectHandle);
		}
	}
}


Defining Attributes

Attributes can only be defined in C++ inside of the header file of your AttributeSet class. It is recommended to add the following block of code to the top of each AttributeSet header file as these macros will automatically generate getter, setter, and intiialization functions for your Attributes:

// Uses macros from AttributeSet.h
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName)
GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName)
GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName)
GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName)
GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

Here are some examples of these functions:

InitCurrentHealth(100.0f);
float GetHealth = GetCurrentHealth();
SetCurrentHealth(1000.0f);

Here is how I define my attributes, using the GASCourseHealthAttributeSet class as an example:

GASCourseHealthAttributeSet.h

public:

	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
	
	UPROPERTY(BlueprintReadOnly, Category = "Character Health Attributes", ReplicatedUsing=OnRep_CurrentHealth)
	FGameplayAttributeData CurrentHealth;
	ATTRIBUTE_ACCESSORS(UGASCourseHealthAttributeSet, CurrentHealth)

	UPROPERTY(BlueprintReadOnly, Category = "Character Health Attributes", ReplicatedUsing=OnRep_MaxHealth)
	FGameplayAttributeData MaxHealth;
	ATTRIBUTE_ACCESSORS(UGASCourseHealthAttributeSet, MaxHealth)

	UPROPERTY(BlueprintReadOnly, Category = "Damage")
	FGameplayAttributeData IncomingDamage;
	ATTRIBUTE_ACCESSORS(UGASCourseHealthAttributeSet, IncomingDamage)
	
protected:
	
	UFUNCTION()
	virtual void OnRep_CurrentHealth(const FGameplayAttributeData& OldCurrentHealth);
	
	UFUNCTION()
	virtual void OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth);

GASCourseHealthAttributeSet.cpp

void UGASCourseHealthAttributeSet::OnRep_CurrentHealth(const FGameplayAttributeData& OldCurrentHealth)
{
	GAMEPLAYATTRIBUTE_REPNOTIFY(UGASCourseHealthAttributeSet, CurrentHealth, OldCurrentHealth);
}

void UGASCourseHealthAttributeSet::OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth)
{
	GAMEPLAYATTRIBUTE_REPNOTIFY(UGASCourseHealthAttributeSet, MaxHealth, OldMaxHealth);
}

void UGASCourseHealthAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	DOREPLIFETIME_CONDITION_NOTIFY(UGASCourseHealthAttributeSet, CurrentHealth, COND_None, REPNOTIFY_Always);
	DOREPLIFETIME_CONDITION_NOTIFY(UGASCourseHealthAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
}

Initializing Attributes - Instant Gameplay Effect

For my GAS Course Project, I have opted-in to using the Instant Gameplay Effect approach because this is the option that Epic recommends, and is the option that can be found in Tranek’s Sample project.

GE_DummyCharacter_Initter


Initializing Attributes - Data Tables

If you choose to intialize your Attributes through a Data Table, you need to use the struct FAttributeMetaData:


 *	DataTable that allows us to define meta data about attributes. Still a work in progress.
 */
USTRUCT(BlueprintType)
struct GAMEPLAYABILITIES_API FAttributeMetaData : public FTableRowBase
{
	GENERATED_USTRUCT_BODY()

public:

	FAttributeMetaData();

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Gameplay Attribute")
	float		BaseValue;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Gameplay Attribute")
	float		MinValue;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Gameplay Attribute")
	float		MaxValue;

	UPROPERTY()
	FString		DerivedAttributeInfo;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Gameplay Attribute")
	bool		bCanStack;
};

On a previous personal project, Project Hero, I had used the Data Table approach for initializing my characters’ attributes:

Name,BaseValue,MinValue,MaxValue,DerivedAttributeInfo,bCanStack
HeroCharacterAttributes.HeroCharacterMovementSpeed,400,0,10000,TRUE
HeroCharacterAttributes.HeroCharacterMovementSpeedMultiplier,1,1,10,TRUE
HeroCharacterAttributes.HeroCharacterJumpHeight,600,0,2000,TRUE
HeroCharacterAttributes.HeroCharacterJumpHeightMultiplier,1,0,10,TRUE
HeroCharacterAttributes.HeroCharacterAirControl,0.5,0.1,10,TRUE
HeroCharacterAttributes.HeroCharacterCrouchSpeed,200,0,10000,TRUE
HeroCharacterAttributes.HeroCharacterGroundFriction,8,0,10,TRUE
HeroCharacterAttributes.HeroCharacterForwardSpeedReduction,1,0,1,FALSE
HeroCharacterAttributes.HeroCharacterBackwardSpeedReduction,0.6,0,1,FALSE
HeroCharacterAttributes.HeroCharacterLateralSpeedReduction,0.8,0,1,FALSE
HeroCharacterAttributes.HeroCharacterForwardSpeedReductionBlocking,0.8,0,1,FALSE
HeroCharacterAttributes.HeroCharacterBackwardSpeedReductionBlocking,0.4,0,1,FALSE
HeroCharacterAttributes.HeroCharacterLateralSpeedReductionBlocking,0.6,0,1,FALSE
HeroCharacterAttributes.MaxHealth,100,0,10000,TRUE
HeroCharacterAttributes.HeroCharacterLevel,1,1,100,TRUE
HeroCharacterAttributes.HeroCharacterMaxLevel,100,1,100,TRUE
HeroCharacterAttributes.HealthRegeneration,1,0,100,TRUE
HeroCharacterAttributes.HealthRegenerationActivationDelay,1.5,0.01,10,TRUE
HeroCharacterAttributes.MaxMana,100,0,10000,TRUE
HeroCharacterAttributes.ManaRegeneration,1.5,0,100,TRUE
HeroCharacterAttributes.ManaRegenerationActivationDelay,1.2,0.01,10,TRUE

AHeroPlayerState::AHeroPlayerState()
{
	AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
	AttributeSetBase = CreateDefaultSubobject<UHeroCharacterAttributes>(TEXT("AttributeSetBase"));

	static ConstructorHelpers::FObjectFinder<UDataTable> HeroAttributesInitialization(TEXT("/Game/Hero/DataTables/PlayerAttributesInitialization/Hero_Attributes_Initialization"));
	PlayerAttributeSet = HeroAttributesInitialization.Object;

	static ConstructorHelpers::FObjectFinder<UDataTable> HeroCooldownAttributesInitialization(TEXT("/Game/Hero/DataTables/PlayerAttributesInitialization/Hero_AbilityCooldownMultiplier_AttributesInitialization"));
	CooldownAttributeSetDataTable = HeroCooldownAttributesInitialization.Object;
}

void AHeroPlayerState::InitializeAttributes()
{
	if (AbilitySystemComponent && PlayerAttributeSet)
	{
		const UAttributeSet* Attributes = AbilitySystemComponent->InitStats(UHeroCharacterAttributes::StaticClass(), PlayerAttributeSet);
	}
}

void AHeroCharacter::PossessedBy(AController* NewController)
{
	Super::PossessedBy(NewController);
	if (NewController->IsLocalPlayerController())
	{
		AHeroPlayerState* PS = GetPlayerState<AHeroPlayerState>();
		if (PS)
		{
			// Set the ASC on the Server. Clients do this in OnRep_PlayerState()
			AbilitySystemComponent = Cast<UAbilitySystemComponent>(PS->GetAbilitySystemComponent());

			// AI won't have PlayerControllers so we can init again here just to be sure. No harm in initing twice for heroes that have PlayerControllers.
			PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this);

			// Set the AttributeSetBase for convenience attribute functions
			BaseHeroAttributes = PS->GetAttributeSetBase();
			PS->InitializeAttributes();

			AbilityCooldownAttributeSet = PS->GetCooldownAttributeSet();
			PS->InitializeCooldownAttributeSet();
		}
	}
}

References:

https://www.thegames.dev/?p=119


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile: I will be taking a break from making additional posts in this topic for the holidays, but I’ll be back in a few weeks with more!


Next Blog Post Topic:

*Gameplay Effect UI Data for Status Effects

2 Likes

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos


Today we are going to talk about Ability Tag Category Remapping, and how you can use these categories to help with project-wide organization of your Gameplay Tags within the context of the Gameplay Ability System. A big shout-out to my colleague at CD Projekt Red, Eryk Dwornicki for showing me this. From what I have researched online, there isn’t any sort of explicit documentation talking about Ability Tag Category Remapping, so this blog post may serve as the first to do so. If others find documentation on this subject, please let me know so that this post can be updated accordingly. Make sure to follow Eryk on Twitter (X): x.com.


Classes to research on your own:

https://docs.unrealengine.com/4.26/en-US/API/Runtime/GameplayTags/FGameplayTagContainer/
https://docs.unrealengine.com/4.27/en-US/API/Plugins/GameplayAbilities/Abilities/UGameplayAbility/

Additional Reading:


What is Ability Tag Category Remapping?

One of the more powerful aspects of Gameplay Tags are that they are use a hierarchal structure of parent/child to organize tags into categories. In the Gameplay Ability System plugin, it uses Gameplay Tags for different aspects of the plugin; for ability tag containers, gameplay effect containers, gameplay events, and more. With Ability Tag Category Remappings you can add a requirement of tag categories to fit one or more branches of your tag hierarchy in order to be visible when adding them to the aforementioned aspect of GAS. To better explain, let’s first use the Ability Tag Container found inside of the UGameplayAbility class:

GameplayAbility.h

	// ----------------------------------------------------------------------------------------------------------------
	//	Ability exclusion / canceling
	// ----------------------------------------------------------------------------------------------------------------

	/** Abilities with these tags are cancelled when this ability is executed */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="AbilityTagCategory"))
	FGameplayTagContainer CancelAbilitiesWithTag;

	/** Abilities with these tags are blocked while this ability is active */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="AbilityTagCategory"))
	FGameplayTagContainer BlockAbilitiesWithTag;

	/** Tags to apply to activating owner while this ability is active. These are replicated if ReplicateActivationOwnedTags is enabled in AbilitySystemGlobals. */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="OwnedTagsCategory"))
	FGameplayTagContainer ActivationOwnedTags;

	/** This ability can only be activated if the activating actor/component has all of these tags */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="OwnedTagsCategory"))
	FGameplayTagContainer ActivationRequiredTags;

	/** This ability is blocked if the activating actor/component has any of these tags */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="OwnedTagsCategory"))
	FGameplayTagContainer ActivationBlockedTags;

	/** This ability can only be activated if the source actor/component has all of these tags */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="SourceTagsCategory"))
	FGameplayTagContainer SourceRequiredTags;

	/** This ability is blocked if the source actor/component has any of these tags */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="SourceTagsCategory"))
	FGameplayTagContainer SourceBlockedTags;

	/** This ability can only be activated if the target actor/component has all of these tags */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="TargetTagsCategory"))
	FGameplayTagContainer TargetRequiredTags;

	/** This ability is blocked if the target actor/component has any of these tags */
	UPROPERTY(EditDefaultsOnly, Category = Tags, meta=(Categories="TargetTagsCategory"))
	FGameplayTagContainer TargetBlockedTags;

As you can see in the UPROPERTY of each tag container, there is the meta specifiier, (Categories=“”) where, by default, there are categories that label each container. AbilityTagCategory, OwnedTagsCategory, SourceTagsCategory, and TargetTagsCategory. What you can do now is in Project Settings → Gameplay Tags, in the Category Remapping section, you can add entries for each Base Category and remap them to one or more branches of your Gameplay Tag hierarchy. You can also pick and choose which categories you’d like to remap, and even create your own categories!

An example in my personal project where I created my own custom categories are found in the TargetTagsGameplayEffectComponent and AssetTagsGameplayEffectComponent classes, where instead of them sharing the OwnedTagsCategory by default, I created new categories called GameplayEffectOwnedTagsCategory and GameplayEffectAssetTagsCategory respectively. The reason for this is because I wanted to differentiate between the tags used for Gameplay Effects and the tags used for Gameplay Abilities.

Here is the AbilityTagsCategory before & after:

Here is the OwnedTagsCategory before & after:


TargetTagsGameplayEffectComponent.h

private:
/** These tags are applied (granted) to the target actor of the Gameplay Effect. The Target would then “own” these Tags. */
UPROPERTY(EditDefaultsOnly, Category = None, meta = (DisplayName = “Add Tags”, Categories = “GameplayEffectOwnedTagsCategory”))
FInheritedTagContainer InheritableGrantedTagsContainer;


AssetTagsGameplayEffectComponent.h

private:
/** The GameplayEffect’s Tags: tags the the GE has and DOES NOT give to the actor. */
UPROPERTY(EditDefaultsOnly, Category = Tags, meta = (DisplayName = “AssetTags”, Categories = “GameplayEffectAssetTagsCategory”))
FInheritedTagContainer InheritableAssetTags;

Why Use Ability Tag Category Remapping?

Anyone who has worked with Gameplay Tags on a project will tell you how easy it is for tags to explode in regards to the shear number of tags and their uses throughout the project. On multiple projects, I have seen tags with mispelling, duplicate tags in different branches of the hierarchy that serve the same purposes, and developers adding tags without any sort of documentation for that tag or even using that tag for its intended use. What I believe Ability Tag Category Remapping serves as a way to control and direct developers to the appropriate branches of the Gameplay Tag hierarchy to add their tags in order to use them for their intended purposes. Need to add an Ability Tag for your gameplay ability? Add a tag to the Ability.Type hierarchy! Need an Asset Tag for your burn status Gameplay Effect? Use the Effect.AssetTag hierarchy! By doing so, you remove the potential of using different tag hierarchies for different purposes and reduce the human error when it comes to using Gameplay Tags.


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

*Gameplay Effect UI Data for Status Effects

4 Likes

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Today we are going to talk about how I handle the mechanic of Status Effects in the GAS Course Project, using Gameplay Effects! Additionally, I will showcase how I use a custom GameplayEffectUIData class to draw unique icons and descriptions for the status effects on screen. As always, I highly recommend reading through Tranek’s documentation found here: GitHub - tranek/GASDocumentation: My understanding of Unreal Engine 5's GameplayAbilitySystem plugin with a simple multiplayer sample project.


EffectDescription

Classes to research on your own:

UGameplayEffect: https://docs.unrealengine.com/4.26/en-US/BlueprintAPI/GameplayEffects/
UGameplayEffectUIData: https://docs.unrealengine.com/4.27/en-US/API/Plugins/GameplayAbilities/UGameplayEffectUIData/

Additional Reading:


What are Status Effects?

In the most simplest terms, Status Effects are any type of passive effect that can be applied to a character that can alter behaviors or multiple attributes associated with that character either for a short duration or permanently. I recommend watching this video by the channel Design Doc for a more in-depth discussion about Status Effects: https://www.youtube.com/watch?v=ThDVGP4UB30.

In the context of the GAS Course Project, I wanted to create a base system that allows for all types of status effects to be applied to characters, starting with NPCs, that can allow for appropriate scalability and UI reflection for the type of ARPG I am intending to demonstrate. Luckily, the Gameplay Ability System was built with the idea of status effects in mind with the use of Gameplay Effects and so I primarily use Gameplay Effects, in addition to unique classes to handle status effects and their dynamic descriptions, to create a base that I believe can work for my intended use-cases. Let’s start with the GASCStatusEffectListener component class.


From Tranek:

“Cannot predict the removal of GameplayEffects. We can however predict adding GameplayEffects with the inverse effects, effectively removing them. This is not always appropriate or feasible and still remains an issue.”

The primary concern when developing the system for status effects was making sure both the application and the removal of the status effects were properly replicated to server and clients. To best handle this, I created a component class to listen for the application and removal of Gameplay Effects with the parent asset tag, Effect.AssetTag.Status, and broadcast these events to everyone. Here is the code for that class:

GASCStatusEffectListener.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "ActiveGameplayEffectHandle.h"
#include "GameplayEffectTypes.h"
#include "GameplayEffect.h"
#include "Components/ActorComponent.h"
#include "GASCStatusEffectListenerComp.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FStatusEffectApplied, FActiveGameplayEffectHandle, StatusEffectSpec);

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent), Blueprintable )
class GASCOURSE_API UGASCStatusEffectListenerComp : public UActorComponent
{
	GENERATED_BODY()

public:	
	// Sets default values for this component's properties
	UGASCStatusEffectListenerComp();

	UFUNCTION(NetMulticast, Reliable)
	void OnStatusEffectApplied(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle);

	UFUNCTION(Server, Reliable)
	void OnStatusEffectRemoved(const FActiveGameplayEffect& ActiveGameplayEffect);
	
	UPROPERTY(BlueprintAssignable)
	FStatusEffectApplied OnStatusEffectAppliedHandle;

	UPROPERTY(BlueprintAssignable)
	FStatusEffectApplied OnStatusEffectRemovedHandle;

	UPROPERTY(EditAnywhere, Category = "GASCourse|StatusEffect|Tags")
	FGameplayTag StatusEffectAssetTag;

	UFUNCTION()
	void ApplyDefaultActiveStatusEffects();

protected:
	// Called when the game starts
	virtual void BeginPlay() override;

	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

	virtual void Deactivate() override;
};

GASCStatusEffectListener.cpp

// Fill out your copyright notice in the Description page of Project Settings.

#include "Game/Character/Components/GASCStatusEffectListenerComp.h"
#include "GASCourseCharacter.h"

// Sets default values for this component's properties
UGASCStatusEffectListenerComp::UGASCStatusEffectListenerComp()
{
	// Set this component to be initialized when the game starts, and to be ticked every frame.  You can turn these features
	// off to improve performance if you don't need them.
	PrimaryComponentTick.bCanEverTick = false;

	// ...
}
void UGASCStatusEffectListenerComp::OnStatusEffectRemoved_Implementation(const FActiveGameplayEffect& ActiveGameplayEffect)
{
	FGameplayTagContainer GameplayEffectAssetTags;
	ActiveGameplayEffect.Spec.GetAllAssetTags(GameplayEffectAssetTags);

	if(GameplayEffectAssetTags.IsEmpty())
	{
		return;
	}

	if(GameplayEffectAssetTags.HasTag(StatusEffectAssetTag))
	{
		OnStatusEffectRemovedHandle.Broadcast(ActiveGameplayEffect.Handle);
	}
}

void UGASCStatusEffectListenerComp::OnStatusEffectApplied_Implementation(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle)
{
	FGameplayTagContainer GameplayEffectAssetTags;
	GameplayEffectSpec.GetAllAssetTags(GameplayEffectAssetTags);

	if(GameplayEffectAssetTags.IsEmpty())
	{
		return;
	}

	if(GameplayEffectAssetTags.HasTag(StatusEffectAssetTag))
	{
		OnStatusEffectAppliedHandle.Broadcast(ActiveGameplayEffectHandle);
	}
}

void UGASCStatusEffectListenerComp::ApplyDefaultActiveStatusEffects()
{
	if(const AGASCourseCharacter* OwningCharacter = Cast<AGASCourseCharacter>(GetOwner()))
	{
		if(const UAbilitySystemComponent* ASC = OwningCharacter->GetAbilitySystemComponent())
		{	
			TArray<FActiveGameplayEffectHandle> ActiveHandles = ASC->GetActiveEffectsWithAllTags(StatusEffectAssetTag.GetSingleTagContainer());
			for(const FActiveGameplayEffectHandle InActiveHandle : ActiveHandles)
			{
				OnStatusEffectAppliedHandle.Broadcast(InActiveHandle);
			}
		}
	}
}

// Called when the game starts
void UGASCStatusEffectListenerComp::BeginPlay()
{
	Super::BeginPlay();

	if(const AGASCourseCharacter* OwningCharacter = Cast<AGASCourseCharacter>(GetOwner()))
	{
		if(UAbilitySystemComponent* ASC = OwningCharacter->GetAbilitySystemComponent())
		{
			ASC->OnGameplayEffectAppliedDelegateToSelf.AddUObject(this, &ThisClass::OnStatusEffectApplied);
			ASC->OnAnyGameplayEffectRemovedDelegate().AddUObject(this, &ThisClass::OnStatusEffectRemoved);
		}
	}
}

void UGASCStatusEffectListenerComp::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	if(OnStatusEffectAppliedHandle.IsBound())
	{
		OnStatusEffectAppliedHandle.Clear();
	}
	if(OnStatusEffectRemovedHandle.IsBound())
	{
		OnStatusEffectRemovedHandle.Clear();
	}
	
	Super::EndPlay(EndPlayReason);
}


void UGASCStatusEffectListenerComp::Deactivate()
{
	if(OnStatusEffectAppliedHandle.IsBound())
	{
		OnStatusEffectAppliedHandle.Clear();
	}
	if(OnStatusEffectRemovedHandle.IsBound())
	{
		OnStatusEffectRemovedHandle.Clear();
	}
	
	Super::Deactivate();
}

The key here is that we are listening for and broadcasting the application/removal events associated with Gameplay Effects that have Asset Tags that follow the tag hierarchal paradigm, Effect.AssetTag.Status, as shown in the component setup in Blueprint:

StatusEffectListener_TagSetup

In cases where a Status Effect might be applied immediately on Begin Play by a character, I created a function to handle this called ApplyDefaultActiveStatusEffects which is then called on BeginPlay() of my GASCourseCharacter class.

Then, in my base GASCourseCharacter class, I add the following code so that I can initialize its component and receive the necessary callbacks for status effect application and removal:

GASCourseCharacter.h

	/** The component responsible for listening to status effect events.
	 *
	 * This component is used to listen to status effect events, such as when a status effect is applied or removed from the character.
	 * It is visible anywhere and has read-only access, and falls under the StatusEffects category.
	 * The meta flag AllowPrivateAccess is set to true, allowing private access to this component.
	 */
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = StatusEffects, meta = (AllowPrivateAccess = "true"))
	class UGASCStatusEffectListenerComp* StatusEffectListenerComp;

	public:

	UFUNCTION()
	void OnStatusEffectApplied(FActiveGameplayEffectHandle InStatusEffectApplied);

	UFUNCTION(BlueprintImplementableEvent)
	void OnStatusEffectApplied_Event(FActiveGameplayEffectHandle InStatusEffectApplied);

	UFUNCTION()
	void OnStatusEffectRemoved(FActiveGameplayEffectHandle InStatusEffectRemoved);
	
	UFUNCTION(NetMulticast, Reliable)
	void OnStatusEffectRemoved_Multicast(FActiveGameplayEffectHandle InStatusEffectRemoved);

	UFUNCTION(BlueprintImplementableEvent)
	void OnStatusEffectRemoved_Event(FActiveGameplayEffectHandle InStatusEffectRemoved);

GASCourseCharacter.cpp

AGASCourseCharacter::AGASCourseCharacter(const class FObjectInitializer& ObjectInitializer) :
	Super(ObjectInitializer.SetDefaultSubobjectClass<UGASCourseMovementComponent>(ACharacter::CharacterMovementComponentName))
{
	StatusEffectListenerComp = ObjectInitializer.CreateDefaultSubobject<UGASCStatusEffectListenerComp>(this, TEXT("StatusEffectListenerComp"));
	StatusEffectListenerComp->SetIsReplicated(true);
}


void AGASCourseCharacter::BeginPlay()
{
	// Call the base class  
	Super::BeginPlay();
	
	GameplayEffectAssetTagsToRemove.AddTag(FGameplayTag::RequestGameplayTag(FName("Effect.AssetTag.Status")));
	StatusEffectListenerComp->ApplyDefaultActiveStatusEffects();
}

void AGASCourseCharacter::OnStatusEffectApplied(FActiveGameplayEffectHandle InStatusEffectApplied)
{
	OnStatusEffectApplied_Event(InStatusEffectApplied);
}

void AGASCourseCharacter::OnStatusEffectRemoved(FActiveGameplayEffectHandle InStatusEffectRemoved)
{
	OnStatusEffectRemoved_Event(InStatusEffectRemoved);
	if(HasAuthority())
	{
		OnStatusEffectRemoved_Multicast(InStatusEffectRemoved);
	}
}

void AGASCourseCharacter::OnStatusEffectRemoved_Multicast_Implementation(FActiveGameplayEffectHandle InStatusEffectRemoved)
{
	OnStatusEffectRemoved_Event(InStatusEffectRemoved);
}

Finally, here is the Blueprint implementation inside of my BP_NPC_Base class:

We use these events to pass along the Gameplay Effect Handle to the NPC’s healthbar UI so that we can properly construct the effect description and assign the status effect icon to the UI widget Blueprint. We will discuss this more in detail later in this post.


GASCourseStatusEffectTable

In order to help me map certain damage types with specific Gameplay Effects, I created a new Data Asset class called GASCourseStatusEffectTable that does just that. This is a special setup that might require refactoring, or could be unnecessary if we say that designers are responsible for applying the appropriate effect after certain damage application; however, I wanted to try to automate in some way this process so that designers only have to worry about setting up the mapping data asset and then just applying the appropriate damage type.

Here is the code:

GASCourseStatusEffectTable.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "Engine/DataAsset.h"
#include "GameplayTagContainer.h"
#include "GameplayEffectTypes.h"
#include "GameplayEffect.h"
#include "GASCourseStatusEffectTable.generated.h"

class UAbilitySystemComponent;

/**
 * 
 */

USTRUCT()
struct FGameplayTagEventResponsePair
{
	GENERATED_USTRUCT_BODY()

	UPROPERTY(EditAnywhere, Category = "Response")
	FGameplayTag StatusEffectStateTag;
	
	UPROPERTY(EditAnywhere, Category = "Response")
	TSubclassOf<UGameplayEffect> ResponseGameplayEffect;
	
};

USTRUCT()
struct FGameplayTagEventResponseTableEntry
{
	GENERATED_USTRUCT_BODY()

	UPROPERTY(EditAnywhere, Category = "Response")
	FGameplayTag StatusEffectTag;
	
	/** Tags that count as "positive" toward to final response count. If the overall count is positive, this ResponseGameplayEffect is applied. */
	UPROPERTY(EditAnywhere, Category="Response")
	TArray<FGameplayTagEventResponsePair> StatusEffectTypes;
	
};

UCLASS()
class GASCOURSE_API UGASCourseStatusEffectTable : public UDataAsset
{
	GENERATED_UCLASS_BODY()

	UPROPERTY(EditAnywhere, Category="Response")
	TArray<FGameplayTagEventResponseTableEntry>	Entries;

	virtual void PostLoad() override;

	/**
	 * Function to apply a gameplay status effect to the target ability system component based on the provided status effect tags.
	 *
	 * @param TargetASC The ability system component to apply the status effect to.
	 * @param InstigatorASC The ability system component that is initiating the application of the status effect.
	 * @param StatusEffectTags The gameplay tags representing the status effect to be applied.
	 */
	UFUNCTION()
	void ApplyGameplayStatusEffect(UAbilitySystemComponent* TargetASC, UAbilitySystemComponent* InstigatorASC, const FGameplayTagContainer& StatusEffectTags);

protected:
	
	/**
	 * Check if any of the given status effect tags matches the entries in the status effect table.
	 *
	 * @param StatusEffectTags The tags to check against the status effect table entries.
	 * @param FoundStatusEffectEntry The found status effect entry when a match is found.
	 * @param FoundTag The found tag when a match is found.
	 * @return True if a match is found, false otherwise.
	 */
	bool HasMatchingStatusEffectTag(const FGameplayTagContainer& StatusEffectTags, FGameplayTagEventResponseTableEntry& FoundStatusEffectEntry, FGameplayTag& FoundTag);
	
};

GASCourseStatusEffectTable.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/GameplayAbilitySystem/GameplayTagResponseTable/GASCourseStatusEffectTable.h"
#include "AbilitySystemComponent.h"

UGASCourseStatusEffectTable::UGASCourseStatusEffectTable(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
{
	
}

void UGASCourseStatusEffectTable::PostLoad()
{
	Super::PostLoad();
}

void UGASCourseStatusEffectTable::ApplyGameplayStatusEffect(UAbilitySystemComponent* TargetASC,
	UAbilitySystemComponent* InstigatorASC, const FGameplayTagContainer& StatusEffectTags)
{
	UE_LOG(LogTemp, Warning, TEXT("Tags: %s"), *StatusEffectTags.ToString());

	FGameplayTagEventResponseTableEntry FoundStatusEffectEntry;
	FGameplayTag FoundStatusTag;
	if(HasMatchingStatusEffectTag(StatusEffectTags, FoundStatusEffectEntry, FoundStatusTag))
	{
		for(FGameplayTagEventResponsePair StatusEffectPair : FoundStatusEffectEntry.StatusEffectTypes)
		{
			if(StatusEffectPair.StatusEffectStateTag == FoundStatusTag)
			{
				FActiveGameplayEffectHandle StatusEffectHandle = InstigatorASC->ApplyGameplayEffectToTarget(StatusEffectPair.ResponseGameplayEffect.GetDefaultObject(), TargetASC);
			}
		}
	}
}

bool UGASCourseStatusEffectTable::HasMatchingStatusEffectTag(const FGameplayTagContainer& StatusEffectTags,
	FGameplayTagEventResponseTableEntry& FoundStatusEffectEntry, FGameplayTag& FoundTag)
{
	bool bHasFoundTag = false;

	for(FGameplayTagEventResponseTableEntry Entry :Entries)
	{
		if(StatusEffectTags.HasTag(Entry.StatusEffectTag))
		{
			FoundStatusEffectEntry = Entry;
			for(FGameplayTag StatusTag : StatusEffectTags.GetGameplayTagArray())
			{
				ensure(StatusTag.MatchesTag(FGameplayTag::RequestGameplayTag(FName("Effect.Gameplay.Status"))));
				if(StatusTag.MatchesTag(FGameplayTag::RequestGameplayTag(FName("Effect.Gameplay.Status"))))
				{
					FoundTag = StatusTag;
					bHasFoundTag = true;
					break;
				}
			}
		}
	}

	return bHasFoundTag;
}

I initialize the GameplayStatusEffectTable in the character here:

GASCourseCharacter.cpp

void AGASCourseCharacter::InitializeAbilitySystem(UGASCourseAbilitySystemComponent* InASC)
{
	if(GetLocalRole() != ROLE_Authority || !InASC)
	{
		return;
	}
	if(DefaultAbilitySet)
	{
		DefaultAbilitySet->GiveToAbilitySystem(InASC, nullptr);
	}

	if(AbilityTagRelationshipMapping)
	{
		InASC->SetTagRelationshipMapping(AbilityTagRelationshipMapping);
	}

	if(GameplayStatusEffectTable)
	{
		InASC->SetGameplayEffectStatusTable(GameplayStatusEffectTable);
	}

	if(InASC)
	{
		InASC->AddGameplayEventTagContainerDelegate(FGameplayTagContainer(Event_OnDeath), FGameplayEventTagMulticastDelegate::FDelegate::CreateUObject(this, &ThisClass::CharacterDeathGameplayEventCallback));
		InASC->RegisterGameplayTagEvent(FGameplayTag(Collision_IgnorePawn), EGameplayTagEventType::NewOrRemoved).AddUObject(this, &AGASCourseCharacter::IgnorePawnCollisionGameplayTagEventCallback);
	}
}

GASCourseAbilitySystemComponent.h

	void SetGameplayEffectStatusTable(UGASCourseStatusEffectTable* NewStatusEffectTable);

	void ApplyGameplayStatusEffect(UAbilitySystemComponent* TargetASC, UAbilitySystemComponent* InstigatorASC, const FGameplayTagContainer& StatusEffectTags) const;

GASCourseAbilitySystemComponent.cpp

void UGASCourseAbilitySystemComponent::SetGameplayEffectStatusTable(UGASCourseStatusEffectTable* NewStatusEffectTable)
{
	if(NewStatusEffectTable)
	{
		GameplayStatusEffectTable = NewStatusEffectTable;
	}
}

void UGASCourseAbilitySystemComponent::ApplyGameplayStatusEffect(UAbilitySystemComponent* TargetASC,
	UAbilitySystemComponent* InstigatorASC, const FGameplayTagContainer& StatusEffectTags) const
{
	if(const AGASCourseCharacter* OwningCharacter = Cast<AGASCourseCharacter>(GetOwnerActor()))
	{
		if(OwningCharacter->GetGameplayStatusEffectTable())
		{
			GameplayStatusEffectTable->ApplyGameplayStatusEffect(TargetASC, InstigatorASC, StatusEffectTags);
		}
	}
}

Here is the damage application setup for when my projectile hits a target:

bool UGASCourseASCBlueprintLibrary::ApplyFireDamageToTarget(AActor* Target, AActor* Instigator, float Damage,
                                                            const FHitResult& HitResult, FDamageContext& DamageContext, bool bApplyBurnStack)
{
	DamageContext.DamageType = DamageType_Elemental_Fire;
	if(bApplyBurnStack)
	{
		FGameplayTagContainer GrantedTags;
		//TODO: Add this to Native Gameplay Tags
		GrantedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Effect.Gameplay.Status.Burn.Stack")));
		DamageContext.GrantedTags = GrantedTags;
	}
	DamageContext.HitResult = HitResult;
	
	constexpr FDamageOverTimeContext DamageOverTimeContext;
	UGameplayEffect* DamageEffect = ConstructDamageGameplayEffect(EGameplayEffectDurationType::Instant, DamageOverTimeContext);
	
	return ApplyDamageToTarget_Internal(Target, Instigator, Damage, DamageContext, DamageEffect);
}

bool UGASCourseASCBlueprintLibrary::ApplyDamageToTarget_Internal(AActor* Target, AActor* Instigator, float Damage,
                                                                 const FDamageContext& DamageContext, UGameplayEffect* GameplayEffect)
{
	if(!Instigator && !Target)
	{
		return false;
	}

	//TODO: Add check to verify ability system component + consider damage/health interface for Non-GAS actors
	if(UGASCourseAbilitySystemComponent* TargetASC = Target->GetComponentByClass<UGASCourseAbilitySystemComponent>())
	{
		if(UGASCourseAbilitySystemComponent* InstigatorASC = Instigator->GetComponentByClass<UGASCourseAbilitySystemComponent>())
		{
			if(UGASCourseGameplayEffect* DamageEffect = Cast<UGASCourseGameplayEffect>(GameplayEffect))
			{
							
				const int32 ExecutionIdx = DamageEffect->Executions.Num();
				DamageEffect->Executions.SetNum(ExecutionIdx + 1);
				FGameplayEffectExecutionDefinition& DamageInfo = DamageEffect->Executions[ExecutionIdx];

				const TSubclassOf<UGASCourseDamageExecution> DamageExecutionBPClass = LoadClass<UGASCourseDamageExecution>(GetTransientPackage(), TEXT("/Game/GASCourse/Game/Systems/Damage/DamageExecution_Base.DamageExecution_Base_C"));
				if (DamageExecutionBPClass->GetClass() != nullptr)
				{
					DamageInfo.CalculationClass = DamageExecutionBPClass;
				}
			
				int32 ModifiersIdx = DamageInfo.CalculationModifiers.Num();
				DamageInfo.CalculationModifiers.SetNum(ModifiersIdx + 2);
				FGameplayEffectExecutionScopedModifierInfo& DamageModifiers = DamageInfo.CalculationModifiers[ModifiersIdx];
				DamageModifiers.ModifierOp = EGameplayModOp::Additive;
			
				FSetByCallerFloat CallerFloat;
				CallerFloat.DataName = FName("");
				CallerFloat.DataTag = Data_IncomingDamage;
				DamageModifiers.ModifierMagnitude = FGameplayEffectModifierMagnitude(CallerFloat);
		
				DamageEffect->Executions[0].CalculationModifiers[0] = DamageModifiers;
				const FGameplayEffectSpecHandle DamageEffectHandle = MakeSpecHandle(DamageEffect, Instigator, Instigator, 1.0f);
				AssignTagSetByCallerMagnitude(DamageEffectHandle, Data_IncomingDamage, Damage);

				//TODO: Investigate how to add custom calculation class to damage application for randomization.
				/*
				FGameplayEffectExecutionScopedModifierInfo& DamageCalculationClass = DamageInfo.CalculationModifiers[++ModifiersIdx];
				DamageCalculationClass.ModifierOp = EGameplayModOp::Additive;
				*/
			
				FGameplayEffectContextHandle ContextHandle = GetEffectContext(DamageEffectHandle);
				if(DamageContext.HitResult.bBlockingHit)
				{
					ContextHandle.AddHitResult(DamageContext.HitResult);
				}
			
				AddGrantedTags(DamageEffectHandle, DamageContext.GrantedTags);
				AddGrantedTag(DamageEffectHandle, DamageContext.DamageType);
			
				InstigatorASC->ApplyGameplayEffectSpecToTarget(*DamageEffectHandle.Data.Get(), TargetASC);
				return true;
			}
		}
	}
	
	return false;
}

Finally, when I apply damage successfully, we pass along this status effect information to the Target Ability System component so that the burn stack effect can be applied:

GASCourseDamageExecution.cpp

TargetAbilitySystemComponent->ApplyGameplayStatusEffect(TargetAbilitySystemComponent, SourceAbilitySystemComponent, Spec.DynamicGrantedTags);

Now that we have a bit more context about how we are applying burn stacks onto a target when dealing fire damage, let’s dive deeper into the Burn status effect itself.


Burn Status Effect

The first status effect that I wanted to make was for Burning, and this effect has a few unique properties that make it interesting.

  • Burn status application requires a certain number of stacks before the actual burn status is applied.
  • Once Burn is applied, it needs to apply burn damage over time.
  • Like most status effects, we also want to support the idea of immunity where another gameplay effect can grant the character immunity to the status entirely.

Let’s start with Burn stacking:

Tranek: GitHub - tranek/GASDocumentation: My understanding of Unreal Engine 5's GameplayAbilitySystem plugin with a simple multiplayer sample project.

We want to apply a burn stack Gameplay Effect every time we deal damage to an enemy with our projectile. Here is the Blueprint setup for the projectile damage and GE application:

As discussed above, we use the GASCourseStatusEffectTable to map the stack to the fire damage application pipeline. Here is how the Burn stack Gameplay Effect is setup:

The most important aspect of the setup is the Stacking section:

What this section is saying is that we want to monitor stacking on a per source basis, with Aggregate by Source. This decision is made so that each source actor applying a stack has to monitor the stacking themselves; in other words, each source needs to apply 5 stacks of burn before having a burn status applied. Alternatively, this can be monitored per Target, so that it doesn’t matter which source applies the burn stack and as long as 5 stacks are reached, the Burn status will be applied. The final choice in this regard is up to debate for my project.

Next, we enable Overflow which allows for a new Gameplay Effect to be applied when the number of stacks applied > than the Stack Limit Count; in this case we want to apply the GE_Burn_Full effect, which handles the actual Burn Status functionality. Please refer to the full burn stack GE image above for additional context on how other Gameplay Effect components are setup.

Now let’s talk about GE_Burn_Full, which is responsible for applying the Burn Status itself; here is what the Gameplay Effect setup looks like:


Here, we are granting and activating the ability, GA_Status_Burn, which is responisble for handling the damage over time:

We make sure that we apply the Asset Tag (Effect.AssetTag.Status.Burn) so that we can have our StatusEffectListener component class broadcast the application/removal of this Gameplay Effect in order to update our UI accordingly. This Asset Tag is also important for how we handle immunity to the Burn status, which I will show in a bit.

The final unique aspect of the Gameplay Effect is the GASCourseGameplayEffectUIData that I use to supply a soft referenced material icon that gets drawn above the NPC healthbar UI, as well as an Effect Descriptor class that we can use to supply verbose descriptions of the gameplay effect. We will be talking about that more later in this post.

Here is the final result in both multiplayer and single player contexts:

Burn_MP_GIF
Burn_SP_GIF


To end the section on the Burn status effect, I wanted to show-case an example of how Status Effect immunity can work. Here is what the GE_Burn_Immunity looks like:

Tranek: GitHub - tranek/GASDocumentation: My understanding of Unreal Engine 5's GameplayAbilitySystem plugin with a simple multiplayer sample project.

The most important Gameplay Effect component is the Immunity Gameplay Effect Component which uses a tag query setup to know which Gameplay Effects to prevent application of while the Immunity effect is active. This is where the Asset Tag, and its hierarchy, is so important. Under Effect Tag Query, I add queries for both the stack and the burn status effect with Effect.AssetTag.Stack.Status.Burn | Effect.AssetTag.Status.Burn respectively. The power lies in being able to grant specific status immunities or groupings of statuses. For example, if GE_Burn_Immunity were to query only Effect.AssetTag.Status, then it would prevent *all statuses! Here is an example of the burn immunity in effect:

Burn_Immunity_GIF

Now that we have shown the Burn status, let’s move on to a simpler example with the Passive Healing effect.


Passive Healing Effect

The Passive Healing status is a much simpler concept than the Burn because it does not support stacking, and with its current setup, does not have any associated immunities; however, with its current setup, adding immunity would be super easy. Passive Healing allows for, as the name suggests, healing to take place passively over time. Here is the setup of GE_PassiveHealing:

The concept to be aware of is that this Gameplay Effect uses a Period of 0.1 seconds to reapply the attribute modification through that period time. For the time being, this effect is infinite but versions of the status effect can be made to last a certain duration.

The unique aspect of the Passive Healing setup is that the modification of the Health attribute takes place through the Gameplay Effect itself:

Here is the final result in both multiplayer and single player contexts:

PassiveHealing_MP_GIF
PassiveHealing_SP_GIF


Now let’s talk about the UI Data class and status descriptor.


GASCourseGameplayEffectUIData

By default, the Gameplay Ability System comes with a bare bones Gameplay Effect Component class called UGameplayEffectUIData and also a simple class that extends from this called GameplayEffectUIData_TextOnly:

GameplayEffectUIData_TextOnly.h

// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "GameplayEffectUIData.h"
#include "GameplayEffectUIData_TextOnly.generated.h"

/**
 * UI data that contains only text. This is mostly used as an example of a subclass of UGameplayEffectUIData.
 * If your game needs only text, this is a reasonable class to use. To include more data, make a custom subclass of UGameplayEffectUIData.
 */
UCLASS()
class GAMEPLAYABILITIES_API UGameplayEffectUIData_TextOnly : public UGameplayEffectUIData
{
	GENERATED_BODY()
	
public:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = Data, meta = (MultiLine = "true"))
	FText Description;
};

From UGameplayEffectUIData, I extended a new custom UIData component class called GASCourseGameplayEffectUIData which stores data related to how I wanted to show status effects onto my games’ UI. These include:

  • An icon representing the status effect.
  • A Blueprintable descriptor class that can be used to dynmically update the description of the status effect after its been applied.

Here is what this class looks like:

GASCourseGameplayEffectUIData.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "GameplayEffectUIData.h"
#include "Game/GameplayAbilitySystem/GameplayEffect/EffectDescriptor/GASCourseEffectDescriptor.h"
#include "GASCourseGameplayEffectUIData.generated.h"

/**
 * 
 */
UCLASS()
class GASCOURSE_API UGASCourseGameplayEffectUIData : public UGameplayEffectUIData
{
	GENERATED_BODY()

public:

	UGASCourseGameplayEffectUIData();

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = Data)
	TSoftObjectPtr<UMaterialInterface> StatusIcon;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = Data, meta = (MultiLine = "true"))
	FText StatusDescription;

	UFUNCTION(BlueprintCallable)
	FText ConstructStatusDescription(FActiveGameplayEffectHandle GameplayEffectHandle);

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = Data)
	TSubclassOf<UGASCourseEffectDescriptor> EffectDescriptor;

	UFUNCTION(BlueprintCallable, meta = (WorldContext))
	UGASCourseEffectDescriptor* InitializeDescriptor(UObject* WorldContextObject);

private:

	UGASCourseEffectDescriptor* EffectDescriptorObj = nullptr;
	
};

GASCourseGameplayEffectUIData.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/GameplayAbilitySystem/GameplayEffect/GASCourseGameplayEffectUIData.h"

#include "AbilitySystemComponent.h"
#include "Game/GameplayAbilitySystem/GameplayEffect/EffectDescriptor/GASCourseEffectDescriptor.h"


UGASCourseGameplayEffectUIData::UGASCourseGameplayEffectUIData()
{

}

FText UGASCourseGameplayEffectUIData::ConstructStatusDescription(FActiveGameplayEffectHandle GameplayEffectHandle)
{
	if(EffectDescriptorObj)
	{
		StatusDescription = EffectDescriptorObj->GetEffectDescriptor(GameplayEffectHandle);
	}

	return StatusDescription;
}

UGASCourseEffectDescriptor* UGASCourseGameplayEffectUIData::InitializeDescriptor(UObject* WorldContextObject)
{
	if(EffectDescriptor)
	{
		EffectDescriptorObj = NewObject<UGASCourseEffectDescriptor>(WorldContextObject, EffectDescriptor);
		
		return EffectDescriptorObj;
	}

	return nullptr;
}

Now let’s dive into the GASCourseEffectDescriptor class.


GASCourseEffectDescriptor

This is the class that can be overriden in Blueprint to update the description of the Gameplay Effect in question. The root of where this data is written comes from the passed in FActiveGameplayEffectHandle GameplayEffectHandle. It is from this handle that the Blueprint can gather required data to add to the description. Here is the code:

GASCourseEffectDescriptor.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "GameplayEffect.h"
#include "GASCourseEffectDescriptor.generated.h"

/**
 * @class UGASCourseEffectDescriptor
 * @brief This class is responsible for defining the effect descriptor for a gameplay effect in GAS Course.
 *
 * The UGASCourseEffectDescriptor class is a subclass of UObject and provides the functionality for getting the effect descriptor of a gameplay effect.
 * It allows for blueprint implementations of the GetEffectDescriptor function to provide custom effect descriptor text based on the specified GameplayEffectHandle.
 */
UCLASS(Blueprintable)
class GASCOURSE_API UGASCourseEffectDescriptor : public UObject
{
	GENERATED_BODY()

public:

	UGASCourseEffectDescriptor();

	UFUNCTION(BlueprintNativeEvent)
	FText GetEffectDescriptor(FActiveGameplayEffectHandle GameplayEffectHandle);
	
	virtual UWorld* GetWorld() const override;

};

GASCourseEffectDescriptor.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/GameplayAbilitySystem/GameplayEffect/EffectDescriptor/GASCourseEffectDescriptor.h"

UGASCourseEffectDescriptor::UGASCourseEffectDescriptor()
{
}

FText UGASCourseEffectDescriptor::GetEffectDescriptor_Implementation(FActiveGameplayEffectHandle GameplayEffectHandle)
{
	FText Empty;
	return Empty;
}

UWorld* UGASCourseEffectDescriptor::GetWorld() const
{
	if(GetOuter() && !HasAnyFlags(RF_ClassDefaultObject))
	{
		return GetOuter()->GetWorld();
	}

	return nullptr;
}

Here are the example descriptors for Burn and Healing status effects:


These demonstrate, with some custom Blueprint functionality (this will be shown at the end of the post in the GASCourseASCBlueprintLibrary class), how we can query data from the Gameplay Effect Handle to construct the effect description!

BurnDescription_InGame
EffectDescription

Lastly, we need to integrate this into our status effect widget. Here is how it works:

On Event Construct of my status effect icon widget Blueprint, I get a reference to the UI data, update the status effect widget image icon, and initialize the description UObject using the widget as a world context object.

Here is how I load in the status effect icon and assign it to the icon brush element:

In the Designer tab of the widget Blueprint, under the Status Effect Icon Image details panel, specifically the Behavior section, I create a new bound function to update the tool tip text with a custom function (Get_StatusEffectIcon_ToolTipText) and setup a special cursor for when its being hovered

Lastly, we use the tool tip text bound function to construct our status description by calling Construct Status Description, passing in the handle we get from our StatusEffectListener component to get the data we need for the description.

To finish up this post, I will give you the code for my GASCourseASCBlueprintLibrary class that I use to handle damage and other helper functions to get data from Gameplay Effect handles. It is in my list of things to do to move damage related things to its own class or subsystem; but for now, it exists in the blueprint library class.


GASCourseASCBlueprintLibrary.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "AbilitySystemBlueprintLibrary.h"
#include "GameplayEffect.h"
#include "Game/GameplayAbilitySystem/GASCourseNativeGameplayTags.h"
#include "Game/GameplayAbilitySystem/GASCourseGameplayAbility.h"
#include "GASCourseASCBlueprintLibrary.generated.h"

/** Represents a context for applying damage to an object or character.
 *  Contains information related to the damage event such as the hit result,
 *  damage type, and additional gameplay tags associated with the damage.
 */

USTRUCT(blueprintable)
struct FDamageContext
{
	GENERATED_USTRUCT_BODY()

public:

	UPROPERTY(EditAnywhere, BlueprintReadWrite )
	FHitResult HitResult;

	UPROPERTY(EditAnywhere, BlueprintReadWrite , meta=(Categories="Damage.Type"))
	FGameplayTag DamageType = DamageType_Physical;

	UPROPERTY(EditAnywhere, BlueprintReadWrite )
	FGameplayTagContainer GrantedTags;
};

/**
 *  @struct FDamageOverTimeContext
 *  @brief Structure representing the context for damage over time.
 *
 *  Structure that holds the parameters necessary for applying damage over time.
 *
 *  @remark This structure is blueprintable.
 */
USTRUCT(blueprintable)
struct FDamageOverTimeContext
{
	GENERATED_USTRUCT_BODY()

public:

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float DamagePeriod = -1.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(Categories="Damage.Type"))
	float DamageDuration = -1.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite )
	bool bApplyDamageOnApplication = true;
};

/**
 * UGASCourseASCBlueprintLibrary is a blueprint library that provides utility functions for applying damage to target actors.
 */

UCLASS()
class GASCOURSE_API UGASCourseASCBlueprintLibrary : public UAbilitySystemBlueprintLibrary
{
	GENERATED_BODY()

public:
	
	/**
	 * Applies damage to the specified target actor using the specified instigator actor, damage amount, and damage context.
	 * This method is blueprint callable and can only be executed by an authority.
	 *
	 * @param Target The actor to apply the damage to.
	 * @param Instigator The actor initiating the damage.
	 * @param Damage The amount of damage to apply.
	 * @param DamageContext The context of the damage being applied.
	 * @return True if the damage was successfully applied, false otherwise.
	 */
	UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = "GASCourse|AbilitySystem|Damage")
	static bool ApplyDamageToTarget(AActor* Target, AActor* Instigator, float Damage, const FDamageContext& DamageContext);

	/**
	 * Applies damage to multiple targets using the provided target data handle and damage context.
	 *
	 * @param TargetHandle - The gameplay ability target data handle representing the targets to apply damage to.
	 * @param Instigator - The actor that caused the damage.
	 * @param Damage - The amount of damage to apply.
	 * @param DamageContext - The context for applying the damage, containing information about the damage event.
	 *
	 * @return True if the damage was successfully applied to at least one target, false otherwise.
	 */
	UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = "GASCourse|AbilitySystem|Damage")
	static bool ApplyDamageToTargetDataHandle(FGameplayAbilityTargetDataHandle TargetHandle, AActor* Instigator, float Damage, const FDamageContext& DamageContext);

	/**
	 * Applies damage over time to a target actor.
	 *
	 * @param Target                      The actor to apply damage over time to.
	 * @param Instigator                  The actor that caused the damage over time.
	 * @param Damage                      The amount of damage to apply over time.
	 * @param DamageContext               The context of the damage being applied.
	 * @param DamageOverTimeContext       The context of the damage over time being applied.
	 *
	 * @return                            Returns true if the damage over time was successfully applied, false otherwise.
	 */
	UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = "GASCourse|AbilitySystem|Damage")
	static bool ApplyDamageOverTimeToTarget(AActor* Target, AActor* Instigator, float Damage, const FDamageContext& DamageContext, const FDamageOverTimeContext& DamageOverTimeContext);

	/**
	 * Applies physical damage to the specified target.
	 *
	 * @param Target    The actor to apply damage to.
	 * @param Instigator    The actor responsible for the damage.
	 * @param Damage    The amount of physical damage to apply.
	 * @param HitResult    The hit result information of the damage.
	 * @param DamageContext    The damage context information. Context is hidden and initialized in the function to pass DamageType_Physical by default.
	 * @return    True if the damage was successfully applied, false otherwise.
	 */

	UFUNCTION(BlueprintCallable, meta=(Hidepin = "DamageContext"), BlueprintAuthorityOnly, Category = "GASCourse|AbilitySystem|Damage")
	static bool ApplyPhysicalDamageToTarget(AActor* Target, AActor* Instigator, float Damage, const FHitResult& HitResult, FDamageContext& DamageContext);

	/**
	 * Apply fire damage to a target actor.
	 *
	 * @param Target The actor to apply the fire damage to.
	 * @param Instigator The actor that initiated the fire damage.
	 * @param Damage The amount of damage to apply.
	 * @param HitResult The hit result of the fire damage.
	 * @param DamageContext The damage context containing additional information about the fire damage. Context is hidden and initialized in the function to pass DamageType_Elemental_Fire by default.
	 * @param bApplyBurnStack Whether to apply a burn stack effect.
	 *
	 * @return True if the fire damage was successfully applied, false otherwise.
	 */
	UFUNCTION(BlueprintCallable, meta=(Hidepin = "DamageContext"), BlueprintAuthorityOnly, Category = "GASCourse|AbilitySystem|Damage")
	static bool ApplyFireDamageToTarget(AActor* Target, AActor* Instigator, float Damage, const FHitResult& HitResult, FDamageContext& DamageContext, bool bApplyBurnStack = true);

	/**
	 * Applies damage to a target actor using a gameplay effect.
	 *
	 * @param Target           The actor to apply damage to.
	 * @param Instigator       The actor causing the damage.
	 * @param Damage           The amount of damage to apply.
	 * @param DamageContext    The context of the damage.
	 * @param GameplayEffect   The gameplay effect to apply.
	 *
	 * @return True if the damage is successfully applied, false otherwise.
	 */
	static bool ApplyDamageToTarget_Internal(AActor* Target, AActor* Instigator, float Damage, const FDamageContext& DamageContext, UGameplayEffect* GameplayEffect);

	static UGameplayEffect* ConstructDamageGameplayEffect(EGameplayEffectDurationType DurationType, const FDamageOverTimeContext& DamageOverTimeContext);

	UFUNCTION(BlueprintPure, Category = "GASCourse|AbilitySystem|Damage")
	static bool FindDamageTypeTagInContainer(const FGameplayTagContainer& InContainer, FGameplayTag& DamageTypeTag);

	/**
	 * Retrieves the gameplay ability slot type from the specified ability spec handle.
	 *
	 * @param AbilitySystem The ability system component to retrieve the ability spec from.
	 * @param AbilitySpecHandle The handle of the ability spec to retrieve the slot type from.
	 * @return The gameplay ability slot type associated with the specified ability spec handle.
	 */
	UFUNCTION(BlueprintPure, Category = "GASCourse|AbilitySystem|GameplayAbility")
	static EGASCourseAbilitySlotType GetGameplayAbilitySlotTypeFromHandle(const UAbilitySystemComponent* AbilitySystem, const FGameplayAbilitySpecHandle& AbilitySpecHandle);

	UFUNCTION(BlueprintCallable, Category = "GASCourse|AbilitySystem|GameplayAbility")
	static void GetAllAbilitiesofAbilitySlotType(const UAbilitySystemComponent* AbilitySystem, EGASCourseAbilitySlotType AbilitySlot, TArray<FGameplayAbilitySpecHandle>& OutAbilityHandles);

	UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = "GASCourse|AbilitySystem|Damage")
	static void SendGameplayEventToTargetDataHandle(FGameplayAbilityTargetDataHandle TargetHandle, FGameplayTag EventTag, FGameplayEventData Payload);

	/**
	 * Retrieves the gameplay attribute from a given modifier struct.
	 *
	 * @param ModifierInfo The gameplay modifier info struct.
	 * @return The gameplay attribute.
	 */
	UFUNCTION(BlueprintCallable, Category = "GASCourse|AbilitySystem|GameplayEffect")
	static FGameplayAttribute GetGameplayAttributeFromModifierStruct(const FGameplayModifierInfo& ModifierInfo);

	/**
	 * Calculates the magnitude of a modifier for a given gameplay effect.
	 *
	 * @param InGameplayEffect The handle to the gameplay effect.
	 * @param ModifierIdx The index of the modifier to calculate the magnitude for.
	 * @param bFactorInStackCount Specifies whether to factor in the stack count of the gameplay effect.
	 *
	 * @return The magnitude of the specified modifier. If the modifier is not found or the gameplay effect is invalid,
	 *         returns 0.0f.
	 */
	UFUNCTION(BlueprintCallable, Category = "GASCourse|AbilitySystem|GameplayEffect")
	static float GetModifierMagnitudeAtIndex(FActiveGameplayEffectHandle InGameplayEffect, int32 ModifierIdx, bool bFactorInStackCount);

	/**
	 * Retrieves the gameplay effect specification handle associated with the given active gameplay effect handle.
	 *
	 * @param InGameplayEffect The active gameplay effect handle for which to retrieve the gameplay effect specification handle.
	 * @return The gameplay effect specification handle associated with the given active gameplay effect handle.
	 */
	UFUNCTION(BlueprintCallable, Category = "GASCourse|AbilitySystem|GameplayEffect")
	static FGameplayEffectSpec GetSpecHandleFromGameplayEffect(FActiveGameplayEffectHandle InGameplayEffect);

	/**
	 * Retrieves the period of a gameplay effect.
	 *
	 * @param InGameplayEffect The handle to the active gameplay effect.
	 * @return The period of the gameplay effect.
	 */
	UFUNCTION(BlueprintPure, Category =  "GASCourse|AbilitySystem|GameplayEffect")
	static float GetPeriodFromGameplayEffect(FActiveGameplayEffectHandle InGameplayEffect);
};

GASCourseASCBlueprintLibrary.cpp


// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/BlueprintLibraries/GameplayAbilitySystem/GASCourseASCBlueprintLibrary.h"
#include "Game/GameplayAbilitySystem/GASCourseAbilitySystemComponent.h"
#include "Game/Systems/Damage/GASCourseDamageExecution.h"
#include "Game/GameplayAbilitySystem/GASCourseNativeGameplayTags.h"
#include "Game/GameplayAbilitySystem/GASCourseGameplayEffect.h"

bool UGASCourseASCBlueprintLibrary::ApplyDamageToTarget(AActor* Target, AActor* Instigator, float Damage, const FDamageContext& DamageContext)
{
	//Initialize DoTContext to default values to make damage instant.
	constexpr FDamageOverTimeContext DamageOverTimeContext;
	UGameplayEffect* DamageEffect = ConstructDamageGameplayEffect(EGameplayEffectDurationType::Instant, DamageOverTimeContext);
	return ApplyDamageToTarget_Internal(Target, Instigator, Damage, DamageContext, DamageEffect);
}

bool UGASCourseASCBlueprintLibrary::ApplyDamageToTargetDataHandle(FGameplayAbilityTargetDataHandle TargetHandle,
	AActor* Instigator, float Damage, const FDamageContext& DamageContext)
{
	TArray<AActor*> Targets = GetAllActorsFromTargetData(TargetHandle);
	bool bDamageApplied = false;
	
	for(AActor* Target: Targets)
	{
		bDamageApplied = ApplyDamageToTarget(Target, Instigator, Damage, DamageContext);
	}
	return bDamageApplied;
}

bool UGASCourseASCBlueprintLibrary::ApplyDamageOverTimeToTarget(AActor* Target, AActor* Instigator, float Damage,
                                                                const FDamageContext& DamageContext, const FDamageOverTimeContext& DamageOverTimeContext)
{
	UGameplayEffect* DamageEffect = ConstructDamageGameplayEffect(EGameplayEffectDurationType::HasDuration, DamageOverTimeContext);
	return ApplyDamageToTarget_Internal(Target, Instigator, Damage, DamageContext, DamageEffect);
}

bool UGASCourseASCBlueprintLibrary::ApplyPhysicalDamageToTarget(AActor* Target, AActor* Instigator, float Damage,
                                                                const FHitResult& HitResult, FDamageContext& DamageContext)
{
	DamageContext.DamageType = DamageType_Physical;
	DamageContext.HitResult = HitResult;
	constexpr FDamageOverTimeContext DamageOverTimeContext;
	UGameplayEffect* DamageEffect = ConstructDamageGameplayEffect(EGameplayEffectDurationType::Instant, DamageOverTimeContext);
	return ApplyDamageToTarget_Internal(Target, Instigator, Damage, DamageContext, DamageEffect);
}

bool UGASCourseASCBlueprintLibrary::ApplyFireDamageToTarget(AActor* Target, AActor* Instigator, float Damage,
                                                            const FHitResult& HitResult, FDamageContext& DamageContext, bool bApplyBurnStack)
{
	DamageContext.DamageType = DamageType_Elemental_Fire;
	if(bApplyBurnStack)
	{
		FGameplayTagContainer GrantedTags;
		GrantedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Effect.Gameplay.Status.Burn.Stack")));
		DamageContext.GrantedTags = GrantedTags;
	}
	DamageContext.HitResult = HitResult;
	
	constexpr FDamageOverTimeContext DamageOverTimeContext;
	UGameplayEffect* DamageEffect = ConstructDamageGameplayEffect(EGameplayEffectDurationType::Instant, DamageOverTimeContext);
	
	return ApplyDamageToTarget_Internal(Target, Instigator, Damage, DamageContext, DamageEffect);
}

bool UGASCourseASCBlueprintLibrary::ApplyDamageToTarget_Internal(AActor* Target, AActor* Instigator, float Damage,
                                                                 const FDamageContext& DamageContext, UGameplayEffect* GameplayEffect)
{
	if(!Instigator && !Target)
	{
		return false;
	}

	//TODO: Add check to verify ability system component + consider damage/health interface for Non-GAS actors
	if(UGASCourseAbilitySystemComponent* TargetASC = Target->GetComponentByClass<UGASCourseAbilitySystemComponent>())
	{
		if(UGASCourseAbilitySystemComponent* InstigatorASC = Instigator->GetComponentByClass<UGASCourseAbilitySystemComponent>())
		{
			if(UGASCourseGameplayEffect* DamageEffect = Cast<UGASCourseGameplayEffect>(GameplayEffect))
			{
							
				const int32 ExecutionIdx = DamageEffect->Executions.Num();
				DamageEffect->Executions.SetNum(ExecutionIdx + 1);
				FGameplayEffectExecutionDefinition& DamageInfo = DamageEffect->Executions[ExecutionIdx];

				const TSubclassOf<UGASCourseDamageExecution> DamageExecutionBPClass = LoadClass<UGASCourseDamageExecution>(GetTransientPackage(), TEXT("/Game/GASCourse/Game/Systems/Damage/DamageExecution_Base.DamageExecution_Base_C"));
				if (DamageExecutionBPClass->GetClass() != nullptr)
				{
					DamageInfo.CalculationClass = DamageExecutionBPClass;
				}
			
				int32 ModifiersIdx = DamageInfo.CalculationModifiers.Num();
				DamageInfo.CalculationModifiers.SetNum(ModifiersIdx + 2);
				FGameplayEffectExecutionScopedModifierInfo& DamageModifiers = DamageInfo.CalculationModifiers[ModifiersIdx];
				DamageModifiers.ModifierOp = EGameplayModOp::Additive;
			
				FSetByCallerFloat CallerFloat;
				CallerFloat.DataName = FName("");
				CallerFloat.DataTag = Data_IncomingDamage;
				DamageModifiers.ModifierMagnitude = FGameplayEffectModifierMagnitude(CallerFloat);
		
				DamageEffect->Executions[0].CalculationModifiers[0] = DamageModifiers;
				const FGameplayEffectSpecHandle DamageEffectHandle = MakeSpecHandle(DamageEffect, Instigator, Instigator, 1.0f);
				AssignTagSetByCallerMagnitude(DamageEffectHandle, Data_IncomingDamage, Damage);

				//TODO: Investigate how to add custom calculation class to damage application for randomization.
				/*
				FGameplayEffectExecutionScopedModifierInfo& DamageCalculationClass = DamageInfo.CalculationModifiers[++ModifiersIdx];
				DamageCalculationClass.ModifierOp = EGameplayModOp::Additive;
				*/
			
				FGameplayEffectContextHandle ContextHandle = GetEffectContext(DamageEffectHandle);
				if(DamageContext.HitResult.bBlockingHit)
				{
					ContextHandle.AddHitResult(DamageContext.HitResult);
				}
			
				AddGrantedTags(DamageEffectHandle, DamageContext.GrantedTags);
				AddGrantedTag(DamageEffectHandle, DamageContext.DamageType);
			
				InstigatorASC->ApplyGameplayEffectSpecToTarget(*DamageEffectHandle.Data.Get(), TargetASC);
				return true;
			}
		}
	}
	
	return false;
}

UGameplayEffect* UGASCourseASCBlueprintLibrary::ConstructDamageGameplayEffect(EGameplayEffectDurationType DurationType,  const FDamageOverTimeContext& DamageOverTimeContext)
{
	UGASCourseGameplayEffect* DamageEffect = NewObject<UGASCourseGameplayEffect>(GetTransientPackage(), FName(TEXT("Damage")));
	if(DurationType == EGameplayEffectDurationType::Instant)
	{
		DamageEffect->DurationPolicy = EGameplayEffectDurationType::Instant;
	}
	else
	{
		DamageEffect->DurationPolicy = EGameplayEffectDurationType::HasDuration;
				
		//DamageOverTimeContext should specify FScalableFloat for duration parameter.
		FScalableFloat Duration;
		Duration.Value = DamageOverTimeContext.DamageDuration;
		DamageEffect->DurationMagnitude = FGameplayEffectModifierMagnitude(Duration);

		//DamageOverTimeContext should specify FScalableFloat for period parameter.
		FScalableFloat Period;
		Period.Value = DamageOverTimeContext.DamagePeriod;
		DamageEffect->Period = Period;
		DamageEffect->bExecutePeriodicEffectOnApplication = DamageOverTimeContext.bApplyDamageOnApplication;
	}
	
	return DamageEffect;
}

bool UGASCourseASCBlueprintLibrary::FindDamageTypeTagInContainer(const FGameplayTagContainer& InContainer, FGameplayTag& DamageTypeTag)
{
	if(InContainer.HasTag(FGameplayTag::RequestGameplayTag(FName("Damage.Type"))))
	{
		for(FGameplayTag Tag : InContainer.GetGameplayTagArray())
		{
			if(Tag.MatchesTag(FGameplayTag::RequestGameplayTag(FName("Damage.Type"))))
			{
				DamageTypeTag = Tag;
				return true;
			}
		}
	}
	
	return false;
}

EGASCourseAbilitySlotType UGASCourseASCBlueprintLibrary::GetGameplayAbilitySlotTypeFromHandle(
	const UAbilitySystemComponent* AbilitySystem, const FGameplayAbilitySpecHandle& AbilitySpecHandle)
{
	EGASCourseAbilitySlotType AbilitySlot = EGASCourseAbilitySlotType::EmptySlot;
	// validate the ASC
	if (!AbilitySystem)
	{
		return AbilitySlot;
	}

	// get and validate the ability spec
	const FGameplayAbilitySpec* AbilitySpec = AbilitySystem->FindAbilitySpecFromHandle(AbilitySpecHandle);
	if (!AbilitySpec)
	{
		return AbilitySlot;
	}

	// try to get the ability instance
	if(const UGASCourseGameplayAbility* AbilityInstance = Cast<UGASCourseGameplayAbility>(AbilitySpec->GetPrimaryInstance()))
	{
		AbilitySlot = AbilityInstance->GetAbilitySlotType();
	}

	return AbilitySlot;
}

void UGASCourseASCBlueprintLibrary::GetAllAbilitiesofAbilitySlotType(const UAbilitySystemComponent* AbilitySystem,  EGASCourseAbilitySlotType AbilitySlot, 
	TArray<FGameplayAbilitySpecHandle>& OutAbilityHandles)
{
	if(AbilitySystem)
	{
		OutAbilityHandles.Empty(AbilitySystem->GetActivatableAbilities().Num());
		for (const FGameplayAbilitySpec& Spec : AbilitySystem->GetActivatableAbilities())
		{
			if(GetGameplayAbilitySlotTypeFromHandle(AbilitySystem, Spec.Handle) == AbilitySlot)
			{
				// add the spec handle to the list
				OutAbilityHandles.Add(Spec.Handle);
			}
		}
	}
}

void UGASCourseASCBlueprintLibrary::SendGameplayEventToTargetDataHandle(FGameplayAbilityTargetDataHandle TargetHandle,
	FGameplayTag EventTag, FGameplayEventData Payload)
{
	TArray<AActor*> Targets = GetAllActorsFromTargetData(TargetHandle);
	for(AActor* Target : Targets)
	{
		SendGameplayEventToActor(Target, EventTag, Payload);
	}
}

FGameplayAttribute UGASCourseASCBlueprintLibrary::GetGameplayAttributeFromModifierStruct(
	const FGameplayModifierInfo& ModifierInfo)
{
	FGameplayAttribute Attribute;

	if(ModifierInfo.Attribute.IsValid())
	{
		Attribute = ModifierInfo.Attribute;
	}

	return Attribute;
}

float UGASCourseASCBlueprintLibrary::GetModifierMagnitudeAtIndex(FActiveGameplayEffectHandle InGameplayEffect, int32 ModifierIdx,
	bool bFactorInStackCount)
{
	float OutModifierMagnitude = 0.0f;
	
	const FGameplayEffectSpec& Spec = GetSpecHandleFromGameplayEffect(InGameplayEffect);
	if(Spec.Def)
	{
		Spec.Def->Modifiers[ModifierIdx].ModifierMagnitude.AttemptCalculateMagnitude(Spec, OutModifierMagnitude);
	}
	
	return OutModifierMagnitude;
}

FGameplayEffectSpec UGASCourseASCBlueprintLibrary::GetSpecHandleFromGameplayEffect(FActiveGameplayEffectHandle InGameplayEffect)
{
	FGameplayEffectSpec OutSpec;
	if(const UAbilitySystemComponent* AbilitySystemComponent = InGameplayEffect.GetOwningAbilitySystemComponent())
	{
		if(const FActiveGameplayEffect* ActiveGameplayEffect = AbilitySystemComponent->GetActiveGameplayEffect(InGameplayEffect))
		{
			OutSpec = ActiveGameplayEffect->Spec;
		}
	}

	return OutSpec;
}

float UGASCourseASCBlueprintLibrary::GetPeriodFromGameplayEffect(FActiveGameplayEffectHandle InGameplayEffect)
{
	float OutPeriod = 0.0f;

	const FGameplayEffectSpec& Spec = GetSpecHandleFromGameplayEffect(InGameplayEffect);
	if(Spec.Def)
	{
		OutPeriod = Spec.GetPeriod();
	}
	
	return OutPeriod;
}

References:


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

*Moving NPC Reaction Ability Logic to State Tree

2 Likes

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Now that Unreal Engine 5.4 is live, I wanted to make a quick blog post about some of the issues I faced when migrating my project over from 5.3; some issues were minor such as changes to the required includes for certain classes while others were more difficult to fix, such as the replication methods in the Gameplay Ability System component when responding to gameplay effect application and removal. I will write the issues and the solutions I implemented in order of difficulty in hopes others will find these solutions useful.


1. FOverlap Syntax Error.

In 5.4, in order to get proper access to the FOverlapResult struct, we need to include a specific class. The simple fix here is to add the following to your includes based on this documentation: FOverlapResult::FOverlapResult | Unreal Engine 5.0 Documentation | Epic Developer Community

#include "Engine/OverlapResult.h"

GASCourseTargetActor_CameraTrace.cpp(205): Error C2027 : use of undefined type 'FOverlapResult'
PrimitiveComponent.h(52): Reference C2027 : see declaration of 'FOverlapResult'
GASCourseTargetActor_CameraTrace.cpp(205): Error C2059 : syntax error: ')'

Here is the code that caused this error to generate:

bool AGASCourseTargetActor_CameraTrace::OverlapMultiByObjectTypes(TArray<TWeakObjectPtr<AActor>>& OutHitActors, const FVector& Pos,
                                                                  const FQuat& Rot, const FCollisionShape& OverlapCollisionShape, const FCollisionQueryParams& Params) const
{
	TArray<FOverlapResult> Overlaps;
	bool bTraceSuccessful = false;
	
	if(QueryChannels.Num() == 0)
	{
		return bTraceSuccessful;
	}
	
	for(const ECollisionChannel QueryChannel : QueryChannels)
	{
		SourceActor->GetWorld()->OverlapMultiByObjectType(Overlaps, Pos, Rot, FCollisionObjectQueryParams(QueryChannel), OverlapCollisionShape, Params);
		for(int32 i = 0; i < Overlaps.Num(); ++i)
		{
			//Should this check to see if these pawns are in the AimTarget list?
			AActor* HitActor = Overlaps[i].OverlapObjectHandle.FetchActor<AActor>();
			if (HitActor && !OutHitActors.Contains(HitActor) && Filter.FilterPassesForActor(HitActor))
			{
				OutHitActors.Add(HitActor);
			}
		}
	}

	return bTraceSuccessful = OutHitActors.Num() > 0 ? true : false;
}

2. Deprecated AnimMontage member of the FGameplayAbilityRepAnimMontage struct.

If you are like me and you wanted to learn more advanced topics in regards to networking and GAS, then you may have come across this blog from Alvaro Jover-Alvarez where he discusses and shows advanced GAS replication tricks: Gameplay Ability System - Advanced Network Optimizations - Devtricks. I definitely recommend reading through it and to try to implement it into your own projects, however, from Unreal Engine 5.4, the AnimMontage member variable of the FGameplayAbilityRepAnimMontage is no longer supported and it has been marked as deprecated with its own variable within the struct called AnimMontage_DEPRECATED:

GASCourseAbilitySystemComponent.cpp(359): Error C2039 : 'AnimMontage': is not a member of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.h(20): Reference C2039 : see declaration of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.cpp(363): Error C2039 : 'AnimMontage': is not a member of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.h(20): Reference C2039 : see declaration of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.cpp(366): Error C2039 : 'AnimMontage': is not a member of 'FGameplayAbilityRepAnimMontage'
GASCourseAbilitySystemComponent.h(20): Reference C2039 : see declaration of 'FGameplayAbilityRepAnimMontage'

/** AnimMontage ref */|
UPROPERTY(meta = (DeprecatedProperty, DeprecationMessage = Use the GetAnimMontage function instead))|
TObjectPtr AnimMontage_DEPRECATED;|

You can either use this deprecated member variable in place of the original AnimMontage member (which I do not recommend as deprecation will eventually lead to full deletion of the variable and could cause worse problems down the line), or you can replace the AnimMontage member variable with either the Animation member variable or the helper get function GetAnimMontage():

/** Animation ref. When playing a dynamic montage this points to the AnimSequence the montage was created from */
UPROPERTY()
TObjectPtr Animation;

UAnimMontage* GetAnimMontage() const;

Below I am showing a before & after snippet of the PlayMontage() function that is overriden based on the original article where you can see how I addressed this problem. For more information, please check out the GameplayAbilityRepAnimMontage class.

Before:

float UGASCourseAbilitySystemComponent::PlayMontage(UGameplayAbility* InAnimatingAbility, FGameplayAbilityActivationInfo ActivationInfo, UAnimMontage* NewAnimMontage, float InPlayRate, FName StartSectionName, float StartTimeSeconds)
{
	float Duration = -1.f;

	UAnimInstance* AnimInstance = AbilityActorInfo.IsValid() ? AbilityActorInfo->GetAnimInstance() : nullptr;
	if (AnimInstance && NewAnimMontage)
	{
		Duration = AnimInstance->Montage_Play(NewAnimMontage, InPlayRate, EMontagePlayReturnType::MontageLength, StartTimeSeconds);
		if (Duration > 0.f)
		{
			if (LocalAnimMontageInfo.AnimatingAbility.Get() && LocalAnimMontageInfo.AnimatingAbility != InAnimatingAbility)
			{
				// The ability that was previously animating will have already gotten the 'interrupted' callback.
				// It may be a good idea to make this a global policy and 'cancel' the ability.
				// 
				// For now, we expect it to end itself when this happens.
			}

			if (NewAnimMontage->HasRootMotion() && AnimInstance->GetOwningActor())
			{
				UE_LOG(LogRootMotion, Log, TEXT("UAbilitySystemComponent::PlayMontage %s, Role: %s")
					, *GetNameSafe(NewAnimMontage)
					, *UEnum::GetValueAsString(TEXT("Engine.ENetRole"), AnimInstance->GetOwningActor()->GetLocalRole())
				);
			}

			LocalAnimMontageInfo.AnimMontage = NewAnimMontage;
			LocalAnimMontageInfo.AnimatingAbility = InAnimatingAbility;
			LocalAnimMontageInfo.PlayInstanceId = (LocalAnimMontageInfo.PlayInstanceId < UINT8_MAX ? LocalAnimMontageInfo.PlayInstanceId + 1 : 0);

			if (InAnimatingAbility)
			{
				InAnimatingAbility->SetCurrentMontage(NewAnimMontage);
			}

			// Start at a given Section.
			if (StartSectionName != NAME_None)
			{
				AnimInstance->Montage_JumpToSection(StartSectionName, NewAnimMontage);
			}
			// Replicate to non owners
			if (IsOwnerActorAuthoritative())
			{
				IGCAbilitySystemReplicationProxyInterface* ReplicationInterface = GetExtendedReplicationInterface();
				FGameplayAbilityRepAnimMontage& MutableRepAnimMontageInfo = ReplicationInterface ? ReplicationInterface->Call_GetRepAnimMontageInfo_Mutable() : GetRepAnimMontageInfo_Mutable();

				// Those are static parameters, they are only set when the montage is played. They are not changed after that.
				MutableRepAnimMontageInfo.AnimMontage = NewAnimMontage;
				MutableRepAnimMontageInfo.PlayInstanceId = (MutableRepAnimMontageInfo.PlayInstanceId < UINT8_MAX ? MutableRepAnimMontageInfo.PlayInstanceId + 1 : 0);

				MutableRepAnimMontageInfo.SectionIdToPlay = 0;
				if (MutableRepAnimMontageInfo.AnimMontage && StartSectionName != NAME_None)
				{
					// we add one so INDEX_NONE can be used in the on rep
					MutableRepAnimMontageInfo.SectionIdToPlay = MutableRepAnimMontageInfo.AnimMontage->GetSectionIndex(StartSectionName) + 1;
				}

				// Update parameters that change during Montage life time.
				AnimMontage_UpdateReplicatedData(MutableRepAnimMontageInfo);

				// Force net update on our avatar actor
				if (AbilityActorInfo->AvatarActor != nullptr)
				{
					AbilityActorInfo->AvatarActor->ForceNetUpdate();
				}
			}
			else
			{
				// If this prediction key is rejected, we need to end the preview
				FPredictionKey PredictionKey = GetPredictionKeyForNewAction();
				if (PredictionKey.IsValidKey())
				{
					PredictionKey.NewRejectedDelegate().BindUObject(this, &UGASCourseAbilitySystemComponent::OnPredictiveMontageRejected, NewAnimMontage);
				}
			}
		}

	}

	return Duration;
}

After:

 UGASCourseAbilitySystemComponent::PlayMontage(UGameplayAbility* InAnimatingAbility, FGameplayAbilityActivationInfo ActivationInfo, UAnimMontage* NewAnimMontage, float InPlayRate, FName StartSectionName, float StartTimeSeconds)
{
	float Duration = -1.f;

	UAnimInstance* AnimInstance = AbilityActorInfo.IsValid() ? AbilityActorInfo->GetAnimInstance() : nullptr;
	if (AnimInstance && NewAnimMontage)
	{
		Duration = AnimInstance->Montage_Play(NewAnimMontage, InPlayRate, EMontagePlayReturnType::MontageLength, StartTimeSeconds);
		if (Duration > 0.f)
		{
			if (LocalAnimMontageInfo.AnimatingAbility.Get() && LocalAnimMontageInfo.AnimatingAbility != InAnimatingAbility)
			{
				// The ability that was previously animating will have already gotten the 'interrupted' callback.
				// It may be a good idea to make this a global policy and 'cancel' the ability.
				// 
				// For now, we expect it to end itself when this happens.
			}

			if (NewAnimMontage->HasRootMotion() && AnimInstance->GetOwningActor())
			{
				UE_LOG(LogRootMotion, Log, TEXT("UAbilitySystemComponent::PlayMontage %s, Role: %s")
					, *GetNameSafe(NewAnimMontage)
					, *UEnum::GetValueAsString(TEXT("Engine.ENetRole"), AnimInstance->GetOwningActor()->GetLocalRole())
				);
			}

			LocalAnimMontageInfo.AnimMontage = NewAnimMontage;
			LocalAnimMontageInfo.AnimatingAbility = InAnimatingAbility;
			LocalAnimMontageInfo.PlayInstanceId = (LocalAnimMontageInfo.PlayInstanceId < UINT8_MAX ? LocalAnimMontageInfo.PlayInstanceId + 1 : 0);

			if (InAnimatingAbility)
			{
				InAnimatingAbility->SetCurrentMontage(NewAnimMontage);
			}

			// Start at a given Section.
			if (StartSectionName != NAME_None)
			{
				AnimInstance->Montage_JumpToSection(StartSectionName, NewAnimMontage);
			}
			// Replicate to non owners
			if (IsOwnerActorAuthoritative())
			{
				IGCAbilitySystemReplicationProxyInterface* ReplicationInterface = GetExtendedReplicationInterface();
				FGameplayAbilityRepAnimMontage& MutableRepAnimMontageInfo = ReplicationInterface ? ReplicationInterface->Call_GetRepAnimMontageInfo_Mutable() : GetRepAnimMontageInfo_Mutable();

				// Those are static parameters, they are only set when the montage is played. They are not changed after that.
				MutableRepAnimMontageInfo.Animation = NewAnimMontage;
				MutableRepAnimMontageInfo.PlayInstanceId = (MutableRepAnimMontageInfo.PlayInstanceId < UINT8_MAX ? MutableRepAnimMontageInfo.PlayInstanceId + 1 : 0);

				MutableRepAnimMontageInfo.SectionIdToPlay = 0;
				if (MutableRepAnimMontageInfo.GetAnimMontage() && StartSectionName != NAME_None)
				{
					// we add one so INDEX_NONE can be used in the on rep
					MutableRepAnimMontageInfo.SectionIdToPlay = MutableRepAnimMontageInfo.GetAnimMontage()->GetSectionIndex(StartSectionName) + 1;
				}

				// Update parameters that change during Montage life time.
				AnimMontage_UpdateReplicatedData(MutableRepAnimMontageInfo);

				// Force net update on our avatar actor
				if (AbilityActorInfo->AvatarActor != nullptr)
				{
					AbilityActorInfo->AvatarActor->ForceNetUpdate();
				}
			}
			else
			{
				// If this prediction key is rejected, we need to end the preview
				FPredictionKey PredictionKey = GetPredictionKeyForNewAction();
				if (PredictionKey.IsValidKey())
				{
					PredictionKey.NewRejectedDelegate().BindUObject(this, &UGASCourseAbilitySystemComponent::OnPredictiveMontageRejected, NewAnimMontage);
				}
			}
		}

	}

	return Duration;
}

3. Gameplay Effect Application & Removal Replication

The last and final issue that I faced when migrating my project over from 5.3 to 5.4 was that in the latest version of the engine, replication of gameplay effect application events was not working correctly for clients! This was a major issue and very surprising to experience as there was not code change on my side during migration that I thought could have caused this. I had spent a few days trying to figure out the issue, and even turned to the Unreal Source discord server, specifically the Gameplay-Ability-System channel for help. Unfortunately, I did not receive any responses but luckily I found a solution that works for me anyway. Although I didn’t receive the help I was asking for, I still recommend others to join the server as there are many helpful members of the community: Unreal Source. For context, here are my posts from discord:

First Post:

Hey everyone, I am experiencing an issue when moving my project to 5.4 where it seems that clients aren’t receiving events that a Gameplay Effect was applied on an NPC on said NPC’s Event Begin Play through Blueprint. The gameplay effect in question applies a Passive Healing status effect that adds a UI element above the NPC health bar to indicate its presence. The functionality of the passive effect is working on the client (the NPC is passive regaining health after receiving damage), but the UI element is not added.

I use a custom component called StatusEffectListenerComponent that listens for gameplay effects with the asset tag (TagName=“Effect.AssetTag.Status”) and sends an active gameplay effect handle back to the owning character (the NPC) that then uses this handle to add the associated icon data to its health bar.

By default, the NPC ability system component is set to replicate but when I manually override it to not replicate in BP, the icon correctly appears but then data such as the health is not correctly present in the health bar. This problem did not exist in 5.3 and only started happening after migrating to 5.4. To avoid giant code spam, I have much of the code as it was working in 5.3 written in this forum blog post: Gameplay Ability System Course Project - Development Blog - #13 by DevinSherry.

Does anyone have any advice as to what could be happening here? Thanks in advance :slight_smile:

Second Post:

Hi again, I wanted to post once more about my issue with client-side gameplay effect application and receiving events when said gameplay effects are applied to an NPC target.

Some progress has been made since then, but I am still running into some roadblocks. I have discovered there are two different delegates to bind to when it comes to responding to gameplay effect application: OnActiveGameplayEffectAddedDelegateToSelf that is called on both server and client when duration-based GEs are applied, and OnGameplayEffectAppliedDelegateToSelf which is called only on server. Originally, I was using OnGameplayEffectAppliedDelegateToSelf which seemed to work fine on my project on 5.3 but not on 5.4 and now using OnActiveGameplayEffectAddedDelegateToSelf fixes the original issue of the passive healing icon appearing for clients on begin play, but introduces a new issue where if the client applies the burning status, the icon appears twice, and the data for the duration between the two instances are completely different. I will provide a video of this example.

I have tried different combinations of server, client, and net multicast events in C++ for when the component is listening for the events and broadcasting it back out to apply the UI data (icon, duration, etc) and nothing has worked 100% as its needed to and I am stuck. Does anyone have any thoughts or advice as to what could be the issue here? Thanks in advance! :slight_smile:

Final Post:

Good news is that I finally fixed the problem, but I am still not 100% why it works or why the problem occurred in the first place when migrating from 5.3 to 5.4; maybe others can shine a light on it now that I can provide a solution that worked for me. Attached is a video of the final result, as well as the code changes I made to get it working. I hope this helps others in some way :slight_smile:

In short, what I ended up doing was creating separate server and client functions inside of the status effect listener component. On begin play of my character class, on authority, I bind to OnGameplayEffectAppliedDelegateToSelf and have it call the server function of the status effect listener component. For non-authority, I bind to OnActiveGameplayEffectAddedDelegateToSelf and have it call the client function of the status effect listener component. For gameplay effect removal, I still use a multicast function from the status effect listener component and bind it to OnAnyGameplayEffectRemovedDelegate.

Here are the required code changes that I made:

GASCourseCharacter.cpp

void AGASCourseCharacter::BeginPlay()
{
	// Call the base class  
	Super::BeginPlay();
	
	GameplayEffectAssetTagsToRemove.AddTag(FGameplayTag::RequestGameplayTag(FName("Effect.AssetTag.Status")));
	if(AbilitySystemComponent)
	{
		if(StatusEffectListenerComp)
		{
			if(GetLocalRole() == ROLE_Authority)
			{
				AbilitySystemComponent->OnGameplayEffectAppliedDelegateToSelf.AddUObject(StatusEffectListenerComp, &UGASCStatusEffectListenerComp::OnStatusEffectApplied_Server);
			}
			else
			{
				AbilitySystemComponent->OnActiveGameplayEffectAddedDelegateToSelf.AddUObject(StatusEffectListenerComp, &UGASCStatusEffectListenerComp::OnStatusEffectApplied_Client);
			}
			AbilitySystemComponent->OnAnyGameplayEffectRemovedDelegate().AddUObject(StatusEffectListenerComp, &UGASCStatusEffectListenerComp::OnStatusEffectRemoved);
			StatusEffectListenerComp->ApplyDefaultActiveStatusEffects();
		}
	}
}

GASCStatusEffectListenerComp.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "ActiveGameplayEffectHandle.h"
#include "GameplayEffectTypes.h"
#include "GameplayEffect.h"
#include "Components/ActorComponent.h"
#include "GASCStatusEffectListenerComp.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FStatusEffectApplied, FActiveGameplayEffectHandle, StatusEffectSpec);

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent), Blueprintable )
class GASCOURSE_API UGASCStatusEffectListenerComp : public UActorComponent
{
	GENERATED_BODY()

public:	
	// Sets default values for this component's properties
	UGASCStatusEffectListenerComp();

	UFUNCTION(Client, Reliable)
	void OnStatusEffectApplied_Client(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle);

	UFUNCTION(Server, Reliable)
	void OnStatusEffectApplied_Server(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle);

	UFUNCTION(NetMulticast, Reliable)
	void OnStatusEffectRemoved(const FActiveGameplayEffect& ActiveGameplayEffect);
	
	UPROPERTY(BlueprintAssignable)
	FStatusEffectApplied OnStatusEffectAppliedHandle;

	UPROPERTY(BlueprintAssignable)
	FStatusEffectApplied OnStatusEffectRemovedHandle;

	UPROPERTY(EditAnywhere, Category = "GASCourse|StatusEffect|Tags")
	FGameplayTag StatusEffectAssetTag;

	UFUNCTION()
	void ApplyDefaultActiveStatusEffects();

protected:
	// Called when the game starts
	virtual void BeginPlay() override;

	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

	virtual void Deactivate() override;

	virtual void InitializeComponent() override;

	virtual void BeginReplication() override;
};

// Fill out your copyright notice in the Description page of Project Settings.

#include "Game/Character/Components/GASCStatusEffectListenerComp.h"
#include "GASCourseCharacter.h"

// Sets default values for this component's properties
UGASCStatusEffectListenerComp::UGASCStatusEffectListenerComp()
{
	// Set this component to be initialized when the game starts, and to be ticked every frame.  You can turn these features
	// off to improve performance if you don't need them.
	PrimaryComponentTick.bCanEverTick = false;
	SetIsReplicatedByDefault(true);

	// ...
}

GASCStatusEffectListenerComp.cpp

// Fill out your copyright notice in the Description page of Project Settings.

#include "Game/Character/Components/GASCStatusEffectListenerComp.h"
#include "GASCourseCharacter.h"

// Sets default values for this component's properties
UGASCStatusEffectListenerComp::UGASCStatusEffectListenerComp()
{
	// Set this component to be initialized when the game starts, and to be ticked every frame.  You can turn these features
	// off to improve performance if you don't need them.
	PrimaryComponentTick.bCanEverTick = false;
	SetIsReplicatedByDefault(true);

	// ...
}

void UGASCStatusEffectListenerComp::OnStatusEffectApplied_Server_Implementation(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle)
{
	FGameplayTagContainer GameplayEffectAssetTags;
	GameplayEffectSpec.GetAllAssetTags(GameplayEffectAssetTags);

	if(GameplayEffectAssetTags.IsEmpty())
	{
		return;
	}

	if(GameplayEffectAssetTags.HasTag(StatusEffectAssetTag))
	{
		OnStatusEffectAppliedHandle.Broadcast(ActiveGameplayEffectHandle);
	}
}


void UGASCStatusEffectListenerComp::OnStatusEffectApplied_Client_Implementation(UAbilitySystemComponent* Source, const FGameplayEffectSpec& GameplayEffectSpec, FActiveGameplayEffectHandle ActiveGameplayEffectHandle)
{
	FGameplayTagContainer GameplayEffectAssetTags;
	GameplayEffectSpec.GetAllAssetTags(GameplayEffectAssetTags);

	if(GameplayEffectAssetTags.IsEmpty())
	{
		return;
	}

	if(GameplayEffectAssetTags.HasTag(StatusEffectAssetTag))
	{
		OnStatusEffectAppliedHandle.Broadcast(ActiveGameplayEffectHandle);
	}
}

void UGASCStatusEffectListenerComp::OnStatusEffectRemoved_Implementation(const FActiveGameplayEffect& ActiveGameplayEffect)
{
	FGameplayTagContainer GameplayEffectAssetTags;
	ActiveGameplayEffect.Spec.GetAllAssetTags(GameplayEffectAssetTags);

	if(GameplayEffectAssetTags.IsEmpty())
	{
		return;
	}

	if(GameplayEffectAssetTags.HasTag(StatusEffectAssetTag))
	{
		OnStatusEffectRemovedHandle.Broadcast(ActiveGameplayEffect.Handle);
	}
}

void UGASCStatusEffectListenerComp::ApplyDefaultActiveStatusEffects()
{
	if(const AGASCourseCharacter* OwningCharacter = Cast<AGASCourseCharacter>(GetOwner()))
	{
		if(const UAbilitySystemComponent* ASC = OwningCharacter->GetAbilitySystemComponent())
		{	
			TArray<FActiveGameplayEffectHandle> ActiveHandles = ASC->GetActiveEffectsWithAllTags(StatusEffectAssetTag.GetSingleTagContainer());
			for(const FActiveGameplayEffectHandle InActiveHandle : ActiveHandles)
			{
				OnStatusEffectAppliedHandle.Broadcast(InActiveHandle);
			}
		}
	}
}

// Called when the game starts
void UGASCStatusEffectListenerComp::BeginPlay()
{
	Super::BeginPlay();
}

void UGASCStatusEffectListenerComp::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	if(OnStatusEffectAppliedHandle.IsBound())
	{
		OnStatusEffectAppliedHandle.Clear();
	}
	if(OnStatusEffectRemovedHandle.IsBound())
	{
		OnStatusEffectRemovedHandle.Clear();
	}
	
	Super::EndPlay(EndPlayReason);
}

void UGASCStatusEffectListenerComp::Deactivate()
{
	if(OnStatusEffectAppliedHandle.IsBound())
	{
		OnStatusEffectAppliedHandle.Clear();
	}
	if(OnStatusEffectRemovedHandle.IsBound())
	{
		OnStatusEffectRemovedHandle.Clear();
	}
	
	Super::Deactivate();
}

void UGASCStatusEffectListenerComp::InitializeComponent()
{
	Super::InitializeComponent();
}

void UGASCStatusEffectListenerComp::BeginReplication()
{
	Super::BeginReplication();
}

To recap briefly, I had to use a combination of two delegate bindings and server/client functions inside of my Status Effect Listener Component to ensure both the server and clients were properly receiving the events for both application and removal of Gameplay Effects in order to properly add and draw UI data on the top of the NPC healthbar.


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:

Next Blog Post Topic:

*View Models & Health Components

1 Like

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Today we are going to talk about View Models and how we can use them to update UI elements for both player and NPC characters. I will showcase how I use a single view model to update and replicate the Health and Max Health attributes of separate UI for these characters, and how I used a custom Health Component to achieve this.


Classes to Research:

MVVMViewModelBase

Additional Reading:


What is a View Model?

A view model is a means of communication between two separate systems, one of which being your UI. In the context of this GAS Course Project, it is a means of communication between gameplay and UI systems. In most cases, if you wanted to bind gameplay data to your UI, you could so in a few different ways. Here are two examples and reasons as to why they weren’t the best. Then we will discuss how using a View Model is an improvement on this paradigm.


Property Binding

Built into UMG are property bindings that you can override to provide the compatible data into the property. For example, when using a Progress Bar, you can override the property Percent with a function. Below is an example of how we can have the Progress Bar update based on variables on character variables of Current Health and Max Health:

The problem with this method is that these property binding functions are updated On Tick and can be proven to be inefficient for many types of games. You could, for example, store a reference to the Owning Player Pawn on construction of the UI, and use that reference to access these variables. Don’t get me wrong, in some cases, this method is valid and shouldn’t be discouraged, especially on solo projects. However, there are better means to handle updating your UI, such as Event Driven methods.


Event Driven

An improvement can be made to only update the UI when the data relevant to said UI is updated. This can be done through custom events, either in the UI itself or inside of the actor that owns a reference to the UI. Sticking with the same example of a simple Health Bar UI, here are two different ways to handle updating the UI when the respective health variables are updated.

The first method is to create a custom event inside of the UI that the owner can invoke whenever their Health variables are updated:


The second method, using the Gameplay Ability System plugin framework, can listen for attribute changes for Current Health and Max Health using the owning actor as the Target Actor (assuming that the owning actor has an Ability System Component). These tasks will execute whenever the associated attribute is changed, and returns both the Old and New values; and using these we can update the Health Bar UI progress bar.

By doing so, we only update the UI when it needs to be updated. However, there is still a problem; there is a direct connection between the UI and its owning actor. In the second example, if a designer were to want to change how health is updated, they would need to update the widget class directly. Vice versa, if a UI designer needed to change how the health variables are passed into the UI they would need to update the logic inside of the character. Lastly, depending on how your project is setup, different actors may need to display the same data differently; for example, as text rather than just a float value. With this being the case, different classes would need to implement methods to pass their data correctly to the UI in order for it to be displayed as intended. This is where View Models shine.

Let’s dive deeper by creating our Health View Model class.


Creating a View Model

Note: Make sure to follow the setup tutorial in the Additional Reading section in order to setup/enable the plugin for use in your project.

I will provide examples on how to create a View Model, both in C++ and in Blueprints; but for the GAS Course Project, we will be using the C++ class. It is also recommended to not create a monolithic View Model that contains all data you would need for various UI, and to only have View Models that contain related data to each other. An exemption can be a view model that contains all attributes of a character to be used in displaying a stats UI for your game. The example we will be doing is only for a characters’ Health; both player and NPC.

Blueprint:

First, create a new Blueprint that derives from MVVMBaseViewModel:

Next, create whichever variables you would require for your View Model; in this case, CurrentHealth and MaxHealth float variables to represent our players’ Health:

The key with creating variables for your View Model is to enable them as Field Notify by clicking the small bell icon next to the variable name. Field Notifies allows you to map functions that can be updated/respond to the broadcasting changes of these variables.

Now let’s create four functions we need for our UI:

float GetCurrentHealth()
float GetMaxHealth()
float GetHealthPercentage()
FText GetHealthAsDisplay()

Make sure to make these functions both Const and Blueprint Pure functions so that they can be used as a Field Notify, as shown below:

Now that we have functions available as Field Notifies, we can assign them to both our CurrentHealth and MaxHealth variables.

CurrentHealth should have the following Field Notifies:

GetCurrentHealth()
GetHealthAsDisplay()
GetHealthPercentage()

MaxHealth should have the following Field Notifies:

GetMaxHealth()
GetHealthAsDisplay()
GetHealthPercentage()

These functions will be used to bind the data from the View Model into the UI and they will be updated whenever CurrentHealth and MaxHealth are updated. Now I will show you the same kind of setup, but in C++; which we are using for the GAS Course Project:

C++

GASC_UVM_Health.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "MVVMViewModelBase.h"
#include "GASC_UVM_Health.generated.h"

/**
 * 
 */
UCLASS()
class GASCOURSE_API UGASC_UVM_Health : public UMVVMViewModelBase
{
	GENERATED_BODY()
	
public:

	UGASC_UVM_Health();

	UFUNCTION(BlueprintPure, FieldNotify)
	float GetCurrentHealth() const;

	UFUNCTION(BlueprintCallable)
	void SetCurrentHealth(const float& NewCurrentHealth);

	UFUNCTION(BlueprintPure, FieldNotify)
	float GetMaxHealth() const;

	UFUNCTION(BlueprintCallable)
	void SetMaxHealth(const float& NewMaxHealth);

	UFUNCTION(BlueprintPure, FieldNotify)
	float GetHealthPercentage() const;

	UFUNCTION(BlueprintPure, FieldNotify)
	FText GetHealthAsDisplay() const;

private:

	UPROPERTY(BlueprintReadOnly, FieldNotify, Setter, Getter,  meta=(AllowPrivateAccess))
	float CurrentHealth = 0.0f;

	UPROPERTY(BlueprintReadOnly, FieldNotify, Setter, Getter,  meta=(AllowPrivateAccess))
	float MaxHealth = 0.0f;
	
};

GASC_UVM_Health.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/HUD/ViewModels/Health/GASC_UVM_Health.h"


UGASC_UVM_Health::UGASC_UVM_Health()
{
}

float UGASC_UVM_Health::GetCurrentHealth() const
{
	return CurrentHealth;
}

float UGASC_UVM_Health::GetMaxHealth() const
{
	return MaxHealth;
}

void UGASC_UVM_Health::SetCurrentHealth(const float& NewCurrentHealth)
{
	if(UE_MVVM_SET_PROPERTY_VALUE(CurrentHealth, NewCurrentHealth))
	{
		UE_MVVM_BROADCAST_FIELD_VALUE_CHANGED(GetHealthPercentage);
		UE_MVVM_BROADCAST_FIELD_VALUE_CHANGED(GetHealthAsDisplay);
	}
}

void UGASC_UVM_Health::SetMaxHealth(const float& NewMaxHealth)
{
	if(UE_MVVM_SET_PROPERTY_VALUE(MaxHealth, NewMaxHealth))
	{
		UE_MVVM_BROADCAST_FIELD_VALUE_CHANGED(GetHealthPercentage);
		UE_MVVM_BROADCAST_FIELD_VALUE_CHANGED(GetHealthAsDisplay);
	}
}

float UGASC_UVM_Health::GetHealthPercentage() const
{
	if(MaxHealth != 0.0f)
	{
		return CurrentHealth / MaxHealth;
	}
	else
	{
		UE_LOG(LogTemp, Warning, TEXT("Max Health == 0.0!"));
		return 0.0f;
	}
}

FText UGASC_UVM_Health::GetHealthAsDisplay() const
{
	FString HealthString = FString::SanitizeFloat(CurrentHealth, 0) + "/" + FString::SanitizeFloat(MaxHealth, 0);
	return FText::AsCultureInvariant(HealthString);
}

With our Health View Model created, we can now create the necessary bindings that link the View Model and the UI elements. We will cover both implementations of Health for both our Player and our NPC; the cool thing is that both can use the same View Model and simply update its data differently. We will cover how we instantiate and update our View Models via a custom Health Component later in this blog post.

For the Player Health UI, I use both a Text Block and a Progress Bar to display the health data.

Note: You may need to navigate to Window->ViewBindings and Window->Viewmodels in order to show the required windows to setup the View Model and its bindings within the UMG editor.

First, press the +Viewmodel button to add a our new View Model class. Make sure to select the class you made, whether it was in C++ or in Blueprint:

Select the top-level class to then update the View Model parameters:

View Model Name: This is simply the variable name representation of the View Model if used within the UI explicitly, such as when used with Creation Types Create Instance or Manual. This doesn’t occur when using Global Viewmodel Collection; not sure about Property Path as I have not used this option for my project.

Notify Field Value Class: This is the View Model class being used.

Creation Type: This refers to how we want to initialize the View Model. For our player, we will be using Global View Model, but later, when we setup our NPC Health UI, we will be using Manual.

Create Instance: The widget automatically creates its own instance of the Viewmodel.

Manual: The widget initializes with the Viewmodel as null, and you need to manually create an instance and assign it.

Global Viewmodel Collection: Refers to a globally-available Viewmodel that can be used by any widget in your project. Requires a Global Viewmodel Identifier.

Property Path: At initialization, execute a function to find the Viewmodel. The Viewmodel Property Path uses member names separated by periods. For example: GetPlayerController.Vehicle.ViewModel. Property paths are always relative to the widget.

When using the Global Viewmodel Collection type, you then need to add a Global Viewmodel Identifier; this is an FName that is very important to notate as it is used to connect the instanced view model during the creation process that will happen later on! I use the identifier VM_Player_Health:

Now we can use the Field Notify functions to connect the view model updates to property bindings of our UI elements. Again, we are using both a Progress Bar and a Text Block. Let’s start with the Progress Bar. Under the View Bindings window, press the + Add Widget button to add a new notify. Navigate the window to find the Progress Bar UI element and then select the Percent variable to setup the binding:

Next, we can select the next field to select which function data we should be using to bind into the Progress Bar percent variable. Navigate to find the GetHealthPercentage() function from the view model class:

The final property binding will look like the following:

Lastly, we can then setup the property binding to update the Text Block text variable using the GetHealthAsDisplay() function from our view model. The final setup will look like this:

The Health Bar UI for the NPC works almost identically to the Health UI as the player, however, the Creation Type used is Manual instead of Global View Model Collection. The reason that I found is that each NPC must have its own view model manually created rather than sharing a single Global View Model is that each enemy has its own health. While developing this, I found that when NPC’s use a Global View Model Collection, when dealing damage to one NPC, the health UI would update for all NPCs in the world. By giving each NPC its own manually constructed view model, each will maintain its own Health and UI correctly!




In order to connect the gameplay logic with the Health View Model, let’s create an explicit actor component to instantiate the view model and monitor our attribute changes for CurrentHealth and MaxHealth. Let’s start with the C++ implementation:

GASC_HealthComponent.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "Components/ActorComponent.h"
#include "MVVMViewModelBase.h"
#include "Game/HUD/ViewModels/Health/GASC_UVM_Health.h"
#include "Game/GameplayAbilitySystem/AttributeSets/GASCourseHealthAttributeSet.h"
#include "GASC_HealthComponent.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthViewModelInstantiated, UGASC_UVM_Health*, HealthViewModel);

/**
 * This class represents a health component for an actor in the game.
 * It provides functionality for managing the current and maximum health values of the actor.
 * The health component also supports replication to ensure consistent gameplay across the network.
 */
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent), Blueprintable )
class GASCOURSE_API UGASC_HealthComponent : public UActorComponent
{
	GENERATED_BODY()

public:
	/**
	 * Default constructor for the UGASC_HealthComponent class.
	 * Sets up the component to be initialized when the game starts and to be ticked every frame.
	 * It also enables replication for the component.
	 */
	UGASC_HealthComponent();


protected:
	virtual void BeginPlay() override;
	
	/**
	 * GetLifetimeReplicatedProps is a method of the UGASC_HealthComponent class.
	 * It is a const method that overrides the GetLifetimeReplicatedProps method of the UActorComponent class.
	 * This method is responsible for defining the properties that will be replicated over the network.
	 * It adds the CurrentHealth and MaxHealth properties to the OutLifetimeProps array using the DOREPLIFETIME macro.
	 * The replicated properties will be automatically synchronized between the server and clients.
	 *
	 * @param OutLifetimeProps - The array of lifetime replicated properties to be populated.
	 */
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

	/**
	 * Initializes the view model for the UGASC_HealthComponent.
	 *
	 * The view model is responsible for managing the health-related properties and functions of the UGASC_HealthComponent.
	 * This method creates a UGASC_UVM_Health instance and adds it to the global view model collection.
	 * It then sets the HealthViewModel property of the UGASC_HealthComponent to the created instance.
	 *
	 * Prerequisites:
	 * - The UGASC_HealthComponent must be attached to an actor.
	 * - The owning game instance must have a valid UMVVMGameSubsystem instance.
	 *
	 * @see UGASC_HealthComponent
	 * @see UMVVMGameSubsystem
	 * @see UMVVMViewModelCollectionObject
	 * @see UGASC_UVM_Health
	 * @see FMVVMViewModelContext
	 * @see CharacterHealthViewModelContextClass
	 * @see CharacterHealthContextName
	 * @see HealthViewModel
	 */
	UFUNCTION()
	void InitializeViewModel();

	/**
	 * @brief CurrentHealth is a float variable that represents the current health of the actor.
	 * It is replicated using the "OnRep_CurrentHealth" function.
	 */
	UPROPERTY(BlueprintReadWrite, ReplicatedUsing="OnRep_CurrentHealth")
	float CurrentHealth = 0.0f;

	/**
	 * @brief The maximum health of the actor.
	 *
	 * This variable represents the maximum health value that an actor can have.
	 * It is used in conjunction with the current health variable to determine the health status of the actor.
	 * The value of this variable should be set according to the specific requirements of the game or application.
	 *
	 * @details
	 * - The variable is of type `float`.
	 * - It is marked with the `ReplicatedUsing` attribute, indicating that changes to its value will be replicated to all clients.
	 * - The default value of the variable is `0.0f`.
	 *
	 * @note
	 * - Modifying the value of this variable directly may have unintended side effects.
	 *   It is recommended to use appropriate functions or methods to update the health value.
	 * - This variable can be accessed and modified during runtime, as needed.
	 * - The variable should be synchronized across all instances of the actor in a networked environment to ensure consistent gameplay.
	 *   The `OnRep_MaxHealth` function will be called when the value of this variable is updated on the server.
	 *   Therefore, any logic related to synchronizing the value on clients should be implemented in that function.
	 *
	 * @see OnRep_MaxHealth
	 *
	 * @warning This variable should not be initialized or modified directly in external code.
	 *          Use appropriate functions or methods within the associated class to manage the health value.
	 */
	UPROPERTY(BlueprintReadWrite, ReplicatedUsing="OnRep_MaxHealth")
	float MaxHealth = 0.0f;

	/**
	 * Callback function invoked when the replicated property CurrentHealth is replicated to clients.
	 * This function is automatically called by the engine when the replicated property changes.
	 * It updates the current health in the health view model, if available.
	 *
	 * Usage:
	 * - Override this function in subclasses of UGASC_HealthComponent to provide custom logic when the current health changes.
	 * - Within the implementation of the overridden function, call the base implementation first, and then add any additional code specific to the subclass.
	 * - Example usage:
	 *   ```cpp
	 *   void UMyHealthComponent::OnRep_CurrentHealth()
	 *   {
	 *       Super::OnRep_CurrentHealth();
	 *
	 *       // Custom logic here
	 *   }
	 *   ```
	 * - Note that the base implementation of this function already sets the current health in the health view model if available, so you may not need to modify this behavior in most cases.
	 */
	UFUNCTION(BlueprintCallable)
	virtual void OnRep_CurrentHealth();

	/**
	 * Called when the MaxHealth property is replicated from the server to the clients.
	 * This method is automatically called by the Unreal Engine's replication system.
	 * It updates the Max Health value in the HealthViewModel if it exists.
	 *
	 * @remarks
	 * This method assumes that the HealthViewModel has been properly initialized and assigned.
	 * If the HealthViewModel is null, no action will be taken.
	 *
	 * @see UGASC_HealthViewModel
	 */
	UFUNCTION(BlueprintCallable)
	virtual void OnRep_MaxHealth();

	/**
	 * Initializes the health attributes for the server.
	 * This method is called on the server when the health component is being initialized.
	 * It retrieves the health attribute values from the owning character's ability system component and sets the local CurrentHealth and MaxHealth variables accordingly.
	 * It also triggers the OnRep_CurrentHealth and OnRep_MaxHealth methods to ensure proper replication.
	 *
	 * @note This method should be called on the server only.
	 */
	UFUNCTION(Reliable, Server)
	void Server_InitializeHealthAttributes();

	UPROPERTY(BlueprintAssignable, BlueprintCallable)
	FOnHealthViewModelInstantiated OnHealthViewModelInstantiated;

	UFUNCTION(BlueprintNativeEvent)
	void HealthViewModelInstantiated(UGASC_UVM_Health* InstantiatedViewModel); 

public:

	/**
	 * CharacterHealthViewModelContextClass is a property that holds the subclass of UMVVMViewModelBase
	 * used to represent the character's health in the health view model.
	 *
	 * This property is editable in the editor and can be accessed by blueprint scripts.
	 *
	 * @see UGASC_HealthComponent::RegisterHealthComponent
	 *
	 * @category View Models
	 * @subcategory Health
	 */
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Health Component|View Model")
	TSubclassOf<UMVVMViewModelBase> CharacterHealthViewModelContextClass;

	/**
	 * CharacterHealthContextName is a variable that holds the name of the context used to represent the character's health in the health view model.
	 *
	 * This variable is editable in the editor and can be accessed by blueprint scripts.
	 *
	 * @see UGASC_HealthComponent::RegisterToHealthViewModel_Client
	 * @see UGASC_HealthComponent::RegisterToHealthViewModel
	 *
	 * @category View Models
	 * @subcategory Health
	 */
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Health Component|View Model")
	FName CharacterHealthContextName;
	
	/**
	 * The HealthAttributeSet variable is a subclass of UGASCourseHealthAttributeSet.
	 * This variable is exposed to the editor and can be accessed by blueprint scripts.
	 * It represents the attribute set for the health component.
	 *
	 * @see UGASC_HealthComponent
	 * @see UGASC_HealthComponent::OnHealthViewModelRegistered_Implementation
	 * @see UGASC_HealthComponent::MonitorHealthChanges_Server
	 *
	 * @category Attribute
	 */
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Health Component|Gameplay AttributeSet")
	TSubclassOf<UGASCourseHealthAttributeSet> HealthAttributeSet;

	/**
	 * @brief The HealthViewModel variable represents the health view model for an actor.
	 *
	 * This variable is of type UGASC_UVM_Health and is marked as BlueprintReadOnly, which means it can only be read from Blueprint.
	 *
	 * The HealthViewModel is responsible for managing the current and maximum health values of the actor.
	 * It provides functionality for updating and retrieving the health values.
	 *
	 * Example usage:
	 * @code
	 * if (HealthViewModel != nullptr) {
	 *     float currentHealth = HealthViewModel->GetCurrentHealth();
	 *     float maxHealth = HealthViewModel->GetMaxHealth();
	 *     // Perform necessary operations with currentHealth and maxHealth
	 * }
	 * @endcode
	 *
	 * @see UGASC_UVM_Health
	 */
	UPROPERTY(BlueprintReadOnly)
	UGASC_UVM_Health* HealthViewModel;
};

GASC_HealthComponent.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "Game/Character/Components/Health/GASC_HealthComponent.h"
#include "MVVMGameSubsystem.h"
#include "MVVMSubsystem.h"
#include "Abilities/Tasks/AbilityTask_WaitAttributeChange.h"
#include "Game/GameplayAbilitySystem/AttributeSets/GASCourseHealthAttributeSet.h"
#include "Game/HUD/ViewModels/Health/GASC_UVM_Health.h"
#include "GASCourse/GASCourseCharacter.h"
#include "Net/UnrealNetwork.h"

// Sets default values for this component's properties
UGASC_HealthComponent::UGASC_HealthComponent()
{
	// Set this component to be initialized when the game starts, and to be ticked every frame.  You can turn these features
	// off to improve performance if you don't need them.
	PrimaryComponentTick.bCanEverTick = false;
	SetIsReplicatedByDefault(true);
}

void UGASC_HealthComponent::BeginPlay()
{
	InitializeViewModel();
	Server_InitializeHealthAttributes();

	Super::BeginPlay();
}

void UGASC_HealthComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	DOREPLIFETIME(UGASC_HealthComponent, CurrentHealth);
	DOREPLIFETIME(UGASC_HealthComponent, MaxHealth);
}

void UGASC_HealthComponent::OnRep_CurrentHealth()
{
	if(HealthViewModel)
	{
		HealthViewModel->SetCurrentHealth(CurrentHealth);
	}
}

void UGASC_HealthComponent::OnRep_MaxHealth()
{
	if(HealthViewModel)
	{
		HealthViewModel->SetMaxHealth(MaxHealth);
	}
}

void UGASC_HealthComponent::InitializeViewModel()
{
	UMVVMGameSubsystem* ViewModelGameSubsystem = GetOwner()->GetGameInstance()->GetSubsystem<UMVVMGameSubsystem>();
	check(ViewModelGameSubsystem);

	UMVVMViewModelCollectionObject* GlobalViewModelCollection = ViewModelGameSubsystem->GetViewModelCollection();
	check(GlobalViewModelCollection);

	UGASC_UVM_Health* CharacterHealthViewModel = NewObject<UGASC_UVM_Health>();
	FMVVMViewModelContext CharacterHealthViewModelContext;
	CharacterHealthViewModelContext.ContextClass = CharacterHealthViewModelContextClass;
	CharacterHealthViewModelContext.ContextName = CharacterHealthContextName;
	if(CharacterHealthViewModelContext.IsValid())
	{
		GlobalViewModelCollection->AddViewModelInstance(CharacterHealthViewModelContext, CharacterHealthViewModel);
		HealthViewModel = CharacterHealthViewModel;

		OnHealthViewModelInstantiated.Broadcast(HealthViewModel);
		HealthViewModelInstantiated(HealthViewModel);
	}
}

void UGASC_HealthComponent::Server_InitializeHealthAttributes_Implementation()
{
	if(AGASCourseCharacter* OwningCharacter = Cast<AGASCourseCharacter>(GetOwner()))
	{
		if(UAbilitySystemComponent* OwningASC = Cast<UAbilitySystemComponent>(OwningCharacter->GetAbilitySystemComponent()))
		{
			if(const UGASCourseHealthAttributeSet* HealthAttributes = Cast<UGASCourseHealthAttributeSet>(OwningASC->GetAttributeSet(HealthAttributeSet)))
			{
				CurrentHealth = HealthAttributes->GetCurrentHealth();
				MaxHealth = HealthAttributes->GetMaxHealth();
				OnRep_CurrentHealth();
				OnRep_MaxHealth();
			}
		}
	}
}

void UGASC_HealthComponent::HealthViewModelInstantiated_Implementation(UGASC_UVM_Health* InstantiatedViewModel)
{
}

The most important thing we are doing here is in GASC_HealthComponent::InitializeViewModel() where we construct the view model object and add it to our Global View Model Collection. Here is what this would look like in Blueprints:

Although the NPC character does not use a Global Viewmodel Collection Creation Type, our player does, and so we make sure we register it here. We then use a delegate to pass this Viewmodel back into the health component to use in Blueprints, but also in a separate delegate that the base NPC class will use when constructing its Health Bar:

Remember that the Context Name must be the same name given under the Global Viewmodel Identifier parameter of the Viewmodel inside of the UI! (VM_Player_Health)

We then use replicated variables for CurrentHealth and MaxHealth to replicate and update our View Model by invoking HealthViewModel->SetCurrentHealth and HealthViewModel->SetMaxHealth. Remember that our Field Notifies will ensure that the GetHealthPercentage() and GetHealthAsDisplay() functions are called and our UI property bindings will reflect the correct data!

Let’s add the Health Component to our base character class:

GASCourseCharacter.h

	/**
	 * @brief The CharacterHealthComponent variable represents the component responsible for handling the health functionality of the character.
	 *
	 * This variable is decorated with UPROPERTY to ensure replication and provide read-only access. It is also marked as EditAnywhere and BlueprintReadOnly, allowing it to be edited in the editor and accessed from blueprints. It falls under the StatusEffects category.
	 * The meta flag AllowPrivateAccess is set to true, allowing private access to this component.
	 */
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = HealthComponent, meta = (AllowPrivateAccess = "true"))
	class UGASC_HealthComponent* CharacterHealthComponent;

GASCourseCharacter.cpp

CharacterHealthComponent = CreateDefaultSubobject<UGASC_HealthComponent>(TEXT(“CharacterHealthComponent”));

We then make sure inside of both our Player and base NPC character Blueprints we override the Character Health Component with the Blueprint class:

Also make sure to update the public variables with the relevant data for the Context Class, Context Name, and Health Attribute Set:

What we need to do now is to create a Blueprint that inherits from this Health Component class so that we can then listen for Attribute Changes and update our View Model!

Note: I tried for so long to make this Health Component entirely C++ but could not for the life of me get things to replicate correctly on all instances, for all characters (both Player and NPC). I had to resort to a hybrid approach of C++ and Blueprints. If anyone can suggest how to make this functional purely in C++, please comment within this thread :slight_smile:

The first thing we do is make a call to the Event Health View Model Instantiated which returns to us the Instantiated view model we create from UGASC_HealthComponent::InitializeViewModel(). We store this reference to a replicated variable called RepHealthViewModel and then use a Reliable, Run On Server, RPC event called Monitor Health Attributes.

We use a Run On Server RPC to listen for attribute changes using the task WaitForAttributeChanged on the server using the GetOwner() of the component as the Target Actor. We do this because the Server is responsible for updating these attributes and replicating them down to the clients.


We not only update the CurrentHealth and MaxHealth replicated variables, but also make calls to both OwningClient and Multicast Reliable events that also update our replicated RepHealthViewModel. We do this depending on whether the owning actor, casted to our GASCourseCharacter, is LocallyControlled or not; in cases for NPCs in our world, we need to NetMultiCast the data so that all clients & server get the data.

Here is the final results of updating our Health Attributes and view models!

ViewModels_Example

We are now able to update different types of UI with the same data through our Health View Model, and any updates or changes required can happen in the view model itself.


References:

Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

*General Combat Targeting

1 Like

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos

Today we are going to talk about the basic principles of combat targeting and how we can use these principles with the Targeting System plugin from Epic to create robust targeting behavior that fits the style of an Action RPG. I will showcase custom target sort classes and debugging tools that I created to give an example projectile ability targeting that feels good and better captures player intent in combat.


Classes to Research:

UTargetingTask
UTargetingPreset
UTargetingSubsystem

Additional Reading:


Principles of Combat Targeting

Targeting can differ depending on the type of combat your game will feature; melee, ranged, a mix of both, etc. However, I have found that there a few basic principles that, when used in different contexts or combinations, will give you targeting results that better match the intent of the player.

Distance to Target

This principle is pretty straight-forward; how close am I to a specific target? In most cases, we’d want to use the horizontal distance between the player and the target, ignoring the Z-axis (vertical) unless your combat system emphasizes verticality, and in the case, your targeting philosophy should reflect that. In my experience, this alone will not give you desired targeting results, but can influence the general targeting weight under certain circumstances. For example, if you have two targets that are relatively close to each other in screen-space but once is signficantly closer, most times it makes sense to choose the closer target. This makes sense for cases such as melee combat, but maybe for ranged combat, you’d want to weight further targets higher than closer ones.

Camera Angle to Target

This principle asks the question; which target am I actually looking at? We use a dot product result from two vectors (the camera’s forward vector & the unit direction between the player and the target) to determine how close the camera is looking at the target. We can then take this dot product float value and get the Acosine(degrees) to get the actual value in-degrees; if that matters for further calcuation or debugging text. Typically this camera angle is a strong indication of player intent and should be weighed appropriately in scoring calculations for picking a target.

Input Direction to Target

This principle asks an important question; in which direction is the player character moving, and how close is the angle between the targets’ relative position to the player and the input direction. In simpler terms, are we moving in the direction of the target? We again use a dot product result from two vectors (the normalized input direction and the unit direction between the player and the target) to determine how closely the input direction matches the direction to the target. This principle is very important when it comes to melee combat as its a strong indication of player intent if they are moving in the direction of a target that they would want to hit them with a sword.

Target Recency Bias

This principle poses the question; is this target more important due to recent actions from either the target itself, or the player? If you take melee combat as an example, when the player is button mashing and attacking a target, it can be assumed that they would want to continue to attack that same target. What you can do is store this target on each melee hit, or mark it with a Gameplay Tag, so that we can properly identify it when performing target selection, filtering, and scoring. What these biases can be, and how you mark them, are influenced by the type of game you are making, and the type of combat you want. However, it is an important aspect of targeting that should be considered.

Target Tag Query

This principle uses Unreal Engine terminology of ‘tagging’ to simply ask the question; is this target special in some way? This can be treated as a binary scoring measurement that can help make the difference in the heat of combat to ensure the best target is selected. When an enemy is about to attack, we can mark them with a Gameplay Tag, Enemy.IsAttacking, and we can use target tag queries to score them a bit higher than enemies not attacking; the result leading to players’ choosing this enemy if two or more targets have similarly matching scores from other principles.

Target Visibility

This principle asks whether or not the target is visible to the player. Depending on the combat scenario, the target being on the screen may or may not matter; or more importantly, how recent was the target on-screen? Do we still consider a target valid if they have left the screen less than 3 seconds ago? There are other factors that can influence how important this aspect is for your combat; does your combat camera try to keep all targets on-screen at all times? Does AI design dictate that enemies will position themselves on-screen, or are there archetypes that challenge players from off-screen?


Gameplay Targeting System Plugin

The Gameplay Targeting System is a free plugin from Epic Games that gives alot of flexibility on how to select, filter, and sort targets. The system supports synchronous and asynchronous execution of target requests that return handles that can then be used to query results. The targeting system is uses three concepts to provide targets; Selection, Filtering, and Sorting. It is also important to state that the order in which these tasks happen does matter, and its crucial to perform these tasks in the order of selection, filtering, and then sorting. Let’s start with selection:

Target Selection

When we say Target Selection, we don’t mean the explicit selection of a specific target; we mean how we query the environment to return a list of potential targets that we can then filter and sort to return either a sorted list based on scoring, or just return the best target. By default, the target system provides a few different selection tasks, but we will talk about the AOE (Area of Effect) task, as shown below:

The task provides alot of different options to allow customization of how this environment query can look like; the shape (box, cylinder, sphere, or capsule), the size, position offsets, collision profiles, object types, and more.

Target Filters

Once you have the initial list of targets, we want to narrow this list down to only the important sub-set of targets based on our different filtering rules. By default, the target system provides one filter task that allows us to filter based on Actor Class:

We can use the Required Actor Class and Ignored Actor Class Filters to ensure we are only getting actors we truly care about. In the above example, we require the actor to be of class GASCourseNPC_Base while also actively ignoring actor classes of GASCoursePlayerCharacter; effectively only taking NPCs into consideration for further scoring and sorts. We may also want to filter enemies that are dead, or off-screen.

Target Sorting

Now that we have our final list of targets, its time to iterate over them and score them based on whatever criteria we want; this is where the true power of this system comes into play. For my project, I felt that having a sort class that scored targets based on their relative positions on-screen compared to the players’ input direction, and so I created such a class:

We will be diving more deeply into this class later on, so I won’t do that here. Essentially, we can have unique sorting classes that can take into consideration different gameplay elements to influence how each target is scored and sorted. Things like distance, camera direction; basically all the principles we listed earlier can be their own sort/scoring mechanism to check against.

Target Preset

Now that we know how we want to select, filter, and score our targets, we need a way to execute these chronologically so that we can get a properly sorted list, or the highest scoring target. This is where Target Presets come in; it is the target system’s way of knowing how to properly execute the aforementioned steps, and in which order. It is also within these presets that we define exactly how each step will execute; customizing parameters and changing execution order.

It is important to repeat that, although execution order may not matter entirely, it is key to understand how the pipeline works when gathering, filtering, and sorting our targets. For example, if we don’t first perform a selection task, we won’t have a list of targets to either filter or sort, making the whole preset useless.

Target Request Handles

The target system uses request handles to provide us with results of the target preset execution because the system provides both synchronous and asynchronous execution of target requests, and so these handles are a safe way to store access to our data. In Blueprints, we can use the Targeting Subsystem and the Targeting Request Handle to access both targeting results and targeting result actors. Depending on whether or not there is sorting involved with the targeting preset, the returned array may or may not be sorted.

If you are using the Gameplay Ability System like I am, there is an explicit ability task called Perform Targeting Request, that can be used to handle our targeting needs.


Custom Target Sorting Solutions

Although the Targeting System plugin from Epic Games handles a lot of stuff out of the box when it comes to targeting, it does not do everything.

UGASCourse_TargetSortBase

For my project, I created a base sorting class (UGASCourse_TargetSortBase) that I use as a base in which all my other sorting tasks extend from. The primary reason for this is due to how I wanted to handle scoring for my targeting.

Scoring

There are a few things that I wanted to do uniquely for my targeting that the default plugin doesn’t support out of the box:

Score Curves: I wanted to be able to define through float curves how scoring can change based on the input value from the scoring evaluation. For example, for the camera angle scoring, I wanted to allow the score to be 1.0f between 0 and 10 degrees, and then fall-off until the max camera angle of 30.0f.

Using float curves gives me the flexibility to adjust how scoring can work rather than relying on a simple clamped mapping range of values.

Additive Scoring: When first using the Target System plugin with my custom sort/scoring classes, I noticed that the score for each target was not properly being added when being passed in each scoring execution. For example, a target score of 1.1f from the camera angle scoring class was not being passed into the consecutive distance scoring class. Without additive scoring that persists through each scoring execution, I was unable to determine the highest scoring target to use. As a result, I had to override the void UGASCourse_TargetSortBase::Execute(const FTargetingRequestHandle& TargetingHandle) const function of my UGASCourse_TargetSortBase class to achieve this:

void UGASCourse_TargetSortBase::Execute(const FTargetingRequestHandle& TargetingHandle) const
{
SetTaskAsyncState(TargetingHandle, ETargetingTaskAsyncState::Executing);

if (TargetingHandle.IsValid())
{
if (FTargetingDefaultResultsSet* ResultData = FTargetingDefaultResultsSet::Find(TargetingHandle))
{
const int32 NumTargets = ResultData->TargetResults.Num();

  	TArray<float> RawScores;
  	for (const FTargetingDefaultResultData& TargetResult : ResultData->TargetResults)
  	{
  		const float RawScore = GetScoreForTarget(TargetingHandle, TargetResult);
  		RawScores.Add(RawScore);
  	}

  	if(ensureMsgf(NumTargets == RawScores.Num(), TEXT("The cached raw scores should be the same size as the number of targets!")))
  	{
  		// Adding the normalized scores to each target result.
  		for (int32 TargetIterator = 0; TargetIterator < NumTargets; ++TargetIterator)
  		{
  			FTargetingDefaultResultData& TargetResult = ResultData->TargetResults[TargetIterator];
  			TargetResult.Score += RawScores[TargetIterator];
  		}
  	}

  	// sort the set
  	ResultData->TargetResults.Sort([this](const FTargetingDefaultResultData& Lhs, const FTargetingDefaultResultData& Rhs)
  		{
  			return Lhs.Score < Rhs.Score;
  		});
  }

}

SetTaskAsyncState(TargetingHandle, ETargetingTaskAsyncState::Completed);

if(GASCourse_TargetingSystemCVars::bEnableSortTargetingDebug)
{
UE_LOG(LogTemp, Warning, TEXT(“Is Source Actor Moving? %s”), bIsSourceActorMoving ? TEXT(“TRUE”) : TEXT(“FALSE”));
UE_LOG(LogTemp, Warning, TEXT(“Final Score Multiplier = %f”), FinalScoreMultiplier);
}
}

Debugging

I wanted to emphasize the need for robust and clear debugging tools for each of my sorting classes, as it made developing and confirming their individual functionality a lot easier.

Using a combination of debug text, debug arrows, coloring, and debug boxes around each target I was able to make debugs that were clear. Each is controlled by a unique set of console variables to both enable debug drawing, and to disable the sorting functionality entirely, at runtime! You will see more when we break down each sorting class.


Here is the code of the UGASCourse_TargetSortBase class:

GASCourse_TargetSortBase.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

include “Tasks/TargetingSortTask_Base.h”
include “GASCourse_TargetSortBase.generated.h”

/**

  • Lifecycle functions for sorting targets based on various criteria.
    */

UCLASS(Blueprintable)
class GASCOURSE_API UGASCourse_TargetSortBase : public UTargetingSortTask_Base
{
GENERATED_BODY()

protected:

/** Lifecycle function called when the task first begins */
virtual void Init(const FTargetingRequestHandle& TargetingHandle) const override;

/** Evaluation function called by derived classes to process the targeting request */
virtual void Execute(const FTargetingRequestHandle& TargetingHandle) const override;

/** Called on every target to get a Score for sorting. This score will be added to the Score float in FTargetingDefaultResultData */
virtual float GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle, const FTargetingDefaultResultData& TargetData) const;

/**

  • @brief Mutable APawn pointer representing the source pawn for sorting targets.
  • The SourcePawn variable is a mutable pointer to an APawn object that is initialized to nullptr.
  • It is used to store the source pawn for sorting targets and can be modified.
    /
    UPROPERTY()
    mutable APawn
    SourcePawn = nullptr;

/**

  • @brief Mutable pointer to a Player Controller representing the source player controller.
  • The SourcePlayerController variable is a mutable pointer to an APlayerController object that is initialized to nullptr.
  • It is used to store the source player controller for sorting targets and can be modified.
    /
    UPROPERTY()
    mutable APlayerController
    SourcePlayerController = nullptr;

/**

  • Member variable to store the flag indicating whether the source actor is currently moving.
    */
    UPROPERTY()
    mutable bool bIsSourceActorMoving;

/**

  • Mutable variable to store the final score multiplier used in sorting targets
  • in GASCourse_TargetSortBase class.
    */
    UPROPERTY()
    mutable float FinalScoreMultiplier = 1.0f;

/**

  • Mutable variable to store the score for sorting targets in the GASCourse_TargetSortBase class.
    */
    UPROPERTY()
    mutable float Score = 0.0f;

public:

/**

  • Boolean flag indicating whether the target is affected by the source actor movement input.
  • If true, the target sorting may consider the source actor’s movement when calculating scores.
  • By default, it is set to false.
    */
    UPROPERTY(EditDefaultsOnly, Category = “GASCourse|Targeting|MovementInput”)
    bool bAffectedBySourceActorMovement = true;

/**

  • @brief Score multiplier applied when source movement input is NOT detected
  • Member variable representing the score multiplier applied when the source movement input is not detected.
  • This value is used in sorting targets in the GASCourse_TargetSortBase class.
    */
    UPROPERTY(EditDefaultsOnly, Category=“GASCourse|Targeting|Score”, meta=(ToolTip=“Score multiplier applied when source movement input is NOT detected”))
    float DefaultScoreMultiplier = 1.0f;

/**

  • Score multiplier applied when source movement input is detected.
  • This variable determines the multiplier to be applied to the score when movement input is detected
  • for the source actor. It affects the sorting of targets in GASCourse. The score can be multiplied
  • by this value when the source actor is moving.
    */
    UPROPERTY(EditDefaultsOnly, Category=“GASCourse|Targeting|Score”, meta=(ToolTip=“Score multiplier applied when source movement input is detected”))
    float ScoreMultiplierWhenMoving = 1.0f;

/**

  • Member variable to store a curve mapping float values to custom scores in GASCourse.
  • If null, score calculations will be handled by class functionality.
    /
    UPROPERTY(EditDefaultsOnly, Category=“GASCourse|Targeting|Score”, meta=(ToolTip=“Custom score mapping based on a float curve value. If null, score calculations will be handled by class functionality.”))
    UCurveFloat
    ScoreCurve = nullptr;

/** Called on every target to get a Score for sorting. This score will be added to the Score float in FTargetingDefaultResultData */
UFUNCTION(BlueprintImplementableEvent, DisplayName=GetScoreForTarget, Category=Targeting)
float BP_GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle, const FTargetingDefaultResultData& TargetData) const;

};

GASCourse_TargetSortBase.cpp

// Fill out your copyright notice in the Description page of Project Settings.

include “Game/Systems/Targeting/Sort/GASCourse_TargetSortBase.h”

namespace GASCourse_TargetingSystemCVars
{
static bool bEnableSortTargetingDebug = false;
FAutoConsoleVariableRef CvarEnableSortingTargetingDebugging(
TEXT(“GASCourseDebug.Targeting.EnableDebug.Sort.Base”),
bEnableSortTargetingDebug,
TEXT(“Enable on-screen debugging for base sort targeting. (Enabled: true, Disabled: false)”));

}

void UGASCourse_TargetSortBase::Init(const FTargetingRequestHandle& TargetingHandle) const
{
Super::Init(TargetingHandle);

if (const FTargetingSourceContext* SourceContext = FTargetingSourceContext::Find(TargetingHandle))
{
if (APawn* InPawn = Cast(SourceContext->SourceActor))
{
SourcePawn = InPawn;
SourcePlayerController = Cast(SourcePawn->GetController());
bIsSourceActorMoving = SourcePawn->GetLastMovementInputVector().Length() > 0.0f;
}
}

FinalScoreMultiplier = bIsSourceActorMoving ? ScoreMultiplierWhenMoving : DefaultScoreMultiplier;
}

void UGASCourse_TargetSortBase::Execute(const FTargetingRequestHandle& TargetingHandle) const
{
SetTaskAsyncState(TargetingHandle, ETargetingTaskAsyncState::Executing);

if (TargetingHandle.IsValid())
{
if (FTargetingDefaultResultsSet* ResultData = FTargetingDefaultResultsSet::Find(TargetingHandle))
{
const int32 NumTargets = ResultData->TargetResults.Num();

  	TArray<float> RawScores;
  	for (const FTargetingDefaultResultData& TargetResult : ResultData->TargetResults)
  	{
  		const float RawScore = GetScoreForTarget(TargetingHandle, TargetResult);
  		RawScores.Add(RawScore);
  	}

  	if(ensureMsgf(NumTargets == RawScores.Num(), TEXT("The cached raw scores should be the same size as the number of targets!")))
  	{
  		// Adding the normalized scores to each target result.
  		for (int32 TargetIterator = 0; TargetIterator < NumTargets; ++TargetIterator)
  		{
  			FTargetingDefaultResultData& TargetResult = ResultData->TargetResults[TargetIterator];
  			TargetResult.Score += RawScores[TargetIterator];
  		}
  	}

  	// sort the set
  	ResultData->TargetResults.Sort([this](const FTargetingDefaultResultData& Lhs, const FTargetingDefaultResultData& Rhs)
  		{
  			return Lhs.Score < Rhs.Score;
  		});
  }

}

SetTaskAsyncState(TargetingHandle, ETargetingTaskAsyncState::Completed);

if(GASCourse_TargetingSystemCVars::bEnableSortTargetingDebug)
{
UE_LOG(LogTemp, Warning, TEXT(“Is Source Actor Moving? %s”), bIsSourceActorMoving ? TEXT(“TRUE”) : TEXT(“FALSE”));
UE_LOG(LogTemp, Warning, TEXT(“Final Score Multiplier = %f”), FinalScoreMultiplier);
}
}

float UGASCourse_TargetSortBase::GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle,
const FTargetingDefaultResultData& TargetData) const
{
return Super::GetScoreForTarget(TargetingHandle, TargetData);
}


UGASCourse_TargetSortInputAngle

Below you will find how I used player input angle to score targets:



InputTargeting_Example

GASCourse_TargetSortInputAngle.h

/ Fill out your copyright notice in the Description page of Project Settings.

#pragma once

include “Game/Systems/Targeting/Sort/GASCourse_TargetSortBase.h”
include “GASCourse_TargetSortInputAngle.generated.h”

/**

  • UGASCourse_TargetSortInputAngle class is a subclass of UGASCourse_TargetSortBase
  • that provides functionality for sorting targets based on input angle.
    */
    UCLASS()
    class GASCOURSE_API UGASCourse_TargetSortInputAngle : public UGASCourse_TargetSortBase
    {
    GENERATED_BODY()

protected:

/** Lifecycle function called when the task first begins */
virtual void Init(const FTargetingRequestHandle& TargetingHandle) const override;

/** Evaluation function called by derived classes to process the targeting request */
virtual void Execute(const FTargetingRequestHandle& TargetingHandle) const override;

/** Called on every target to get a Score for sorting. This score will be added to the Score float in FTargetingDefaultResultData */
virtual float GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle, const FTargetingDefaultResultData& TargetData) const;

/**

  • @brief Mutable FVector representing the input direction for sorting targets.
    */
    UPROPERTY()
    mutable FVector InputDirection;

};

GASCourse_TargetSortInputAngle.cpp

// Fill out your copyright notice in the Description page of Project Settings.

include “Game/Systems/Targeting/Sort/GASCourse_TargetSortInputAngle.h”
include “Kismet/KismetMathLibrary.h”
include “Engine/LocalPlayer.h”
include “Kismet/KismetTextLibrary.h”

namespace GASCourse_TargetingSystemCVars
{
static bool bEnableInputTargetingDebug = false;
FAutoConsoleVariableRef CvarEnableInputTargetingDebugging(
TEXT(“GASCourseDebug.Targeting.EnableDebug.Sort.InputAngle”),
bEnableInputTargetingDebug,
TEXT(“Enable on-screen debugging for input based targeting(Enabled: true, Disabled: false)”));

static bool bDisableInputSortTargeting = false;
FAutoConsoleVariableRef CvarDisableInputSortTargeting(
TEXT(“GASCourseDebug.Targeting.Disable.Sort.InputAngle”),
bDisableInputSortTargeting,
TEXT(“Disable input based sorting for targeting.(Enabled: true, Disabled: false)”));

};

void UGASCourse_TargetSortInputAngle::Init(const FTargetingRequestHandle& TargetingHandle) const
{
Super::Init(TargetingHandle);

if(SourcePawn)
{
FRotator Delta = UKismetMathLibrary::NormalizedDeltaRotator(SourcePawn->GetActorRotation(), SourcePawn->GetControlRotation());
Delta.Pitch = 0.0f;
InputDirection = Delta.UnrotateVector(SourcePawn->GetLastMovementInputVector());

#if !UE_BUILD_SHIPPING
if(GASCourse_TargetingSystemCVars::bEnableInputTargetingDebug)
{
FlushPersistentDebugLines(SourcePawn->GetWorld());
FlushDebugStrings(SourcePawn->GetWorld());
FVector LineEndInputDirection = SourcePawn->GetActorLocation() + InputDirection.GetSafeNormal() * 500.0f;
DrawDebugDirectionalArrow(SourcePawn->GetWorld(), SourcePawn->GetActorLocation(), LineEndInputDirection, 50.0f, FColor::Yellow, false, 5.0f, 0, 5.0f);
}
#endif
}

}

void UGASCourse_TargetSortInputAngle::Execute(const FTargetingRequestHandle& TargetingHandle) const
{
Super::Execute(TargetingHandle);
}

float UGASCourse_TargetSortInputAngle::GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle,
const FTargetingDefaultResultData& TargetData) const
{
if(GASCourse_TargetingSystemCVars::bDisableInputSortTargeting)
{
return 0.0f;
}

if(SourcePawn && SourcePlayerController)
{
if(TargetData.HitResult.HasValidHitObjectHandle())
{
if(AActor* HitActor = TargetData.HitResult.HitObjectHandle.FetchActor())
{
FRotator Delta = UKismetMathLibrary::NormalizedDeltaRotator(SourcePawn->GetActorRotation(), SourcePawn->GetControlRotation());
Delta.Pitch = 0.0f;
InputDirection = Delta.UnrotateVector(SourcePawn->GetLastMovementInputVector());

  		FVector UnitDirection = UKismetMathLibrary::GetDirectionUnitVector(SourcePawn->GetActorLocation(), HitActor->GetActorLocation());
  		float InputToTargetDotProduct = UKismetMathLibrary::Dot_VectorVector(InputDirection.GetSafeNormal(), UnitDirection);

  		Score = ScoreCurve ? ScoreCurve->GetFloatValue(InputToTargetDotProduct) : InputToTargetDotProduct;

#if !UE_BUILD_SHIPPING
if(GASCourse_TargetingSystemCVars::bEnableInputTargetingDebug)
{
UE_LOG(LogTemp, Warning, TEXT(“CURRENT SCORE: %f”), TargetData.Score);
UE_LOG(LogTemp, Warning, TEXT(“Input ANGLE = %f”), UKismetMathLibrary::DegAcos(InputToTargetDotProduct));
FString ScoreString = UKismetTextLibrary::Conv_DoubleToText(Score, HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString FinalScoreString = UKismetTextLibrary::Conv_DoubleToText(Score * FinalScoreMultiplier, HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString InputAngleString = UKismetTextLibrary::Conv_DoubleToText(UKismetMathLibrary::DegAcos(InputToTargetDotProduct), HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString EndResults = *FString::Printf(TEXT(“Score = %s | Final Score = %s | Angle = %s°” ), *ScoreString, *FinalScoreString, *InputAngleString);

  			FLinearColor ScoreColor = UKismetMathLibrary::LinearColorLerp(FLinearColor::Red, FLinearColor::Green, Score);

  			FVector ActorOrigin(0.0f);
  			FVector ActorExtents(0.0f);
  			HitActor->GetActorBounds(true, ActorOrigin, ActorExtents, false);
  			DrawDebugBox(HitActor->GetWorld(), HitActor->GetActorLocation(), ActorExtents, ScoreColor.ToFColor(true), false, 5.0f, 0, 2.0f);
  			DrawDebugString(HitActor->GetWorld(), FVector(-ActorExtents.X, -125.0f, ActorExtents.Z), EndResults, HitActor, ScoreColor.ToFColor(true), 5.0f, true, 1.0f);
  		
  			UE_LOG(LogTemp, Warning, TEXT("Score = %f /n Final Score = %f"), Score,  Score * FinalScoreMultiplier);
  			FVector LineEndUnitDirection = SourcePawn->GetActorLocation() + UnitDirection * 500.0f;
  			DrawDebugDirectionalArrow(SourcePawn->GetWorld(), SourcePawn->GetActorLocation(), LineEndUnitDirection, 50.0f, FColor::Red, false, 5.0f, 0, 5.0f);
  		}

#endif
return Score * FinalScoreMultiplier;
}
}
}
return 0.0f;
}


UGASCourse_TargetSortCameraAngle

Below you will find how I used player camera angle to score targets:



CameraTargeting_Example

GASCourse_TargetSortCameraAngle.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

include “Game/Systems/Targeting/Sort/GASCourse_TargetSortBase.h”
include “GASCourse_TargetSortCameraAngle.generated.h”

/**

  • Class representing a camera angle-based target sorting task within the GASCourse module.
  • This class inherits from UGASCourse_TargetSortBase.
    */
    UCLASS(Blueprintable)
    class GASCOURSE_API UGASCourse_TargetSortCameraAngle : public UGASCourse_TargetSortBase
    {
    GENERATED_BODY()

public:
/**

  • Represents the maximum angle that the camera can be tilted when targeting in the GASCourse module.
  • This variable is used to limit the angle of the camera when calculating scores based on camera angles.
    */
    UPROPERTY(EditDefaultsOnly, Category = “GASCourse|Targeting|Camera”)
    float MaxCameraAngle = 30.0f;

/** Lifecycle function called when the task first begins */
virtual void Init(const FTargetingRequestHandle& TargetingHandle) const override;

/** Evaluation function called by derived classes to process the targeting request */
virtual void Execute(const FTargetingRequestHandle& TargetingHandle) const override;

/** Called on every target to get a Score for sorting. This score will be added to the Score float in FTargetingDefaultResultData */
virtual float GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle, const FTargetingDefaultResultData& TargetData) const;

protected:
/**

  • Represents a mutable pointer to an instance of APlayerCameraManager.
  • This variable is used to manage the player camera and retrieve camera-related information
  • needed for targeting and scoring calculations within the GASCourse module.
    /
    UPROPERTY()
    mutable APlayerCameraManager
    CameraManager = nullptr;

};

GASCourse_TargetSortCameraAngle.cpp

// Fill out your copyright notice in the Description page of Project Settings.

include “Game/Systems/Targeting/Sort/GASCourse_TargetSortCameraAngle.h”
include “Kismet/KismetMathLibrary.h”
include “Kismet/KismetTextLibrary.h”

namespace GASCourse_TargetingSystemCVars
{
static bool bEnableCameraTargetingDebug = false;
FAutoConsoleVariableRef CvarEnableCameraTargetingDebugging(
TEXT(“GASCourseDebug.Targeting.EnableDebug.Sort.CameraAngle”),
bEnableCameraTargetingDebug,
TEXT(“Enable on-screen debugging for camera based targeting. (Enabled: true, Disabled: false)”));

static bool bDisableCameraSortTargeting = false;
FAutoConsoleVariableRef CvarDisableCameraSortTargeting(
TEXT(“GASCourseDebug.Targeting.Disable.Sort.CameraAngle”),
bDisableCameraSortTargeting,
TEXT(“Disable camera based sorting for targeting.(Enabled: true, Disabled: false)”));

}

void UGASCourse_TargetSortCameraAngle::Init(const FTargetingRequestHandle& TargetingHandle) const
{
Super::Init(TargetingHandle);

if(GASCourse_TargetingSystemCVars::bDisableCameraSortTargeting)
{
return;
}

if (const FTargetingSourceContext* SourceContext = FTargetingSourceContext::Find(TargetingHandle))
{
if (APawn* InstigatorPawn = Cast(SourceContext->SourceActor))
{
if(APlayerController* PlayerController = Cast(InstigatorPawn->GetController()))
{
CameraManager = PlayerController->PlayerCameraManager;
}

#if !UE_BUILD_SHIPPING
if(GASCourse_TargetingSystemCVars::bEnableCameraTargetingDebug)
{
FlushPersistentDebugLines(SourcePawn->GetWorld());
FlushDebugStrings(SourcePawn->GetWorld());
FRotator ControlRotation = SourcePlayerController->GetControlRotation();
ControlRotation.Pitch = 0.0f;
FVector LineEndControlRotation = SourcePawn->GetActorLocation() + UKismetMathLibrary::Conv_RotatorToVector(ControlRotation) * 500.0f;
DrawDebugDirectionalArrow(SourcePawn->GetWorld(), SourcePawn->GetActorLocation(), LineEndControlRotation, 50.0f, FColor::Green, false, 5.0f, 0, 5.0f);
}
#endif
}
}
}

void UGASCourse_TargetSortCameraAngle::Execute(const FTargetingRequestHandle& TargetingHandle) const
{
Super::Execute(TargetingHandle);
}

float UGASCourse_TargetSortCameraAngle::GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle,
const FTargetingDefaultResultData& TargetData) const
{
if((GASCourse_TargetingSystemCVars::bDisableCameraSortTargeting))
{
return 0.0f;
}

if(CameraManager)
{
if(TargetData.HitResult.HasValidHitObjectHandle())
{
if(AActor* HitActor = TargetData.HitResult.HitObjectHandle.FetchActor())
{
FVector CameraLocation = CameraManager->GetCameraLocation();
FVector CameraForwardVector = CameraManager->GetActorForwardVector();
FVector UnitDirection = UKismetMathLibrary::GetDirectionUnitVector(SourcePawn->GetActorLocation(), HitActor->GetActorLocation());

  		float CameraToTargetDotProduct = UKismetMathLibrary::Dot_VectorVector(CameraForwardVector, UnitDirection);
  		float DotToDegrees = UKismetMathLibrary::DegAcos(CameraToTargetDotProduct);
  		
  		Score = ScoreCurve ? ScoreCurve->GetFloatValue(DotToDegrees) : UKismetMathLibrary::MapRangeClamped(DotToDegrees, 0.0f, MaxCameraAngle, 1.0f, 0.0f);

#if !UE_BUILD_SHIPPING
if(GASCourse_TargetingSystemCVars::bEnableCameraTargetingDebug)
{
UE_LOG(LogTemp, Warning, TEXT(“CURRENT SCORE: %f”), TargetData.Score);
UE_LOG(LogTemp, Warning, TEXT(“Camera ANGLE = %f”), DotToDegrees);
FString ScoreString = UKismetTextLibrary::Conv_DoubleToText(Score, HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString FinalScoreString = UKismetTextLibrary::Conv_DoubleToText(Score * FinalScoreMultiplier, HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString CameraAngleString = UKismetTextLibrary::Conv_DoubleToText(DotToDegrees, HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString EndResults = *FString::Printf(TEXT(“Score = %s | Final Score = %s | Angle = %s°” ), *ScoreString, *FinalScoreString, *CameraAngleString);

  			FLinearColor ScoreColor = UKismetMathLibrary::LinearColorLerp(FLinearColor::Red, FLinearColor::Green, Score);

  			FVector ActorOrigin(0.0f);
  			FVector ActorExtents(0.0f);
  			HitActor->GetActorBounds(true, ActorOrigin, ActorExtents, false);
  			DrawDebugBox(HitActor->GetWorld(), HitActor->GetActorLocation(), ActorExtents, ScoreColor.ToFColor(true), false, 5.0f, 0, 2.0f);
  			DrawDebugString(HitActor->GetWorld(), FVector(-ActorExtents.X, -125.0f, ActorExtents.Z), EndResults, HitActor, ScoreColor.ToFColor(true), 5.0f, true, 1.0f);
  			FVector LineEndUnitDirection = SourcePawn->GetActorLocation() + UnitDirection * 500.0f;
  			DrawDebugDirectionalArrow(SourcePawn->GetWorld(), SourcePawn->GetActorLocation(), LineEndUnitDirection, 50.0f, FColor::Red, false, 5.0f, 0, 5.0f);
  		}

#endif
return Score * FinalScoreMultiplier;
}
}
}
return 0.0f;
}


UGASCourse_TargetSortDistance

Below you will find how I used distance to score targets:



DistanceTargeting_Example

GASCourse_TargetSortDistance.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

include “Game/Systems/Targeting/Sort/GASCourse_TargetSortBase.h”
include “GASCourse_TargetSortDistance.generated.h”

/**

  • @class UGASCourse_TargetSortDistance
  • @brief A class that represents a target sorting task based on distance in GASCourse.
  • UGASCourse_TargetSortDistance is derived from UGASCourse_TargetSortBase and provides functionality for sorting
  • targets based on distance.
    */
    UCLASS()
    class GASCOURSE_API UGASCourse_TargetSortDistance : public UGASCourse_TargetSortBase
    {
    GENERATED_BODY()

public:
/**

  • @brief Editable default maximum distance for targeting in GASCourse.
  • Represents the maximum distance in float units within which targets can be considered for sorting in the GASCourse_TargetSortDistance class.
  • The default value is set to 600.0f and can be edited in the GASCourse editor. This parameter is applicable when target sorting based on distance is enabled and not disabled.
  • @see UGASCourse_TargetSortDistance
    */
    UPROPERTY(EditDefaultsOnly, Category = “GASCourse|Targeting|Distance”)
    float MaxDistance = 600.0f;

protected:

/** Lifecycle function called when the task first begins */
virtual void Init(const FTargetingRequestHandle& TargetingHandle) const override;

/** Evaluation function called by derived classes to process the targeting request */
virtual void Execute(const FTargetingRequestHandle& TargetingHandle) const override;

/** Called on every target to get a Score for sorting. This score will be added to the Score float in FTargetingDefaultResultData */
virtual float GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle, const FTargetingDefaultResultData& TargetData) const;

};

GASCourse_TargetSortDistance.cpp

// Fill out your copyright notice in the Description page of Project Settings.

include “Game/Systems/Targeting/Sort/GASCourse_TargetSortDistance.h”
include “Kismet/KismetMathLibrary.h”
include “Kismet/KismetTextLibrary.h”

namespace GASCourse_TargetingSystemCVars
{
static bool bEnableDistanceTargetingDebug = false;
FAutoConsoleVariableRef CvarEnableDistanceTargetingDebugging(
TEXT(“GASCourseDebug.Targeting.EnableDebug.Sort.Distance”),
bEnableDistanceTargetingDebug,
TEXT(“Enable on-screen debugging for distance based targeting. (Enabled: true, Disabled: false)”));

static bool bDisableDistanceSortTargeting = false;
FAutoConsoleVariableRef CvarDisableDistanceSortTargeting(
TEXT(“GASCourseDebug.Targeting.Disable.Sort.Distance”),
bDisableDistanceSortTargeting,
TEXT(“Disable distance based sorting for targeting.(Enabled: true, Disabled: false)”));

}

void UGASCourse_TargetSortDistance::Init(const FTargetingRequestHandle& TargetingHandle) const
{
Super::Init(TargetingHandle);

if(GASCourse_TargetingSystemCVars::bDisableDistanceSortTargeting)
{
return;
}

#if !UE_BUILD_SHIPPING
if(GASCourse_TargetingSystemCVars::bEnableDistanceTargetingDebug)
{
FlushPersistentDebugLines(SourcePawn->GetWorld());
FlushDebugStrings(SourcePawn->GetWorld());
}
#endif

}

void UGASCourse_TargetSortDistance::Execute(const FTargetingRequestHandle& TargetingHandle) const
{
Super::Execute(TargetingHandle);
}

float UGASCourse_TargetSortDistance::GetScoreForTarget(const FTargetingRequestHandle& TargetingHandle,
const FTargetingDefaultResultData& TargetData) const
{
if(GASCourse_TargetingSystemCVars::bDisableDistanceSortTargeting)
{
return 0.0f;
}

if(SourcePawn)
{
if(TargetData.HitResult.HasValidHitObjectHandle())
{
if(AActor* HitActor = TargetData.HitResult.HitObjectHandle.FetchActor())
{
float DistanceToTarget = UKismetMathLibrary::Vector_Distance(SourcePawn->GetActorLocation(), HitActor->GetActorLocation());
Score = ScoreCurve ? ScoreCurve->GetFloatValue(DistanceToTarget) : UKismetMathLibrary::MapRangeClamped(DistanceToTarget, 0.0f, MaxDistance, 1.0f, 0.0f);

#if !UE_BUILD_SHIPPING
if(GASCourse_TargetingSystemCVars::bEnableDistanceTargetingDebug)
{
UE_LOG(LogTemp, Warning, TEXT(“CURRENT SCORE: %f”), TargetData.Score);
FVector UnitDirection = UKismetMathLibrary::GetDirectionUnitVector(SourcePawn->GetActorLocation(), HitActor->GetActorLocation());
UE_LOG(LogTemp, Warning, TEXT(“Distance to %s = %f”), *HitActor->GetName(), DistanceToTarget);
FString ScoreString = UKismetTextLibrary::Conv_DoubleToText(Score, HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString FinalScoreString = UKismetTextLibrary::Conv_DoubleToText(Score * FinalScoreMultiplier, HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString DistanceString = UKismetTextLibrary::Conv_DoubleToText(DistanceToTarget, HalfToEven, false, false, 1, 324,
1, 2).ToString();
FString EndResults = *FString::Printf(TEXT(“Score = %s | Final Score = %s | Distance = %s” ), *ScoreString, *FinalScoreString, *DistanceString);

  			FLinearColor ScoreColor = UKismetMathLibrary::LinearColorLerp(FLinearColor::Red, FLinearColor::Green, Score);

  			FVector ActorOrigin(0.0f);
  			FVector ActorExtents(0.0f);
  			HitActor->GetActorBounds(true, ActorOrigin, ActorExtents, false);
  			DrawDebugBox(HitActor->GetWorld(), HitActor->GetActorLocation(), ActorExtents, ScoreColor.ToFColor(true), false, 5.0f, 0, 2.0f);
  			DrawDebugString(HitActor->GetWorld(), FVector(-ActorExtents.X, -125.0f, ActorExtents.Z), EndResults, HitActor, ScoreColor.ToFColor(true), 5.0f, true, 1.0f);
  		
  			UE_LOG(LogTemp, Warning, TEXT("Score = %f /n Final Score = %f"), Score,  Score * FinalScoreMultiplier);
  			FVector LineEndUnitDirection = SourcePawn->GetActorLocation() + UnitDirection * DistanceToTarget;
  			FVector LineEndMaxDistanceDirection = SourcePawn->GetActorLocation() + UnitDirection * MaxDistance;
  			DrawDebugDirectionalArrow(SourcePawn->GetWorld(), SourcePawn->GetActorLocation(), LineEndUnitDirection, 50.0f, ScoreColor.ToFColor(true), false, 5.0f, 0, 5.0f);
  			DrawDebugDirectionalArrow(SourcePawn->GetWorld(), SourcePawn->GetActorLocation() + FVector(0.0f, 0.0f, 10.0f), LineEndMaxDistanceDirection, 50.0f, FColor::Yellow, false, 5.0f, 0, 5.0f);
  		}

#endif
return Score * FinalScoreMultiplier;
}
}
}
return 0.0f;
}


Custom Target Filter Solution | BP_TargetFilter_MatchesTagQuery

As mentioned previously, the use of Gameplay Tags can be powerful for labeling our targets when in specific states so that we can use these labels as markup for scoring them or filtering them. I decided to create this in Blueprints as the logic itself is not overly complicated and so I do not believe its worth any sort of performance gain to move it over to C++.


I use the BP_TargetFilter_MatchesTagQuery to check if a target is dead using the Status.Death tag. This allows me to ensure that dead enemies will not be targeted, even during the brief few seconds between dying and being removed from the world.


The last thing that I did was add the following code into the TargetingSubsystem.cpp to allow me to properly debug draw the final results of the executed target preset:

TargetingSubsystem.cpp

static bool bDebugDrawFinalTargetScoring = false;
FAutoConsoleVariableRef CvarDebugDrawFinalTargetinScoring(
TEXT(“GASCourseDebug.Targeting.EnableDebug.ShowFinalScores”),
bDebugDrawFinalTargetScoring,
TEXT(“Toggles if we want to visualize the final scoring of the targeting handle (Enabled: true, Disabled: false)”)

void UTargetingSubsystem::ExecuteTargetingRequestWithHandleInternal(FTargetingRequestHandle TargetingHandle, FTargetingRequestDelegate CompletionDelegate, FTargetingRequestDynamicDelegate CompletionDynamicDelegate)

if(TargetingSystemCVars::bDebugDrawFinalTargetScoring)
{
if (const FTargetingDefaultResultsSet* ResultSet = FTargetingDefaultResultsSet::Find(TargetingHandle))
{
if (const FTargetingSourceContext* SourceContext = FTargetingSourceContext::Find(TargetingHandle))
{
if (APawn* InPawn = Cast(SourceContext->SourceActor))
{
FlushPersistentDebugLines(InPawn->GetWorld());
FlushDebugStrings(InPawn->GetWorld());
}
}

  				float HighScore = 0.0f;
  				for(FTargetingDefaultResultData Result : ResultSet->TargetResults)
  				{
  					if(Result.Score >= HighScore)
  					{
  						HighScore = Result.Score;
  					}
  				}

  				for(FTargetingDefaultResultData Result : ResultSet->TargetResults)
  				{
  					AActor* HitActor = Result.HitResult.GetActor();
  					FVector ActorOrigin(0.0f);
  					FVector ActorExtents(0.0f);

  					FLinearColor ScoreColor = UKismetMathLibrary::LinearColorLerp(FLinearColor::Red, FLinearColor::Green, UKismetMathLibrary::SafeDivide(Result.Score, HighScore));
  					FString ScoreString = *FString::Printf(TEXT("Final Score = %f" ), Result.Score);
  					
  					HitActor->GetActorBounds(true, ActorOrigin, ActorExtents, false);
  					DrawDebugBox(HitActor->GetWorld(), HitActor->GetActorLocation(), ActorExtents, ScoreColor.ToFColor(true), false, 5.0f, 0, 2.0f);
  					DrawDebugString(HitActor->GetWorld(), FVector(-ActorExtents.X, -125.0f, ActorExtents.Z), ScoreString, HitActor, ScoreColor.ToFColor(true), 5.0f, true, 1.0f);
  				}
  			}
  		}


Other Concepts to Consider

We have written about how I use the targeting system for my project, but there are other considerations to take into account that will most likely come up in my project, but also be present within your combat:

Bone Selection

Assuming your targets are characters with skeletal meshes, you may want to consider different bones/sockets of the character in your targeting selection logic. If your game supports limb targeting, or you have large monsters, you could look into how to label these bones so that you can extend target presets to take them into account within a singular target.

Target Locking

A very common mechanic in action games is the use of target locking, or hard-locking, which means that the player explicitly wants to target a singular enemy and keep this target until the target dies, or the player manually removes the lock. How can we extend the target system to take into account target locking? How can we use target presets to query the right target for the player to lock onto? How do we handle target switching while in target lock mode? Can players switch between targets using the thumb-stick, or other input?


References:


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

Input Movement Interruption

5 Likes

Hello again everyone! Welcome to another entry in this on-going blog/development thread for my GAS Course Project. For more context of this project, please see the very first post in this thread, and as usual, you can find my socials here:

Twitch : Twitch
Discord : UE5 - GAS Course Project - JevinsCherries
Tiktok : Devin Sherry (@jevinscherriesgamedev) | TikTok
Youtube : https://youtube.com/@jevinscherries?si=GKt32rDN6z2wn5xV
Twitter : https://twitter.com/jevincherries
Instagram : Jevins Cherries Gamedev (@jevinscherriesgamedev) • Instagram photos and videos


Today we are going to talk about what I call a custom Locomotion Interruption system that is responsible for allowing players to blend out of animations early if they are using movement input; WASD or the Left Thumbstick. For many games, such as action games, allowing this can make the game feel more responsive and in control.

After


Classes to Research:

UAnimInstance: Unreal Engine C++ Fundamentals – UAnimInstance – Jolly Monster Studio

Additional Reading:


I will be explaining how to implement this simple system both with Blueprints and with C++; in both implementations, we rely on a custom animation notify state that will be explained later in this post. For now, all we need to know is that this notify state is responsible for adding & removing gameplay tags at the start & end of the notify state that allows us to label when we want movement interrupt to be possible.

Blueprint Implementation Breakdown

In both implementations, we handle everything inside of our player controller class.

To start, we use the async ability task

Wait Gameplay Tag Count Changed on Actor

from the OnPossess event so that we can use the possessed pawn as the actor to listen from. The gameplay tag we are listening for isStatus.Movement.CanInterrupt; and we are relying on the fact that a tag count of 0 is false, and a tag count > than 0 is true which we store into a boolean variable, bAllowMovementInterrupt.

We use the result of the Tag Count as a conversion to boolean to control whether or not we allow for movement interruption.

Then we register our Enhanced Input action IA_Move so that we can listen for when our movement input is triggered. This single Input Action is used for both keyboard (WASD) and gamepad (Left Thumbstick); while any of these inputs are pressed, triggered will be, well, triggered.

We first check if our bAllowMovementInterrupt is true before we start anything because we only want to allow interruption if this tag is present on the possessed pawn character. From there, we do safety validity checks on our character variable we stored from the OnPossess event, as well as the Anim Instance object from the characters’ mesh. The last check we do is to see if we have the DefaultSlot active. DefaultSlot is the default anim slot name used, but in the case of my project, this also represents full body animations; your anim slots might be different depending on how you set them up. The key here is that we only want to be able to interrupt full body animations; possible also lower body animations, again depending on how you set them up.

Once we pass all of our checks, we can do one final valid check for the Current Active Montage before attempting to evoke the Montage Stop with Blend Out, while also passing in the blend our arguements from the active montage. By doing so, we get a blend out when stopping the montage that can be changed on a per montage basis, depending on what we want.

The reason why this works is because in our Gameplay Ability, when we use the Play Montage and Wait for Event task, we have early exit/end ability paths for when the montage is either cancelled, interrupted, or ends normally. Without this, the ability wouldn’t end correctly!


C++ Implementation Breakdown

The C++ implementation is almost identical to the Blueprint implementation, but with additional code to help with replication of the system. Let’s start with how we declare the Status.Movement.CanInterrupt tag inside of our native gameplay tags class:

GASCourseNativeGameplayTags.h

UE_DECLARE_GAMEPLAY_TAG_EXTERN(Status_CanMoveInterrupt);

GASCourseNativeGameplayTags.cpp

UE_DEFINE_GAMEPLAY_TAG(Status_CanMoveInterrupt, "Status.Movement.CanInterrupt")

Next, inside of the player controller header file, we first create the delegate callback function to use when the Status.Movement.CanInterrupt tag count is changed, RegisterCanMoveInterruptTagCountChanged. We also create a function CanMoveInterrupt that will be responsible for check the current active montage and blending it out. Lastly, we create a boolean variable, bCanMoveInterrupt, that is used as our gating mechanism and an Input Action variable that we set inside of Blueprint to let us know which action to listen to.

GASCoursePlayerController.h

* @brief Callback method triggered when the count of a gameplay tag changes.
*
* This method is called whenever the count associated with a gameplay tag is updated. It provides information about the gameplay tag and the new count value.
*
* @param Tag The gameplay tag that has been updated.
* @param NewCount The new count value associated with the gameplay tag.
*/
void RegisterCanMoveInterruptTagCountChanged(const FGameplayTag Tag, int32 NewCount);

UFUNCTION()
void CanMoveInterrupt();

UPROPERTY(Replicated, BlueprintReadOnly, Transient)
bool bCanMoveInterrupt = false;

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
UInputAction* MovementInputAction = nullptr;


GASCoursePlayerController.cpp

First, we add the bCanMoveInterrupt variable to our replicated variable properties list.

void AGASCoursePlayerController::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	
	DOREPLIFETIME(AGASCoursePlayerController, HitResultUnderMouseCursor);
	DOREPLIFETIME(AGASCoursePlayerController, MouseDirectionDeprojectedToWorld);
	DOREPLIFETIME(AGASCoursePlayerController, MousePositionDeprojectedToWorld);
	DOREPLIFETIME(AGASCoursePlayerController, CameraRotation);
	DOREPLIFETIME(AGASCoursePlayerController, bUsingGamepad);
	DOREPLIFETIME(AGASCoursePlayerController, bCanMoveInterrupt);
}

Then, when we register our input component, we bind the MovementInputAction trigger event to callback to our CanMoveInterrupt function.

void AGASCoursePlayerController::SetupInputComponent()
{
	Super::SetupInputComponent();

	// Set up action bindings
	if (UGASCourseEnhancedInputComponent* EnhancedInputComponent = CastChecked<UGASCourseEnhancedInputComponent>(InputComponent))
	{
		check(EnhancedInputComponent);

		EnhancedInputComponent->BindAction(MovementInputAction, ETriggerEvent::Triggered, this, &ThisClass::CanMoveInterrupt);
	}
}

When OnPossess occurs, we register the RegisterCanMoveInterruptTagCountChanged event using our call-back delegate function RegisterCanMoveInterruptTagCountChanged.

void AGASCoursePlayerController::OnPossess(APawn* InPawn)
{
	Super::OnPossess(InPawn);

	AGASCoursePlayerState* PS = GetPlayerState<AGASCoursePlayerState>();
	if (PS)
	{
		// Init ASC with PS (Owner) and our new Pawn (AvatarActor)
		PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, InPawn);
	}

	if(UGASCourseAbilitySystemComponent* ASC = GetGASCourseAbilitySystemComponent())
	{
		ASC->GenericGameplayEventCallbacks.FindOrAdd(Event_Gameplay_OnDamageDealt).AddUObject(this, &AGASCoursePlayerController::OnDamageDealtCallback);
		ASC->RegisterGameplayTagEvent(Status_CanMoveInterrupt, EGameplayTagEventType::AnyCountChange).AddUObject(this, &AGASCoursePlayerController::RegisterCanMoveInterruptTagCountChanged);
	}
}

Now for the most important part, when CanMoveInterrupt is called, its identical to the Blueprint implementation.

void AGASCoursePlayerController::CanMoveInterrupt()
{
	if(bCanMoveInterrupt)
	{
		if(AGASCoursePlayerCharacter* PlayerCharacter = Cast<AGASCoursePlayerCharacter>(GetPawn()))
		{
			UAnimInstance* AnimInstance = PlayerCharacter->GetMesh()->GetAnimInstance();
			check(AnimInstance);
			if(UAnimMontage* AnimMontage = AnimInstance->GetCurrentActiveMontage())
			{
				if(AnimInstance->IsSlotActive(FName("DefaultSlot")))
				{
					const FAlphaBlendArgs& BlendOutArgs = AnimMontage->GetBlendOutArgs();
					AnimInstance->Montage_StopWithBlendOut(BlendOutArgs, AnimMontage);
				}
			}
		}
	}
}

Gameplay Event Notify State

ANS_ApplyGameTagState

For the animation notify state itself, I stuck with a Blueprint only implementation for the sake of brevity and the need for C++ seemed unnecessary.

Received Notify Begin is responsible for getting the owner actor the mesh component and using that as the actor to Add Loose Gameplay Tags to. The Gameplay Tags container variable is exposed so that this notify state can be used to serve other purposes outside of the Locomotion Interruption system.

Received Notify End is responsible for removing the loose gameplay tags added on the notify state begin event. This ensures that the tags are managed correctly and we don’t accidentally have tags remain behind after the notify state, or animation, ends.

Finally, we add this notify state to the animation montage. The start position is at the minimum point in which we would want locomotion interruption to occur, and in most cases, should last for the entire remaining duration of the montage. This way, the player has a large window of opportunity to interrupt the montage with their movement input.


Here are the results:

Before

After


Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :slight_smile:


Next Blog Post Topic:

Input Buffering

2 Likes

Hey everyone! Just wanted to make a short update to a previous entry in regards to movement interruption. It turns out that both the Valley of the Ancients and Game Animation Sample Project have an even better solution than I proposed :smiley: Here is the breakdown:

It all starts with the enumerator, E_TraversalBlendOutCondition:

Force Blend Out: This option will automatically blend out of the montage once the notify state begins.

With Movement Input: This option will blend out of the montage when the LastInputVector of the owning character movement component is not nearly equal to zero. (This is the solution that I had made for my project!)

If Falling: This option will blend out of the montage when the character movement component detects that we are falling.

Here is what the Blueprint, BP_NotifyState_MontageBlendOut, looks like:


Now, all you have to do is add the BP_NotifyState_MontageBlendOut to your montages and select which type of interruption you want to use!

This solution is better than mine for a few different reasons:

  • It supports different types of interruptions.
  • Its built into the animation notify state itself, without the need to use gameplay tags or additional code.
  • It works in multiplayer by default! At least for my project.

I hope others will find the solution helpful and save time in the future! Thank you for taking the time to read this post, and I hope you were able to take something away from it. Please let me know if I got any information wrong, or explained something incorrectly! Also add any thoughts or questions or code review feedback so we can all learn together :smiley: