TRIGGER HAPPY
The player character used for the game. You can slide, vault, dash, fire a gun, reload it, activate abilities, etc.
It was created with an incomplete prototype verison as a reference.
PLAYER CHARACTER
diagram of player functionality
The fundamental player functionality is split up into two different components, each using a finite state machine (FSM). One for movement and one for combat.
Both FSMs make use of a so-called “context”, holding information regarding what the player wants to do, is doing, and anything else relevant. This is the primary way state switching is handled.
Input is handled in blueprints and is used to set certain values in each state machine context.
movement system demonstration
The movement FSM has states for walking, crouching, sprinting, sliding, vaulting, and dashing.
The combat FSM has states for idle, firing the gun, reloading, melee, as well using a combat ability.
The ability state is hooked up to an ability component with a “current combat ability” - this is what activates upon switching to this state. It also holds unlocked passive abilities.
combat system demonstration
UWalkState.cpp: Enter() Tick() Move()
bool UWalkState::Enter(FMovementContext Context)
{
//set character movement component max speed, acceleration, etc.
float MoveSpeed = FallbackMoveSpeed;
if (AttributeComponent) MoveSpeed = AttributeComponent->GetAttributeValue(AttributeTags::Attribute_Movement_WalkSpeed);
if (OwnerCharacterMovementComponent) OwnerCharacterMovementComponent->MaxWalkSpeed = MoveSpeed;
CustomCharMoveComp->OnWalkStateEnter.Broadcast();
return true;
}
void UWalkState::Tick(float DeltaTime, FMovementContext Context)
{
Super::Tick(DeltaTime, Context);
if (!OwnerPawn) return;
Move(Context.MoveInput);
if (Context.bWantsToSprint && OwnerPawn->GetVelocity().Size() > 0)
FSM->SetState(USprintState::StaticClass(), Context);
if (Context.bWantsToCrouch)
FSM->SetState(UCrouchState::StaticClass(), Context);
if (Context.bWantsToDash)
FSM->SetState(UDashState::StaticClass(), Context);
}
void UWalkState::Move(FVector2f MoveInput)
{
FVector ForwardBack = OwnerPawn->GetActorForwardVector() * MoveInput.Y;
FVector LeftRight = OwnerPawn->GetActorRightVector() * MoveInput.X;
FVector ResultMoveDir = (ForwardBack + LeftRight).GetSafeNormal();
OwnerPawn->AddMovementInput(ResultMoveDir);
}
SlideVaultState.cpp: Enter() Tick() Exit()
bool USlideVaultState::Enter(FMovementContext Context)
{
if (!Context.bCanVault) return false;
StartSlide(Context.VaultStartLocation);
CustomCharMoveComp->OnSlideVaultStart.Broadcast();
//set height
if (OwnerCapsuleComponent)
{
OwnerCapsuleComponent->SetCapsuleHalfHeight(CurrentCapsuleHalfHeight);
GroundOffset = (InitialCapsuleHalfHeight - CurrentCapsuleHalfHeight) / 2;
FVector NewLocation = FVector(
OwnerPawn->GetActorLocation().X,
OwnerPawn->GetActorLocation().Y,
OwnerPawn->GetActorLocation().Z - GroundOffset);
OwnerPawn->SetActorLocation(NewLocation);
}
return true;
}
void USlideVaultState::Tick(float DeltaTime, FMovementContext Context)
{
Super::Tick(DeltaTime, Context);
if (TryWalkTransition(Context)) return;
}
bool USlideVaultState::Exit()
{
StopSlide();
CustomCharMoveComp->OnSlideVaultFinish.Broadcast();
//set crouch height back to initial
if (OwnerCapsuleComponent)
OwnerCapsuleComponent->SetCapsuleHalfHeight(InitialCapsuleHalfHeight);
FVector NewLocation = FVector(
OwnerPawn->GetActorLocation().X,
OwnerPawn->GetActorLocation().Y,
OwnerPawn->GetActorLocation().Z + GroundOffset);
OwnerPawn->SetActorLocation(NewLocation);
CustomCharMoveComp->OnSlideVaultExecuted();
return true;
}
CustomCharacterMovementComponent.cpp: BeginPlay()
void UCustomCharacterMovementComponent::BeginPlay()
{
Super::BeginPlay();
//Initial Setting
Owner = GetOwner();
if (!Owner) return;
Pawn = Cast<APawn>(GetOwner());
if (!Pawn) return;
if (Owner)
AttributeComponent = Owner->GetComponentByClass<UAttributeComponent>();
//Initial Value
float DashCharges = FallbackMaxDashCharges;
if (AttributeComponent)
DashCharges = AttributeComponent->GetAttributeValue(AttributeTags::Attribute_Dash_MaxCharges);
CurrentDashCharges = DashCharges;
CurrentDashCooldownTime = 0;
CurrentSlideCooldownTime = 0;
CurrentVaultCooldownTime = 0;
InputContext.bMeleeDashUnlocked = false;
InputContext.bMeleeSlideUnlocked = false;
//Movement Finite State Machine
// fsm setup
MovementFSM = NewObject<UMovementFiniteStateMachine>();
MovementFSM->Init(Owner, Pawn, this);
UWalkState* WalkState = NewObject<UWalkState>();
USprintState* SprintState = NewObject<USprintState>();
UCrouchState* CrouchState = NewObject<UCrouchState>();
UDashState* DashState = NewObject<UDashState>();
USlideState* SlideState = NewObject<USlideState>();
URegularVaultState* RegularVaultState = NewObject<URegularVaultState>();
USlideVaultState* SlideVaultState = NewObject<USlideVaultState>();
MovementFSM->SetupStates({
WalkState,
SprintState,
CrouchState,
DashState,
SlideState,
RegularVaultState,
SlideVaultState
});
WalkState->Init(FallbackWalkSpeed);
SprintState->Init(
FallbackSprintSpeed, VaultTraceLength, VaultTraceRadius, MovementTraceChannel,
SprintToVaultHeightRequirement, RegularVaultableTagName, SlideVaultableTagName);
CrouchState->Init(
CrouchDownSpeed, UncrouchSpeed, FallbackCrouchSpeed, CrouchHeightMultiplier);
DashState->Init(
FallbackDashSpeed, DashDuration, AccelerationDuringDash, DashFallSpeedInAir, DashGravityScale,
bCanDashInAir, VaultTraceLength, VaultTraceRadius, MovementTraceChannel,
DashToVaultHeightRequirement, RegularVaultableTagName, SlideVaultableTagName,
MaxDashHitCapacity, FallbackDashHitDamage, FallbackForwardHitKnockbackStrengthForSurvivingEnemies,
UpwardHitKnockbackStrengthForSurvivingEnemies, FallbackForwardHitKnockbackStrengthForDyingEnemies);
SlideState->Init(
FallbackSlideStrength, SlideFrictionLevel, FallbackSlideDuration, SlideHeightMultiplier,
MaxSlideHitCapacity, FallbackSlideHitDamage, FallbackForwardSlideHitKnockbackStrengthForSurvivingEnemies,
UpwardSlideHitKnockbackStrengthForSurvivingEnemies, FallbackForwardSlideHitKnockbackStrengthForDyingEnemies,
SlideHitTraceLength, SlideHitTraceRadius);
RegularVaultState->Init();
SlideVaultState->Init(
SlideVaultStrength, SlideVaultFrictionLevel, SlideVaultHeightMultiplier, SlideVaultableTagName, MovementTraceChannel);
// inital state set
MovementFSM->SetState(UWalkState::StaticClass(), InputContext);
//Components
if (AActor* OwnerActor = MovementFSM->GetOwnerActor())
{
UCharacterMovementComponent* CharMoveComp = OwnerActor->GetComponentByClass<UCharacterMovementComponent>();
CharMoveComp->BrakingDecelerationFalling = FallSpeedInAir;
CharMoveComp->GravityScale = GravityScale;
}
// Subscribe to AttributeValue Changed for max charges to increase current charges by the positive delta of the change
//Subscribe to Events
AttributeComponent->OnAttributeValueChanged.AddDynamic(this, &UCustomCharacterMovementComponent::OnAttributeValueChanged);
}
reflection on player character
This project was not my first time making a player character, but it was my first time making one with a state machine architecture. I learned how useful they can be for controlling the flow of actions outside of AI.
Since it was my first time, there are some things I wish I did differently. The biggest one being where I kept my combat and movement functions. I originally kept the functions in each state, but they definitely should have lived in their respective components, where states call whatever function they need from their parent component. Once I realized more global information needed to be in the component, like ticking a melee cooldown, it was too late to refactor properly.
A smaller issue was having two movement components - unreal engine 5’s own and the custom one I wrote. Since my custom component was using unreal’s pre-built movement functions anyways, I should have just made mine inherit from unreal’s.
Lastly, I wish I could have had more time to work on the abilities. It’s done in a pretty messy way, where it’s a little hard to read the scripts and understand how they tick down, manage cooldowns, etc. Because this was only a four-week project, I ended up with not enough time to properly plan out the system.
MORE PROJECTS