Gameplay Ability System Course Project - Development Blog

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

3 Likes