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!
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.
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:
UENUM()
enum EMovementState
{
Walking,
Falling
};
And inside our class declaration, after our tick function let's add:
private:
EMovementState MovementState;
void DoMovement_Walking(const float DeltaTime);
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:
...
switch (MovementState)
{
case Walking:
DoMovement_Walking(DeltaTime);
break;
case Falling:
DoMovement_Falling(DeltaTime);
break;
}
UpdateComponentVelocity();
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.
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:
FVector GetDesiredInputMovement(const FVector InputVector) const;
And add the implementation, which is just our horizontal movement calculation from before:
FVector USimpleMovementComponent::GetDesiredInputMovement(const FVector InputVector) const
{
const FVector& Input = InputVector.GetClampedToMaxSize2D(1.0f);
const FVector Movement = Input * MoveSpeed;
return Movement;
}
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:
bool 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:
bool USimpleMovementComponent::Move(FHitResult& OutInitialHit, const float DeltaTime)
{
const FRotator& Rotation = UpdatedComponent->GetComponentRotation();
const FVector MovementDelta = Velocity * DeltaTime;
SafeMoveUpdatedComponent(MovementDelta, Rotation, true, OutInitialHit);
if (OutInitialHit.IsValidBlockingHit())
{
HandleImpact(OutInitialHit, DeltaTime, MovementDelta);
FHitResult Hit(OutInitialHit);
SlideAlongSurface(MovementDelta, 1.0f - OutInitialHit.Time, OutInitialHit.Normal, Hit, true);
return true;
}
return false;
}
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.
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:
bool CheckForGround(FHitResult& OutHit) const;
The internals involve a bit more lines though.
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:
UPROPERTY()
UCapsuleComponent* UpdatedCollider;
And above our TickComponent
we need to add an override for BeginPlay
to grab our collider:
virtual void BeginPlay() override;
Then in our cpp file add the capsule collider header:
#include "Components/CapsuleComponent.h"
And implement the BeginPlay
function like so:
void USimpleMovementComponent::BeginPlay()
{
Super::BeginPlay();
UpdatedCollider = Cast<UCapsuleComponent>(UpdatedComponent);
}
We should now have a capsule collider reference in runtime to work with.
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:
bool USimpleMovementComponent::CheckForGround(FHitResult& OutHit) const
{
if (!UpdatedCollider)
{
return false;
}
Next we setup some constants and calculate our start and end sweep location:
constexpr static float ZOffset = 5.0f;
const FVector Offset = FVector::UpVector * ZOffset;
const FVector ColliderLocation = UpdatedCollider->GetComponentLocation();
const FVector StartLocation = ColliderLocation;
const FVector EndLocation = ColliderLocation - Offset;
constexpr static
is just an optimization for a purely constant value, but you can also make this a regularUPROPERTY
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
FCollisionQueryParams SweepParams;
SweepParams.AddIgnoredActor(UpdatedCollider->GetOwner());
FCollisionResponseParams ResponseParams;
UpdatedCollider->InitSweepCollisionParams(SweepParams, ResponseParams);
FCollisionShape SweepShape = UpdatedCollider->GetCollisionShape();
const ECollisionChannel CollisionChannel = UpdatedCollider->GetCollisionObjectType();
Finally we perform the sweep using the world object, and return if we hit or not:
bool bDidHit = GetWorld()->SweepSingleByChannel(
OutHit, StartLocation, EndLocation, FQuat::Identity, CollisionChannel, SweepShape, SweepParams,
ResponseParams);
return bDidHit;
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:
void USimpleMovementComponent::DoMovement_Walking(const float DeltaTime)
{
const FVector InputVector = ConsumeInputVector();
const FVector HorizontalMovement = GetDesiredInputMovement(InputVector);
Velocity += HorizontalMovement;
FHitResult Hit;
Move(Hit, DeltaTime);
const bool bIsGrounded = CheckForGround(Hit);
if (!bIsGrounded)
{
MovementState = 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.
void USimpleMovementComponent::DoMovement_Falling(const float DeltaTime)
{
const FVector InputVector = ConsumeInputVector();
const FVector HorizontalMovement = GetDesiredInputMovement(InputVector);
// Apply gravity
const float GravityForce = GetGravityZ();
const FVector VerticalMovement = FVector::UpVector * GravityForce * DeltaTime;
const float TerminalVelocity = GetPhysicsVolume()->TerminalVelocity;
Velocity += HorizontalMovement + VerticalMovement;
Velocity.Z = FMath::Max(Velocity.Z, -TerminalVelocity);
FHitResult Hit;
bool bDidHit = Move(Hit, DeltaTime);
// Check if we hit ceiling
if (bDidHit && Velocity.Z > 0)
{
const float Dot = Hit.ImpactNormal.Dot(-FVector::UpVector);
const float Angle = FMath::Acos(Dot);
const float StopAngleInRadians = FMath::DegreesToRadians(MaxCeilingStopAngle);
if (Angle <= StopAngleInRadians)
{
Velocity.Z = 0;
}
}
const bool bIsGrounded = CheckForGround(Hit);
if (bIsGrounded && Velocity.Z <= 0)
{
MovementState = Walking;
Velocity.Z = 0;
}
}
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.
And that leads us (finally!) to jumping. As with any action we need to setup the input.
In our CactusPlayerPawn
(or your equivalent) we add an input action (next the other input actions):
UPROPERTY(EditDefaultsOnly, Category="Input")
UInputAction* JumpAction;
And a function declaration (below our other input functions):
void OnInput_Jump();
For our implementation the input binding is the same as the others (in SetupPlayerInputComponent
):
PlayerEnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Triggered, this,
&ACactusPlayerPawn::OnInput_Jump);
And the jump function will just trigger the jump on our movement component, which we'll setup soon:
void ACactusPlayerPawn::OnInput_Jump()
{
MovementComponent->Jump();
}
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!
The movement component header needs two things: a jump force variable, and a jump function (both public
):
public:
...
UPROPERTY(EditAnywhere, Category="Movement")
float JumpForce;
...
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:
void USimpleMovementComponent::Jump()
{
if (MovementState == Walking)
{
Velocity.Z += JumpForce;
MovementState = Falling;
}
}
All that refactoring for our 5 lines worth of jump code gives us the ability to jump!
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: