Jade Friend

Sigma-Phoenix Productions

Home
Games 3D art and Animation Photography
Blog

Unnamed Tank Game

Unreal 5.2 project primarily using C++, originally based off of Udemy's ToonTanks tutorial but is being developed further. So far i'm really enjoying this project as a way to increase my technical skill with C++ and familiarise myself with the unreal engine.

Weapon's Showcase

Fire Spreading

All objects in the game contain the capacity to be set on fire as it is based within the health component. Using a subclass of the DamageType class (UDamageType_Heat), i can identify incoming damage as heat damage and treat it differently, adding to a stored value that when it reaches its maximum will enable the fire spreading.

While on fire, objects will send heat-type damage to objects around them every frame for the duration of their burn-time, allowing the fire to spread to other objects through the same health component that allowed it to be set on fire.

void UHealthComponent::OnFire()
{


	//get all objects current object is touching
	//apply damage (heat)
	TArray result;

	AActor* owner = this->GetOwner();

	owner->GetOverlappingActors(result);


	UE_LOG(LogTemp, Warning, TEXT("Overlapping Objects: %i"), result.Num() );
	

	for (int i = 0; i < result.Num(); i++)
	{
		UE_LOG(LogTemp, Warning, TEXT("Applying heat to %s"), *result[i]->GetName());
		UGameplayStatics::ApplyDamage(result[i], burnDamage, owner->GetInstigatorController(), owner, UDamageType_Heat::StaticClass());
	}


}

void UHealthComponent::FireEnd()
{
	fireComponent->Complete();
	toonTanksGameMode->ActorDied(GetOwner());
}

void UHealthComponent::DamageTaken(
            AActor* DamagedActor, float Damage, const UDamageType* DamageType,
            class AController* Instigator, AActor* DamageCauser
            )
{
	if (Damage <= 0.f || bFireTick) return;
	
	if (Cast(DamageType))
	{
		currentHeat += Damage;

		float percentage = currentHeat / heatThreshold;
		
		percentage = FMath::Clamp(percentage, 0.f, 1.f);


		heatMaterialInstance->SetScalarParameterValue(FName(TEXT("fVisibility")), percentage);

		if (currentHeat >= heatThreshold && !bFireTick)
		{
			bFireTick = true;
			//start timer
			fireComponent = UGameplayStatics::SpawnEmitterAtLocation(
                this, fireEffect, GetOwner()->GetActorLocation(),
                GetOwner()->GetActorRotation()
            );

			FTimerHandle fireTimerHandle;
			FTimerDelegate fireTimerDelegate = FTimerDelegate::CreateUObject(this, &UHealthComponent::FireEnd);

			GetWorld()->GetTimerManager().SetTimer(fireTimerHandle, fireTimerDelegate, burnTime, false);

		}
		return;
	}

	currentHP -= Damage;
	if (currentHP <= 0.f && toonTanksGameMode)
	{
		toonTanksGameMode->ActorDied(DamagedActor);
	}
}

Laser System

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

	beamArray.Init({}, beamCount);
	beamTick = false;
}

// Called every frame
void UCPP_LaserSystem::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	CalculateBeam();
}

void UCPP_LaserSystem::BeamStart( USceneComponent* spawnObject)
{
	FRotator rotation = FRotator::ZeroRotator;
	FName attachName;

	beamSpawnObject = spawnObject;

	for (int i = 0; i < beamCount; i++)
	{
		beamArray[i] = UNiagaraFunctionLibrary::SpawnSystemAttached(
			laserEffect, spawnObject, attachName , 
			beamSpawnPosition, rotation, EAttachLocation::SnapToTarget, false,
			 true, ENCPoolMethod::None, true);
	}
	beamTick = true;
}

void UCPP_LaserSystem::BeamEnd()
{
	for (int i = 0; i < beamCount; i++)
	{
		beamArray[i]->Deactivate();

	}
	beamTick = false;
}

void UCPP_LaserSystem::CalculateBeam()
{
	if (!beamTick )
	{
		return;
	}
	beamSpawnPosition = beamSpawnObject->GetComponentLocation();
	beamSpawnVector = beamSpawnObject->GetForwardVector();

	bool bHit;
	FVector location = beamSpawnPosition;
	FVector normal = beamSpawnVector;

	FHitResult hitResult;
	//for beamCount
	for (int i = 0; i < beamCount; i++)
	{

		beamArray[i]->SetNiagaraVariableFloat("BeamSpawnProb", 1);
		beamArray[i]->SetNiagaraVariableVec3("BeamStart", location);

		bHit = GetWorld()->LineTraceSingleByChannel(
			hitResult, location, (normal * beamLength) + location,
			ECollisionChannel::ECC_Visibility);

		if (!bHit)
		{
			
			for (int j = i + 1; j < beamArray.Num(); j++)
			{
				beamArray[j]->SetNiagaraVariableFloat("BeamSpawnProb", 0);
			}
			beamArray[i]->SetNiagaraVariableVec3("BeamEnd", (normal * beamLength) + location);

			break;
		}
		else if (!hitResult.GetActor()->GetComponentByClass())
		{
			for (int j = i + 1; j < beamArray.Num(); j++)
			{
				beamArray[j]->SetNiagaraVariableFloat("BeamSpawnProb", 0);
			}
			location = hitResult.Location + (normal * 2.f);
			beamArray[i]->SetNiagaraVariableVec3("Normal", hitResult.ImpactNormal);
			beamArray[i]->SetNiagaraVariableVec3("BeamEnd", location);

			UGameplayStatics::ApplyDamage(
			hitResult.GetActor(), beamDamage,
			this->GetOwner()->GetInstigatorController(), this->GetOwner(), heatDamageType);

			break;
		}

		normal = FMath::GetReflectionVector(normal, hitResult.ImpactNormal);
		location = hitResult.Location + (normal * 2.f);
		beamArray[i]->SetNiagaraVariableVec3("Normal", hitResult.ImpactNormal);
		beamArray[i]->SetNiagaraVariableVec3("BeamEnd", location);

		UGameplayStatics::ApplyDamage(
			hitResult.GetActor(), beamDamage,
			this->GetOwner()->GetInstigatorController(), this->GetOwner(), heatDamageType);

	}
}

i adapted my laser script from this laser reflection tutorial which was designed for first-person gameplay and didnt contain an implimentation to select what reflects and apply damage. My laser script checks if the object it has hit contains a simple (mostly blank) component, and then it will process the next beam of the laser and repeat until it goes through the maximum beamcount initialised at the start of the level.

To apply damage, i take advantage of the health component and the ApplyDamage function in Gameplay Statics to apply a subclass of the UDamageType component that will be processed differently when it reaches the object it is hitting, setting the object on fire instead of applying direct damage to the object's HP.

Ammunition Types & Weapon Switching

For the other variations of ammunition that the player can use (Basic, Bounce and Piercing), they inherit from a single projectile that contains the base functions for explosions, knockback and the ProjectileMovementComponent. Thanks to this inheritence, the types of ammunition can be stored as Subclasses of the primary class and easily switched out in the firing mechanic which can spawn any subclass of the initial firing mechanic

Base Projectile

The base projectile contains the main function for producing explosion which applies radial damage and sweeps nearby physics objects to do a radial impulse for knockback damage. Its a relatively simple script but it contains everything i'll need for building on it with my other projectile types.

Originally this projectile called Destroy on itself when hitting an object, however as the bouncing projectile hits multiple times i moved the call to Destroy into individual inherited scripts so that i could control more about how each projectile interacts with the game. The main simple projectile mostly uses this projectile base with little difference other than the implimentation of the Destroy call at the end of the explosion trigger.


#include "ProjectileBase.h"
#include "Components/StaticMeshComponent.h"
#include "GameFramework/ProjectileMovementComponent.h"
#include "Particles/ParticleSystemComponent.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/DamageType.h"

// Sets default values
AProjectileBase::AProjectileBase()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = true;

	baseMesh = CreateDefaultSubobject(TEXT("Base Mesh"));
	RootComponent = baseMesh;

	projectileMovementComp = CreateDefaultSubobject(TEXT("ProjectileMovement"));

	particleTrail = CreateDefaultSubobject(TEXT("ParticleSystem"));
	particleTrail->SetupAttachment(RootComponent);

}

// Called when the game starts or when spawned
void AProjectileBase::BeginPlay()
{
	Super::BeginPlay();

	baseMesh->OnComponentHit.AddDynamic(this, &AProjectileBase::OnHit);
	
	UGameplayStatics::PlaySoundAtLocation(this, launchSound, GetActorLocation());
}

// Called every frame
void AProjectileBase::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

}

void AProjectileBase::TriggerExplosion(float dam, float damRadius, float knock, float knockRadius)
{
	TArray ignoredActors;

	auto projectileOwner = GetOwner();
	if (projectileOwner == nullptr) return;
	auto ownerInstigator = projectileOwner->GetInstigatorController();

	//vars used in both passes of explosion hits
	UWorld* worldContext = GetWorld();
	FVector actorLocation = GetActorLocation();


	auto damageTypeClass = UDamageType::StaticClass();

	//apply damage in a radius
	UGameplayStatics::ApplyRadialDamage(
		worldContext,
		dam,
		actorLocation,
		damRadius,
		damageTypeClass,
		ignoredActors,
		this,
		ownerInstigator,
		false,
		ECollisionChannel::ECC_Visibility);
	//sweep radius for actors
	TArray  hitResult;
	FCollisionShape sphereCol = FCollisionShape::MakeSphere(knockRadius);

	bool bSweepHit = GetWorld()->SweepMultiByChannel(
		hitResult,
		actorLocation,
		actorLocation + FVector(0.01f, 0.01f, 0.01f),
		FQuat::Identity,
		ECC_WorldStatic,
		sphereCol);

	//Visualises the explosion radii to dial in ranges for each projectile
	DrawDebugSphere(worldContext, actorLocation, damRadius, 40, FColor::Red, false, 2.0f);
	DrawDebugSphere(worldContext, actorLocation, knockRadius, 40, FColor::Orange, false, 2.0f);

	//for everything hit in the sweep, apply knockback from the position of the explosion
	if (bSweepHit)
	{
		for (auto& hit : hitResult)
		{
			UStaticMeshComponent* meshComp = Cast(hit.GetActor()->GetRootComponent());
			if (meshComp)
			{
				meshComp->AddRadialImpulse(
			actorLocation, knockRadius, knock, ERadialImpulseFalloff::RIF_Linear, true);
			}
		}
	}
	//sound and visual fx
	if (hitParticles)
	{
		UGameplayStatics::SpawnEmitterAtLocation(this, hitParticles, GetActorLocation(), GetActorRotation());
	}
	if (hitSound)
	{
		UGameplayStatics::PlaySoundAtLocation(this, hitSound, GetActorLocation());
	}
	if (hitCameraShakeClass)
	{
		GetWorld()->GetFirstPlayerController()->ClientPlayCameraShake(hitCameraShakeClass);
	}
}

void AProjectileBase::OnHit(
			UPrimitiveComponent* hitComp, AActor* otherActor,
			UPrimitiveComponent* otherComp, FVector normalImpulse,
			const FHitResult& hit)
{
	auto projectileOwner = GetOwner();

	if (otherActor && otherActor != this && otherActor != projectileOwner)
	{
		if (canExplode)
		{
			TriggerExplosion(projectileDamage, projectileDamageRadius, projectileKnockback, projectileKnockbackRadius);
		}
	}
}

Piercing Projectile

void ACPP_PiercingProjectile::Tick(float DeltaTime)
{
	if (!GetOwner())
	{
		SetOwner(Cast(UGameplayStatics::GetPlayerPawn(this, 0)));
		TriggerExplosion(originalDamage / 4.f, originalDamRadius / 5.f, originalKnockback / 2.f, originalKnockRadius / 5.f);
	}
}

void ACPP_PiercingProjectile::SetPierceCount(int newCount)
{
		pierceAmount = newCount;
}

void ACPP_PiercingProjectile::OnHit(
			UPrimitiveComponent* hitComp, AActor* otherActor,
			UPrimitiveComponent* otherComp, FVector normalImpulse, const FHitResult& hit)
{
	if (pierceAmount == 0)
	{
		projectileDamage = originalDamage;
		projectileKnockback = originalKnockback;
	}
	else
	{
		projectileDamage = 0.f;
		projectileKnockback = 0.f;
		projectileDamageRadius = 0.f;
		projectileKnockbackRadius = 0.f;
	}
	//Call the original OnHit function from the base projectile
	Super::OnHit(hitComp, otherActor, otherComp, normalImpulse, hit);

	if (pierceAmount > 0)
	{
		//calculate where it should spawn the next projectile
		FVector spawnLocation = CalculateObjectPenetration(otherActor);
		ACPP_PiercingProjectile* newProjectile = GetWorld()->SpawnActor(
			projectileClass,spawnLocation, this->GetActorRotation());
		//     spawn a new projectile with the same trajectory at the hit location
		pierceAmount--;
		newProjectile->SetPierceCount(pierceAmount);
	}
	Destroy();
}

//Calculates object penetration by casting a sweep back towards the projectile hit point to obtain an exit point
FVector ACPP_PiercingProjectile::CalculateObjectPenetration(AActor* otherActor)
{
	FVector actorLocation = this->GetActorLocation();

	//get start and end points for sweep
	FVector sweepStartLoc = actorLocation + (this->GetActorForwardVector() * traceLength);

	//sweep radius for actors
	TArray  hitResult;
	FCollisionShape sphereCol = FCollisionShape::MakeSphere(5.f);

	bool bSweepHit = GetWorld()->SweepMultiByChannel(
		hitResult,
		sweepStartLoc,
		actorLocation,
		FQuat::Identity,
		ECC_WorldStatic,
		sphereCol);

	if (bSweepHit)
	{
		for (auto& hit : hitResult)
		{
			if (hit.GetActor() == otherActor)
			{
				return (hit.Location + (this->GetActorForwardVector() * offset)) ;
			}
		}
	}
	return sweepStartLoc;
}

The piercing projectile, when it hits an object, will search for the other side of the object it hits and spawn and new version of the pierce projectile with less "pierce count". When the pierce count reaches zero (the pierce count lowering by one for each object it pierces) it will explode like the base projectile on contact but with less force and damage, however when the piercing count is above zero, the piercing projectile produces small explosions when it pierces an object as it blows through on the other side.

Bounce Projectile

Compared to the Laser system and the piercing projectile, the bouncing projectile doesnt use as much code and instead uses the physics behaviour given to it by the projectile movement component to make it bounce around the level. Instead my code focuses on controlling when it destroys and how it explodes when colliding with objects.

Put simply, when the velocity of the bouncing projectile goes below a certain amount, typically after 2-3 bounces, it disables the ability for it to explode until its velocity reaches zero, it performs a larger explosion that does more damage before disappearing from the level. Initially i wanted to keep the projectiles in the level so that tou could get them unstuck or keep them moving, but eventually i decided it would be better for the projectile to "Finish" with a larger explosion to make it more fun to get behind physics objects and send them flying from weird places.

#include "CPP_BounceProjectile.h"

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



}


// Called every frame
void ACPP_BounceProjectile::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	FVector currentVelocity = GetVelocity();
	
	

	if (currentVelocity.Length() < threshold && canExplode)
	{
		canExplode = false;

	}
	else if (currentVelocity.Length() == 0)
	{
		TriggerExplosion(projectileDamage * 2, projectileDamageRadius * 2, projectileKnockback * 2, projectileKnockbackRadius * 2);
		Destroy();
	}

}