A project using this tutorial series is tracked on GitHub. The revision after this part is c36e06c.

Hej!

This part will handle what happens when we are grounded on geometry that can move. Currently the geometry will move away without us following with it. For horizontally moving geometry we will probably just fall off, and with vertically moving geometry it will clip through us. After this part we will solve both issues which will enable us to create features such as moving platforms and elevators. As a bonus we will also handle platform yaw rotation (Z-axis) but not pitch/roll (more explained why later).

Ground Actor

We first need to keep track of what is directly underneath us so that we can use that to apply additional translation and rotation.

Let's add a new variable to our header:

cpp
12
UPROPERTY()
AActor* GroundActor;

Let's also add a function to manage it:

cpp
1
void UpdateGroundActor();

In our cpp file we can add a default in the constructor as usual:

cpp
1234567
USimpleMovementComponent::USimpleMovementComponent(): MoveSpeed(600.0f), JumpForce(420.0f), MaxCeilingStopAngle(5.0f),
                                                      MaxStepUpHeight(20.0f),
                                                      MinStepUpSteepness(10.0f),
                                                      MaxStepDownHeight(20.0f),
                                                      GroundActor(nullptr)
{
}

And the implementation of UpdateGroundActor will utilize our CheckForGround function to determine if we're currently standing on a valid actor and assign GroundActor to that:

cpp
1234567891011121314
void USimpleMovementComponent::UpdateGroundActor()
{
  FHitResult GroundHit;
  const bool bDidHit = CheckForGround(GroundHit);

  if (bDidHit)
  {
    GroundActor = GroundHit.GetActor();
  }
  else
  {
    GroundActor = nullptr;
  }
}

This variable should be kept up-to-date each frame so it is appropriate to add a call to it next to UpdateComponentVelocity() in TickComponent which work in a similar way:

cpp
123456789
void USimpleMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType,
                                             FActorComponentTickFunction* ThisTickFunction)
{

  ...
  
  UpdateComponentVelocity();
  UpdateGroundActor();
}

Translation (movement)

Let's add another function that will handle adding any movement caused by our ground actor:

cpp
1
void AdjustFromGroundMovement(const float DeltaTime);

The implementation is fairly straightforward; check the velocity of GroundActor and move our component accordingly. This will move us with the ground:

cpp
12345678910
void USimpleMovementComponent::AdjustFromGroundMovement(const float DeltaTime)
{
  if (GroundActor)
  {
    FRotator NewRotation = UpdatedCollider->GetComponentRotation();
    FVector GroundActorLocationDelta = GroundActor->GetVelocity() * DeltaTime;

    MoveUpdatedComponent(GroundActorLocationDelta, NewRotation, false);
  }
}

We then call this after our other movements and stepping functions in our DoMovement_Walking state:

cpp
1234567891011121314
void USimpleMovementComponent::DoMovement_Walking(const float DeltaTime)
{

  ...

  StepDown();

  AdjustFromGroundMovement(DeltaTime);

  const bool bIsGrounded = CheckForGround(Hit);

  ...
  
}

We can now stand on a moving object and we will move together with it!

We can also still step up and down on a moving stair-like object:

And we can use vertical "elevators":

Rotation (yaw)

There's no GetVelocity equivalent of rotation (that I know of), so instead we need to track the rotation of our ground actor ourselves. We can do this by adding an additional variable to our header:

cpp
1
float GroundActorLastYaw;

We also set a default of 0.0f as usual in our constructor.

We then edit our UpdateGroundActor function to also track the yaw:

cpp
1234567891011
...

if (bDidHit)
{
  GroundActor = GroundHit.GetActor();
  GroundActorLastYaw = GroundActor->GetActorRotation().Yaw;
}
else
{

  ...

And now we can tackle the actual rotation logic by editing AdjustFromGroundMovement.

First we calculate the yaw delta similarly to the location:

cpp
12
const float GroundActorCurrentYaw = GroundActor->GetActorRotation().Yaw;
const float GroundActorYawDelta = GroundActorCurrentYaw - GroundActorLastYaw;

Then we check if the delta is not zero at which point we need to handle changes.

When we're standing on something that rotates we also need to rotate with it so we continue to face the "same" direction. We can do this by simply adding the yaw delta to our own rotation:

cpp
1234
if (FMath::Abs(GroundActorYawDelta) > 0.0f)
{
  NewRotation.Yaw += GroundActorYawDelta;
}

Moving with the rotation

This works as perfectly as long as we're standing on the same origin point of the rotating platform, but once we're standing just slightly off we also need to handle a change to our own location relative to the platform, so we continue to stand on the same spot on the platform itself.

If we imagine a platform (top view), where we (blue open circle) stand on its edge:

And that platform rotates 90°:

Then in addition to rotating our "look at" direction we also need to translate our location.

To do that we can calculate our initial location as an offset relative to the platform's origin (e.g. we are 100 units on the X-axis away from the center of the platform), rotate that offset by the change of rotation that we want to apply, and the add the newly rotated offset back onto our initial location.

Here's a picture to help visualize:

  • The initial offset (self location relative to ground origin) is denoted S.

  • The rotated offset that is relative to ground origin is denoted Og.

  • The rotated offset relative to self is denoted Os, which is the final value that we want to apply to our current location.

Implementation

Getting the initial offset S is done by taking our absolute world location and subtracting the world location of our platform:

cpp
12
FVector SelfLocationRelativeToGroundActor = UpdatedCollider->GetComponentLocation() - GroundActor->
        GetActorLocation();

We also want to ignore changes in height (Z-axis) since we're only handling yaw for now so we want to act as if we're working on a 2D-plane:

cpp
1
SelfLocationRelativeToGroundActor.Z = 0;

Next we compute an FRotator for our rotation delta:

cpp
1
const FRotator DeltaRotator = FRotator::MakeFromEuler(FVector(0, 0, GroundActorYawDelta));

To get the rotated offset Og we call RotateVector on said FRotator passing the location that we want to rotate (our current offset S):

cpp
12
const FVector OffsetRelativeToGroundActor = DeltaRotator.
        RotateVector(SelfLocationRelativeToGroundActor);

Then we calculate the offset relative to our current location Os by subtracting S from Og:

cpp
12
const FVector RotationOffsetRelativeToSelf = OffsetRelativeToGroundActor -
        SelfLocationRelativeToGroundActor;

We can then add this offset to our location delta for the entire adjustment function:

cpp
1
GroundActorLocationDelta += RotationOffsetRelativeToSelf;

The entire AdjustFromGroundMovement now looks like this:

cpp
123456789101112131415161718192021222324252627
void USimpleMovementComponent::AdjustFromGroundMovement(const float DeltaTime)
{
  if (GroundActor)
  {
    FRotator NewRotation = UpdatedCollider->GetComponentRotation();
    FVector GroundActorLocationDelta = GroundActor->GetVelocity() * DeltaTime;
    const float GroundActorCurrentYaw = GroundActor->GetActorRotation().Yaw;
    const float GroundActorYawDelta = GroundActorCurrentYaw - GroundActorLastYaw;

    if (FMath::Abs(GroundActorYawDelta) > 0.0f)
    {
      NewRotation.Yaw += GroundActorYawDelta;
      FVector SelfLocationRelativeToGroundActor = UpdatedCollider->GetComponentLocation() - GroundActor->
        GetActorLocation();
      SelfLocationRelativeToGroundActor.Z = 0;

      const FRotator DeltaRotator = FRotator::MakeFromEuler(FVector(0, 0, GroundActorYawDelta));
      const FVector OffsetRelativeToGroundActor = DeltaRotator.
        RotateVector(SelfLocationRelativeToGroundActor);
      const FVector RotationOffsetRelativeToSelf = OffsetRelativeToGroundActor -
        SelfLocationRelativeToGroundActor;
      GroundActorLocationDelta += RotationOffsetRelativeToSelf;
    }

    MoveUpdatedComponent(GroundActorLocationDelta, NewRotation, false);
  }
}

And with that we rotate correctly on platforms:

Results

We can now utilize basic moving geometry in our level design.

Pitch/roll?

If the platform we're standing on rotates on an axis besides the Z-axis (yaw) we will currently clip through. The reason is that our character shouldn't directly rotate pitch or roll since we always want to stay upright. Instead, this will be handled by much more generalized logic that handles "encroaching" geometry which we will look at next.