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. Currently I'm not sure where to be taking this in the future, but I had a lot of fun making everything.
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);
}
}
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.
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
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);
}
}
}
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.
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();
}
}