Logo

Ljung

.dev

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

Hej!

With gravity done implementing jumping is theoretically easy; just add a force upwards for one tick and let gravity handle the rest. In practice however, with our current code, there is much to be done.

The main problem is that we do a ground check every tick, and because of how jumping works we'll probably just zero out our vertical velocity as soon as we start jumping.

Of course, this can be solved with more code, but it quickly gets messy if we don't think about our architecture. Luckily, there's a very common code pattern that can be used to solve this: State Machines!

State Machine

Concept

If you're unfamiliar with State Machines it is very simple. Given a set of distinct abstract "states" (e.g. "Walking", "Falling", "Swimming" etc.) we switch between which set of code we use based on a single variable called "the current state", which can only be one of the state at a time.

The current state is changed based on "transitions" - conditions in each state that causes it to enter another state. For example, if we're "Walking" and we suddenly have no ground, we're now "Falling".

The built-in CharacterMovementComponent actually use this pattern! As well as many other concepts in game development like AI.

Code

Based on this, we should introduce two states, "Walking" and "Falling". Both states share similar functionality (for example both consume and apply horizontal movement) but they differ slightly. I've made a diagram that outline the basic states and functions we're going to refactor/use:

Blue elements represent common functionality between the states, and circles are state transitions.

To support this, we're going to rewrite most of our movement component, but let's start with the state machine. We're going to use an Enum to hold our possible states, a variable with our current state, and two functions for walking and falling respectively.

To our movement component header let's add above our class declaration:

cpp
1UENUM()
2enum EMovementState
3{
4  Walking,
5  Falling
6};

And inside our class declaration, after our tick function let's add:

cpp
1private:
2  EMovementState MovementState;
3
4  void DoMovement_Walking(const float DeltaTime);
5  void DoMovement_Falling(const float DeltaTime);

To keep things nice and functional we'll provide the DeltaTime for them to avoid having to grab it elsewhere.

In our cpp file inside TickComponent we'll remove everything after our velocity reset and replace with the state check and functions:

cpp
1...
2
3switch (MovementState)
4{
5case Walking:
6  DoMovement_Walking(DeltaTime);
7  break;
8case Falling:
9  DoMovement_Falling(DeltaTime);
10  break;
11}
12
13UpdateComponentVelocity();

And that's all for the state machine itself! However we're now missing our walking/falling functionality, but before that we need to implement some additional functions.

Desired Input

Since both Walking and Falling want to use desired (horizontal) input we will move that into a separate function to avoid code duplication. We'll however omit the ConsumeInputVector call from that since that directly modifies a class variable, and we want to keep our functions as pure as possible. Instead we'll provide the input vector as a function parameter.

In our header file, below our MovemenState variable, add:

cpp
1FVector GetDesiredInputMovement(const FVector InputVector) const;

And add the implementation, which is just our horizontal movement calculation from before:

cpp
1FVector USimpleMovementComponent::GetDesiredInputMovement(const FVector InputVector) const
2{
3  const FVector& Input = InputVector.GetClampedToMaxSize2D(1.0f);
4  const FVector Movement = Input * MoveSpeed;
5  return Movement;
6}

Move

The Move function will perform the bulk of our previous functionality. The function will take DeltaTime and return if we hit something, as well as output the hit result.

In our header we add the declaration just above our DoMovement functions:

cpp
1bool Move(FHitResult& OutInitialHit, const float DeltaTime);

And for our definition we basically do the same as our previous tick function, with added handling for the return values:

cpp
1bool USimpleMovementComponent::Move(FHitResult& OutInitialHit, const float DeltaTime)
2{
3  const FRotator& Rotation = UpdatedComponent->GetComponentRotation();
4  const FVector MovementDelta = Velocity * DeltaTime;
5
6  SafeMoveUpdatedComponent(MovementDelta, Rotation, true, OutInitialHit);
7
8  if (OutInitialHit.IsValidBlockingHit())
9  {
10    HandleImpact(OutInitialHit, DeltaTime, MovementDelta);
11
12    FHitResult Hit(OutInitialHit);
13    SlideAlongSurface(MovementDelta, 1.0f - OutInitialHit.Time, OutInitialHit.Normal, Hit, true);
14
15    return true;
16  }
17  return false;
18}

Note that we copy the hit result to use with SlideAlongSurface, because it may modify the hit result in turn, and we are only interested in the initial hit for additional handling later.

We also no longer reset our Z velocity as that is specific to the Falling state.

Ground Check

This is where it becomes a bit more complex, as we cannot use the same hit check for ground as we have for Move, since you might move upwards or collide with a wall. Instead we need to manually implement a similar function that sweeps the world and checks for ground.

The function signature is simple enough; return a bool and hit result similar to the Move function. In our header about the Move function add:

cpp
1bool CheckForGround(FHitResult& OutHit) const;

The internals involve a bit more lines though.

Collider Reference

Manual collision checking is done with either line traces or shape sweeps. In our case we'll do a shape sweep that mimics the same shape as our collider. Our movement component doesn't know about our capsule collider however, so we need keep a reference to it.

In our header just above our MovementState variable add:

cpp
1UPROPERTY()
2UCapsuleComponent* UpdatedCollider;

And above our TickComponent we need to add an override for BeginPlay to grab our collider:

cpp
1
2virtual void BeginPlay() override;

Then in our cpp file add the capsule collider header:

cpp
1
2#include "Components/CapsuleComponent.h"

And implement the BeginPlay function like so:

cpp
1void USimpleMovementComponent::BeginPlay()
2{
3  Super::BeginPlay();
4
5  UpdatedCollider = Cast<UCapsuleComponent>(UpdatedComponent);
6}

We should now have a capsule collider reference in runtime to work with.

Sweep

A shape sweep requires a few parameters:

  • Start location

  • End location

  • Rotation

  • Collision channel

  • Shape

  • Query/response parameters

Most of these can be extracted from our capsule collider, however we need to take note that locations are based on the center point of the shape (not the base).

We'll start of the GroundCheck function by checking if we have our UpdatedCollider:

cpp
1bool USimpleMovementComponent::CheckForGround(FHitResult& OutHit) const
2{
3  if (!UpdatedCollider)
4  {
5    return false;
6  }

Next we setup some constants and calculate our start and end sweep location:

cpp
1constexpr static float ZOffset = 5.0f;
2const FVector Offset = FVector::UpVector * ZOffset;
3const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
4const FVector StartLocation = ColliderLocation;
5const FVector EndLocation = ColliderLocation - Offset;

constexpr static is just an optimization for a purely constant value, but you can also make this a regular UPROPERTY if you intend to tweak it a lot.

Next, we setup our sweep configuration. We need four things:

  • FCollisionQueryParams which we use to make sure we don't self-collide

  • FCollisionResponseParams which controls a few extra flags, that we copy directly from our collider

  • FCollisionShape with the correct capsule shape, which we just grab from our collider as well

  • ECollisionChannel value that tells what to collide against, also provided by the collider

cpp
1FCollisionQueryParams SweepParams;
2SweepParams.AddIgnoredActor(UpdatedCollider->GetOwner());
3
4FCollisionResponseParams ResponseParams;
5UpdatedCollider->InitSweepCollisionParams(SweepParams, ResponseParams);
6
7FCollisionShape SweepShape = UpdatedCollider->GetCollisionShape();
8const ECollisionChannel CollisionChannel = UpdatedCollider->GetCollisionObjectType();

Finally we perform the sweep using the world object, and return if we hit or not:

cpp
1bool bDidHit = GetWorld()->SweepSingleByChannel(
2  OutHit, StartLocation, EndLocation, FQuat::Identity, CollisionChannel, SweepShape, SweepParams,
3  ResponseParams);
4
5return bDidHit;

State: Walking

Our walking function is quite simply now since we've refactored a lot of the code into functions. It is about the same as our previous code, with the addition of changing state to Falling when we aren't grounded anymore:

cpp
1void USimpleMovementComponent::DoMovement_Walking(const float DeltaTime)
2{
3  const FVector InputVector = ConsumeInputVector();
4  const FVector HorizontalMovement = GetDesiredInputMovement(InputVector);
5  Velocity += HorizontalMovement;
6
7  FHitResult Hit;
8  Move(Hit, DeltaTime);
9
10  const bool bIsGrounded = CheckForGround(Hit);
11  
12  if (!bIsGrounded)
13  {
14    MovementState = Falling;
15  }
16}

State: Falling

Our Falling function is similar to our Walking function with the additional code for apply gravity and state change. If you go back to the diagram you'll also see that we need to handle hitting the ceiling, otherwise some interesting oddities will happen.

To check for ceiling we first check if we hit something, and we're moving upwards (Velocity.Z > 0), then grab the dot product of the impact normal and check the angle to see if we hit a flat ceiling, and stop if we do.

If you're unfamiliar with the concepts, the impact normal is a vector that points straight out from the surface we hit, the dot product is a value that tells how close two vectors (like our normal and straight down) are rotation-wise, and to get the angle (in radians) we get the arc-cosine of the value.

cpp
1void USimpleMovementComponent::DoMovement_Falling(const float DeltaTime)
2{
3  const FVector InputVector = ConsumeInputVector();
4  const FVector HorizontalMovement = GetDesiredInputMovement(InputVector);
5
6  // Apply gravity
7  const float GravityForce = GetGravityZ();
8  const FVector VerticalMovement = FVector::UpVector * GravityForce * DeltaTime;
9  const float TerminalVelocity = GetPhysicsVolume()->TerminalVelocity;
10
11  Velocity += HorizontalMovement + VerticalMovement;
12  Velocity.Z = FMath::Max(Velocity.Z, -TerminalVelocity);
13
14  FHitResult Hit;
15  bool bDidHit = Move(Hit, DeltaTime);
16
17  // Check if we hit ceiling
18  if (bDidHit && Velocity.Z > 0)
19  {
20    const float Dot = Hit.ImpactNormal.Dot(-FVector::UpVector);
21    const float Angle = FMath::Acos(Dot);
22    const float StopAngleInRadians = FMath::DegreesToRadians(MaxCeilingStopAngle);
23    if (Angle <= StopAngleInRadians)
24    {
25      Velocity.Z = 0;
26    }
27  }
28
29  const bool bIsGrounded = CheckForGround(Hit);
30  if (bIsGrounded && Velocity.Z <= 0)
31  {
32    MovementState = Walking;
33    Velocity.Z = 0;
34  }
35}

For the ceiling check I've extracted the angle into a UPROPERTY value called MaxCeilingStopAngle and set its default to 5.0f, so we hit a ceiling the angle of the hit is within 5 degrees of straight up.

For our state change we also check that we're moving downwards so we don't accidentally snap when for example jumping over something.

Jumping!

And that leads us (finally!) to jumping. As with any action we need to setup the input.

Input

In our CactusPlayerPawn (or your equivalent) we add an input action (next the other input actions):

cpp
1UPROPERTY(EditDefaultsOnly, Category="Input")
2UInputAction* JumpAction;

And a function declaration (below our other input functions):

cpp
1void OnInput_Jump();

For our implementation the input binding is the same as the others (in SetupPlayerInputComponent):

cpp
1PlayerEnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Triggered, this,
2                                         &ACactusPlayerPawn::OnInput_Jump);

And the jump function will just trigger the jump on our movement component, which we'll setup soon:

cpp
1void ACactusPlayerPawn::OnInput_Jump()
2{
3  MovementComponent->Jump();
4}

For the input action, since jump is always a "press" and not a "hold" like the other inputs, we also add a trigger with "Pressed" to it:

We add the action to our input mapping context and set our desired input keys:

Don't forget to set the input action reference in our Pawn BP!

Movement Code

The movement component header needs two things: a jump force variable, and a jump function (both public):

cpp
1public:
2
3  ...
4
5  UPROPERTY(EditAnywhere, Category="Movement")
6  float JumpForce;
7
8  ...
9
10  void Jump();

I've set the default value of JumpForce in the constructor to 420.0f as a homage to the same default in the built-in character movement component.

And finally finally, the implementation of the jump function we're we simply check if we're Walking, add our jump force, and move to Falling:

cpp
1void USimpleMovementComponent::Jump()
2{
3  if (MovementState == Walking)
4  {
5    Velocity.Z += JumpForce;
6    MovementState = Falling;
7  }
8}

Results

All that refactoring for our 5 lines worth of jump code gives us the ability to jump!

Ceiling

We should also stop when we hit a flat-ish ceiling, but slide along any angled ceilings:

Interestingly, because of how SlideAlongSurface works, we also slightly brake (proportional to the normal) when we hit a slope, giving us somewhat natural physics: