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.
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) TArrayresult; 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(); } }