(Feat) Add footsteps

This commit is contained in:
2026-08-11 15:23:10 +02:00
parent e8e128457a
commit 176beab9fc
4776 changed files with 18806 additions and 121 deletions
@@ -0,0 +1,363 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "FootstepComponent.h"
#include "Components/CapsuleComponent.h"
#include "Engine/World.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Kismet/GameplayStatics.h"
UFootstepComponent::UFootstepComponent()
{
PrimaryComponentTick.bCanEverTick = true;
// 20 Hz, et pas la frequence d'affichage. Le composant n'accumule qu'une
// DISTANCE, mesuree entre deux positions exactes : allonger l'intervalle ne
// degrade donc rien, la somme des deplacements reste la meme. A 120 fps
// c'est six fois moins de reveils pour un resultat identique a l'oreille.
//
// Un TickComponent a nous et pas un appel depuis le Tick du pawn, contrairement
// a UHeadBobComponent : lui doit tourner avant que la camera soit composee, ici
// une frame de retard sur un son ne s'entend pas -- et surtout ce composant doit
// rester posable sur n'importe quel ACharacter, y compris ceux qu'on n'ecrit pas.
PrimaryComponentTick.TickInterval = 0.05f;
// Foulees calees sur les frequences de UHeadBobComponent, avec les vitesses
// du pawn : StrideLength = Vitesse / (2 x StrideFrequency).
// accroupi : 200 / (2 x 0.70) = 143
// marche : 400 / (2 x 1.10) = 182
// sprint : 650 / (2 x 1.55) = 210
// Elles sont ici et non en initialiseurs de membre pour que le constructeur
// reste le seul endroit a lire quand on cherche les valeurs par defaut.
CrouchGait = FFootstepGaitSettings(143.f, 0.35f);
WalkGait = FFootstepGaitSettings(182.f, 0.80f);
SprintGait = FFootstepGaitSettings(210.f, 1.00f);
}
void UFootstepComponent::BeginPlay()
{
Super::BeginPlay();
OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter)
{
UE_LOG(LogTemp, Warning, TEXT("UFootstepComponent : le proprietaire n'est pas un ACharacter, les bruits de pas sont desactives."));
SetComponentTickEnabled(false);
return;
}
OwnerMovement = OwnerCharacter->GetCharacterMovement();
// Le seul signal de decollage et de reception qui existe sur les quatre
// machines. Voir le commentaire de HandleMovementModeChanged.
OwnerCharacter->MovementModeChangedDelegate.AddDynamic(this, &UFootstepComponent::HandleMovementModeChanged);
if (!SoundBank)
{
UE_LOG(LogTemp, Warning, TEXT("UFootstepComponent : aucune banque de sons assignee sur %s, aucun bruit de pas ne sera joue."),
*GetNameSafe(GetOwner()));
}
}
void UFootstepComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (OwnerCharacter)
{
OwnerCharacter->MovementModeChangedDelegate.RemoveDynamic(this, &UFootstepComponent::HandleMovementModeChanged);
}
Super::EndPlay(EndPlayReason);
}
void UFootstepComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!bEnableFootsteps || !OwnerCharacter || !OwnerMovement || !SoundBank || !ShouldPlaySounds())
{
return;
}
const FVector Location = OwnerCharacter->GetActorLocation();
// Premier tick valide : on se contente de memoriser la position. Sans ce
// garde-fou, le premier deplacement mesure partirait de l'origine du monde
// et declencherait une rafale de pas au spawn.
if (!bHasLastLocation)
{
LastLocation = Location;
bHasLastLocation = true;
return;
}
const float Travelled = (Location - LastLocation).Size2D();
LastLocation = Location;
// En l'air on ne compte pas de foulee : le vol est traite par les deux cues
// de HandleMovementModeChanged. On y profite du passage pour echantillonner
// la vitesse de chute, introuvable au moment de l'impact.
if (OwnerMovement->IsFalling())
{
MaxFallSpeed = FMath::Max(MaxFallSpeed, -OwnerMovement->Velocity.Z);
return;
}
const float Speed = OwnerMovement->Velocity.Size2D();
const bool bIsMoving = Speed > IdleSpeedThreshold;
if (bIsMoving && !bWasMoving)
{
// Entree en mouvement : on amorce le compteur pour que le premier pas
// tombe tout de suite plutot qu'apres deux metres de silence.
DistanceSinceLastStep = GetGaitSettings(SelectGait(Speed)).StrideLength * FirstStepFraction;
StepsSinceMoveStart = 0;
}
else if (!bIsMoving && bWasMoving)
{
// Immobilisation : le pas final. Conditionne a un vrai pas deja joue,
// sinon un simple a-coup de collision ferait claquer un arret alors que
// le personnage n'a pas avance d'un centimetre.
if (bPlayStopSounds && StepsSinceMoveStart > 0)
{
FVector StopLocation;
if (const FFootstepSurfaceSounds* Sounds = QueryGroundSounds(StopLocation))
{
const FFootstepGaitSettings& StopSettings = GetGaitSettings(LastGait);
PlayCue(Sounds->GetStopSound(LastGait), StopSettings.Volume, StopSettings.PitchVariation, StopLocation);
}
}
DistanceSinceLastStep = 0.f;
StepsSinceMoveStart = 0;
}
bWasMoving = bIsMoving;
if (!bIsMoving)
{
return;
}
DistanceSinceLastStep += Travelled;
const EFootstepGait Gait = SelectGait(Speed);
const float StrideLength = GetGaitSettings(Gait).StrideLength;
if (DistanceSinceLastStep >= StrideLength)
{
// Soustraction et pas remise a zero : le reliquat de distance doit
// survivre au pas, sinon le rythme derive a chaque hoquet de framerate.
DistanceSinceLastStep -= StrideLength;
// Mais borne quand meme : apres une teleportation ou un long gel, le
// reliquat vaudrait plusieurs foulees et partirait en rafale.
DistanceSinceLastStep = FMath::Min(DistanceSinceLastStep, StrideLength);
++StepsSinceMoveStart;
PlayFootstepNow();
}
}
void UFootstepComponent::PlayFootstepNow()
{
if (!OwnerMovement || !SoundBank || !ShouldPlaySounds())
{
return;
}
const EFootstepGait Gait = SelectGait(OwnerMovement->Velocity.Size2D());
FVector StepLocation;
const FFootstepSurfaceSounds* Sounds = QueryGroundSounds(StepLocation);
if (!Sounds)
{
return;
}
const FFootstepGaitSettings& Settings = GetGaitSettings(Gait);
PlayCue(Sounds->GetStepSound(Gait), Settings.Volume, Settings.PitchVariation, StepLocation);
// Memorise APRES coup : c'est cette allure-la que le son d'arret devra
// reprendre, pas celle qu'aura le personnage une fois immobile.
LastGait = Gait;
}
void UFootstepComponent::HandleMovementModeChanged(ACharacter* Character, EMovementMode PrevMovementMode, uint8 PreviousCustomMode)
{
if (!bEnableFootsteps || !OwnerMovement || !SoundBank || !ShouldPlaySounds())
{
return;
}
const EMovementMode NewMode = OwnerMovement->MovementMode;
// Decollage
if (NewMode == MOVE_Falling && PrevMovementMode == MOVE_Walking)
{
MaxFallSpeed = 0.f;
// Un saut, et pas une sortie de rebord : c'est le SIGNE de la vitesse
// verticale qui tranche, exactement comme dans l'AnimBP du corps. Sans
// ce test, marcher au bord d'une caisse ferait grogner le personnage.
if (OwnerMovement->Velocity.Z > 0.f)
{
FVector JumpLocation;
if (const FFootstepSurfaceSounds* Sounds = QueryGroundSounds(JumpLocation))
{
const FFootstepGaitSettings& Settings = GetGaitSettings(SelectGait(OwnerMovement->Velocity.Size2D()));
PlayCue(Sounds->Jump, AirVolume, Settings.PitchVariation, JumpLocation);
}
}
// Le cycle de foulee repart de zero a la reception : bWasMoving remis a
// faux fait passer le premier tick au sol par la branche "entree en
// mouvement", donc par FirstStepFraction. Retomber en pleine course
// redonne ainsi un pas tout de suite.
DistanceSinceLastStep = 0.f;
StepsSinceMoveStart = 0;
bWasMoving = false;
return;
}
// Reception
if (PrevMovementMode == MOVE_Falling && NewMode == MOVE_Walking)
{
if (MaxFallSpeed > LandMinFallSpeed)
{
FVector LandLocation;
if (const FFootstepSurfaceSounds* Sounds = QueryGroundSounds(LandLocation))
{
const FFootstepGaitSettings& Settings = GetGaitSettings(SelectGait(OwnerMovement->Velocity.Size2D()));
PlayCue(Sounds->Land, AirVolume, Settings.PitchVariation, LandLocation);
}
}
MaxFallSpeed = 0.f;
DistanceSinceLastStep = 0.f;
StepsSinceMoveStart = 0;
bWasMoving = false;
}
}
void UFootstepComponent::SetFootstepsEnabled(bool bEnabled)
{
bEnableFootsteps = bEnabled;
if (!bEnabled)
{
ResetStride();
}
}
void UFootstepComponent::ResetStride()
{
DistanceSinceLastStep = 0.f;
StepsSinceMoveStart = 0;
MaxFallSpeed = 0.f;
bWasMoving = false;
// La position memorisee aussi : un respawn deplace le pawn de plusieurs
// centaines de metres, et ce deplacement ne doit pas compter comme un pas.
bHasLastLocation = false;
}
EFootstepGait UFootstepComponent::SelectGait(float HorizontalSpeed) const
{
if (OwnerMovement && OwnerMovement->IsCrouching())
{
return EFootstepGait::Walk;
}
// Le seuil se lit sur la vitesse REELLE et pas sur AFpsPlayer::IsSprinting() :
// le composant reste ainsi posable sur un PNJ, qui n'a pas de touche Maj.
return (HorizontalSpeed > SprintSpeedThreshold) ? EFootstepGait::Run : EFootstepGait::Jog;
}
const FFootstepGaitSettings& UFootstepComponent::GetGaitSettings(EFootstepGait Gait) const
{
switch (Gait)
{
case EFootstepGait::Walk: return CrouchGait;
case EFootstepGait::Run: return SprintGait;
default: return WalkGait;
}
}
const FFootstepSurfaceSounds* UFootstepComponent::QueryGroundSounds(FVector& OutLocation) const
{
if (!SoundBank || !OwnerCharacter)
{
return nullptr;
}
const UCapsuleComponent* Capsule = OwnerCharacter->GetCapsuleComponent();
const float HalfHeight = Capsule ? Capsule->GetScaledCapsuleHalfHeight() : 0.f;
const FVector Center = OwnerCharacter->GetActorLocation();
const FVector Feet = Center - FVector(0.f, 0.f, HalfHeight);
// Repli de position : si le trace ne touche rien, le son part quand meme,
// depuis les pieds. Un silence serait plus difficile a diagnostiquer.
OutLocation = Feet;
const UWorld* World = GetWorld();
if (!World)
{
return &SoundBank->DefaultSurface;
}
// bTraceComplex : voir le commentaire de la propriete. Sans lui on recupere
// le PhysicalMaterial du BodySetup, presque toujours vide, et tout le decor
// sonne sur DefaultSurface.
FCollisionQueryParams Params(SCENE_QUERY_STAT(Footstep), bTraceComplex, OwnerCharacter);
Params.bReturnPhysicalMaterial = true;
FHitResult Hit;
if (!World->LineTraceSingleByChannel(Hit, Center, Feet - FVector(0.f, 0.f, TraceDepth), TraceChannel, Params))
{
return &SoundBank->DefaultSurface;
}
OutLocation = Hit.ImpactPoint;
return &SoundBank->GetSurfaceSounds(UGameplayStatics::GetSurfaceType(Hit));
}
void UFootstepComponent::PlayCue(USoundBase* Sound, float Volume, float PitchVariation, const FVector& Location) const
{
if (!Sound || Volume <= 0.f)
{
return;
}
const float Pitch = (PitchVariation > 0.f)
? FMath::FRandRange(1.f - PitchVariation, 1.f + PitchVariation)
: 1.f;
// Le pas du joueur local en 2D : joue en 3D depuis ses propres chevilles, il
// passe par l'attenuation et sonne creux, alors qu'il devrait etre le son le
// plus present du mixage. Les Cue n'ont pas de SoundClass, donc les deux
// chemins tombent sur SC_SFX, la classe par defaut du projet -- le curseur
// "Effets" les couvre sans qu'on ait rien a taguer.
if (bPlayLocalStepsIn2D && OwnerCharacter && OwnerCharacter->IsLocallyControlled())
{
UGameplayStatics::PlaySound2D(this, Sound, Volume * LocalVolumeScale, Pitch, 0.f, Concurrency, GetOwner());
return;
}
// PlaySoundAtLocation et pas SpawnSoundAttached : un pas est un one-shot de
// quelques dixiemes de seconde, l'attacher creerait puis detruirait un
// UAudioComponent trois fois par seconde et par joueur pour rien.
UGameplayStatics::PlaySoundAtLocation(this, Sound, Location, FRotator::ZeroRotator,
Volume, Pitch, 0.f, Attenuation, Concurrency, GetOwner());
}
bool UFootstepComponent::ShouldPlaySounds() const
{
// Un serveur dedie n'a pas de sortie audio : inutile d'y tracer le sol pour
// quatre personnages. Le projet tourne en serveur d'ecoute, mais la garde ne
// coute qu'une comparaison et evite d'y repenser le jour ou ca change.
const UWorld* World = GetWorld();
return World && World->GetNetMode() != NM_DedicatedServer;
}