DEV Community

CBanks901
CBanks901

Posted on

Black hole force demo

Hello, this a small tech demo showing my take on a black hole force Black hole force for objects, particularly static meshes here. How this works is when these meshes overlap the zone it moves them and then after a certain period of time (ten seconds) it pushes them all outwards with a enormous force akin to an explosion. Almost all of the work done here is done with C++ and Unreal Engine. I'll be breaking down my thought process with this and showing some of the main aspects of the project. If you'd like to see the entire project and/or try it for yourself, please head over to my github page. Without any further delay I'll go into the details.

For this to work, only two main actors are necessary; the first is the one I've called BlackHoleForce, and the second one I've called BaseMeshActor. Both are simple actor instances. The MainBaseActor is simply the parent and any actual objects are derived from it. This was done so that any objects with that same parent can be counted and added to the array for ease of access. Another important class is the BlackHoleRedoGameModeBase which is the place where I store the counters for the objects on screen.

I'll start here with the MainBaseActor.
The following is the header file of this class: MainBaseActor.h

UCLASS()
class BLACKHOLEREDO_API AMainBaseActor : public AActor
{
    GENERATED_BODY()

public: 
    // Sets default values for this actor's properties
    AMainBaseActor();

    // The root of the object
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Components, meta = (AllowPrivateAccess = "true"))
    class USceneComponent* MainSceneRoot;

    // The mesh of the instance
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = Components, meta = (AllowPrivateAccess = "true"))
    class UStaticMeshComponent* Static_Mesh;

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public: 
    // Called every frame
    virtual void Tick(float DeltaTime) override;

    // The direction in which the instance follows. If true it moves normally, if not it goes the other way.
    UPROPERTY(BlueprintReadWrite)
    bool NormalDirection;
};
Enter fullscreen mode Exit fullscreen mode

Everything here is pretty standard with the exception of a few custom variables which include:

  • A StaticMeshComponent(allows subclasses to change this to differnt shapes)

  • The sceneroot which is just the root of the object.

  • A custom variable called NormalDirection which tracks the direction in which this object will spin.

This is inside the MainBaseActor.cpp file:

// Sets default values
AMainBaseActor::AMainBaseActor()
{
    // 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;

    MainSceneRoot = CreateDefaultSubobject<USceneComponent>(FName("Root"));
    MainSceneRoot->SetWorldLocation(FVector(0, 0, 0));

    // The main static meshcomponent
    Static_Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("SM"));

    // Make the scene component the root of this object
    RootComponent = MainSceneRoot;

    // disable tick by default
    SetActorTickEnabled(false);

    Static_Mesh->SetComponentTickEnabled(true);
    Static_Mesh->bTickInEditor = true;
    Static_Mesh->SetupAttachment(MainSceneRoot);
    Static_Mesh->SetWorldLocation(FVector(0, 0, 0) );
    Static_Mesh->SetSimulatePhysics(true);
    Static_Mesh->SetEnableGravity(true);
    Static_Mesh->SetNotifyRigidBodyCollision(true);
    Static_Mesh->BodyInstance.SetCollisionProfileName(FName("BlockAllDynamic"));

}

Enter fullscreen mode Exit fullscreen mode

The BeginPlay and Tick are left to the default settings so I didn't post them here. Mostly what's going on here is that the scene root is being initialized, and the static mesh is initializing some default vaues such as gravity, collision profile, physics and locations for use in game. The NormalDirection boolean isn't initialized here because it will constantly be changed in other places later.

The next class I'd like to go over is the BlackHoleGameModeBase.
Here is the BlackHoleGameModeBase.h code:

class BLACKHOLEREDO_API ABlackHoleRedoGameModeBase : public AGameModeBase
{
    GENERATED_BODY()

    virtual void StartPlay() override;

protected:
    // Widget object
    UPROPERTY(VisibleInstanceOnly)
    class UUserWidget* myWidget;

    // Handles the creation of the widget
    UFUNCTION(BlueprintCallable)
    void CreateDefaultWidget();

    // Simply swap the widget in and out of view when called
    UFUNCTION()
    void ToogleWidgetView();

public:
    // Holds a reference to the Widget class itself
    UPROPERTY(EditAnywhere, Category = "Shadow")
    TSubclassOf<class UUserWidget> WidgetClassRef;

    // Counter variable that holds the number of items that are spawned in. This value is mainly used for the 
    // widget itself.
    UPROPERTY(BlueprintReadWrite)
    int spawned_items;
};

Enter fullscreen mode Exit fullscreen mode

Quick notes about the BlackHoleGameMode header file:

  • Creates a reference to the main widget that displays information to the user (mywidget)

  • CreateDefaultWidget method handles the setup and creation of the widget (mywidget)

  • ToogleWidget method disables or enables the widget from the view

  • WidgetClassRef makes it easier to get the widget class of the type that's required which is just a UserWidget

  • Finally spawned items integer counter keeps track of the items currently avaialable on screen.

Now here's the BlackHoleGameMode.cpp file:

void ABlackHoleRedoGameModeBase::StartPlay()
{
    Super::StartPlay();

    check(GEngine != nullptr);


    if (IsValid(WidgetClassRef) )
    {
        CreateDefaultWidget();
        spawned_items = 0;          // iniitialize the number of items when the simulation is loaded
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("Unable to initialize widget class"));
        return;
    }

}

void ABlackHoleRedoGameModeBase::CreateDefaultWidget()
{
    if (WidgetClassRef == nullptr) return;

    // assuming that the widgetclass reference itself is valid, begin the process of creating the widget
    myWidget = Cast<UUserWidget>(CreateWidget(GetWorld(), WidgetClassRef) );

    if (myWidget != nullptr)
    {
        GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Blue, TEXT("Successful viewport creation!!"));
        ToogleWidgetView();
    }
}

void ABlackHoleRedoGameModeBase::ToogleWidgetView()
{
    // remove the widget from viewport if it is active, and if not, then add it to the viewport
    if (myWidget->IsInViewport())
    {
        myWidget->RemoveFromViewport();
    }
    else
    {
        myWidget->AddToViewport();
    }
}
Enter fullscreen mode Exit fullscreen mode

Quick summary of BlackHoleGameMode.cpp file

  • The startplay method just checks the class reference variable for validation and if it's valid then it initializes the spawned items counter

  • The CreateDefaultWidget method simply checks if the class is valid, and from there creates a widget. Once that widget is created, it is checked for validity and if it is valid, then it is immeditately added to the viewport.

  • Finally, the ToogleWidgetView method simply adds or removes the widget based on what it is currently

With that being set up, the next class I'd like to go over is the CubeMeshActor which is a specific instance of the MainBaseActor.

This is the header file for the CubeMesh class:

UCLASS()
class BLACKHOLEREDO_API ACubeMesh : public AMainBaseActor
{
    GENERATED_BODY()
public:
    ACubeMesh();
    ~ACubeMesh();

    // The custom function thats used to track object hits for this object
    UFUNCTION()
    void OnComponentHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);

    // Functions as DoOnce method
    UFUNCTION()
    void EmptyMethod();

    bool dOnceA;

protected:
    virtual void BeginPlay() override;

    virtual void Destroyed();
public:
    // Called every frame
    virtual void Tick(float DeltaTime) override;

    // Increases the total count inside the game mode
    void IncreaseItemCount();

    // So we can reference the main gamemode
    UPROPERTY()
    class ABlackHoleRedoGameModeBase* gamemode;
};
Enter fullscreen mode Exit fullscreen mode

The CubeMesh class includes the components of its parent along with some extra functions which include:

  • The hit component function which enables mesh collisions

  • A BlackHoleGameMode reference which we can use to reference the main game mode

  • The IncreasedItemCount function increases the count by 1 inside the game mode every time a object of this type is created.

  • A destroyed method which is only important because before it's destroyed, it reduces the count inside the gamemode by 1

  • The EmptyMethod which is the handler for time delays

  • A doOnce variable that acts like a mutex for collision

Next is the CubeMesh.cpp file:

void ACubeMesh::OnComponentHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{

    if (OtherActor != NULL)
    {
        if (AMainBaseActor* ref = Cast<AMainBaseActor>(OtherActor) )
        {
            // If we can reset it, then basically add a impusle force to the object and change this instances direction to the
            // mirror, along with the mesh that it hit.
            if (dOnceA)
            {
                dOnceA = false;

                FVector newVec = FVector(Hit.Normal.X, Hit.Normal.Y, 1.0);
                ref->Static_Mesh->AddImpulse(newVec * 500, FName("None"), true);


                if (NormalDirection)
                {
                    ref->NormalDirection = true;
                    NormalDirection = false;
                }
                else
                {
                    ref->NormalDirection = false;
                    NormalDirection = true;
                }

                // Delay it slightly before allowing the donce variable to be reset
                FTimerHandle TimerHandle;
                GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &ACubeMesh::EmptyMethod, .2, false);
            }
        }
    }
}

void ACubeMesh::EmptyMethod()
{
    dOnceA = true;
}

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

    // Short life span so this actor will be destroyed after 17 seconds
    SetLifeSpan(17);

    // Enable collisions for the static mesh that was initialized inside the baseactor class
    if (Static_Mesh->GetStaticMesh())
    {
        Static_Mesh->OnComponentHit.AddDynamic(this, &ACubeMesh::OnComponentHit);
    }

    dOnceA = true;

    // Initialize a reference to the main game mode
    gamemode = Cast<ABlackHoleRedoGameModeBase>(UGameplayStatics::GetGameMode(GetWorld()));

    // Delay the instancing a bit since we'll use many of them at once.
    FTimerHandle TimerHandle;
    GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &ACubeMesh::IncreaseItemCount, .5f, false);
}

void ACubeMesh::Destroyed()
{
    // When a instance is destroyed, subtract one from the count inside the main game mode
    if (IsValid(gamemode))
        gamemode->spawned_items -= 1;
    else
        UE_LOG(LogTemp, Warning, TEXT("You're deleing?"));
}

void ACubeMesh::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
}

void ACubeMesh::IncreaseItemCount()
{
    // assuming the gamemode reference is valid, increase the spawn iitem integer variable by 1
    if (IsValid(gamemode))
    {
        gamemode->spawned_items += 1;
    }
    else
        UE_LOG(LogTemp, Warning, TEXT("Impossible to access the game mode?"));
}

Enter fullscreen mode Exit fullscreen mode

Quick Summary of CubeMesh.cpp:

  • TheOnComponentHit function first checks if the colliding object is of the same type as this actor. Then if the dOnce variable is true, it is set to false and then we add a impulse force using the hit components x and y, excluding z so no up and down motion. After this the direction of this object and the other one is inverted. Finally we pause the timer for about a quarter of a second so the impulse has time to be applied. The rest is handled inside the EmptyMethod.

  • The EmptyMethod simply sets the dOnce variable to true.

  • In BeginPlay the first thing that's done is the life span is limited to 17 seconds. The static mesh is initialized to use the hit component, the dOnce is defaulted to true, the gamemode reference is intialized, and a timer handle is used to increase the item count. This delay is only used since there are meshes in the simulation initially so this avoids access errors.

  • The IncreaseItemCount uses the gamemode reference to increase the counter value inside it.

  • The Destroyed method does the oppisite of IncreaseItemCount and is only activated at the end of this instances life cycle.

The final actor is the BlackHoleActor class. This is the class that makes actors rotate until a time limit is passed and then pushes them outward.

Here is the header file for the BlackHoleActor class:

class AMainBaseActor;   // forward declaration, this class is added in the cpp file
UCLASS()
class BLACKHOLEREDO_API ABlackHoleActor : public AActor
{
    GENERATED_BODY()

public: 
    // Sets default values for this actor's properties
    ABlackHoleActor();

    // The root of this mesh
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Components)
    class USceneComponent* CustomRoot;

    // Moves along the spline in a normal direction
    UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Category = Components)
    class USceneComponent* HolePosition;

    // A scene component which spins in the opposite direction from the normal scene component
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = Components)
    class USceneComponent* DirectionMirror;

    // The spline reference that objects follow once inside this actors radius
    UPROPERTY(BlueprintReadWrite, VisibleAnywhere, Category = Components)
    class USplineComponent* customSpline;

    // Array of a generic subtype of MainBaseActors. Children from this class will be used in this example
    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    TArray<AMainBaseActor*> ActorArray;

    // Should always be true except when the endtime has gone beyond ten seconds, in which case this is temporarily set to false
    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    bool AllowCollisions;

    // Corresponds to the input of the spline itself. As such this only has a limit of 4 seconds because the spline only has 4 points
    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    float SplineTime;

    // The total time in which all objects inside will spin before being launched
    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    float EndTime;

    // Will be used as the overlap for when objects come inside this radius
    UPROPERTY(BlueprintReadOnly, Category = Components)
    class UBoxComponent* BoxCollisionComp;

    // counter for the last object added to a array of actors. This is so the direction that a object spins on the spline
    // goes in a direction opposite to the last object added
    UPROPERTY(BlueprintReadOnly)
    bool LastAddition;

    // Base cylinder meshes
    UPROPERTY()
    UStaticMesh* BaseCylinder;

    // Base cube mesh
    UPROPERTY()
    UStaticMesh* BaseCube;

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

    // Returns the inverted time
    float FlippedSplineTimeValue();

    // Used for time delays or lags before the sequence is reset
    void CustomDelayFunction();

    FTimerHandle slightdelay;

public: 
    // Called every frame
    virtual void Tick(float DeltaTime) override;

    // For when other actors begin to overlap this zone
    UFUNCTION()
    void OnBoxBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, 
        int OtherBodyIndex, bool From_Sweep, const FHitResult& Sweep_Result);
};
Enter fullscreen mode Exit fullscreen mode

Quick BlackHoleActor Notes:

  • CustomRoot is the new root for this actor

  • The HolePosition component is a scene component that will always be moving along a spline path

  • DirectionMirror is the same as the HolePosition component and does the same thing but in the opposite direction

  • The customspline component is the spline that both components will move along.

  • ActorArray contains a list of all actors of type MainBaseActor

  • The AllowCollisions boolean is to keep track of when collisions are allowed.

  • SplineTime float is a variable which should only be a maxium of four, which corresponds to the points of the spline.

  • EndTime is the total time of the spin once a overlap is detected. This corresponds to the vfx which is added and spawned at this time also

  • The BoxCollisioncomp is the overlap/box trigger that will be used for overlaps of objects.

  • The LastAddition boolean simply keeps track of the last direction of the object that was just added. This is used to flip the direction of new objects so that they don't all spin the same way

  • BaseCylinder and BaseCube are simple object types. The reason this is done here is because this object will be responsible for object creation after the initial run

  • FlippedSplineTimeValue method flips the spline time for inverted objects

  • CustomDelayMethod is used for input delays later on

  • The FTimerHandle is the variable that will be used for the delay function CustomDelayMethod

  • OnBoxBeginOverlap method is used for the overlap of the trigger box.

Now for the BlackHoleActor.cpp code:

#include "BlackHoleActor.h"
#include "Components/BoxComponent.h"
#include "Components/SplineComponent.h"
#include "Kismet/GameplayStatics.h"
#include "Math/UnrealMathUtility.h"
#include "CubeMesh.h"
#include "BlackHoleRedoGameModeBase.h"
#include "MainBaseActor.h"

// Sets default values
ABlackHoleActor::ABlackHoleActor()
{
    // 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;
    SetActorTickEnabled(false);
    CustomRoot = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
    CustomRoot->SetWorldLocation(FVector(0, 0, 0));
    RootComponent = this->CustomRoot;

    //
    HolePosition = CreateDefaultSubobject<USceneComponent>(FName("Helper"));
    HolePosition->SetupAttachment(CustomRoot);
    HolePosition->SetRelativeLocation(FVector(0, 0, 100));

    // Set up the mirror scene component, initialize its world location and attach it to the root
    DirectionMirror = CreateDefaultSubobject<USceneComponent>(TEXT("MirrorLocation"));
    //DirectionMirror->SetWorldLocation(FVector(0, 0, 0));
    DirectionMirror->SetupAttachment(CustomRoot);
    DirectionMirror->SetRelativeLocation(FVector(0, 0, 100));

    // Create the box component, set its location above a certain height, and finally attach it to the root
    BoxCollisionComp = CreateDefaultSubobject<UBoxComponent>(FName("Collision"));
    BoxCollisionComp->SetWorldLocation(FVector(0, 0, 110) );
    BoxCollisionComp->SetupAttachment(CustomRoot);

    // Stretch the box out here
    BoxCollisionComp->SetBoxExtent(FVector(700, 700, 100));

    AllowCollisions = true;
    LastAddition = false;

    customSpline = CreateDefaultSubobject<USplineComponent>(TEXT("Spline"));
    customSpline->SetupAttachment(CustomRoot);
    customSpline->SetRelativeLocation(FVector(-370, 0, 260));

    // Make it closed so the loop is in a circle
    customSpline->SetClosedLoop(true);

    // Clear the default spline and add new based locally to the spline
    customSpline->ClearSplinePoints();
    customSpline->AddSplinePoint(FVector(-89.979538, 1.889979, 80), ESplineCoordinateSpace::Local);
    customSpline->AddSplinePoint(FVector(61.457996, 233.428894, 0), ESplineCoordinateSpace::Local);
    customSpline->AddSplinePoint(FVector(549.934326, -7.602399, 100), ESplineCoordinateSpace::Local);
    customSpline->AddSplinePoint(FVector(49.977882, -489.968689, 0.0), ESplineCoordinateSpace::Local);
    customSpline->SetRelativeScale3D(FVector(1.5f, 1.0f, 1.0f));

    static ConstructorHelpers::FObjectFinder<UStaticMesh>BaseCubeRef(TEXT("StaticMesh'/Engine/Basicshapes/Cube'"));
    static ConstructorHelpers::FObjectFinder<UStaticMesh>BaseCylinderRef(TEXT("StaticMesh'/Engine/BasicShapes/Cylinder'"));

    // If the constructions objects have failed the stop
    if (BaseCylinderRef.Object != nullptr)
        BaseCylinder = BaseCylinderRef.Object;
    else
        return;

    if (BaseCubeRef.Object != nullptr)
        BaseCube = BaseCubeRef.Object;
}

// Called when the game starts or when spawned
void ABlackHoleActor::BeginPlay()
{
    Super::BeginPlay();
    BoxCollisionComp->OnComponentBeginOverlap.AddDynamic(this, &ABlackHoleActor::OnBoxBeginOverlap);
    SetActorTickEnabled(false);
}

float ABlackHoleActor::FlippedSplineTimeValue()
{
    // Use the currentsplineTime and subtract 4 from it so it's always inverted. 
    // So 3.0f normally would return (4.0f - 3.0f) 1.0f which is the opposite end in this case.
    return 4.0f - SplineTime;
}

void ABlackHoleActor::CustomDelayFunction()
{
    AllowCollisions = true;

    for (int i = 0; i < ActorArray.Num(); i++)
    {
        if (IsValid(ActorArray[i]))
            ActorArray[i]->Static_Mesh->SetNotifyRigidBodyCollision(true);
        else
            ActorArray.RemoveAt(i);
    }

    // Remove all items from the list
    ActorArray.Empty();
    EndTime = 0.0f;

    // Makes it so the simulation continously spawns both cubes and cylinders for this example
    for (int k = 1; k < 4; k++)
    {
        ACubeMesh* Cylinder = GetWorld()->SpawnActor<ACubeMesh>(GetActorLocation() + FVector(0, 0, k * 2000.f), FRotator());
        Cylinder->Static_Mesh->SetStaticMesh(BaseCylinder);
    }

    for (int i = 1; i < 3; i++)
    {
        ACubeMesh* Cube = GetWorld()->SpawnActor<ACubeMesh>(GetActorLocation() + FVector(0, 0, i * 2000.f), FRotator());
        Cube->Static_Mesh->SetStaticMesh(BaseCube);
    }
}

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

    if (EndTime < 10.f)
    {
        EndTime += DeltaTime;

        if (SplineTime < 4.0f)
        {
            SplineTime += DeltaTime;
            // Use the location based on the splinetime float variable to get a location on the spline and change the holeposition scene component to always 
            // update
            HolePosition->SetWorldLocation(customSpline->GetLocationAtSplineInputKey(SplineTime, ESplineCoordinateSpace::World));

            DirectionMirror->SetWorldLocation(customSpline->GetLocationAtSplineInputKey(FlippedSplineTimeValue(), ESplineCoordinateSpace::World));

            // Go through each element in the array and set its location and torque depending on the direction that it is current set as
            for (int i = 0; i < ActorArray.Num(); i++)
            {
                // Checks if the element inside the array is still active
                if (IsValid(ActorArray[i]) )
                {
                    // The normal direction
                    if (ActorArray[i]->NormalDirection)
                    {
                        FVector VinterpToVec = FVector(FMath::VInterpTo(ActorArray[i]->Static_Mesh->GetComponentLocation(),
                            HolePosition->GetComponentLocation(), DeltaTime, 2.0f).X,
                            FMath::VInterpTo(ActorArray[i]->Static_Mesh->GetComponentLocation(),
                                HolePosition->GetComponentLocation(), DeltaTime, 2.0f).Y, 0.0f);

                        ActorArray[i]->Static_Mesh->SetWorldLocation(FVector(VinterpToVec.X, VinterpToVec.Y, HolePosition->GetComponentLocation().Z), false, nullptr, ETeleportType::TeleportPhysics);

                        ActorArray[i]->Static_Mesh->AddTorqueInDegrees(FVector(90, 290, 360), FName("None"), true);
                    }
                    // The inverted direction
                    else
                    {
                        FVector VinterpToVec = FVector(FMath::VInterpTo(ActorArray[i]->Static_Mesh->GetComponentLocation(),
                            DirectionMirror->GetComponentLocation(), DeltaTime, 2.0f).X,
                            FMath::VInterpTo(ActorArray[i]->Static_Mesh->GetComponentLocation(),
                                DirectionMirror->GetComponentLocation(), DeltaTime, 2.0f).Y, 0.0f);

                        ActorArray[i]->Static_Mesh->SetWorldLocation(FVector(VinterpToVec.X, VinterpToVec.Y, DirectionMirror->GetComponentLocation().Z), false, nullptr, ETeleportType::TeleportPhysics);
                        ActorArray[i]->Static_Mesh->AddTorqueInDegrees(FVector(90, 290, 360), FName("None"), true);
                    }
                }
                // If the element isn't valid, remove it to avoid problems
                else
                    ActorArray.RemoveAt(i);
            }
        }
        // When the timer is above four, reset it to zero so that it continously repeats
        else
            SplineTime = 0.0f;
    }
    // When the timer limit has been reached, begin the process of sending every object inside the array flying outwards 
    else
    {
        AllowCollisions = false;

        for (int i = 0; i < ActorArray.Num(); i++)
        {
            if (IsValid(ActorArray[i]))
            {
                FVector UpHelper = ActorArray[i]->Static_Mesh->GetComponentLocation();
                UpHelper.Z += 300.f;
                FVector dir = customSpline->FindDirectionClosestToWorldLocation(UpHelper, ESplineCoordinateSpace::World);
                dir *= 4000.0f;
                dir += FVector(0.f, 0.f, 2000.f);
                ActorArray[i]->Static_Mesh->AddImpulse(dir, "NONE", true);
                ActorArray[i]->Static_Mesh->AddForce(FVector(0.f, 0.f, 2000.f), "NONE", true);
            }
            else
                ActorArray.RemoveAt(i);
        }

        GetWorld()->GetTimerManager().SetTimer(slightdelay, this, &ABlackHoleActor::CustomDelayFunction, 2.0, false);
        SetActorTickEnabled(false);
    }
}

void ABlackHoleActor::OnBoxBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int OtherBodyIndex, bool From_Sweep, const FHitResult& Sweep_Result)
{
    // If the boolean is true to allow more collisions then proceed
    if (AllowCollisions)
    {
        // If the incoming actor is of some subset of MainBaseActor then continue, otherwise ignore for now
        if (AMainBaseActor* ref = Cast<AMainBaseActor>(OtherActor))
        {
            // If the incoming actor is not already inside the character array then proceed to add it
            if (ActorArray.Find(ref) == INDEX_NONE)
            {
                ActorArray.Add(ref);

                if (ActorArray.Num() > 1)
                {
                    LastAddition = !LastAddition;
                    ActorArray.Last()->NormalDirection = LastAddition;
                }

                if (!IsActorTickEnabled() )
                    SetActorTickEnabled(true);
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Notes about the BlackHoleActor.cpp:

  • The constructor method intializes all objects including both scene components, the spline, the overlap component, the boolean variables allowcollisions/lastaddition as well as initializes both the cubes and spheres objects inside this actor

  • The BeginPlay method disales ticks and setups up the box component as the primary overlap

  • FlippedTimeValue returns the inverted splinetime. Since it will always be a max of 4.0f, we can just subtract it to get a inverted value

  • The OnBoxBeginOverlapComponent function first checks if collisions are allowed using the allowedcollisions boolean value. If this is the case then it casts to the MainBaseActor class. If this cast is successful, then it tries to find out if the incoming object already exists in the ActorArray variables. This is to avoid duplicate entries of the same object in the list and because the object flies around it causes issues. Assuing it doesn't exist, then the new object gets added to the array. If the number of Actors inside that array are greater than 1, then everytime a instance is added, then the LastAddition boolean is inverted (true to false/false to true). The new elements' NormalDirection is then set to the new LastAddition value. This is used in the tick of this actor and makes actors go in different directions depending on this boolean. The final part of this block simply enables tick on the actor if it isn't already set to true.

  • The Tick Function allows new objects to rotate until the endTime is reached. The first thing that happens is EndTime is checked to be less than ten. If this is true then deltaTime is constantly added to it until that threshold is reached. If this isn't true then it prepares to dispense all objects from the center by adding special impulses and forces to them. Invalid objects are disabled, the AllowCollisions bool is set to false, and the actors Tick is stopped. The CustomDelayFunction is called to reinitialize the process again. While the EndTime float value is less than 10, it also checks if the SplineTime value is less than 4.0f. If it is we add the change in time value to it, and if it exceeds 4, then it is reset back to 0. Inside the SplineTime's true component, we constantly update the HolePosition scene component and the DirectionMirror scene component to match the customSpline using a input key of SplineTime. The DirectionMirror one uses the FlippedSplineValue method as its input however. As these components are being updated, we go through all the elements inside the ActorArray variable and if they are valid their position is updated. This updated is dependent on what it's current NormalDirection boolean is. If true it follows the HolePosition scene component which is following a normal path, and if false it follows the mirror path. Regardless a Torque is also added and any invalid elements are removed immediately from the array. This is because some elements that survive through the last cycle may come back into play, but because these objects are on a timer, they may not last until the end, creating out of bounds errors.

  • The CustomDelayFunction activates after a complete cycle. The first thing this does allow new collisions by setting the boolean to true. Then it loops through all elements in the ActorArray and disables collisions on the static meshses or removes invalid objects (to avoid out of bounds errors). Then all valid references are cleared and the EndTimer is reset to 0 for a new cycle. Finally a set array of cubes and cylinders are spawned just aboved this actor, making this process endless until the user quits

With all of the code completed here, the last thing that needs to be completed is actually dropping the actors into the scene but before that I'd like to explain a few things:

  1. The spline that was created follows a very specific path and is a closed looped. This was done in code but can be changed for other purposes. However, more points means that the splineTime variable will need to be updated as well since it only is intended for 4 points.

  2. The way this simulation was initialized, you'll need to drop a few initial objects into the scene just above the BlackHoleActor itself to trigger the sequence of events. After this nothing else is required.

  3. Additional code like the escape key, movement (limiting pitch), and the code binding the data from the gamemode to the widget is included inside the project itself.

  4. While this will work perfectly fine, it doesn't have any visual effect on the blackhole. In other words, the objects will fly around a target and then be pushed out. I simply wanted to use a Niagara effect to combat this so in my implementation I instantiated a blueprint instance of the BlackHoleActor so that I could add one using the information from the source code that was created. These are images of how it was done. All of this is executed via the event tick portion of the actor which is disabled until a collision is handled. Once the tick is enabled, this code is handled in such a way that it only happens once per run. Here are some sample images of that in order from left to right:

Image description

Image description

Image description

With all that in mind, it should look like this:

Image description

Image description

Postmortem:

I had previously already done this before but with characters instead of physics objects. Properties were similiar but not quite the same. For characters it was more like a suction instead of flying and they would be blasted outwards simliar to this but not as far.

One thing that I learned from doing it this way was to account for multiple objects ensared in a single cycle. Because of the fact that there were many objects, one thing I had to do was temporarily disable collisions just as the blast occurs and disable them shortly after. For characters I just sent them up and backawards from the center, but because these were rotating and moving in the air, even with the correct calculations they wouldn't always fly as intended if there were more than 3. The impulse for multiple hits sent them crashing down instead of flying.

The other takeaway from this is the bumping mechanism. I originally didn't put any thought into it until I got the project up and running. They would follow the path but not bump into each other in a realistic way, or they would fuse together. This is why in the code I posted above you see things like torque, and the NormalDirection boolean to account for changes on hits.

Both of these lessons are key components that I'll take with me into future projects going forward. If you'd like to see the code above up close or just how it works, please have a look at my github page.

Thank you for reading all the way to the end if you made it this far.

Sincerely,

Christian

Discussion (0)