The Ancient Oath Technical Highlights

  1. Architected, designed, and implemented a scalable melee combat framework.
  2. Designed and implemented AI behaviours using State Tree.
  3. Integrated character animation with equippable weapons.
  4. Created and implemented character animations for the project.
  5. Implemented UI using (UMG).

These technical highlights represent some of the gameplay systems I developed for The Ancient Oath. While they do not cover every feature of the project, they demonstrate the gameplay architecture, system design, and implementation approach I took for The Ancient Oath demo using Unreal Engine 5.4. 

Overview
Responsibilities

Attacks

The combat system supports two attack types for each weapon:

  • Primary attacks: a three-hit combo.
  • Secondary attacks: a two-hit combo.

Each attack configured using FAttack structure stored in the weapon’s data asset. The structure contains the information required for an attack, including the attack montage, approach montages, and motion warping settings.

➕➕
FAttack.h
USTRUCT(BlueprintType)
struct FAttack
{
	GENERATED_BODY()

	/*
	* Attack Montage
	*/
	UPROPERTY(EditDefaultsOnly) TObjectPtr<UAnimMontage> Attack;

	/*
	 * Offset from the target location. For example, if set to 0, the warp location and target location will be the same.
	*/
	UPROPERTY(EditDefaultsOnly) float MotionWarpLocationOffset = 100.f;

	/*
	* Disables motion warping if the distance to the target is less than "MotionWarpLocationOffset".
	*/
	UPROPERTY(EditDefaultsOnly) bool bDisableWarpWithinOffset = true;

	/*
	* the distance for Motion warping trace.
	*/
	UPROPERTY(EditDefaultsOnly) float MotionWarpTraceDistance = 100.f;

	/*
	* The radius of the trace used for motion warping.
	* The default value works well in most cases.
	* 
	* Note: If you change the value, keep in mind that it is not recommended to use a large value.
	*/
	UPROPERTY(EditDefaultsOnly) float MotionWarpTraceRadius = 50.f;

	/*
	* Enables the use of approach montages when distance to the target is greater than "MinApproachDistance".
	*/
	UPROPERTY(EditDefaultsOnly) bool bEnableApproachMontages = true;

	/*
	* Minimum distance to the target required to trigger an approach montage.
	*/
	UPROPERTY(EditDefaultsOnly) float MinApproachDistance = 300.f;

	/*
	* Montage to play when approaching the target while in targeting mode.
	*/
	UPROPERTY(EditDefaultsOnly) TObjectPtr<UAnimMontage> TargetingApproachMontage;

	/*
	* Montage to play when approaching the target while in non-targeting mode.
	*/
	UPROPERTY(EditDefaultsOnly) TObjectPtr<UAnimMontage> NonTargetingApproachMontage;

};

Player Attacks

When the player presses the attack button, UCombatComponent::Attack() validates the attack, retrieves the current attack data from FAttack, acquires a target, and determines whether an approach montage is required before playing the attack montage.

➕➕
CombatComponent.cpp
void UCombatComponent::Attack(const TArray<FAttack>& Attacks, bool bForceAttack)
{
	if (!CanAttack(bForceAttack) || Attacks.IsEmpty())
	{
		return;
	}

	if (!Attacks.IsValidIndex(AttackCount)) 
	{
		AttackCount = 0; //Rests the attack Combo
	}
	check(Attacks.IsValidIndex(AttackCount));

	ActivateCombatStateTag(CombatStateTags::CombatState_Attacking);

	float MotionWarpTraceDistance  = Attacks[AttackCount].MotionWarpTraceDistance;
	float MotionWarpLocationOffset = Attacks[AttackCount].MotionWarpLocationOffset;
	float MotionWarpTraceRadius    = Attacks[AttackCount].MotionWarpTraceRadius;
	bool bDisableWarpWithinOffset  = Attacks[AttackCount].bDisableWarpWithinOffset;

	bool  bEnableApproachMontages = Attacks[AttackCount].bEnableApproachMontages;
	float MinApproachDistance     = Attacks[AttackCount].MinApproachDistance;

	UAnimMontage* TargetingApproachMontage    = Attacks[AttackCount].TargetingApproachMontage;
	UAnimMontage* NonTargetingApproachMontage = Attacks[AttackCount].NonTargetingApproachMontage;
	UAnimMontage* AttackToPlay				  = Attacks[AttackCount].Attack;

#if WITH_EDITOR
	if (bEnableTestAttack) 
	{
		AttackToPlay = TestAttack.Attack;
		if (!ensureMsgf(AttackToPlay, TEXT("TestAttack.Attack is nullptr")))
		{
			return; 
		}

		MotionWarpTraceDistance     = TestAttack.MotionWarpTraceDistance;
		MotionWarpLocationOffset	= TestAttack.MotionWarpLocationOffset;
		MotionWarpTraceRadius		= TestAttack.MotionWarpTraceRadius;
		bDisableWarpWithinOffset    = TestAttack.bDisableWarpWithinOffset;
		bEnableApproachMontages     = TestAttack.bEnableApproachMontages;
		MinApproachDistance         = TestAttack.MinApproachDistance;
		TargetingApproachMontage    = TestAttack.TargetingApproachMontage;
		NonTargetingApproachMontage = TestAttack.NonTargetingApproachMontage;
	}
#endif //  WITH_EDITOR

	ABaseCharacter* FoundTarget = nullptr;
	if (OwningCharacter->IsPlayerControlled())
	{
		FoundTarget = FindAttackTarget(MotionWarpTraceRadius, MotionWarpTraceDistance); 
	}
	else
	{
		FoundTarget = GetAttackTarget();
	}

	// handle Approaching the target 
	if (FoundTarget && bEnableApproachMontages)
	{
		FApproachAttackTargetInfo Info;

		Info.Target = FoundTarget; 

		Info.TargetingApproachMontage = TargetingApproachMontage; 
		Info.NonTargetingApproachMontage = NonTargetingApproachMontage; 
		Info.AttackMontage = AttackToPlay; 

		Info.MinApproachDistance = MinApproachDistance; 
		Info.MotionWarpLocationOffset = MotionWarpLocationOffset; 
		Info.bDisableWarpWithinOffset = bDisableWarpWithinOffset; 

		if (ApproachAttackTarget(Info))
		{
			return;
		}
	}

	if (AttackToPlay) 
	{
		PlayMontage(AttackToPlay, OwningCharacter->GetCharacterAnimInstance(), this, &UCombatComponent::AttackMontageBlendoutCallback);
		DrawToAttackTarget(FoundTarget, MotionWarpLocationOffset, bDisableWarpWithinOffset);
	}
}
Target Acquisition

FindAttackTarget() first performs a sphere trace from the character’s location in the direction of the camera’s forward vector using the current attack’s FAttack::MotionWarpTraceDistance and FAttack::MotionWarpTraceRadius settings.

If a valid hostile target was not found, it performs a second sphere trace with a larger radius centred on the player using UPlayerCombatComponent::SecondMotionWarpTraceRadius. The actors returned by the second trace are filtered to include only hostile characters, and the nearest hostile actor is selected as the attack target.

If the player is currently in target lock, the function also updates the locked target by calling SwitchFaceTarget(). Finally, the selected target is returned to UCombatComponent::Attack().

First Trace

Second Trace

➕➕
CombatComponent.cpp
ABaseCharacter* UCombatComponent::FindAttackTarget(float TraceShpereRadius, float MotionWarpTraceDistance)
{
	FVector TraceStart;
	FVector TraceEnd;
	CalculateTraceDirection(TraceShpereRadius, MotionWarpTraceDistance, TraceStart, TraceEnd);

	TArray<FHitResult> HitResults;
	SphereTraceMultiForPawns(TraceStart, TraceEnd, TraceShpereRadius, HitResults, bShowAttackRaduisDebug);

	for (const FHitResult& Hit : HitResults)
	{
		ABaseCharacter* HitActor = Cast<ABaseCharacter>(Hit.GetActor()); 

		if (IsCharacterHostile(Cast<ABaseCharacter>(HitActor))) 
		{
			return HitActor; 
		}
	}

	return nullptr;
}

UCombatComponent::FindAttackTarget() override in UPlayerCombatComponent

➕➕
PlayerCombatComponent.cpp
ABaseCharacter* UPlayerCombatComponent::FindAttackTarget(float TraceShpereRadius, float MotionWarpTraceDistance) 
{
	// Clear the current attack target. Otherwise, motion warping may continue warping towards the previous target even when no new valid hostile target is found.
	ClearCurrentAttackTarget();
	ABaseCharacter* FoundTarget = Super::FindAttackTarget(TraceShpereRadius, MotionWarpTraceDistance); 

	bool bDebugUsedSecondTrace = false; 
	if (!FoundTarget)
	{
		bDebugUsedSecondTrace = true; 

		const FVector Origin = PlayerCharacter->GetActorLocation(); 
		TArray<FHitResult> HitResults;

		SphereTraceMultiForPawns(Origin, Origin, SecondMotionWarptraceRadius, HitResults);


		TArray<AActor*> HitActors;
		HitActors.Reserve(10);  
		for (const FHitResult& Hit : HitResults) 
		{
			if (IsCharacterHostile(Cast<ABaseCharacter>(Hit.GetActor())))
			{
				HitActors.AddUnique(Hit.GetActor());
			}
		}

		float Distance;
		AActor* NearestTarget = UGameplayStatics::FindNearestActor(Origin, HitActors, Distance); 
		if (NearestTarget && NearestTarget->Implements<UTargetableActor>())
		{
			FoundTarget = Cast<ABaseCharacter>(NearestTarget);
		}
	}
	
	if (FoundTarget)
	{
		AssignAttackTarget(FoundTarget); 
		if (IsInTargetLock()) 
		{
			SwitchFaceTarget(GetAttackTarget(), SecondMotionWarpTargetSwitchCameraLag);
		}
	}

	if (bShowAttackRaduisDebug)
	{
		constexpr bool bPersistentLines = false;
		if (bDebugUsedSecondTrace)
		{
			Debugs::DrawSphere(GetWorld(), PlayerCharacter->GetActorLocation(), SecondMotionWarptraceRadius, bPersistentLines, FoundTarget ? FColor::Green : FColor::Red, 2.f, 15, 1);
		}
		
		if (FoundTarget)
		{
			const FQuat Quat(FRotator(0.f, 90.f, 0.f));
			Debugs::DrawCapsule(GetWorld(), FoundTarget->GetActorLocation(), 2.f,FColor::Red, bPersistentLines, 90.f, 36.f, Quat, 2.f);
		}
	}
	return FoundTarget;
}
Approach Attacks

If a target is found and the current attack has approach montages enabled (bEnableApproachMontages), UCombatComponent::Attack() calls UCombatComponent::ApproachAttackTarget().

This function checks whether the distance to the target is greater than FAttack::MinApproachDistance.

If so, it plays either TargetingApproachMontage or NonTargetingApproachMontage, depending on whether the player is in target lock state, when the approach montage finishes, the attack montage is played automatically.

In this case, UCombatComponent::Attack() returns immediately because the attack continues through the approach montage’s blend out callback.

If the target is within FAttack::MinApproachDistance, UCombatComponent::Attack() skips the approach montage and plays the attack montage immediately.

 

➕➕
CombatComponent.cpp
bool UCombatComponent::ApproachAttackTarget(const FApproachAttackTargetInfo& Info)
{
	checkf(Info.Target, TEXT("%s: 'Info.target' must be a valid pointer."), *GetNameSafe(OwningCharacter));
	checkf(Info.TargetingApproachMontage, TEXT("%s: 'Info.TargetingApproachMontage' must be a valid pointer."), *GetNameSafe(OwningCharacter));
	checkf(Info.NonTargetingApproachMontage, TEXT("%s: 'Info.NonTargetingApproachMontage' must be a valid pointer."), *GetNameSafe(OwningCharacter));
	checkf(Info.AttackMontage, TEXT("%s: 'Info.AttackMontage' must be a valid pointer."), *GetNameSafe(OwningCharacter)); 

	const float DistanceToTarget = FVector::Dist(Info.Target->GetActorLocation(), OwningCharacter->GetActorLocation());
	if (DistanceToTarget > Info.MinApproachDistance)
	{
		auto PlayAttackMontageOnBlendout = [this, Info](UAnimMontage*, bool)-> void 
			{
				PlayMontage(Info.AttackMontage, OwningCharacter->GetCharacterAnimInstance(), this, &UCombatComponent::AttackMontageBlendoutCallback);
				DrawToAttackTarget(Info.Target, Info.MotionWarpLocationOffset, Info.bDisableWarpWithinOffset);
			};

		UAnimMontage* ApproachMontage = IsInTargetLock() ? Info.TargetingApproachMontage : Info.NonTargetingApproachMontage; 

		PlayMontage(ApproachMontage, OwningCharacter->GetCharacterAnimInstance(), PlayAttackMontageOnBlendout);
		DrawToAttackTarget(Info.Target, Info.MotionWarpLocationOffset, Info.bDisableWarpWithinOffset);  
		return true;
	}
	return false;
}

Player Attacks Showcase

Note: Damage has been increased for this Showcase so each attack is a one hit kill, allowing the combat flow and target switching to be shown more clearly.

AI Attacks

The AI Attack system supports multiple attack strategies that allow enemies to coordinate their attacks.

The available attack strategies are:

  1. Solo Attack: A single AI character attacks the target.
  2. Cooperative Attack: Two AI characters coordinate their attacks against the target. One AI character acts as the attacker while the other provides support.
  3. Timed Attack Tier One: Forms a squad of up to three AI characters that coordinate their attacks and attack the target simultaneously.
  4. Timed Attack  Tier Two : Similar to Tier One, but the squad size is determined by the target’s configured maximum number of simultaneous attackers.

When an AI character wants to attack, it calls UCombatComponent::RequestAttack() on the target’s combat component, passing itself as the requesting attacker.

UCombatComponent::RequestAttack() first checks whether the target can be attacked. If not, the function returns false.

If the target is the player and the requesting AI character is off screen, the function performs an additional check by searching for another hostile AI that is currently visible to the player. If a visible hostile is found, that AI is granted permission to attack instead of the original requester, and the original request is rejected by returning false. This ensures that, whenever possible, the player sees the enemy that has been selected to attack.

If no suitable visible AI is found, the original off screen AI is granted permission to attack. Once permission is granted, the attacker is registered by calling UCombatComponent::AddAttacker(), and the function returns true.

➕➕
CombatComponent.cpp
bool UCombatComponent::RequestAttack(ABaseCharacter* Attacker)
{
	// cannot request attack if the target is doing executions.
	const bool bIsInExecution = GetCombatStateTagContainer().HasTag(CombatStateTags::CombatState_Execution); 
	if (bIsInExecution)
	{
		RemoveAllAttackers(); 
		return false;
	}

	if (!GetCurrentAttackers().IsEmpty() || MaxSimultaneousAttackers == 0) 
	{
		return false; 
	}
	
	if (OwningCharacter->IsPlayerControlled() && IsValid(Attacker) && !Attacker->WasRecentlyRendered(0.1f))
	{
		if (CombatAIDebug::ShowTacticalDebug)
		{
			Debugs::DrawCapsule(GetWorld(), Attacker->GetActorLocation(), CombatAIDebug::TacticalDebugDuration,FColor::Silver, false, 90.f, 36.f, FQuat(FRotator(0.f, 90.f, 0.f)), 2.f);
		}

		TArray<ABaseCharacter*> Hostiles;
		FindHostiles(Hostiles);
		for (ABaseCharacter* PotentialAttacker : Hostiles)
		{
			const bool bIsAttackerOnScreen = PotentialAttacker->WasRecentlyRendered(0.1f);
			if (!bIsAttackerOnScreen)
			{
				continue;
			}

			constexpr bool bIsAddedByRequest = true, bCanRequestSupport = true;
			AddAttacker(PotentialAttacker, bIsAddedByRequest, bCanRequestSupport);

			if (CombatAIDebug::ShowTacticalDebug)
			{
				Debugs::DrawCapsule(GetWorld(), PotentialAttacker->GetActorLocation(), CombatAIDebug::TacticalDebugDuration, FColor::Black, false, 92.f, 38.f, FQuat(FRotator(0.f, 90.f, 0.f)), 2.f);
			}
			return false; //The original attacker's request is denied because a better attacker was chosen instead.
		}
	}
	
	constexpr bool bIsAddedByRequest = true, bCanRequestSupport = true;
	AddAttacker(Attacker, bIsAddedByRequest, bCanRequestSupport);

	if (CombatAIDebug::ShowTacticalDebug)
	{
		Debugs::PrintMessageOnScreen(FString::Printf(TEXT("AttackerName: %s"), *GetNameSafe(Attacker)));
		Debugs::DrawCapsule(GetWorld(), Attacker->GetActorLocation(), CombatAIDebug::TacticalDebugDuration, FColor::Red); 
	}
	return true;
}

When an attacker is added to UCombatComponent::CurrentAttackers, the AI character is granted permission to attack by activating the Tactics_AttackPermitted tag.

If bCanRequestSupport is true, the decision is based on UCombatComponent::SupportRequestPercentage; otherwise, the AI performs a solo attack by activating the Tactics_SoloAttack tag.

➕➕
CombatComponent.cpp
void UCombatComponent::AddAttacker(ABaseCharacter* Attacker, bool bIsAddedByRequest /*false*/ , bool bCanRequestSupport /*false*/)
{
	GetWorld()->GetTimerManager().SetTimer(EvaluateCurrentAttackersTimerHandle, this, &UCombatComponent::EvaluateCurrentAttackersCallback, EvaluateCurrentAttackersTime, true);

	CurrentAttackers.AddUnique(Attacker);
	Attacker->GetCombatComponent()->ActivateTacticsTag(TacticsTags::Tactics_AttackPermitted);

	if (!bIsAddedByRequest)
	{
		return;
	}

	UCombatComponent* AttackerCombatComponent = Attacker->GetCombatComponent();
	float SoloAttackChance = FMath::FRand();

#if WITH_EDITOR
	// handles forced attack
	const bool bForcedAttackStrategyEnabled = AttackerCombatComponent->bUseForcedAttackStrategy == true;

	const bool bForcedSoloAttack = AttackerCombatComponent->ForcedAttackStrategy == EAttackStrategy::EAS_SoloAttack;
	const bool bForcedAttack = AttackerCombatComponent->ForcedAttackStrategy != EAttackStrategy::EAS_SoloAttack;

	if (bForcedAttackStrategyEnabled && bForcedSoloAttack)
	{
		SoloAttackChance = 2;
	}
	else if (bForcedAttackStrategyEnabled && bForcedAttack)
	{
		SoloAttackChance = -1;
	}

#endif

	//  handle requesting support 
	if (bCanRequestSupport && SoloAttackChance <= AttackerCombatComponent->SupportRequestPercentage)
	{
		AttackerCombatComponent->RequestSupport();
	}
	else
	{
		AttackerCombatComponent->ActivateTacticsTag(TacticsTags::Tactics_SoloAttack);
	}
}

If the AI character can request support, UCombatComponent::RequestSupport() is called.

The function first searches for nearby allied AI characters using UCombatComponent::FindAllies().

If no suitable allies are available, the AI falls back to a solo attack.

When allies are available, UCombatComponent::RequestSupport() selects a coordinated attack strategy using the configured UCombatComponent::StrategyProbabilities. A selection chance is generated and compared against the accumulated percentage of each strategy to determine which coordinated attack should be performed.

➕➕
CombatComponent.cpp
void UCombatComponent::RequestSupport()
{
	if (!GetAttackTarget())
	{
		return;
	}

	// if no Allies were found then it use soloAttack
	TArray<ABaseCharacter*> FoundAllies; 
	const bool bHasFoundAllies = FindAllies(FoundAllies);
	if (!bHasFoundAllies) 
	{
		ActivateTacticsTag(TacticsTags::Tactics_SoloAttack);
		return;
	}

	UCombatComponent* AttackTargetCombatComponent = GetAttackTarget()->GetCombatComponent();

	const int32 NumberOfCurrentAttackers = AttackTargetCombatComponent->GetCurrentAttackers().Num();
	int32 MaxSquadMembers = AttackTargetCombatComponent->MaxSimultaneousAttackers; 
	
	if (NumberOfCurrentAttackers > MaxSquadMembers)
	{
		return;
	}

	const TMap<EAttackStrategy, float> StrategyPercentages =
{
	{ EAttackStrategy::EAS_CoopAttack, StrategyProbabilities.CooperativeAttack },
	{ EAttackStrategy::EAS_TimedAttackTierOne, StrategyProbabilities.TimedAttackTierOne },
	{ EAttackStrategy::EAS_TimedAttackTierTwo, StrategyProbabilities.TimedAttackTierTwo }
};

	const float SelectionChance = FMath::FRand();
	float AccumulatedChance = 0.0f;
	EAttackStrategy Strategy = EAttackStrategy::EAS_NONE;

	for (const TPair<EAttackStrategy, float>& Percent : StrategyPercentages) 
	{
		AccumulatedChance += Percent.Value;
		if (SelectionChance <= AccumulatedChance) 
		{
			Strategy = Percent.Key;
			break;
		}
	}

#if WITH_EDITOR
	if (bUseForcedAttackStrategy)
	{
		if (ForcedAttackStrategy == EAttackStrategy::EAS_NONE)
		{
			Debugs::PrintMessageOnScreen(FString::Printf(TEXT("%s: ForcedAttackStrategy: NONE"), *GetNameSafe(OwningCharacter)));
			return;
		}
		else
		{
			Strategy = ForcedAttackStrategy;
		}
	}
#endif // WITH_EDITOR

	ActiveAttackStrategy = Strategy;  
	ActivateTacticsTag(TacticsTags::Tactics_SupportRequester); 

	switch (Strategy) 
	{
	case EAttackStrategy::EAS_CoopAttack:

		CoordinateCooperativeAttack();
		break;

	case EAttackStrategy::EAS_TimedAttackTierOne:

		MaxSquadMembers = 3;
		CoordinateTimedAttack(Strategy, MaxSquadMembers);
		break;

	case EAttackStrategy::EAS_TimedAttackTierTwo:

		CoordinateTimedAttack(Strategy, MaxSquadMembers);
		break;

	default:
		checkNoEntry();
		break;
	}
}

Cooperative AI Attack

UCombatComponent::CoordinateCooperativeAttack() organises a two-member cooperative attack against a shared target.

The function first forms a two-member attack squad by calling UCombatComponent::FormAttackSquad(). If a valid squad cannot be formed, the cooperative attack is aborted.

Before coordinating the attack, the function verifies that every squad member is armed (has a main weapon equipped). If any member is unarmed, the cooperative attack is cancelled.

The support requester falls back to a solo attack, while the remaining squad members exit the coordinated attack.

The function then identifies the support requester to determine the shared attack target.

To improve positioning, the target’s future location is predicted using PredictFutureLocation() rather than relying on its current position.

Each squad member’s distance to the predicted target location is then evaluated to determine its role:

  • Attacker – The squad member furthest from the predicted target location.
  • Supporter – The squad member closest to the predicted target location.

Using the equipped weapon’s cooperative attack settings, the function calculates the ideal starting position for each role relative to the predicted target location.

Finally, each squad member is assigned its cooperative attack role, destination, animation data, partner reference, and the tactical tags required to execute the coordinated attack by calling UCombatComponent::AssignCooperativeAttackRole().

➕➕
CombatComponent.cpp
void UCombatComponent::CoordinateCooperativeAttack()
{
	FormAttackSquad(EAttackStrategy::EAS_CoopAttack, 2); 

	if (AttackSquadMembers.IsEmpty())
	{
		Debugs::PrintMessageOnScreen(TEXT("AttackSquadMembers.IsEmpty()"));
		FinishAttack();
		return;
	}

	bool bWithoutWeapon = false;
	for (ABaseCharacter* SquadMember : AttackSquadMembers)
	{
		if (!SquadMember->GetCombatComponent()->GetEquippedWeapons().MainWeapon)
		{
			bWithoutWeapon = true;
			break;
		}
	}

	if (bWithoutWeapon) 
	{
		TArray<ABaseCharacter*> Squad = AttackSquadMembers;
		for (ABaseCharacter* SquadMember : Squad)
		{
			if (SquadMember->GetCombatComponent()->GetTacticsTagContainer().HasTagExact(TacticsTags::Tactics_SupportRequester))
			{
				SquadMember->GetCombatComponent()->ActivateTacticsTag(TacticsTags::Tactics_SoloAttack);
			}
			else
			{
				SquadMember->GetCombatComponent()->FinishAttack(); 

				if (CombatAIDebug::ShowTacticalDebug)
				{
					Debugs::DrawCapsule(GetWorld(), SquadMember->GetActorLocation(), CombatAIDebug::TacticalDebugDuration, FColor::Blue, false, 90.f, 36.f,  FQuat(FRotator(0.f, 90.f, 0.f)), 2.f);
				}
			}
		}
		return;
	}
		
	// Get the Attack target from the SupportRequester in AttackSquadMembers
	ABaseCharacter* TargetToAttack = nullptr;
	for (ABaseCharacter* Member : AttackSquadMembers) 
	{
		const bool bIsSupportRequester = Member->GetCombatComponent()->GetTacticsTagContainer().HasTagExact(TacticsTags::Tactics_SupportRequester);
		if (bIsSupportRequester)
		{
			TargetToAttack = Member->GetCombatComponent()->GetAttackTarget(); 
			break;
		}
	}

	if (!IsValid(TargetToAttack))
	{
		Debugs::PrintMessageOnScreen(TEXT("CoordinateTimedAttack: TargetToAttack == nullptr"));
		FinishAttack();
		return;
	}

	const FVector FutureTargetLocation = PredictFutureLocation(TargetToAttack, TargetLocationPredictionTime, PredictionTimeScalingFactor, CombatAIDebug::ShowTacticalDebug, CombatAIDebug::TacticalDebugDuration);

	UCombatComponent* ClosestCombatComponent = nullptr;
	UCombatComponent* FurtherCombatComponent = nullptr;
	 
	float ClosestDistance = TNumericLimits<float>::Max();  
	float FurtherDistance = -TNumericLimits<float>::Max(); 

	for (ABaseCharacter* Member : AttackSquadMembers) 
	{
		const float CurrentDistance = (Member->GetActorLocation() - FutureTargetLocation).Length();  

		UCombatComponent* CurrentCombatComponent = Member->GetCombatComponent(); 

		// Update closest component if current distance is less than closest distance 
		if (CurrentDistance < ClosestDistance)  
		{
			// Update the further component first if we already have a closest
			if (ClosestCombatComponent)  
			{
				FurtherDistance = ClosestDistance; 
				FurtherCombatComponent = ClosestCombatComponent; 
			}

			ClosestDistance = CurrentDistance;  
			ClosestCombatComponent = CurrentCombatComponent;  
		}
		// Update further component if current distance is greater than further distance or further component is nullptr
		else if (CurrentDistance > FurtherDistance || !FurtherCombatComponent)
		{
			FurtherDistance = CurrentDistance;
			FurtherCombatComponent = CurrentCombatComponent;
		}
		// rare case: if the closest and further are the same distance, but different members
		else if (CurrentDistance == ClosestDistance && CurrentCombatComponent != ClosestCombatComponent)
		{
			FurtherDistance = CurrentDistance;
			FurtherCombatComponent = CurrentCombatComponent;
		}
	}

	if (ClosestCombatComponent == nullptr || FurtherCombatComponent == nullptr)
	{
		FinishAttack();
		return;
	}

	// rename the varibles to better readiblity
	UCombatComponent* AttackerCombatComponent  = FurtherCombatComponent;
	UCombatComponent* SupporterCombatComponent = ClosestCombatComponent; 

	// get attacker Location and distances
	const FVector AttackerLocation = AttackerCombatComponent->GetOwningCharacter()->GetActorLocation(); 
	const float Attacker_RequiredDistance = AttackerCombatComponent->GetEquippedWeaponAnimations().CooperativeAttack.AttackInitiationDistance;  
	const float Attacker_CurrentDistance = (AttackerLocation - FutureTargetLocation).Length(); 

	// I might need to set montages for both and clear it later
	const FVector SupporterLocation = SupporterCombatComponent->GetOwningCharacter()->GetActorLocation();
	const float SuporterDistanceToAttacker = SupporterCombatComponent->GetEquippedWeaponAnimations().CooperativeAttack.SuporterDistanceToAttacker;
	const float Supporter_RequiredDistance = Attacker_RequiredDistance - SuporterDistanceToAttacker;
	const float Supporter_CurrentDistanceFromAttacker = (SupporterLocation - AttackerLocation).Length();


	const FVector Direction = (AttackerLocation - FutureTargetLocation).GetSafeNormal();
	
	//Attacker
	const FVector AdjustedDirection = Direction * Attacker_RequiredDistance; 
	const FVector AttakerCoopAttackStartLocation = FutureTargetLocation + AdjustedDirection;   
	AssignCooperativeAttackRole
		(
			TargetToAttack, AttackerCombatComponent, AttakerCoopAttackStartLocation,
			AttackerCombatComponent->GetEquippedWeaponAnimations().CooperativeAttack,
			SupporterCombatComponent->GetOwningCharacter(),
			TacticsTags::Tactics_CooperativeAttack_Attacker
		);

	// supporter
	const FVector SupporterAdjustedDirection = Direction * Supporter_RequiredDistance; 
	const FVector SupporterCoopAttackStartLocation = FutureTargetLocation + SupporterAdjustedDirection;
	AssignCooperativeAttackRole 
	(
		TargetToAttack, SupporterCombatComponent, SupporterCoopAttackStartLocation,  
		SupporterCombatComponent->GetEquippedWeaponAnimations().CooperativeAttack,
		AttackerCombatComponent->GetOwningCharacter(),
		TacticsTags::Tactics_CooperativeAttack_Supporter
	);


	if (CombatAIDebug::ShowTacticalDebug)
	{
		const FQuat Quat(FRotator(0.f, 90.f, 0.f));
		
		Debugs::DrawArrow(GetWorld(), AttackerLocation, AttakerCoopAttackStartLocation, CombatAIDebug::TacticalDebugDuration, FColor::Purple);
		Debugs::DrawSphere(GetWorld(), AttackerLocation, 10.f, false, FColor::Purple, CombatAIDebug::TacticalDebugDuration);
		Debugs::DrawCapsule(GetWorld(), AttackerCombatComponent->GetOwningCharacter()->GetActorLocation(), CombatAIDebug::TacticalDebugDuration, 
			FColor::Purple, false, 90.f, 36.f, Quat, 2.f);

		// Supporter
		Debugs::DrawArrow(GetWorld(), SupporterLocation, SupporterCoopAttackStartLocation, 3.f, FColor::Turquoise);
		Debugs::DrawSphere(GetWorld(), SupporterLocation, 10.f, false, FColor::Turquoise, 3.f);
		Debugs::DrawCapsule(GetWorld(), SupporterCombatComponent->GetOwningCharacter()->GetActorLocation(), CombatAIDebug::TacticalDebugDuration,
			FColor::Turquoise, false, 90.f, 36.f, Quat, 2.f);

		Debugs::DrawArrow(GetWorld(), AttakerCoopAttackStartLocation, SupporterCoopAttackStartLocation, CombatAIDebug::TacticalDebugDuration, FColor::Yellow);
	}
}

Cooperative AI Attack Showcase

Timed Attack

UCombatComponent:: CoordinateTimedAttack() organises a coordinated group attack so that all squad members arrive at the target and begin attacking at approximately the same time.

The function first forms an attack squad and predicts the target’s future location to account for movement during the attack.

Each squad member then calculates the estimated time required to reach the predicted target location based on its distance to the target and its running speed. The squad member with the longest travel time begins moving immediately, while every other squad member delays the start of its attack by the difference between its own travel time and the longest travel time.

This staggered start ensures that, despite travelling different distances, the squad reaches the target and begins attacking at approximately the same time.

➕➕
CombatComponent.cpp
void UCombatComponent::CoordinateTimedAttack(EAttackStrategy Strategy, int32 MaxSquadMembers) 
{
	if (GetAttackTarget() == nullptr)
	{
		FinishAttack();
		Debugs::PrintMessageOnScreen(TEXT("CoordinateTimedAttack: AttackTarget is nullptr"));
		return;
	}

	// first form the the squad 
	FormAttackSquad(Strategy, MaxSquadMembers); 

	const FVector FutureTargetLocation = PredictFutureLocation(GetAttackTarget(), TargetLocationPredictionTime, PredictionTimeScalingFactor, CombatAIDebug::ShowTacticalDebug, CombatAIDebug::TacticalDebugDuration);

	TMap<float, ABaseCharacter*> TimesToAttack; 
	TimesToAttack.Reserve(MaxSquadMembers);
	for (ABaseCharacter* SquadMemeber : GetAttackSquad())  
	{
		SquadMemeber->GetCombatComponent()->ActivateTacticsTag(TacticsTags::Tactics_TimedAttack); 

		const float Distance = FVector::Dist(SquadMemeber->GetActorLocation(), FutureTargetLocation);
		// Calculate the time to reach the future target location based on the running speed //it use runing because the character will run towards the target
		const float Time = Distance / SquadMemeber->GetRunSpeed();
		TimesToAttack.Add(Time, SquadMemeber);
	}

	// Find the largest time required for any squad member to reach the future target location
	float LargestTime = 0;
	for (const TPair<float, ABaseCharacter*>& Pair : TimesToAttack)
	{
		if (Pair.Key > LargestTime)
		{
			LargestTime = Pair.Key;
		}
	}

	// Set the time to attack for each squad member
	for (const TPair<float, ABaseCharacter*>& Pair : TimesToAttack)
	{
		float Time = LargestTime; 
		// For the member with the largest time, start the attack immediately
		if (LargestTime == Pair.Key)
		{
			Pair.Value->GetCombatComponent()->StartTimedAttackTimer(-1);
		}
		else if (LargestTime > Pair.Key)
		{
			Time = LargestTime - Pair.Key; 
			Pair.Value->GetCombatComponent()->StartTimedAttackTimer(Time);  
		}
	}
}

Timed Attack AI Attack Showcase

AI Strafing Behaviour

A StateTree task calculates a valid strafing location around an attack target for AI-controlled characters.

The goal is to produce natural-looking movement while ensuring the selected location remains within the desired range and is reachable through the navigation system.

Each execution finds a new strafe position based on configurable movement parameters, allowing the AI to continuously reposition during combat.

The strafe system defines two configurable radii around the attack target:

  • Inner Radius
  • Outer Radius

These two values create a combat ring, so during strafing, the AI character will always remain inside this ring.

If the found location is:

  • outside the Outer Radius, it is projected back inside the ring.
  • inside the Inner Radius, it is projected back outside the exclusion zone.

This prevents the AI from drifting too far away from the target or getting too close to it.

Strafe Sequences

Each sequence contains a random number of strafes between:

  • MinimumStrafeCount
  • MaximumStrafeCount

For example: Target Strafe Count = 4

The AI moves around the target four times before trying to attack or re-entering the strafing state. When the sequence finishes, the task reports Succeeded, allowing the StateTree to transition to another behaviour. However, getting aggravated will cancel this, and the AI character will move to the attack state and attack the target, depending on the attack strategy.

When the AI character reaches the strafe location, it enters a waiting period before beginning another movement toward the new destination.

The waiting time is randomly selected between configurable minimum and maximum values.

This pause allows the AI to briefly hold its position instead of continuously circling the target, producing more believable combat pacing.

Movement Selection

Each new strafe randomly selects one of four movement patterns.

  • Toward Target
  • Away From Target
  • Left Of Target
  • Right Of Target

The selected direction determines how the destination is generated.

Navigation Validation

A mathematically valid location is not always reachable therefore, after finding a strafe location, the task queries Unreal’s Navigation System to verify that a valid path exists.

If the location does not have a valid path or is outside the navigation mesh, a navigation raycast is performed from the AI character’s location to the strafe location to locate the best location the AI can move to. Next, the system adjusts the point back toward the AI, producing a reachable location while preserving the original movement intent.

This allows the AI to continue strafing naturally even when combat takes place near walls, obstacles, or navigation boundaries.

➕➕
STTask_FindStrafeLocation.h
/*
* The direction on the target side not the owning character
*/
UENUM(BlueprintType)
enum class EMovementDirections : uint8
{
	TowardTarget     UMETA(DisplayName = "TowardTarget"),
	AwayFromTarget   UMETA(DisplayName = "AwayFromTarget"),
	LeftOfTarget     UMETA(DisplayName = "LeftOfTarget"),
	RightOfTarget    UMETA(DisplayName = "RightOfTarget"),

	Max UMETA(Hidden)
};


USTRUCT()
struct FFindStrafeLocationInstanceData
{
	GENERATED_BODY()

	/*
	* Reference to the owning character of type AEnemy.
	*/
	UPROPERTY(EditAnywhere, Category = Context)
	TObjectPtr<AEnemy> OwnerCharacter = nullptr; 

	UPROPERTY(EditAnywhere, Category = Context)
	TObjectPtr <AEnemyAIController> OwnerAIController = nullptr;
	/*
	* Inner radius parameter defining the area around the target that is excluded from strafe locations.
	*/
	UPROPERTY(EditAnywhere, Category = Parameter, meta = (Units = "cm"))
	float InnerRadius = 0.f; 

	/*
	* Outer radius parameter within which the strafe location will be determined.
	*/
	UPROPERTY(EditAnywhere, Category = Parameter, meta = (Units = "cm"))
	float OuterRadius = 0.f;

	/*
	* Maximum angle parameter used for LeftOfTarget and RightOfTarget calculations.
	*/
	UPROPERTY(EditAnywhere, Category = Parameter, meta = (Units = "deg"))
	float MaximumAngle = 0.0f;

	/*
	* Minimum angle parameter used for LeftOfTarget and RightOfTarget calculations.
	*/
	UPROPERTY(EditAnywhere, Category = Parameter, meta = (Units = "deg"))
	float MinimumAngle = 0.0f;

	/*
	 * Maximum movement distance for moving TowardTarget and AwayFromTarget.
	 * Note: MaximumMovementDistance should not be significantly larger than OuterRadius or InnerRadius.
	 */
	UPROPERTY(EditAnywhere, Category = Parameter, meta = (Units = "cm"))
	float MaximumMovementDistance = 300.f; 

	/*
	* Minimum movement distance parameter.
	*/
	UPROPERTY(EditAnywhere, Category = Parameter, meta = (Units = "cm"))
	float MinimumMovementDistance = 100.f;

	/*
	* Reference to the target actor around which to strafe.
	*/
	UPROPERTY(EditAnywhere, Category = Input)
	TObjectPtr<AActor> AttackTarget; 

	/*
	* Output vector for the calculated strafe location.
	*/
	UPROPERTY(EditAnywhere, Category = Output) 
	FVector StrafeLocation{0.f, 0.f, 0.f };

	/*
	* Maximum number of strafes.
	*/
	UPROPERTY(EditAnywhere, Category = Parameter)
	int32 MaximumStrafeCount = 5;

	/*
	* Minimum number of strafes.
	*/
	UPROPERTY(EditAnywhere, Category = Parameter)
	int32 MinimumStrafeCount = 2;

	/*
	* Number of remaining strafes.
	*/
	UPROPERTY(VisibleAnywhere, Category = Output)
	int32 RemainingStrafe = 0;

	
	UPROPERTY(VisibleAnywhere, Category = Output)
	int32 TargetStrafeCount = 0;


	UPROPERTY(VisibleAnywhere, Category = Output) 
	bool bHasRemainingStrafes = false;
};



USTRUCT(Category = Combat, meta = (DisplayName = "FindStrafeLocation"))
struct THEANCIENTOATH_API FSTTask_FindStrafeLocation : public FStateTreeAIActionTaskBase
{
	GENERATED_BODY()
public:
	
	using FInstanceDataType = FFindStrafeLocationInstanceData;

	FSTTask_FindStrafeLocation() = default;

	virtual EStateTreeRunStatus EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition) const;
	virtual const UStruct* GetInstanceDataType() const override { return FInstanceDataType::StaticStruct(); }

private:

	EMovementDirections GetRandomMovementDirection() const;

#if WITH_EDITORONLY_DATA
	/*
	* Enables the use of a forced direction.ShowDebugDuration
	* Note: For debugging purposes only.
	*/
	UPROPERTY(EditAnywhere, meta = (InlineEditConditionToggle)) 
	bool bForceDirection = false;

	/*
	* Specifies the forced direction for character movement.
	* This will override random movement direction.
	* Only editable if bForceDirection is true.
	* Note: For debugging purposes only.
	*/
	UPROPERTY(EditAnywhere, Category = "Debug", meta = (EditCondition = "bForceDirection")) 
	EMovementDirections ForcedDirection = EMovementDirections::TowardTarget;

	/*
	* Enables the use of a forced angle.
	* Note: For debugging purposes only.
	*/
	UPROPERTY(EditAnywhere, meta = (InlineEditConditionToggle))
	bool bForceAngle = false;

	/*
	* Specifies the forced angle for character movement.
	* This will override random movement between set angles.
	* Only editable if bForceAngle is true.
	* Note: For debugging purposes only.
	*/
	UPROPERTY(EditAnywhere, Category = "Debug", meta = (EditCondition = "bForceAngle", Units = "deg"))
	float ForcedAngle = 0.0f;

	/*
	* Enables the use of a forced maximum movement distance.
	* Note: For debugging purposes only.
	*/
	UPROPERTY(EditAnywhere, meta = (InlineEditConditionToggle))
	bool bForceMaxMovementDistance = false;

	/*
	* Specifies the forced maximum distance for character movement from its current location.
	* Only editable if bForceMaxMovementDistance is true.
	* Note: For debugging purposes only.
	*/
	UPROPERTY(EditAnywhere, Category = "Debug", meta = (EditCondition = "bForceMaxMovementDistance", Units = "cm"))
	float ForcedMaxMovementDistance = 0.0f;


	UPROPERTY(meta = (InlineEditConditionToggle))  
	bool bEnableCustomStrafe = false;
	/*
	*  Note: if set to 0 strafing will be disabled 
	*/
	UPROPERTY(EditAnywhere, Category = "Debug", meta = (EditCondition = "bEnableCustomStrafe")) int32 CustomStrafeCount = 0;


	void DebugLocation(UWorld* World, const FVector& LineStart, const FVector& LineEnd, const FColor DebugColour, float DebugTime, const FString& DebugText = TEXT("")) const;
	void DebugDirectionArrows(UWorld* World, const FVector& ArrowStart, const FVector& ArrowEnd, const FColor DebugColour, float DebugTime, const FString& DebugText = TEXT("")) const;
	
	FString DebugDirectionToString(EMovementDirections MovementDirection) const;

#endif
};
➕➕
STTask_FindStrafeLocation.cpp
namespace StrafeAIDebug
{
	bool ShowStrafeDebug = false;
	FAutoConsoleVariableRef CVarShowStrafeDebug(
		TEXT("CombatSystem.AI.Strafe.DebugSelectedActors"),
		ShowStrafeDebug,
		TEXT("Enable debug drawing for AI Strafing behaviour. Enable:true/1, Disable: false/0.\n")
		TEXT("Colour Meanings:\n")
		TEXT("Black   : Direction arrow from the Attack Target Location to the selected actor.\n")
		TEXT("Blue    : Original strafe location.\n")
		TEXT("Yellow  : New adjusted strafe location if the original strafe location is outside the outer radius or inside the inner radius.\n")
		TEXT("Red     : Location outside the navigation bounds volume.\n")
		TEXT("Green   : Hit location by the navigation raycast.\n")
		TEXT("Orange  : Adjusted hit location determined by the navigation raycast.\n")
		, ECVF_Default);

	float StrafeDebugDuration = 3.f;
	FAutoConsoleVariableRef CVarStrafeDebugDuration(TEXT("CombatSystem.AI.Strafe.DebugDuration"), StrafeDebugDuration,
		TEXT("Set the duration for Strafe debug display. Default value is 3 seconds."), ECVF_Default);
}

EStateTreeRunStatus FSTTask_FindStrafeLocation::EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition) const
{
	FInstanceDataType& InstanceData = Context.GetInstanceData(*this);

	if (!InstanceData.AttackTarget)
	{
		return EStateTreeRunStatus::Failed;
	}

	if (!InstanceData.bHasRemainingStrafes)
	{
		InstanceData.TargetStrafeCount = FMath::RandRange(InstanceData.MinimumStrafeCount, InstanceData.MaximumStrafeCount); 

#if WITH_EDITOR
		if (bEnableCustomStrafe)
		{
			InstanceData.TargetStrafeCount = CustomStrafeCount; 
		}
#endif 
		InstanceData.RemainingStrafe = InstanceData.TargetStrafeCount; 

		InstanceData.OwnerAIController->bStrafeActive = true;
		InstanceData.bHasRemainingStrafes = true;
	}

	InstanceData.RemainingStrafe--; // decrease the strafes 

	if (InstanceData.RemainingStrafe <= -1 && InstanceData.bHasRemainingStrafes) // using -1 because using 0 will skip the last strafe
	{
		InstanceData.bHasRemainingStrafes = false;
		InstanceData.OwnerAIController->bStrafeActive = false;
		
		return EStateTreeRunStatus::Succeeded;
	}

	const FVector AttackTargetLocation = InstanceData.AttackTarget->GetActorLocation();
	const FVector MyLocation = InstanceData.OwnerCharacter->GetActorLocation();

	const float DistanceToTarget = FVector::Distance(AttackTargetLocation, MyLocation); 
	const FVector DirectionToTarget = (MyLocation - AttackTargetLocation).GetSafeNormal();

	UWorld* World = InstanceData.OwnerCharacter->GetWorld();

#if WITH_EDITOR
	int32 DebugKey = 1000;
	const bool bEnableDebug = StrafeAIDebug::ShowStrafeDebug && InstanceData.OwnerAIController->GetPawn()->IsSelected();
	const float DebugDuration = StrafeAIDebug::StrafeDebugDuration;

	if (bEnableDebug)
	{
		Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: SelectedActor: %s"), *InstanceData.OwnerCharacter->GetName()), ++DebugKey, FColor::MakeRandomColor(), DebugDuration);
		Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: TargetStrafeCount: %d"), InstanceData.TargetStrafeCount), ++DebugKey, FColor::MakeRandomColor(), DebugDuration);
		Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: RemainingStrafe: %d"), InstanceData.RemainingStrafe),  ++DebugKey, FColor::MakeRandomColor(), DebugDuration);

		FVector Debug_DirectionLocation = DirectionToTarget; 
		Debug_DirectionLocation *= 200.f; // Scale for visibility by 200.f because it's actual value is normalised.
		const FVector Debug_Location = Debug_DirectionLocation + AttackTargetLocation;
		DebugDirectionArrows(World, AttackTargetLocation, Debug_Location, FColor::Black, DebugDuration, TEXT("Direction Arrow"));
	}
#endif  //WITH_EDITOR

	EMovementDirections MovementDirection = GetRandomMovementDirection();

	int32 MovementDistance = FMath::RandRange(InstanceData.MinimumMovementDistance, InstanceData.MaximumMovementDistance); 
	float Angle = FMath::RandRange(InstanceData.MinimumAngle, InstanceData.MaximumAngle); 
	
#if WITH_EDITOR
	if (bForceDirection)
	{
		MovementDirection = ForcedDirection;
	}

	if (bForceAngle)
	{
		Angle = ForcedAngle; 
	}

	if (bForceMaxMovementDistance)
	{
		MovementDistance = ForcedMaxMovementDistance; 
	}

	if (bEnableDebug)
	{
		if ((MovementDirection == EMovementDirections::TowardTarget) || (MovementDirection == EMovementDirections::AwayFromTarget))
		{
			Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: MovementDistance: %d"), MovementDistance), ++DebugKey, FColor::MakeRandomColor(), DebugDuration);
		}
		
		if ((MovementDirection == EMovementDirections::LeftOfTarget) || (MovementDirection == EMovementDirections::RightOfTarget)) 
		{
			Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: Angle: %f"), Angle), ++DebugKey, FColor::MakeRandomColor(), DebugDuration);
		}

		Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: Movement Direction: %s"), *DebugDirectionToString(MovementDirection)), ++DebugKey, FColor::MakeRandomColor(), DebugDuration); 
	}
#endif  //WITH_EDITOR

	FVector StrafeLocation(0.f, 0.f, 0.f);
	FVector StrafeDirection(0.f, 0.f, 0.f);

	switch (MovementDirection)
	{
	case EMovementDirections::TowardTarget:
		
		StrafeDirection = -DirectionToTarget;
		StrafeDirection *= MovementDistance;
		StrafeLocation = StrafeDirection + MyLocation;
		break;

	case EMovementDirections::AwayFromTarget:
		
		StrafeDirection = DirectionToTarget;
		StrafeDirection *= MovementDistance;
		StrafeLocation = StrafeDirection + MyLocation;
		break;

	case EMovementDirections::LeftOfTarget:

		StrafeDirection = DirectionToTarget.RotateAngleAxis(-Angle, FVector::UpVector);
		StrafeDirection *= DistanceToTarget;
		StrafeLocation = StrafeDirection + AttackTargetLocation;
		break;

	case EMovementDirections::RightOfTarget:

		StrafeDirection = DirectionToTarget.RotateAngleAxis(Angle, FVector::UpVector);
		StrafeDirection *= DistanceToTarget;
		StrafeLocation = StrafeDirection + AttackTargetLocation;
		break;
	}

#if WITH_EDITOR
	if (bEnableDebug) 
	{
		DebugLocation(World, AttackTargetLocation, StrafeLocation, FColor::Blue, DebugDuration, TEXT("Original Strafe Location"));
	}
#endif // WITH_EDITOR

	/*
	* Clamp the strafe location to remain within the outer radius.
	*/
	float StrafeLocationDistanceFromTarget = FVector::Dist(StrafeLocation, AttackTargetLocation);
	if (StrafeLocationDistanceFromTarget >= InstanceData.OuterRadius) 
	{
		if (MovementDirection == EMovementDirections::TowardTarget)  
		{
			StrafeDirection *= -1.f;
		}
		constexpr float RadiusPadding = 30.f;
		StrafeDirection = StrafeDirection.GetSafeNormal() * (InstanceData.OuterRadius - RadiusPadding);
		StrafeLocation = AttackTargetLocation + StrafeDirection;

#if WITH_EDITOR
		if (bEnableDebug) 
		{
			
			Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: Point is out of OuterRadius")), ++DebugKey, FColor::MakeRandomColor(), DebugDuration); 
			DebugLocation(World, AttackTargetLocation, StrafeLocation, FColor::Yellow, DebugDuration, TEXT("AdjustedLocation")); 
		} 
#endif // WITH_EDITOR
	}

	/*
	* Clamp the strafe location to remain within the inner radius.
	*/
	StrafeLocationDistanceFromTarget = FVector::Dist(StrafeLocation, AttackTargetLocation);// it needs recalculation in case 'StrafeLocation' changes

	if (StrafeLocationDistanceFromTarget <= InstanceData.InnerRadius) 
	{
		if (MovementDirection == EMovementDirections::TowardTarget)
		{
			StrafeDirection *= -1; // negate if the MovementDirection is forward because the facing direction is from target to my location
		}
		constexpr float RadiusPadding = 30.f;
		StrafeDirection = StrafeDirection.GetSafeNormal() * (InstanceData.InnerRadius + RadiusPadding);
		StrafeLocation = AttackTargetLocation + StrafeDirection;

#if WITH_EDITOR
		if (bEnableDebug) 
		{
			Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: Point is inside InnerRadius")), ++DebugKey, FColor::MakeRandomColor(), DebugDuration); 
			DebugLocation(World, AttackTargetLocation, StrafeLocation, FColor::Yellow, DebugDuration, TEXT("AdjustedLocation"));
		}
#endif //WITH_EDITOR
	}

	UNavigationSystemV1* NavigationSystem = UNavigationSystemV1::GetNavigationSystem(World);
	if (NavigationSystem == nullptr)
	{
		Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: NavigationSystem is invalid")));
		return EStateTreeRunStatus::Failed; 
	}
	
	double Cost = 0; 
	ENavigationQueryResult::Type NavigationQueryResult = NavigationSystem->GetPathCost(MyLocation, StrafeLocation, Cost); 
	
	if (NavigationQueryResult != ENavigationQueryResult::Success)
	{
		FVector HitLocation; 
		NavigationSystem->NavigationRaycast(InstanceData.OwnerCharacter, MyLocation, StrafeLocation, HitLocation);  
		
#if WITH_EDITOR
		if (bEnableDebug)
		{
			Debugs::PrintMessageOnScreen(FString::Printf(TEXT("Strafe: Location is out Navigation")), ++DebugKey, FColor::MakeRandomColor(), DebugDuration);
			DebugLocation(World, AttackTargetLocation, StrafeLocation, FColor::Red, DebugDuration, TEXT("Location is out Navigation")); 
			DebugLocation(World, AttackTargetLocation, HitLocation, FColor::Green, DebugDuration, TEXT("Hit Location by Navigation Raycast")); 
		}
#endif // WITH_EDITOR

		const FVector DirectionToMyLocation = (MyLocation - HitLocation).GetSafeNormal();

		constexpr float DistanceOffset = 50.f;
		const FVector AdjustedDirection = DirectionToMyLocation * DistanceOffset;
		StrafeLocation = HitLocation + AdjustedDirection;  

#if WITH_EDITOR
		if (bEnableDebug) 
		{
			DebugLocation(World, AttackTargetLocation, StrafeLocation, FColor::Orange, DebugDuration, TEXT("Adjusted Hit Location"));  
		}
#endif // WITH_EDITOR
	}
	// Assign the calculated strafe location to the instance data StrafeLocation
	InstanceData.StrafeLocation = StrafeLocation;

#if WITH_EDITOR
	if (bEnableDebug)
	{
		// debug radius
		FVector CylinderEnd = AttackTargetLocation; 
		CylinderEnd.Z += 20.f;
		Debugs::DrawCylinder(World, AttackTargetLocation, CylinderEnd, InstanceData.OuterRadius, DebugDuration, FColor::Red, 30);
		Debugs::DrawCylinder(World, AttackTargetLocation, CylinderEnd, InstanceData.InnerRadius, DebugDuration, FColor::Green, 30);

		// debug forward and right vector for AttackTarget 
		const FVector AttackTargetForwardVector = InstanceData.AttackTarget->GetActorForwardVector();
		const FVector AttackTargetRightVector   = InstanceData.AttackTarget->GetActorRightVector();

		FVector ForwardDirection = AttackTargetLocation + (AttackTargetForwardVector * 100.f); 
		FVector RightDirection   = AttackTargetLocation + (AttackTargetRightVector   * 100.f);  

		Debugs::DrawDebugStringAtLocation(World, TEXT("Attack Target Location"), AttackTargetLocation, FColor::Black, DebugDuration);
		DebugDirectionArrows(World, AttackTargetLocation, ForwardDirection, FColor::Red, DebugDuration, TEXT("Forward"));
		DebugDirectionArrows(World, AttackTargetLocation, RightDirection, FColor::Green, DebugDuration, TEXT("Right")); 
		
		// debug forward and right vector for OwnerCharacter 
		const FVector OwnerCharacterForwardVector = InstanceData.OwnerCharacter->GetActorForwardVector();  
		const FVector OwnerCharacterRightVector   = InstanceData.OwnerCharacter->GetActorRightVector(); 

		ForwardDirection = MyLocation + (OwnerCharacterForwardVector * 100.f);
		RightDirection   = MyLocation + (OwnerCharacterRightVector   * 100.f);

		Debugs::DrawDebugStringAtLocation(World, TEXT("Owner Character Location"), MyLocation, FColor::Black, DebugDuration);  
		DebugDirectionArrows(World, MyLocation, ForwardDirection, FColor::Red, DebugDuration, TEXT("Forward"));
		DebugDirectionArrows(World, MyLocation, RightDirection, FColor::Green, DebugDuration, TEXT("Right"));
	}
#endif //WITH_EDITOR
	return EStateTreeRunStatus::Running; 
}

EMovementDirections FSTTask_FindStrafeLocation::GetRandomMovementDirection() const
{
	// Get the number of enumeration values
	int32 NumDirections = static_cast<int32>(EMovementDirections::Max); 

	// Generate a random index within the range of the enum values
	int32 RandomIndex = FMath::RandRange(0, NumDirections - 1);  

	// Return the corresponding enum value
	return static_cast<EMovementDirections>(RandomIndex); 
}

#if WITH_EDITOR
void FSTTask_FindStrafeLocation::DebugLocation(UWorld* World, const FVector& LineStart, const FVector& LineEnd,const FColor DebugColour, float DebugTime, const FString& DebugText) const
{
	Debugs::DrawLine(World, LineStart, LineEnd, false, DebugTime, DebugColour, 1.f);
	Debugs::DrawSphere(World, LineEnd, 10.f, false, DebugColour, DebugTime);

	FVector DebugTextLocation = LineEnd;
	DebugTextLocation.Z += 15.f; 

	Debugs::DrawDebugStringAtLocation(World, DebugText, DebugTextLocation, DebugColour, DebugTime); 
}

void FSTTask_FindStrafeLocation::DebugDirectionArrows(UWorld* World, const FVector& ArrowStart, const FVector& ArrowEnd, const FColor DebugColour, float DebugTime, const FString& DebugText) const
{
	Debugs::DrawArrow(World, ArrowStart, ArrowEnd, DebugTime, DebugColour);
	Debugs::DrawDebugStringAtLocation(World, DebugText, ArrowEnd, DebugColour, DebugTime);
}

FString FSTTask_FindStrafeLocation::DebugDirectionToString(EMovementDirections MovementDirection) const
{
	FString Direction = TEXT("Direction Not Set");

	switch (MovementDirection)
	{
	case EMovementDirections::TowardTarget: 
		Direction = TEXT("TowardTarget");
		break;

	case EMovementDirections::AwayFromTarget: 
		Direction = TEXT("AwayFromTarget");
		break;

	case EMovementDirections::LeftOfTarget: 
		Direction = TEXT("LeftOfTarget");
		break;

	case EMovementDirections::RightOfTarget: 
		Direction = TEXT("RightOfTarget"); 
		break; 
	}

	return Direction; 
}
#endif  // WITH_EDITOR

AI strafe Showcase

Weapon System

The weapon system is a data-driven architecture that supports multiple weapon types, character skeletons, and dual-wielding combinations.

Each weapon stores its behaviour, animations, and configuration inside a Weapon Data Asset, allowing designers to create and modify weapons without changing C++ code.

The system currently supports four weapon classes:

  • Katana
  • Axe
  • Longsword
  • Polearm

Weapon data asset

When a weapon is equipped, it becomes the character’s main weapon and determines the animation set used by the combat system.

Rather than storing a single animation set, each Weapon Data Asset contains a map of

TMap<USkeleton*, FWeaponAnimations> this allows the same weapon to support multiple character skeletons.

The Ancient Oath demo contains characters that use different skeletons, each requiring its own animation set. Mapping animation data by USkeleton* allows a weapon to automatically select the correct animations for the character wielding it while sharing the same gameplay behaviour.

Dual Wielding

The weapon system supports dual wielding for any weapon type. However, in The Ancient Oath demo, dual wielding is currently implemented for Katanas and Axes.

Examples include:

  • Two Katanas
  • Two Axes
  • Katana (Main Hand) + Axe (Off Hand)
  • Axe (Main Hand) + Katana (Off Hand)

Each can have completely different attack animations, combos, and executions.

➕➕
Weapon.h
/**
* @brief Checks compatibility between provided weapons for dual wielding and populates the 'FWeaponAnimations' with dual animations.
*
* @param  1. Weapon_1                 :First weapon to check for Dual Wielding.
* @param  2. Weapon_2                 :Second weapon to check for Dual Wielding.
* @param  3. AnimationsSkeloton       :The skeletal mesh component of the weapon wielder.
* @param  4. Out_DualWeaponAnimations :Output parameter to store the dual weapon animations if dual wielding is possible.
* 
* @return Returns true if dual wielding is possible, false otherwise.
*/
static bool IsDualWieldingPossible(AWeapon* Weapon_1, AWeapon* Weapon_2, USkeletalMeshComponent* SkeletalMeshComponent, FWeaponAnimations& out_DualWeaponAnimations);

/* 
* @brief Checks compatibility between provided weapons for dual wielding.
*
* @param  1. Weapon_1 :First weapon to check for Dual Wielding.
* @param  2. Weapon_2 :Second weapon to check for Dual Wielding.
*
* @return Returns true if dual wielding is possible, false otherwise.
*/
static bool IsDualWieldingPossible(AWeapon* Weapon_1, AWeapon* Weapon_2);
➕➕
Weapon.cpp
bool AWeapon::Internal_IsDualWieldingPossible(AWeapon* PrimaryWeapon, AWeapon* SecondaryWeapon, USkeletalMeshComponent* SkeletalMeshComponent, FWeaponAnimations& out_DualWeaponAnimations)
{
	if (PrimaryWeapon == nullptr || SecondaryWeapon == nullptr)
	{
		return false;
	}

	const TArray<FCompatibleWeaponType>& CompatibleWeaponTypes = PrimaryWeapon->GetWeaponData()->GetCompatibleWeapons();

	if (CompatibleWeaponTypes.IsEmpty())
	{
		return false;
	}

	for (const FCompatibleWeaponType& WeaponType : CompatibleWeaponTypes)
	{
		const EWeaponType SecondaryWeaponType = SecondaryWeapon->GetWeaponData()->GetWeaponType();
		if (WeaponType.GetDualWeaponType() == SecondaryWeaponType)
		{
			if (SkeletalMeshComponent)
			{
				out_DualWeaponAnimations = WeaponType.GetDualWeaponAnimations(SkeletalMeshComponent);
			}
			// this is outside the if statment because we sometimes only want to check if weapons are Compatible for dual weilding
			return true;
		}
	}
	return false;
}

bool AWeapon::IsDualWieldingPossible(AWeapon* Weapon_1, AWeapon* Weapon_2, USkeletalMeshComponent* SkeletalMeshComponent, FWeaponAnimations& out_DualWeaponAnimations)
{
	if (IsValid(Weapon_1) && Weapon_1->CanBeDualWielded())
	{
		 
		return AWeapon::Internal_IsDualWieldingPossible(Weapon_1, Weapon_2, SkeletalMeshComponent, out_DualWeaponAnimations);
	}

	if (IsValid(Weapon_2) && Weapon_2->CanBeDualWielded())
	{
		
		return AWeapon::Internal_IsDualWieldingPossible(Weapon_2, Weapon_1, SkeletalMeshComponent, out_DualWeaponAnimations); 
	}

	return false;
}

bool AWeapon::IsDualWieldingPossible(AWeapon* Weapon_1, AWeapon* Weapon_2)
{
	FWeaponAnimations EmptyNotUsed;
	return AWeapon::IsDualWieldingPossible(Weapon_1, Weapon_2, nullptr, EmptyNotUsed);
}
×

Table of Contents