Logo

Ljung

.dev

Note: Any code shown is made with Unreal Engine in mind, but the general concepts should be applicable to most 3D-engines.

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

Hej!

In this first part of diving into the movement code, we'll be implementing basic forward/backward/strafing movement as well as camera look. We still have some input-handling boilerplate to go through but we'll start with just the bare minimum so no collision or gravity yet.

It is fairly straightforward. The logic goes:

  1. Grab our desired input in a format we can understand

  2. Translate that input into a direction that is relative to the player pawn's rotation

  3. Pass the input to the movement component

  4. Consume the input in the movement component

  5. Derive a world-space delta to apply based on input and movement speed

  6. Move our player pawn by the calculated delta

The first part is probably the more complex as we have to setup our input bindings first.

Input Bindings

This section will double down as a crash course in Enhanced Input. Without going into too much detail, the main difference between Enhanced Input and a traditional input binding that you might be familiar with is that Enhanced Input takes common operations that are usually expressed in code and abstracts them into configurable objects. Things like handling raw input, deadzones, hold time, rapid firing etc. can be customized through dropdowns instead of code.

You can read more about it on the docs here.

An abstract action that we want to handle ("move", "jump", "shoot") are configured as Input Action objects. The object that binds concrete input (e.g. the "W" key) to an action is an Input Mapping Context object. There are some extra layers to this but that is what we're going to use in our basic scenario.

We need two input actions: Move, and Look.

Right-click an area of your choice in the Content Drawer and add two actions:

I named them IA_Move and IA_Look.

Open IA_Move and set Value Type to Axis2D (Vector2D).

Do the same for IA_Look.

Next right-click again and create an Input Mapping Context object. I named mine IMC_Default.

Open it and add a mapping for IA_Move. Then add control bindings for whatever inputs you want to handle. For this tutorial I'm adding WASD and Gamepad Left Thumbstick 2D-Axis. The idea is to make input mappings so that the final value (the Vector2D value type) has X representing forward (positive) and backward (negative) movement, and Y representing right (positive) and left (negative) movement. To accomplish this for WASD we need to add a few swizzles and negates which is out of this scope, but the result should look like this:

(Dead zone is added to prevent the neutral position from drifting for gamepads.)

Add another mapping entry for IA_Look. This entry is much simpler as we can directly use the input values for mouse XY and the right thumbstick for gamepads. The idea here is that the X-axis represents yaw (left-right) and the Y-axis represents pitch (up-down). It should look like this:

Input Handling

We can now move over to the code that will utilize these objects.

First, to access Enhanced Input in C++ we need to add it to our build script. Open your Build.cs (mine is located as Source/Cactus/Cactus.Build.cs) and add "EnhancedInput" to the public dependencies:

csharp
1PublicDependencyModuleNames.AddRange(new string[]
2  {"Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput"});

(You may need to regenerate project files after doing this.)

Next, we need to do the following things:

  • Setup the Input Mapping Context object in the controller class

  • Add the input actions as UPROPERTY fields so we can set them from the editor

  • Add an empty (for now) Move and Look function

  • Override SetupPlayerInputComponent to handle binding the actions to code

Input Mapping Context

The idea (I think) behind mapping contexts is that you can have different contexts depending on what your doing (main gameplay, pause menu, spectator etc.) that can alter how you handle inputs. For our scenario we'll stick with a single one, and we'll let the Player Controller handle it since it seems appropriate.

We need a property for the Input Mapping Context object that we can set from the editor, and we need to handle using it in our BeginPlay function which is called at the start of the game. In your player controller header file add to the class:

cpp
1public:
2  UPROPERTY(EditDefaultsOnly)
3  class UInputMappingContext* DefaultInputMappingContext;
4  
5protected:
6  virtual void BeginPlay() override;

Then we add the BeginPlay implementation where we fetch the Enhanced Input subsytem and set our context (we also need to include the relevant header):

cpp
1#include "CactusPlayerController.h"
2
3#include "EnhancedInputSubsystems.h"
4
5void ACactusPlayerController::BeginPlay()
6{
7  Super::BeginPlay();
8
9  if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(
10    GetLocalPlayer()))
11  {
12    Subsystem->ClearAllMappings();
13    Subsystem->AddMappingContext(DefaultInputMappingContext, 0);
14  }
15}
16

That's it for the Player Controller class. Don't forget to set the context object (IMC_Default) in your Player Controller BP!

Input Action fields

Next up we need to add two fields of type UInputAction* for the move action and look action. Inside your player pawn header file add:

cpp
1UPROPERTY(EditDefaultsOnly, Category="Input")
2UInputAction* MoveAction;
3
4UPROPERTY(EditDefaultsOnly, Category="Input")
5UInputAction* LookAction;

I've added them just below the component declarations, within the public scope. We also need to have UInputAction accessible. I like to forward declare them however you can also import the action header file. Above my player pawn class definition add:

cpp
1class UInputAction;

Stub functions

We need to add two functions, one for Move and one for Look. We'll implement them soon but for now we just need to get the input handling out of the way. In your pawn header add the functions to the class:

cpp
1private:
2  void OnInput_Move(const FInputActionValue& Value);
3  void OnInput_Look(const FInputActionValue& Value);

We also need to forward declare FInputActionValue:

cpp
1struct FInputActionValue;

Then add the empty implementations to the cpp file. Optionally you can also log the input value to verify that the bindings work later on:

cpp
1void ACactusPlayerPawn::OnInput_Move(const FInputActionValue& Value)
2{
3  UE_LOG(LogTemp, Log, TEXT("Move: %s"), *Value.Get<FVector2D>().ToString());
4}
5
6void ACactusPlayerPawn::OnInput_Look(const FInputActionValue& Value)
7{
8  UE_LOG(LogTemp, Log, TEXT("Look: %s"), *Value.Get<FVector2D>().ToString());
9}

SetupPlayerInputComponent

SetupPlayerInputComponent comes from Pawn and is a protected virtual function that is called when it is time to setup any input bindings (looking at the Unreal source it probably during APawn::PawnClientRestart).

We need to override it and add our own bindings. The base implementation is empty, but we can still call the Super variant in case that ever changes. Binding actions is similar to the built-in way except we cast the input component to the Enhanced Input variant, so it accepts our input action objects.

In the pawn header file add:

cpp
1protected:
2  virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;

And for the cpp file add:

cpp
1void ACactusPlayerPawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
2{
3  Super::SetupPlayerInputComponent(PlayerInputComponent);
4
5  UEnhancedInputComponent* PlayerEnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent);
6  if (PlayerEnhancedInputComponent)
7  {
8    PlayerEnhancedInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this,
9                                             &ACactusPlayerPawn::OnInput_Move);
10    PlayerEnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this,
11                                             &ACactusPlayerPawn::OnInput_Look);
12  }
13}

Testing

Everything should be ready to accept input now. Don't forget to assign the Input Mapping Context object in the Player Controller BP, and the inputs actions in the Pawn BP. If you added the log statements to the Move and Look function, try pressing WASD and moving the mouse. You should see output similar to this:

The inputs should give these values:

  • W should give X=1 Y=0

  • S should give X=-1 Y=0

  • A should give X=0 Y=-1

  • D should give X=0 Y=1

  • Dragging the mouse up should give positive Y

  • Dragging the mouse down should give negative Y

  • Dragging the mouse right should give positive X

  • Dragging the mouse left should give negative X

Look function

We'll cover the look function first since it's simple. It involves some math but is unrelated to most things we will deal with later on.

First we add some editor-exposed variables to adjust how our camera look works. In the pawn header add:

cpp
1UPROPERTY(EditAnywhere, Category="Input")
2FVector2D CameraSensitivity;
3
4UPROPERTY(EditAnywhere, Category="Input")
5FVector2D CameraPitchRange;

We should also set sensible defaults in our constructor in the cpp file:

cpp
1ACactusPlayerPawn::ACactusPlayerPawn(): CameraSensitivity(FVector2D(180.0f, 90.0f)),
2                                        CameraPitchRange(FVector2D(-80.0f, 80.0f))
3{
4  ...
5}

Next, we implement the Look function. The logic goes:

  1. Grab our FVector2D value

  2. Multiply it by the camera sensitivity and delta time

  3. Calculate a new pitch (up and down) value, clamped between our range

  4. Set the cameras rotation to use the new pitch

  5. Calculate a new yaw (left and right) value based on actor rotation

  6. Set actor rotation to use new yaw

The code looks like this:

cpp
1void ACactusPlayerPawn::OnInput_Look(const FInputActionValue& Value)
2{
3  const FVector2D VectorValue = Value.Get<FVector2D>() * CameraSensitivity * GetWorld()->GetDeltaSeconds();
4  
5  // Update pitch
6  const FRotator& CameraRotation = CameraComponent->GetComponentRotation();
7  const float NewPitch = FMath::Clamp(CameraRotation.Pitch + VectorValue.Y, CameraPitchRange.X, CameraPitchRange.Y);
8  const FRotator NewCameraRotator = FRotator(NewPitch, CameraRotation.Yaw, CameraRotation.Roll);
9  CameraComponent->SetWorldRotation(NewCameraRotator);
10  
11  // Update yaw
12  const FRotator& ActorRotation = GetActorRotation();
13  const float NewYaw = ActorRotation.Yaw + VectorValue.X;
14  const FRotator NewActorRotator = FRotator(ActorRotation.Pitch, NewYaw, ActorRotation.Roll);
15  SetActorRotation(NewActorRotator);
16}

You should now be able to look around with the mouse (or gamepad). Feel free to adjust the sensitivity setting in your Pawn BP.

(I've added a cube "arm" to the camera component of our Pawn to help visualize, as well as created a map to test future features).

Move function

Next up is the Move function. The Pawn implementation is very minimal; most of the logic lies in the Movement Component.

The Pawn implementation only needs to transform our local-space XY input into world-space for our movement component to consume. The code looks like this:

cpp
1void ACactusPlayerPawn::OnInput_Move(const FInputActionValue& Value)
2{
3  const FVector2D RawInput = Value.Get<FVector2D>();
4  const FVector ForwardInput = RawInput.X * GetActorForwardVector();
5  const FVector RightInput = RawInput.Y * GetActorRightVector();
6  AddMovementInput(ForwardInput + RightInput);
7}

AddMovementInput is a Pawn function that automatically pipes the input through to the first available Movement Component, which in our case is our USimpleMovementComponent that we've created in our Pawn constructor.

Movement Component

Let's move on to the Movement Component.

First we need to override its tick function, which is the function that gets called every frame. Add to your Movement Component header file:

cpp
1public:
2  virtual void TickComponent(float DeltaTime, ELevelTick TickType,
3                             FActorComponentTickFunction* ThisTickFunction) override;

Then add the implementation. Inside we need to add a few sanity checks before running the main logic:

cpp
1void USimpleMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType,
2                                             FActorComponentTickFunction* ThisTickFunction)
3{
4  Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
5
6  if (ShouldSkipUpdate(DeltaTime))
7  {
8    return;
9  }
10
11  if (!PawnOwner || !UpdatedComponent)
12  {
13    return;
14  }
15
16  // Handle movement
17}

Now we're ready to start moving.

A Movement Component already has access to the root component it manages (in our case our Pawn Capsule Component) in the form of the variable UpdatedComponent. The component can be manipulated directly, but the Movement Component also has access to many helpful functions. One important function is MoveUpdatedComponent which as the name implies handles moving it.

A Movement Component also has a variable called Velocity which can be used to calculate movement. It is helpful to treat it as actual physics velocity. Since Unreal Units (UU) are equivalent of centimeters, we'll use velocity as UU/s.

When we're working with the velocity variable we also need to call UpdateComponentVelocity() at the end of a tick to handle the changes internally.

First we need to grab our input provided previously in our Pawn by AddMovementInput(). This is done by calling ConsumeInputVector().

cpp
1const FVector& Input = ConsumeInputVector().GetClampedToMaxSize2D(1.0f);

Note: the reason we clamp it is because otherwise we the magnitude larger than1 when moving diagonally, which will make us exceed our max speed (by ~1.4x or the square root of 2). This is less pronounced with a gamepad than with WASD because of how the joystick works but still present.

This is actually in many older FPS games, and often considered a useful technique when speedrunning for example, so if you want that you can keep it. Otherwise we simply need to clamp the max size of our input vector.

This input is then used to calculate a directional force, scaled by our desired movement speed.

cpp
1const FVector DesiredInputForce = Input * MoveSpeed;

MoveSpeed is just a float that has been added to our Movement Component. The value equals how many Unreal units we move per second (cm/s). It is helpful to expose it as a UPROPERTY variable so we can change it in the editor. I've also added a constructor to set a default value:

cpp
1public:
2  UPROPERTY(EditAnywhere, Category="Movement")
3  float MoveSpeed;
4
5  USimpleMovementComponent();
cpp
1USimpleMovementComponent::USimpleMovementComponent(): MoveSpeed(600.0f)
2{
3}

The resulting DesiredInputForce is then added to our Velocity, and we calculate a new vector, MovementDelta which will equal how much we move this tick:

cpp
1Velocity += DesiredInputForce;
2const FVector MovementDelta = Velocity * DeltaTime;

We then call the relevant functions to update our actual location.

cpp
1const FRotator& Rotation = UpdatedComponent->GetComponentRotation();
2MoveUpdatedComponent(MovementDelta, Rotation, false);
3UpdateComponentVelocity();

Note that MoveUpdatedComponent requires a rotation as well, so we just grab our current rotation.

Finally we also need to clear our Velocity at the start of a tick so that it doesn't accumulate, since we won't be implementing friction/deceleration right now.

cpp
1Velocity.X = 0;
2Velocity.Y = 0;

The final tick function looks like this:

cpp
1void USimpleMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType,
2                                             FActorComponentTickFunction* ThisTickFunction)
3{
4  Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
5
6  if (ShouldSkipUpdate(DeltaTime))
7  {
8    return;
9  }
10
11  if (!PawnOwner || !UpdatedComponent)
12  {
13    return;
14  }
15  
16  // Reset velocity
17  Velocity.X = 0;
18  Velocity.Y = 0;
19
20  // Calculate force
21  const FVector& Input = ConsumeInputVector().GetClampedToMaxSize2D(1.0f);
22  const FVector DesiredInputForce = Input * MoveSpeed;
23  Velocity += DesiredInputForce;
24  const FVector MovementDelta = Velocity * DeltaTime;
25
26  // Move
27  const FRotator& Rotation = UpdatedComponent->GetComponentRotation();
28  MoveUpdatedComponent(MovementDelta, Rotation, false);
29  UpdateComponentVelocity();
30}
31

Results

We should be able to move around! However we are currently levitating and we can move through walls 👻.