Making magnetic-ish balls that all follow each other, with interesting results

Each ball gets an array of all the other balls in the scene, then it grabs the closest ball with its invisible physics arm and forces the grabbed ball to move towards a position in between itself and the ball it just grabbed.

Well, here’s the result so far hahah:

In the Escape Room section of the UE C++ course, I was making balls to use as the mass triggers to open the door to escape.

Having these metal balls floating around gave me an idea to try to make them magnetic, and not just try to move towards the closest ball, but move in between the two closest balls and form whatever shape may form from that. The effects are still a bit strange but I think I’m getting closer.

So to start, I wanted to be able to put a MagneticBall component on a ball, and have it interact with all other MagneticBall actors.

First thing we gotta do is find all the metal balls in the level, if we want to be able to interact with potentially all of them from the perspective of a single ball.

What I’ve found is TObjectIterator and TActorIterator (either one can work, but TActorIterator is less prone to weirdness if you can use it apparently) for iterating through actors in the scene to find all the magnetic ball instances. The best way to identify them cleanly I’m still trying to figure out, but I’ve got it working. It returns a TArray of AActor* pointers to our floating balls.

// -----------------------------------------------------------------------------
/// Retrieve all actors whose name starts with "MagneticBall". Return TArray.
/// \n Not very fool proof, but it works for now.
TArray<AActor*> UMagneticBalls::GetAllMagneticBalls()
{
    const uint32 ThisObjectID = GetOwner()->GetUniqueID();
    TArray<AActor*> Balls;

    // Find all objects with UMagneticBalls component. Add them to our array.
    for (TActorIterator<AActor> Actor(GetWorld()); Actor; ++Actor) {
        // Ensure we're at the highest AActor level so we don't get unexpected crashes later.
        if (Actor->GetName().StartsWith("MagneticBall") && Actor->GetOwner() == nullptr) {
            // Skip itself to avoid marking itself as closest.
            if (Actor->GetUniqueID() == ThisObjectID) {
                continue;
            }
            // Make sure we add the pointer to the list, not the actual object.
            Balls.Add(*Actor);
        }
    }

    if (Balls.Num() == 0) {
        UE_LOG(LogTemp, Error, TEXT("Unable to find any balls. Did you name them MagneticBall?"));
    }

    return Balls;
}

Next, we need to get the distance to all of the other balls from this ball, so that we can figure out which one(s) are the closest. I needed some way of pairing up each AActor* pointer to each ball, along with its distance. I’m new to C++ so I don’t know how to best create a dictionary or hashmap, so I made a UStruct called FBallDistances:


/// Contains AActor pointer to Ball, and it's distance in float.
USTRUCT()
struct FBallDistances
{
    GENERATED_BODY()
    UPROPERTY() AActor* Ball;
    float Distance;
};

And then checked all the distances in our Balls array and make a new array of these FBallDistances results:

// -----------------------------------------------------------------------------
/// Calculate distances to all balls in level, and pair those distances up with each ball Actor pointer. \n\n
/// Return resulting TArray of FBallDistances (custom UStruct in header).
TArray<FBallDistances> UMagneticBalls::GetBallDistancePairs()
{
    // Make Array where we can store each ball pointer and its distance.
    TArray<FBallDistances> Dict;

    for (AActor* Ball : BallsInLevel) {
        if (Ball) {
            const float DistanceToBall = GetOwner()->GetDistanceTo(Ball);

            FBallDistances ThisBall;
            ThisBall.Ball = Ball;
            ThisBall.Distance = DistanceToBall;
            Dict.Add(ThisBall);
        }
        else {
            UE_LOG(LogTemp, Error, TEXT("Encountered null pointer. Expected pointer to MagneticBall actor."));
        }
    }

    return Dict;
}

Next, we want to figure out which two balls in that unsorted array of balls/distances are the closest to this ball. Something like this for me is where practicing at least Easy problems on Leetcode can come in handy, just to get the practice of iterating in haha.

// -----------------------------------------------------------------------------
/// Find the two closest balls to this actor. Return result.
TArray<AActor*> UMagneticBalls::FindClosestBalls(TArray<FBallDistances> ListOfBalls)
{
    FBallDistances Closest;
    FBallDistances SecondClosest;

    TArray<AActor*> Results;
    bool FirstCheck = true;

    for (FBallDistances Ball: ListOfBalls) {

        if (FirstCheck) {
            // We'll move the first ball checked directly into Closest.
            Closest.Ball = Ball.Ball;
            Closest.Distance = Ball.Distance;
            FirstCheck = false;
        }
        // Then for each iteration after, we push the closest ball to the top.
        else if (Ball.Distance < Closest.Distance) {
            // Push previous closest down to 2nd.
            SecondClosest.Ball = Closest.Ball;
            // Push newest up to closest.
            Closest.Ball = Ball.Ball;
            Closest.Distance = Ball.Distance;
        }
    }

    Results.Push(Closest.Ball);
    Results.Push(SecondClosest.Ball);

    return Results;

}

Unfortunately, something above is not reliable in that in some frames I’m getting null or weird results for the SecondClosest.Ball, which means I can’t always determine a position based on two balls (any help or ideas would be great there) since functions like GetActorLocation() will crash on these incorrect pointers that sometimes show up… I’ve been trying to troubleshoot this all day long. I think some data is lost somewhere when moving between variables above. No problem, I can come back to that later. For now let’s do just the closest ball.

Next is to determine the destination. Now this I’m still trying to figure out as my knowledge of math with Vectors is about nil, but I think this is along the right lines.

// -----------------------------------------------------------------------------
/// Set destination based on two closest balls.
void UMagneticBalls::SetDestination()
{
    BallsAndDistances = GetBallDistancePairs();
    ClosestBalls = FindClosestBalls(BallsAndDistances);
    // TODO: Figure out problems with sometimes missing ClosestBalls[1].

    // Single Ball Solution:
    UStaticMeshComponent* ClosestBallMesh = ClosestBalls[0]->FindComponentByClass<UStaticMeshComponent>();
    FVector ClosestBallLocation = ClosestBalls[0]->GetActorLocation();
    BallPhysicsHandle->GrabComponentAtLocation(ClosestBallMesh, NAME_None, ClosestBallLocation);
    // Current vector - target vector to get the direction, divided by 2 to get the midpoint.
    BallPhysicsHandle->SetTargetLocation(CurrentPosition - ClosestBallLocation / 2);
}

Now we can finally get to work in the tick component.

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

    // All of this is to ensure we don't do access violations on null pointers,
    // like when the player isn't holding anything from ItemGrabber component.
    if (PlayerHoldingItem()) {
        FString GrabbedObjectName = PlayerPhysicsHandle->GrabbedComponent->GetOwner()->GetName();
        // If we're grabbing this item as a player..
        if (GrabbedObjectName == GetOwner()->GetName()) {
            // Then release it's hold on fellow balls. (not quite working yet)
            BallPhysicsHandle->ReleaseComponent();
        }
    }

    CurrentPosition = GetOwner()->GetActorLocation();
    SetDestination();

    // Debug view of travel lines.
    if (EnableDebugView) {
        FRotator TargetRotation;
        BallPhysicsHandle->GetTargetLocationAndRotation(OUT Destination, OUT TargetRotation);
        DrawDebugLine(
            GetWorld(),              // InWorld.
            CurrentPosition,         // LineStart.
            Destination,             // LineEnd.
            DebugLineColor,          // Color.
            false,                   // PersistentLines.
            0.1,                     // LifeTime.
            0,                       // DepthPriority
            1                        // Thickness.
        );
    }
}

And the result is in that first video haha. If you read all of this you can see I’ve got things not working how I want, and missing some other intended mechanics, but the result is really interesting so far regardless so I had to share it.

I made a few changes to be able to include two-closest-balls calculations and now they’re clumping together better, jittering around and making random shapes. I love it.

Essentially the source ball will grab the closest ball, and force it into a position between the closest ball and the second closest ball, and all the balls are doing this to each other all at the same time, so the result is interesting movement and clumping now.

// -----------------------------------------------------------------------------
/// Set destination based on two closest balls.
void UMagneticBalls::SetDestination()
{
    BallsAndDistances = GetBallDistancePairs();
    ClosestBalls = FindClosestBalls(BallsAndDistances);

    AActor* BallOne = ClosestBalls[0];
    AActor* BallTwo = ClosestBalls[1];

    UStaticMeshComponent* ClosestBallMesh = BallOne->FindComponentByClass<UStaticMeshComponent>();
    FVector ClosestBallLocation = BallOne->GetActorLocation();
    FVector SecondClosestLocation = CurrentPosition;

    // We can do a simple check like this to see if BallTwo is even valid, to avoid crashes for now.
    if (BallTwo->GetActorLocation().Size()) {
        SecondClosestLocation = BallTwo->GetActorLocation();
    }

    BallPhysicsHandle->GrabComponentAtLocation(ClosestBallMesh, NAME_None, ClosestBallLocation);
    // Ball2 vector + Ball1 vector to get the direction, divided by 2 to get the midpoint.
    // This will move this ball towards in between the two closest balls.
    BallPhysicsHandle->SetTargetLocation((SecondClosestLocation + ClosestBallLocation) / 2);
}

Then for being able to actually grab a ball as the player, while it’s being grabbed by other balls, we can do this in the tick component:

    // To ensure we don't do access violations on null pointers,
    // like when the player isn't holding anything from ItemGrabber component.
    if (PlayerHoldingItem()) {
        // If we're grabbing this ball as a player..
        FString GrabbedObjectName = PlayerPhysicsHandle->GrabbedComponent->GetOwner()->GetName();
        if (GrabbedObjectName == GetOwner()->GetName()) {
            // Then release all the holds from other balls on this ball.
            for (AActor* Ball : BallsInLevel) {
                UPhysicsHandleComponent* BallHandle = Ball->FindComponentByClass<UPhysicsHandleComponent>();
                if (BallHandle->GrabbedComponent->GetOwner()->GetName() == GetOwner()->GetName())
                    // This will probably fight with GrabComponent with each Ball, but it mostly works.
                    BallHandle->ReleaseComponent();
            }
        }
    }

And it works!.. I think!