Sample of a custom climbing movement done in Unreal Engine 5 with Paper2D.
This project is an example of how to write a custom climbing movement in a Paper2D game, with the constraint of being fully replicated over network.
Prerequisites:
For an Unreal Engine 4 version, check the branch ue4.25.
- Keyboard/Gamepad controls
- Detecting when character can climb
- Switching to climbing movement mode
- Moving while climbing
- Allowing to jump while climbing
- Z(A)QSD/Left Thumbstick: move
- Space/Face Bottom Button: jump
- Left CTRL/Right Trigger (hold): climb
To climb you have to maintain Left CTRL/Right Trigger the whole time. Releasing this input or moving out of a grid while climbing will immediatly make the character fall. It is possible to jump while climbing. If so, the character will have a slight cooldown before climbing again.
It is encouraged to test the climbing system in multiplayer with Net PktLag=X
, Net PktLoss=X
, Net PktOrder=X
debug commands.
This is the result in multiplayer with Net PktLoss=10
(client on left):
This is the result in multiplayer with Net PktLag=100
(client on left):
While the map is created with a TileMap, it is not used to identify the tiles that character can climb. Instead we use climbable volumes that are directly placed in the level to represent the climbable surfaces:
The implementation of ASampleClimbableVolume.cpp
is quite simple as it only serve to detect overlapping with
the character:
void ASampleClimbableVolume::NotifyActorBeginOverlap(class AActor* Other)
{
Super::NotifyActorBeginOverlap(Other);
if (IsValid(Other) && IsValid(this))
{
StaticCast<ASampleCharacter*>(Other)->AddClimbableVolume(this);
}
}
void ASampleClimbableVolume::NotifyActorEndOverlap(class AActor* Other)
{
Super::NotifyActorEndOverlap(Other);
if (IsValid(Other) && IsValid(this))
{
StaticCast<ASampleCharacter*>(Other)->RemoveClimbableVolume(this);
}
}
In ASampleCharacter.cpp
we enable climbing if the character is overlapping at least one climbing volume:
void ASampleCharacter::AddClimbableVolume(ASampleClimbableVolume* Volume)
{
Volumes.Add(Volume);
SetClimbEnabled(true);
}
void ASampleCharacter::RemoveClimbableVolume(ASampleClimbableVolume* Volume)
{
Volumes.Remove(Volume);
if (Volumes.Num() == 0)
{
SetClimbEnabled(false);
}
}
The climbing system works in a similar way to the crouching system. When pressing/releasing the
Climb
input, we toggle a boolean bWantsToClimb
in the movement component:
void ASampleCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
...
PlayerInputComponent->BindAction("Climb", IE_Pressed, this, &ASampleCharacter::StartClimb);
PlayerInputComponent->BindAction("Climb", IE_Released, this, &ASampleCharacter::StopClimb);
...
}
void ASampleCharacter::StartClimb()
{
USampleCharacterMovementComponent* MoveComponent = Cast<USampleCharacterMovementComponent>(GetMovementComponent());
if (MoveComponent)
{
if (CanClimb())
{
MoveComponent->bWantsToClimb = true;
}
}
}
void ASampleCharacter::StopClimb()
{
USampleCharacterMovementComponent* MoveComponent = Cast<USampleCharacterMovementComponent>(GetMovementComponent());
if (MoveComponent)
{
MoveComponent->bWantsToClimb = false;
}
}
This boolean is replicated to the server with a custom FSavedMove
structure:
void FSavedMove_SampleCharacter::SetMoveFor(ACharacter* Character, float InDeltaTime, FVector const& NewAccel, class FNetworkPredictionData_Client_Character& ClientData)
{
// Character -> Save
USampleCharacterMovementComponent* MoveComponent = Cast<USampleCharacterMovementComponent>(Character->GetMovementComponent());
...
bWantsToClimb = MoveComponent->bWantsToClimb;
Super::SetMoveFor(Character, InDeltaTime, NewAccel, ClientData);
}
void FSavedMove_SampleCharacter::PrepMoveFor(ACharacter* Character)
{
// Save -> Character
USampleCharacterMovementComponent* MoveComponent = Cast<USampleCharacterMovementComponent>(Character->GetCharacterMovement());
if (MoveComponent)
{
...
MoveComponent->bWantsToClimb = bWantsToClimb;
}
Super::PrepMoveFor(Character);
}
And is used to switch from/to our custom climbing movement mode:
void USampleCharacterMovementComponent::UpdateCharacterStateBeforeMovement(float DeltaSeconds)
{
Super::UpdateCharacterStateBeforeMovement(DeltaSeconds);
...
// Proxies get replicated climb state.
if (CharacterOwner->GetLocalRole() != ROLE_SimulatedProxy)
{
// Check for a change in climb state. Players toggle climb by changing bWantsToClimb.
const bool bIsClimbing = IsClimbing();
if (bIsClimbing && (!bWantsToClimb || !CanClimbInCurrentState()))
{
UnClimb(false);
}
else if (!bIsClimbing && bWantsToClimb && CanClimbInCurrentState())
{
Climb(false);
}
}
}
void USampleCharacterMovementComponent::UpdateCharacterStateAfterMovement(float DeltaSeconds)
{
Super::UpdateCharacterStateAfterMovement(DeltaSeconds);
// Proxies get replicated climb state.
if (CharacterOwner->GetLocalRole() != ROLE_SimulatedProxy)
{
// Unclimb if no longer allowed to be climbing
if (IsClimbing() && !CanClimbInCurrentState())
{
UnClimb(false);
}
}
}
Entering the climbing state sets a custom movement mode:
SetMovementMode(EMovementMode::MOVE_Custom, (uint8)ESampleMovementMode::MOVE_Climbing);
In this mode, all the physics is handled in PhysCustomClimbing
that is a copy of PhysFlying
with slight modifications:
void USampleCharacterMovementComponent::PhysCustomClimbing(float deltaTime, int32 Iterations)
{
if (deltaTime < MIN_TICK_TIME)
{
return;
}
RestorePreAdditiveRootMotionVelocity();
// Apply acceleration
if (!HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity())
{
CalcVelocity(deltaTime, GroundFriction, false, GetMaxBrakingDeceleration());
}
ApplyRootMotionToVelocity(deltaTime);
Iterations++;
bJustTeleported = false;
FVector OldLocation = UpdatedComponent->GetComponentLocation();
const FVector Adjusted = Velocity * deltaTime;
FHitResult Hit(1.f);
SafeMoveUpdatedComponent(Adjusted, UpdatedComponent->GetComponentQuat(), true, Hit);
if (!bJustTeleported && !HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity())
{
Velocity = (UpdatedComponent->GetComponentLocation() - OldLocation) / deltaTime;
}
}
The important part is to override GetMaxSpeed
and GetMaxBrakingDeceleration
functions:
float USampleCharacterMovementComponent::GetMaxSpeed() const
{
if (IsClimbing())
{
return MaxClimbSpeed;
}
return Super::GetMaxSpeed();
}
float USampleCharacterMovementComponent::GetMaxBrakingDeceleration() const
{
if (IsClimbing())
{
return BrakingDecelerationClimbing;
}
return Super::GetMaxBrakingDeceleration();
}
Inputs are handled in ASampleCharacter.cpp
:
void ASampleCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
...
PlayerInputComponent->BindAxis("MoveRight", this, &ASampleCharacter::MoveRight);
PlayerInputComponent->BindAxis("MoveUp", this, &ASampleCharacter::MoveUp);
}
void ASampleCharacter::MoveRight(float Value)
{
// Apply the input to the character motion
AddMovementInput(FVector(1.0f, 0.0f, 0.0f), Value);
}
void ASampleCharacter::MoveUp(float Value)
{
// Can only move up if climbing
USampleCharacterMovementComponent* MoveComponent = Cast<USampleCharacterMovementComponent>(GetMovementComponent());
if (MoveComponent && MoveComponent->IsClimbing())
{
AddMovementInput(FVector(0.0f, 0.0f, 1.0f), Value);
}
}
Climbing is done by holding down the Climb
input, and it is possible to jump while climbing.
This is done by overriding CanAttemptJump
and adding a climbing cooldown in DoJump
to prevent the character from re-entering the climbing state right after:
bool USampleCharacterMovementComponent::CanClimbInCurrentState() const
{
return bClimbEnabled && ClimbTimer <= 0.0f && UpdatedComponent && !UpdatedComponent->IsSimulatingPhysics();
}
bool USampleCharacterMovementComponent::CanAttemptJump() const
{
if (CanEverJump() && IsClimbing())
{
return true;
}
return Super::CanAttemptJump();
}
bool USampleCharacterMovementComponent::DoJump(bool bReplayingMoves)
{
bool bWasClimbing = IsClimbing();
if (Super::DoJump(bReplayingMoves))
{
if (bWasClimbing)
{
ClimbTimer = ClimbCooldown;
}
return true;
}
return false;
}
As for bWantsToClimb
, this cooldown is replicated via the custom FSavedMove
:
void FSavedMove_SampleCharacter::SetMoveFor(ACharacter* Character, float InDeltaTime, FVector const& NewAccel, class FNetworkPredictionData_Client_Character& ClientData)
{
// Character -> Save
USampleCharacterMovementComponent* MoveComponent = Cast<USampleCharacterMovementComponent>(Character->GetMovementComponent());
ClimbTimer = MoveComponent->ClimbTimer;
...
Super::SetMoveFor(Character, InDeltaTime, NewAccel, ClientData);
}
void FSavedMove_SampleCharacter::PrepMoveFor(ACharacter* Character)
{
// Save -> Character
USampleCharacterMovementComponent* MoveComponent = Cast<USampleCharacterMovementComponent>(Character->GetCharacterMovement());
if (MoveComponent)
{
MoveComponent->ClimbTimer = ClimbTimer;
...
}
Super::PrepMoveFor(Character);
}
It is important to implement IsImportantMove
, CanCombineWith
and CombineWith
functions correctly so we don't
send too many packets between the client and server:
bool FSavedMove_SampleCharacter::CanCombineWith(const FSavedMovePtr& NewMove, ACharacter* Character, float MaxDelta) const
{
const FSavedMove_SampleCharacter* SampleNewMove = (FSavedMove_SampleCharacter*)&NewMove;
if (!FMath::IsNearlyEqual(ClimbTimer, SampleNewMove->ClimbTimer, ClimbTimerThresholdCombine))
{
return false;
}
if ((ClimbTimer <= 0.0f) != (SampleNewMove->ClimbTimer <= 0.0f))
{
return false;
}
if ((ClimbTimer > 0.0f) != (SampleNewMove->ClimbTimer > 0.0f))
{
return false;
}
return Super::CanCombineWith(NewMove, Character, MaxDelta);
}
void FSavedMove_SampleCharacter::CombineWith(const FSavedMove_Character* OldMove, ACharacter* InCharacter, APlayerController* PC, const FVector& OldStartLocation)
{
const FSavedMove_SampleCharacter* SampleNewMove = (FSavedMove_SampleCharacter*)&OldMove;
ClimbTimer = SampleNewMove->ClimbTimer;
Super::CombineWith(OldMove, InCharacter, PC, OldStartLocation);
}
bool FSavedMove_SampleCharacter::IsImportantMove(const FSavedMovePtr& LastAckedMove) const
{
const FSavedMove_SampleCharacter* SampleLastAckedMove = (FSavedMove_SampleCharacter*)&LastAckedMove;
if (!FMath::IsNearlyEqual(ClimbTimer, SampleLastAckedMove->ClimbTimer, ClimbTimerThresholdCombine))
{
return true;
}
return Super::IsImportantMove(LastAckedMove);
}
Sprites are coming from The Spriters Resource.
Font from FontSpace.
Licensed under the MIT License.