HORSE is a Basketball Party Game inspired from the popular playground game of the same name. The majority of the game was made using C++ in UE5, with anything UI related being executed using Blueprints.
By adding a PhysicsHandle Component on the Player and creating a cusotm "Grabber" class, I was able to add the functionality of picking up and throwing the ball. The Grabber checks against a custom trace channel to see if any valid objects are within grabbing distance. The PhysicsHandle deals with grabbing the object. In Tick, the location of the grabbed object is updated.
When the ball is thrown, it is released from the PhysicsHandle and an impulse is added to it. By holding down the Throw button before releasing, more power is added to the impulse. I added a shake to the ball using a Vector Timeline to add small offsets to the ball while the throw is charging up.
The springy feel of the ball while it's grabbed is achieved by altering some of the values found on the PhysicsHandle.
bool UGrabber::GetGrabbableInReach(FHitResult& OutHitResult) const
{
FVector Start = GetComponentLocation();
FVector End = Start + GetForwardVector() * MaxGrabDistance;
DrawDebugLine(GetWorld(), Start, End, FColor::Orange);
FCollisionShape Sphere = FCollisionShape::MakeSphere(GrabRadius);
return GetWorld()->SweepSingleByChannel(
OutHitResult,
Start, End,
FQuat::Identity,
ECC_GameTraceChannel2,
Sphere
);
}
void UGrabber::Grab()
{
//Only attempt to grab if we have a physics component, or if the turn is still on-going
if (GetPhysicsHandleComponent() == nullptr || GameMode->IsTurnOver)
{
return;
}
//Test to see if any grabbable objects are within range
FHitResult HitResult;
bool HasHit = GetGrabbableInReach(HitResult);
if (HasHit && GameMode)
{
//Reset projectile spawn points ready to use again
if (Spawner)
{
Spawner->SetVisibility(true, 0, Spawner->SpawnedObjects.Num());
Spawner->ResetLocation();
}
UPrimitiveComponent* HitComponent = HitResult.GetComponent();
AActor* HitActor = HitResult.GetActor();
if (Cast(HitActor))
{
if (GameMode->IsPlacingPhase)
{
return;
}
GameMode->SetGrabbedBallUI(true, true);
PlayerCharacter->IsHoldingBall = true;
}
ToyboxObject = Cast(HitActor);
if (ToyboxObject)
{
ToyboxObject->SecondMesh->AttachToComponent(ToyboxObject->Mesh, FAttachmentTransformRules::KeepWorldTransform);
if (!GetMatchingToyboxObject(HitComponent))
{
return;
}
}
FRotator TargetRotation = FRotator(ToyBoxRotationX, GetComponentRotation().Yaw, ToyBoxRotationZ);
//If we've grabbed a toybox spawner, spawn a toybox object and grab that object
if (AToyboxSpawner* ToyboxSpawner = Cast(HitActor))
{
if (GameMode->ToyboxObjectsLeftToSpawn == 0)
{
return;
}
//Make sure the toybox spawner outline is deactivated
if (CurrentOutlinedComponent)
{
CurrentOutlinedComponent->SetRenderCustomDepth(false);
CurrentOutlinedComponent = nullptr;
GameMode->GrabbableObjectInView(false);
}
ToyboxObject = ToyboxSpawner->SpawnToyboxObject();
if (ToyboxObject)
{
//Stop spinning toybox objects spinning while holding
if (ToyboxObject->ShouldSpin)
{
ToyboxObject->IsSpinning = false;
}
ToyBoxObjectMesh = ToyboxObject->Mesh;
SetGrabbedComponent(ToyBoxObjectMesh, HitResult.GetActor()->GetActorLocation(), TargetRotation);
if (GameMode)
{
GameMode->PlacedToyboxObjects.Add(ToyboxObject);
GameMode->ToyboxObjectsLeftToSpawn--;
GameMode->UpdatePlacingPhaseText(ToyboxObject, true);
}
//Save the profile of the toybox object and set all channels to overlap
ChangeToyboxMeshCollision(true);
}
return;
}
SetGrabbedComponent(HitComponent, HitResult.GetActor()->GetActorLocation(), TargetRotation);
if (Cast(GrabbedComponent->GetOwner()))
{
GetOwner()->Tags.Add("HoldingBall");
}
//Turn off object outline if there are any activated
if (CurrentOutlinedComponent)
{
CurrentOutlinedComponent->SetRenderCustomDepth(false);
CurrentOutlinedComponent = nullptr;
GameMode->GrabbableObjectInView(false);
}
if (ToyboxObject)
{
//Stop spinning toybox objects spinning while holding
if (ToyboxObject->ShouldSpin)
{
ToyboxObject->IsSpinning = false;
}
ToyBoxObjectMesh = ToyboxObject->Mesh;
ChangeToyboxMeshCollision(true);
if (GameMode)
{
GameMode->UpdatePlacingPhaseText(ToyboxObject, true);
}
}
else
{
ToyBoxObjectMesh = nullptr;
ToyboxObject = nullptr;
}
}
}
void UGrabber::Release()
{
//If an object is being grabbed
if (GetPhysicsHandleComponent() && GrabbedComponent)
{
if (Cast(GrabbedComponent->GetOwner()))
{
GameMode->SetGrabbedBallUI(false, true);
PlayerCharacter->IsHoldingBall = false;
}
//If the grabbed object is a ToyBox object
if (GrabbedComponent->GetOwner()->ActorHasTag("Toybox") && ToyBoxObjectMesh)
{
if (ToyboxObject->CurrentOverlappingActors > 0)
{
return;
}
//Let spinning objects spin again on release
if (ToyboxObject->ShouldSpin)
{
ToyboxObject->IsSpinning = true;
}
if (GameMode)
{
GameMode->UpdatePlacingPhaseText(ToyboxObject, false);
}
ChangeToyboxMeshCollision(false);
}
if (Cast(GrabbedComponent->GetOwner()))
{
GetOwner()->Tags.Remove("HoldingBall");
}
HoldDistance = 50.f;
GrabbedComponent->WakeAllRigidBodies();
GrabbedComponent->GetOwner()->Tags.Remove("Grabbed");
GrabbedComponent = nullptr;
GetPhysicsHandleComponent()->ReleaseComponent();
if (ToyboxObject)
{
ToyboxObject->SecondMesh->DetachFromComponent(FDetachmentTransformRules::KeepWorldTransform);
}
if (Spawner)
{
Spawner->SetVisibility(false, 0, Spawner->SpawnedObjects.Num());
}
}
}
void UGrabber::Throw(bool IsRandomThrow)
{
if (GrabbedComponent == nullptr)
{
return;
}
if (GameMode)
{
GameMode->ThrowLocation = GetOwner()->GetActorLocation();
}
if (IsRandomThrow)
{
GrabbedComponent->
AddImpulse(FVector(GetForwardVector().X + FMath::RandRange(-1.f, 1.f),
GetForwardVector().Y + FMath::RandRange(-1.f, 1.f),
GetForwardVector().Z + FMath::RandRange(-1.f, 1.f))
* CurrentThrowPower, NAME_None, false);
}
else
{
GrabbedComponent->SetPhysicsLinearVelocity(FVector(0, 0, 0));
GrabbedComponent->AddImpulse(GetForwardVector() * CurrentThrowPower, NAME_None, false);
}
FVector Velocity = GetForwardVector() * CurrentThrowPower;
if (PlayerCharacter->GetCharacterMovement()->IsFalling())
{
if (PlayerActionsLeft > 0)
{
GameMode->ActionPerformed(PlayerActionName);
PlayerActionsLeft--;
}
}
Release();
}
void UGrabber::SetGrabbedComponent(UPrimitiveComponent* ComponentToSet, FVector Location, FRotator Rotation)
{
if (ToyboxObject)
{
ComponentToSet->SetSimulatePhysics(true);
}
UE_LOG(LogTemp, Display, TEXT("%s : turned on physics sim"), *ComponentToSet->GetName());
ComponentToSet->WakeAllRigidBodies();
ComponentToSet->GetOwner()->Tags.Add("Grabbed");
GetPhysicsHandleComponent()->GrabComponentAtLocationWithRotation(
ComponentToSet,
NAME_None,
Location,
Rotation
);
GrabbedComponent = GetPhysicsHandleComponent()->GetGrabbedComponent();
}
To make shots easier for Players I wanted them to see the trajectory of their throw. Unreal Engine has a built in function called PredictProjectilePath. It takes in a velocity and location, aswell as other Parameters such as whether it should consider collisions with a certain trace channel, to create a path of debug spheres of where the ball will go.
Executing this function only creates one path, so to allow for multiple bounces, I call the function again and decrement the "MaxBounces" int variable so I can restrict the amount of times it is called. This time I plug in the mirrored velocity of the ball at the point of impact, aswell as the impact location.
The Preview Path also works with the bounce pad and cannon objects I have in the game. If the path collides with a bounce pad for example, it will show the bounce path the ball will make. For a cannon, the path will shoot out of where the cannon faces, so everything is reasonably accurate.
As mentioned, the PredictProjectilePath function creates debug spheres to show the ball trajectory. As these won't show up on a packaged build I had to replace these spheres with my own object. I created an array of spheres that get spawned at the start of the game, and when the projectile path changes, I set the location of as many points are needed to the path.
To make the collisions in the path clear for the Player, I change the material of the points that come before a collision to yellow. Increasing the throw power also correctly updates the path with the added distance.
Because the points work with the bounce pads and cannons in the level, you can create quite mesmerising paths, especially when combined with spinning cannons that update the path as they spin around.
void UGrabber::CreateProjectilePreview(float BouncesLeft, FVector Location, FVector Velocity, bool HideFirstPoint)
{
FPredictProjectilePathParams ProjectileParams = FPredictProjectilePathParams(10,
Location,
Velocity,
3);
ProjectileParams.SimFrequency = 15;
ProjectileParams.bTraceWithChannel = true;
ProjectileParams.TraceChannel = ECC_GameTraceChannel3;
if (BouncesLeft == 0)
{
ProjectileParams.bTraceWithCollision = false;
}
else
{
ProjectileParams.bTraceWithCollision = true;
}
ProjectileParams.ActorsToIgnore = IgnoreActorArray;
FPredictProjectilePathResult ProjectilePathResult;
bool HasHit = UGameplayStatics::PredictProjectilePath(this, ProjectileParams, ProjectilePathResult);
int i;
if (HideFirstPoint)
{
i = 1;
}
else
{
i = 0;
}
for (i; i < ProjectilePathResult.PathData.Num(); i++)
{
//Aslong as there is a Point that can be moved
if (Spawner && Spawner->SpawnedObjects.Num() - 1 > ProjectilePointsActive)
{
//Once the Points start clipping past the floor, stop this function
if (ProjectilePathResult.PathData[i].Location.Z < -10)
{
return;
}
//Move the projectile point
auto &Object = Spawner->SpawnedObjects[ProjectilePointsActive];
Object->SetActorLocation(ProjectilePathResult.PathData[i].Location);
UStaticMeshComponent* Mesh = Object->FindComponentByClass();
if (HasHit && i == ProjectilePathResult.PathData.Num() - 1)
{
if (Mesh)
{
Mesh->SetMaterial(0, Object->HitMaterial);
}
}
else
{
if (Mesh)
{
Mesh->SetMaterial(0, Object->NormalMaterial);
}
}
ProjectilePointsActive++;
}
else
{
return;
}
Spawner->SetVisibility(true, 0, ProjectilePointsActive + 1);
}
if (HasHit && BouncesLeft > 0)
{
const auto &Result = ProjectilePathResult.PathData[ProjectilePathResult.PathData.Num() - 1];
FVector BounceLocation = Result.Location;
FVector HitVelocity = Result.Velocity;
FVector MirrorVelocity = HitVelocity.MirrorByVector(ProjectilePathResult.HitResult.Normal);
BounceLocation += ProjectilePathResult.HitResult.Normal * 0.1;
if (ProjectilePathResult.HitResult.GetComponent()->ComponentHasTag("Bounce"))
{
FVector BounceActorUpVector = ProjectilePathResult.HitResult.GetActor()->GetActorUpVector();
CreateProjectilePreview(BouncesLeft - 1, BounceLocation, ((BounceActorUpVector * 750) + (MirrorVelocity * 1.5)) / 2, true);
}
else if (ProjectilePathResult.HitResult.GetActor()->ActorHasTag("Pusher"))
{
AActor* HitActor = ProjectilePathResult.HitResult.GetActor();
FVector HitActorLocation = HitActor->GetActorLocation();
USceneComponent* ShootPoint = Cast(HitActor->GetDefaultSubobjectByName(TEXT("BallShootPoint")));
FVector PusherActorForwardVector = ShootPoint->GetUpVector();
if (ShootPoint)
{
BounceLocation = ShootPoint->GetComponentLocation();
}
CreateProjectilePreview(BouncesLeft - 1, BounceLocation, PusherActorForwardVector * 1000, false);
}
else
{
CreateProjectilePreview(BouncesLeft - 1, BounceLocation, MirrorVelocity * 0.65, true);
}
}
}
The ToyBox objects, such as the Bounce Pad and Cannons, are the edge that allows Players to create complex shots and help them win the game. Performing certain tricks like bouncing the ball off the Bounce Pad are tracked using events. If you make a successful shot, the next Players must match the amount of tricks you performed.
You can win the game if you can pull off a complex shot with lots of chained tricks that the next players can't match.
There is a general "ToyboxObject" class that contains functionality that all the objects share. For unique behaviours, blueprint subclasses are made for each object, with the bounce of the pad being added as a custom "Bouncer" component for example.
To make the game harder over time, every round a Player is allowed to place a new ToyBox Object, which can be rotated and placed in the positioned in an optimal location for performing more tricks. Objects can only be placed if they aren't overlapping other objects or specific "No Place" zones, designed so the game doesn't get softlocked, such as around the hoop and in the spawn area.
Calculating the amount of objects overlapping the ToyBox object is done by incremeneting or decrementing a "CurrentOverlappingActors" int variable whenever OnOverlapBegin or OnOverlapEnd is called, which is a low cost method of finding this information. When the value is 0, it can be placed.
I also change the collision profile of the objects while grabbing them. Ordinarily the Bounce Pad blocks most objects, which I couldn't have while grabbing it. So while grabbing, the object collision profiles are set to OverlapAllDynamic, and reset to their original collision profiles on release.
#include "ToyboxObject.h"
#include "Components/SphereComponent.h"
#include "Kismet/GameplayStatics.h"
// Sets default values
AToyboxObject::AToyboxObject()
{
// 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;
Mesh = CreateDefaultSubobject("Mesh");
RootComponent = Mesh;
SecondMesh = CreateDefaultSubobject("Second Mesh");
SecondMesh->SetupAttachment(Mesh);
PlacingSpaceCollider = CreateDefaultSubobject("Placing Space Collider");
PlacingSpaceCollider->SetupAttachment(Mesh);
}
// Called when the game starts or when spawned
void AToyboxObject::BeginPlay()
{
Super::BeginPlay();
Mesh->OnComponentBeginOverlap.AddDynamic(this, &AToyboxObject::OnOverlapBegin);
Mesh->OnComponentEndOverlap.AddDynamic(this, &AToyboxObject::OnOverlapEnd);
PlacingSpaceCollider->OnComponentBeginOverlap.AddDynamic(this, &AToyboxObject::OnOverlapBegin);
PlacingSpaceCollider->OnComponentEndOverlap.AddDynamic(this, &AToyboxObject::OnOverlapEnd);
//Save the original material and collision profile
OriginalMeshMaterial = Mesh->GetMaterial(0);
MeshCollisionProfileName = Mesh->GetCollisionProfileName();
if (SecondMesh->GetStaticMesh())
{
OriginalSecondMeshMaterial = SecondMesh->GetMaterial(0);
SecondMeshCollisionProfileName = SecondMesh->GetCollisionProfileName();
}
}
// Called every frame
void AToyboxObject::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (ShouldSpin && IsSpinning)
{
Mesh->AddLocalRotation(SpinRotation);
}
}
void AToyboxObject::SetSpinRotation(FRotator Rotation)
{
SpinRotation = Rotation;
}
void AToyboxObject::OnOverlapBegin(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
//Return if overlappng with other colliders on the actor
if (OtherActor == this || OtherActor == UGameplayStatics::GetPlayerPawn(this, 0))
{
return;
}
CurrentOverlappingActors++;
}
void AToyboxObject::OnOverlapEnd(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
//Return if overlappng with other colliders on the actor
if (OtherActor == this || OtherActor == UGameplayStatics::GetPlayerPawn(this, 0))
{
return;
}
CurrentOverlappingActors--;
}
void AToyboxObject::SetMaterial(bool IsGrabbing, bool CanPlace)
{
if (IsGrabbing)
{
if (CanPlace)
{
Mesh->SetMaterial(0, CanDropGrabbedMaterial);
if (SecondMesh->GetStaticMesh())
{
SecondMesh->SetMaterial(0, CanDropGrabbedMaterial);
}
}
else
{
Mesh->SetMaterial(0, CannotDropGrabbedMaterial);
if (SecondMesh->GetStaticMesh())
{
SecondMesh->SetMaterial(0, CannotDropGrabbedMaterial);
}
}
}
else
{
Mesh->SetMaterial(0, OriginalMeshMaterial);
if (SecondMesh->GetStaticMesh())
{
SecondMesh->SetMaterial(0, OriginalSecondMeshMaterial);
}
}
}
void AToyboxObject::SetCollision(bool IsGrabbing)
{
if (IsGrabbing)
{
Mesh->SetCollisionResponseToAllChannels(ECR_Overlap);
if (SecondMesh->GetStaticMesh())
{
SecondMesh->SetCollisionResponseToAllChannels(ECR_Overlap);
}
}
else
{
Mesh->SetCollisionProfileName(MeshCollisionProfileName);
if (SecondMesh->GetStaticMesh())
{
SecondMesh->SetCollisionProfileName(SecondMeshCollisionProfileName);
}
}
}
I knew because Players take turns in my game and that you play on one screen, I didn't need to implement local multiplayer or have multiple PLayers. Instead, I created an array of structs called "PlayerInfo". Each struct contains information such as a PlayerName and number of HORSE letters collected. Some of this information is created by the Players in the main menu when they choose number of Players, type the player names and type in the HORSE word. This info is then carried by the Game Instance which sets the PlayerInfo struct array when the game begins.
I created a BlueprintImplementableEvent in my custom GameMode class called TurnChange which is called when a Player fails or makes a shot. One of the functions called in TurnChange is ChangePlayers, which among other things is in charge of incrementing a "CurrentPlayer" int variable, which corresponds to the index of a struct in "PlayerInfo". The UI is then set for this struct, such as PlayerName and HORSE letters collected, creating the illusion of multiple Players.
#include "BasketballGameMode.h"
#include "Kismet/GameplayStatics.h"
#include "CryptRaiderCharacter.h"
#include "Basketball.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "ToyboxObject.h"
void ABasketballGameMode::BeginPlay()
{
Super::BeginPlay();
PlayerController = GetWorld()->GetFirstPlayerController();
Player = Cast(UGameplayStatics::GetPlayerPawn(this, 0));
}
void ABasketballGameMode::ChangePlayers()
{
IsTurnOver = false;
SetBallActivation(false);
OnTurnChangeDelegate.Broadcast();
if (PlayerController)
{
PlayerController->GetPawn()->SetActorLocation(ResetLocation);
Player->GetCharacterMovement()->StopMovementImmediately();
}
//The requirements for a shot being considered taken
if (PlayerInfo[CurrentPlayer].SuccessfulShot || !PlayerInfo[CurrentPlayer].CanCreateShot && !PlayerInfo[CurrentPlayer].SuccessfulShot)
{
PlayerInfo[CurrentPlayer].HasShot = true;
//Once a Player has made a shot, disable movement for other Players
if (PlayerInfo[CurrentPlayer].SuccessfulShot && PlayerInfo[CurrentPlayer].CanCreateShot)
{
SuccessfulShotPlayer = CurrentPlayer;
for (int i = 0; i < PlayerInfo.Num(); i++)
{
PlayerInfo[i].CanCreateShot = false;
}
}
}
if (Player)
{
Player->SpawnBall();
if (PlayerController)
{
PlayerController->SetControlRotation(FRotator(0, 180, 0));
}
}
IncrementCurrentPlayer();
//Change rounds if everyone has had their turn
if (PlayerInfo[CurrentPlayer].HasShot)
{
IncrementCurrentRound();
SetPlacingPhase(true);
}
}
void ABasketballGameMode::IncrementCurrentPlayer()
{
//If reached the end of the array
if (CurrentPlayer + 1 == PlayerInfo.Num())
{
CurrentPlayer = 0;
}
else
{
CurrentPlayer++;
}
//If this Player is already out of the game
if (PlayerInfo[CurrentPlayer].LettersCollected == HorseTextInput.Num())
{
IncrementCurrentPlayer();
}
}
void ABasketballGameMode::IncrementCurrentLeader()
{
//If reached the end of the array
if (CurrentLeader + 1 == PlayerInfo.Num())
{
CurrentLeader = 0;
}
else
{
CurrentLeader++;
}
CurrentPlayer = CurrentLeader;
//If this Player is already out of the game
if (PlayerInfo[CurrentPlayer].LettersCollected == HorseTextInput.Num())
{
IncrementCurrentLeader();
}
}
void ABasketballGameMode::IncrementCurrentRound()
{
CurrentRound++;
IncrementCurrentLeader();
//Reset the Player back to the spawn location
if (PlayerController)
{
PlayerController->GetPawn()->SetActorLocation(ResetLocation);
}
//Clear the taken shots
for (int i = 0; i < PlayerInfo.Num(); i++)
{
PlayerInfo[i].HasShot = false;
PlayerInfo[i].CanCreateShot = true;
}
}
void ABasketballGameMode::SetBallActivation(bool IsActive)
{
IsBallActivated = IsActive;
if (IsActive)
{
OnBallActivated(true);
}
else
{
OnBallActivated(false);
}
}
bool ABasketballGameMode::IsGameOver(int PlayersLost)
{
if (PlayersLost + 1 == PlayerInfo.Num())
{
return true;
}
return false;
}
void ABasketballGameMode::SetPlacingPhase(bool Activate)
{
if (Activate)
{
ToyboxObjectsLeftToSpawn = 1;
IsPlacingPhase = true;
}
else
{
IsPlacingPhase = false;
PlacedToyboxObjects.Empty();
ToyboxObjectsLeftToSpawn = 0;
}
SetPlacingPhaseText(Activate);
}
void ABasketballGameMode::GrabbingReleasingToyboxObject(bool IsGrabbing)
{
if (IsGrabbing)
{
OnPhaseChangedDelegate.Broadcast(true);
}
else
{
OnPhaseChangedDelegate.Broadcast(false);
}
}