(Feat) Add Animation

This commit is contained in:
2026-08-06 13:27:21 +02:00
parent fca1b271f1
commit 72a34f6e40
64 changed files with 1943 additions and 83 deletions
@@ -0,0 +1,62 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "FpsArmsAnimInstance.h"
#include "FpsPlayer.h"
#include "GameFramework/CharacterMovementComponent.h"
void UFpsArmsAnimInstance::NativeInitializeAnimation()
{
Super::NativeInitializeAnimation();
OwningPlayer = Cast<AFpsPlayer>(TryGetPawnOwner());
}
void UFpsArmsAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
// L'AnimInstance est initialisee avec le composant, c'est-a-dire potentiellement
// avant que le pawn soit completement en place. On retente donc tant qu'on n'a
// personne, plutot que d'abandonner definitivement sur un echec au demarrage.
if (!OwningPlayer.IsValid())
{
OwningPlayer = Cast<AFpsPlayer>(TryGetPawnOwner());
if (!OwningPlayer.IsValid())
{
return;
}
}
const AFpsPlayer* Player = OwningPlayer.Get();
const UCharacterMovementComponent* Movement = Player->GetCharacterMovement();
if (!Movement)
{
return;
}
// Size2D et pas Size : en pleine chute la composante verticale ferait courir
// les bras alors que le joueur ne pose pas un pied.
GroundSpeed = Movement->Velocity.Size2D();
// Le test d'acceleration est ce qui distingue « le joueur avance » de « le
// joueur a lache la touche et glisse encore ». Sans lui, l'animation de course
// continue pendant toute la decceleration, les pieds patinant dans le vide --
// invisible sur des bras, mais le meme AnimInstance servira au corps un jour.
bIsMoving = GroundSpeed > MovingSpeedThreshold
&& !Movement->GetCurrentAcceleration().IsNearlyZero();
bIsSprinting = Player->IsSprinting();
bIsFalling = Movement->IsFalling();
bIsCrouching = Movement->IsCrouching();
// La cadence suit la vitesse REELLE et pas l'etat de sprint : c'est ce qui
// fait que les bras accelerent pendant la montee en vitesse, au lieu de
// sauter d'une cadence a l'autre a l'instant ou la touche est pressee.
// Elle couvre donc aussi l'accroupi, gratuitement.
LocomotionPlayRate = FMath::Clamp(
GroundSpeed / ReferenceLocomotionSpeed,
MinLocomotionPlayRate,
MaxLocomotionPlayRate);
}
+95 -6
View File
@@ -3,6 +3,8 @@
#include "FpsPlayer.h"
#include "Animation/AnimInstance.h"
#include "Animation/AnimMontage.h"
#include "Camera/CameraComponent.h"
#include "Camera/CameraTypes.h"
#include "Components/CapsuleComponent.h"
@@ -14,11 +16,13 @@
#include "GameFramework/PlayerController.h"
#include "InputActionValue.h"
#include "CraftingComponent.h"
#include "HeadBobComponent.h"
#include "InteractionComponent.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Net/UnrealNetwork.h"
#include "PickupItem.h"
#include "SurvivalGameInstance.h"
#include "SurvivalStatsComponent.h"
#include "SurvivalUserSettings.h"
@@ -74,6 +78,31 @@ AFpsPlayer::AFpsPlayer()
// ecraserait la rotation chaque frame et tous les effets de tilt seraient invisibles.
FirstPersonCamera->bUsePawnControlRotation = false;
// Bras vue premiere personne, accroches a la camera pour suivre le regard.
FirstPersonArms = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("FirstPersonArms"));
FirstPersonArms->SetupAttachment(FirstPersonCamera);
// Symetrique exact du mesh 3e personne ci-dessous : chacun ne voit que ses
// propres bras. Sans ca, les bras d'un ami flotteraient devant notre objectif.
FirstPersonArms->SetOnlyOwnerSee(true);
// Un mesh colle a l'objectif projetterait une ombre enorme en travers du
// decor. L'ombre du joueur, elle, vient du mesh 3e personne.
FirstPersonArms->SetCastShadow(false);
FirstPersonArms->bCastDynamicShadow = false;
// Les bras ne sont pas dans le monde, ils sont devant l'objectif : une
// collision les ferait pousser des objets et surtout bloquer le trace
// d'interaction, qui part exactement d'entre eux.
FirstPersonArms->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// Chez l'hote, le pawn de chaque ami porte AUSSI ce composant, invisible pour
// tout le monde. Sans cette option le serveur evaluerait quatre AnimBlueprints
// pour quatre meshes que personne ne rend.
FirstPersonArms->VisibilityBasedAnimTickOption = EVisibilityBasedAnimTickOption::OnlyTickPoseWhenRendered;
// Point de depart a affiner dans BP_FpsPlayer : la bonne valeur depend de
// l'origine du FBX (racine aux pieds ? au bassin ?) et de l'orientation
// d'export. Le yaw de -90 est le decalage habituel d'un FBX Blender/Maya.
FirstPersonArms->SetRelativeLocationAndRotation(FVector(0.f, 0.f, -160.f), FRotator(0.f, -90.f, 0.f));
HeadBob = CreateDefaultSubobject<UHeadBobComponent>(TEXT("HeadBob"));
InteractionComponent = CreateDefaultSubobject<UInteractionComponent>(TEXT("InteractionComponent"));
InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(TEXT("InventoryComponent"));
CraftingComponent = CreateDefaultSubobject<UCraftingComponent>(TEXT("CraftingComponent"));
@@ -131,10 +160,18 @@ void AFpsPlayer::BeginPlay()
{
if (DefaultMappingContext)
{
// On ACTIVE le contexte ici, mais on ne le DECLARE pas comme
// remappable : c'est USurvivalGameInstance qui s'en charge, parce
// que le menu principal n'a pas de pawn et doit quand meme
// afficher les touches.
// La DECLARATION des contextes remappables appartient au
// GameInstance -- le menu principal n'a pas de pawn et doit
// pourtant afficher les touches. On la demande quand meme ici, et
// AVANT d'activer le contexte : c'est elle qui traduit les touches
// vers la disposition clavier du joueur, et un AZERTY doit trouver
// ses touches des la premiere image, pas au premier passage dans
// l'ecran de reglages. L'appel est idempotent.
if (USurvivalGameInstance* GameInstance = GetGameInstance<USurvivalGameInstance>())
{
GameInstance->RegisterMappableContexts();
}
Subsystem->AddMappingContext(DefaultMappingContext, 0);
}
else
@@ -176,6 +213,10 @@ void AFpsPlayer::ApplyUserSettings()
bSprintToggleMode = Settings->GetSprintToggle();
bCrouchToggleMode = Settings->GetCrouchToggle();
// Le curseur "secousses de camera" vaut aussi pour le balancement : un joueur
// qui le met a zero demande une camera fixe, pas une camera fixe qui respire.
HeadBob->SetIntensityScale(CameraShakeMultiplier);
// Repasser en maintien alors que la touche n'est plus enfoncee laisserait le
// personnage bloque en sprint : on repart d'un etat propre.
if (!bSprintToggleMode && bIsSprinting)
@@ -228,7 +269,15 @@ void AFpsPlayer::UpdateCameraEffects(float DeltaTime)
CrouchTiltSpring.Update(Step, CrouchTiltFrequency, CrouchTiltDamping, CrouchTiltRampTime, 1.5f);
// ==================================================================
// 3. FOV de sprint
// 3. Balancement de tete
// ==================================================================
// Integre ici et applique dans ApplyCameraTransform, exactement comme les
// ressorts ci-dessus : c'est ce qui permet a CalcCamera de recomposer la
// transform sans faire avancer le temps deux fois.
HeadBob->UpdateBob(Step);
// ==================================================================
// 4. FOV de sprint
// ==================================================================
// On exige un vrai deplacement : sinon le FOV s'ouvrirait juste en
// maintenant Shift a l'arret, ce qui donne un effet de pompage bizarre.
@@ -257,7 +306,18 @@ void AFpsPlayer::ApplyCameraTransform()
// demi-hauteur courante de la capsule, ce saut est annule exactement --
// a condition de lire les deux au meme instant, d'ou l'appel depuis CalcCamera.
const float CapsuleHalfHeight = GetCapsuleComponent()->GetScaledCapsuleHalfHeight();
FirstPersonCamera->SetRelativeLocation(FVector(0.f, 0.f, EyeHeightAboveFeet - CapsuleHalfHeight + PunchSpring.Offset));
// Le balancement s'AJOUTE au reste. Son Y est un decalage lateral dans le
// repere de la capsule, dont le yaw suit deja le regard (bUseControllerRotationYaw) :
// la tete se porte donc bien a droite de ce qu'on regarde, sans conversion.
// Le tangage, lui, n'est pas dans la capsule -- c'est voulu, le balancement
// reste horizontal meme quand on regarde ses pieds.
const FVector BobOffset = HeadBob->GetLocationOffset();
FirstPersonCamera->SetRelativeLocation(FVector(
BobOffset.X,
BobOffset.Y,
EyeHeightAboveFeet - CapsuleHalfHeight + PunchSpring.Offset + BobOffset.Z));
// ==================================================================
// Rotation
@@ -274,6 +334,9 @@ void AFpsPlayer::ApplyCameraTransform()
// regarde vers le bas. C'est ce qui vend l'impact a l'atterrissage.
ViewOffset.Pitch += PunchSpring.Offset * PunchPitchPerCm;
// Roulis et tangage du balancement, dans le meme sac que le reste.
ViewOffset += HeadBob->GetRotationOffset();
if (const AController* OwningController = GetController())
{
FirstPersonCamera->SetWorldRotation(OwningController->GetControlRotation() + ViewOffset);
@@ -812,9 +875,35 @@ void AFpsPlayer::StopCrouch()
void AFpsPlayer::Interact()
{
// Le geste part AVANT la RPC, pas apres sa reponse : un montage declenche a
// l'aller-retour serveur donnerait des bras qui repondent 60 ms apres le clic,
// et le joueur lirait ca comme un jeu qui rame. Le prix a payer est un geste
// dans le vide si le serveur refuse l'interaction -- infiniment moins genant.
//
// Le test de type est ici et pas dans le composant d'interaction : celui-ci ne
// connait que l'interface IInteractable et n'a pas a apprendre ce qu'est un
// pickup. Un coffre ou un etabli n'ont pas a declencher un geste de ramassage.
if (Cast<APickupItem>(InteractionComponent->GetFocusedActor()))
{
PlayArmsMontage(GrabMontage);
}
InteractionComponent->TryInteract();
}
float AFpsPlayer::PlayArmsMontage(UAnimMontage* Montage, float PlayRate)
{
// IsLocallyControlled : chez l'hote, le pawn de chaque ami passerait aussi par
// ici a chaque ramassage et animerait un mesh que personne ne rend.
if (!Montage || !IsLocallyControlled())
{
return 0.f;
}
UAnimInstance* ArmsAnim = FirstPersonArms->GetAnimInstance();
return ArmsAnim ? ArmsAnim->Montage_Play(Montage, PlayRate) : 0.f;
}
void AFpsPlayer::OnStartCrouch(float HalfHeightAdjust, float ScaledHalfHeightAdjust)
{
Super::OnStartCrouch(HalfHeightAdjust, ScaledHalfHeightAdjust);
@@ -0,0 +1,141 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "HeadBobComponent.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
UHeadBobComponent::UHeadBobComponent()
{
// C'est le pawn qui appelle UpdateBob depuis son propre Tick, juste avant de
// composer la transform de la camera. Un tick a nous ne servirait qu'a
// introduire une incertitude d'ordre.
PrimaryComponentTick.bCanEverTick = false;
// Valeurs de depart, a affiner a l'oeil dans BP_FpsPlayer. Elles sont ici et
// non en initialiseurs de membre pour que le constructeur reste le seul
// endroit a lire quand on cherche "combien ca bouge par defaut".
IdleProfile = FHeadBobProfile(0.15f, 0.55f, 0.05f, 0.03f, 0.f);
WalkProfile = FHeadBobProfile(1.1f, 1.6f, 1.2f, 0.4f, 0.f);
SprintProfile = FHeadBobProfile(1.55f, 2.1f, 1.7f, 0.8f, 0.f);
CrouchProfile = FHeadBobProfile(0.7f, 1.f, 0.9f, 0.3f, 0.f);
AirProfile = FHeadBobProfile(0.f, 0.f, 0.f, 0.f, 0.f);
// On demarre deja sur le profil d'immobilite : partir de zeros ferait monter
// la respiration pendant la premiere seconde de jeu, ce qui se voit.
BlendedProfile = IdleProfile;
}
void UHeadBobComponent::BeginPlay()
{
Super::BeginPlay();
OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter)
{
UE_LOG(LogTemp, Warning, TEXT("UHeadBobComponent : le proprietaire n'est pas un ACharacter, le balancement de tete est desactive."));
return;
}
// GetCharacterMovement() est un simple accesseur sur un membre du Character,
// mais le stocker evite un appel indirect par frame pour rien.
OwnerMovement = OwnerCharacter->GetCharacterMovement();
}
void UHeadBobComponent::UpdateBob(float DeltaTime)
{
// IsLocallyControlled : chez l'hote, le pawn de chaque ami porte AUSSI ce
// composant. Sans cette garde, le serveur calculerait trois balancements pour
// des cameras que personne ne regarde.
if (!bEnableHeadBob || !OwnerCharacter || !OwnerMovement || !OwnerCharacter->IsLocallyControlled())
{
// Remise a zero et pas simple sortie : couper l'effet en pleine partie
// laisserait sinon la camera figee sur le dernier decalage calcule.
LocationOffset = FVector::ZeroVector;
RotationOffset = FRotator::ZeroRotator;
return;
}
// Meme garde-fou que les ressorts de camera : un hoquet de framerate ferait
// sauter la phase de plusieurs pas d'un coup.
const float Step = FMath::Min(DeltaTime, 1.f / 30.f);
BlendTowards(SelectTargetProfile(), Step);
// La phase avance a la frequence FONDUE, pas a celle du profil vise : c'est
// ce qui fait que le rythme des pas accelere progressivement au demarrage du
// sprint au lieu de doubler d'une frame a l'autre.
StridePhase = FMath::Fmod(StridePhase + BlendedProfile.StrideFrequency * 2.f * PI * Step, 2.f * PI);
const float Intensity = GlobalIntensity * IntensityScale;
// Une foulee = deux pas. Le balancement lateral suit la foulee entiere (on
// bascule sur un pied puis sur l'autre), la montee-descente bat deux fois plus
// vite, une bosse par pied qui touche le sol. C'est ce rapport 1:2 qui dessine
// le fameux 8 couche et qui fait qu'on lit une marche plutot qu'un flottement.
const float LateralWave = FMath::Sin(StridePhase);
const float VerticalWave = FMath::Sin(StridePhase * 2.f);
// X reste nul : une composante avant-arriere donnerait un effet de zoom qui,
// en vue premiere personne, se lit comme du mal de mer plus que comme un pas.
LocationOffset = FVector(
0.f,
LateralWave * BlendedProfile.LateralAmount * Intensity,
VerticalWave * BlendedProfile.VerticalAmount * Intensity);
// Le roulis suit le balancement lateral : la tete penche du cote ou le corps
// se porte. Le tangage suit la montee-descente, et vaut 0 par defaut.
RotationOffset = FRotator(
VerticalWave * BlendedProfile.PitchAmount * Intensity,
0.f,
LateralWave * BlendedProfile.RollAmount * Intensity);
}
const FHeadBobProfile& UHeadBobComponent::SelectTargetProfile() const
{
if (OwnerMovement->IsFalling())
{
return AirProfile;
}
// Vitesse a plat : la composante verticale d'une pente ou d'un ascenseur n'a
// rien a voir avec la cadence des pas.
const float GroundSpeed = OwnerMovement->Velocity.Size2D();
// L'immobilite passe AVANT l'accroupissement : accroupi sans bouger, on
// respire, on ne marche pas sur place.
if (GroundSpeed < IdleSpeedThreshold)
{
return IdleProfile;
}
if (OwnerCharacter->bIsCrouched)
{
return CrouchProfile;
}
return (GroundSpeed > SprintSpeedThreshold) ? SprintProfile : WalkProfile;
}
void UHeadBobComponent::BlendTowards(const FHeadBobProfile& Target, float Step)
{
BlendedProfile.StrideFrequency = FMath::FInterpTo(BlendedProfile.StrideFrequency, Target.StrideFrequency, Step, ProfileBlendSpeed);
BlendedProfile.VerticalAmount = FMath::FInterpTo(BlendedProfile.VerticalAmount, Target.VerticalAmount, Step, ProfileBlendSpeed);
BlendedProfile.LateralAmount = FMath::FInterpTo(BlendedProfile.LateralAmount, Target.LateralAmount, Step, ProfileBlendSpeed);
BlendedProfile.RollAmount = FMath::FInterpTo(BlendedProfile.RollAmount, Target.RollAmount, Step, ProfileBlendSpeed);
BlendedProfile.PitchAmount = FMath::FInterpTo(BlendedProfile.PitchAmount, Target.PitchAmount, Step, ProfileBlendSpeed);
}
void UHeadBobComponent::ResetBob()
{
StridePhase = 0.f;
BlendedProfile = IdleProfile;
LocationOffset = FVector::ZeroVector;
RotationOffset = FRotator::ZeroRotator;
}
void UHeadBobComponent::SetIntensityScale(float NewScale)
{
IntensityScale = FMath::Max(NewScale, 0.f);
}
@@ -0,0 +1,171 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "KeyboardLayout.h"
#if PLATFORM_WINDOWS
// WindowsHWrapper et pas un <Windows.h> nu : il pose lui-meme les gardes
// Pre/PostWindowsApi qui neutralisent les macros et les typedefs de l'API Win32
// entrant en collision avec ceux du moteur (INT, FLOAT, TEXT...).
#include "Windows/WindowsHWrapper.h"
#endif
namespace KeyboardLayout
{
#if PLATFORM_WINDOWS
/**
* Position physique de chaque touche de reference, en scancode PS/2 set 1.
*
* Ces codes decrivent l'EMPLACEMENT sur le clavier, pas le caractere grave
* dessus : c'est ce qui rend la table independante de la disposition. Ils sont
* figes depuis le clavier IBM de 1984 et ne bougeront plus.
*
* Table ecrite a la main plutot que deduite d'un HKL americain charge a la
* volee : LoadKeyboardLayout ajoute la disposition a celles du systeme, et un
* jeu n'a pas a toucher a la configuration clavier de la machine.
*
* Construite au PREMIER APPEL et pas en variable globale : les constantes de
* EKeys sont initialisees par le moteur au demarrage, et une table globale
* pourrait naitre avant elles.
*/
static const TMap<FName, uint32>& GetReferenceScanCodes()
{
static const TMap<FName, uint32> ScanCodes = []
{
TMap<FName, uint32> Map;
Map.Reserve(48);
// Rangee des chiffres
Map.Add(EKeys::Tilde.GetFName(), 0x29);
Map.Add(EKeys::One.GetFName(), 0x02);
Map.Add(EKeys::Two.GetFName(), 0x03);
Map.Add(EKeys::Three.GetFName(), 0x04);
Map.Add(EKeys::Four.GetFName(), 0x05);
Map.Add(EKeys::Five.GetFName(), 0x06);
Map.Add(EKeys::Six.GetFName(), 0x07);
Map.Add(EKeys::Seven.GetFName(), 0x08);
Map.Add(EKeys::Eight.GetFName(), 0x09);
Map.Add(EKeys::Nine.GetFName(), 0x0A);
Map.Add(EKeys::Zero.GetFName(), 0x0B);
Map.Add(EKeys::Hyphen.GetFName(), 0x0C);
Map.Add(EKeys::Equals.GetFName(), 0x0D);
// Rangee du haut
Map.Add(EKeys::Q.GetFName(), 0x10);
Map.Add(EKeys::W.GetFName(), 0x11);
Map.Add(EKeys::E.GetFName(), 0x12);
Map.Add(EKeys::R.GetFName(), 0x13);
Map.Add(EKeys::T.GetFName(), 0x14);
Map.Add(EKeys::Y.GetFName(), 0x15);
Map.Add(EKeys::U.GetFName(), 0x16);
Map.Add(EKeys::I.GetFName(), 0x17);
Map.Add(EKeys::O.GetFName(), 0x18);
Map.Add(EKeys::P.GetFName(), 0x19);
Map.Add(EKeys::LeftBracket.GetFName(), 0x1A);
Map.Add(EKeys::RightBracket.GetFName(), 0x1B);
Map.Add(EKeys::Backslash.GetFName(), 0x2B);
// Rangee de repos
Map.Add(EKeys::A.GetFName(), 0x1E);
Map.Add(EKeys::S.GetFName(), 0x1F);
Map.Add(EKeys::D.GetFName(), 0x20);
Map.Add(EKeys::F.GetFName(), 0x21);
Map.Add(EKeys::G.GetFName(), 0x22);
Map.Add(EKeys::H.GetFName(), 0x23);
Map.Add(EKeys::J.GetFName(), 0x24);
Map.Add(EKeys::K.GetFName(), 0x25);
Map.Add(EKeys::L.GetFName(), 0x26);
Map.Add(EKeys::Semicolon.GetFName(), 0x27);
Map.Add(EKeys::Apostrophe.GetFName(), 0x28);
// Rangee du bas
Map.Add(EKeys::Z.GetFName(), 0x2C);
Map.Add(EKeys::X.GetFName(), 0x2D);
Map.Add(EKeys::C.GetFName(), 0x2E);
Map.Add(EKeys::V.GetFName(), 0x2F);
Map.Add(EKeys::B.GetFName(), 0x30);
Map.Add(EKeys::N.GetFName(), 0x31);
Map.Add(EKeys::M.GetFName(), 0x32);
Map.Add(EKeys::Comma.GetFName(), 0x33);
Map.Add(EKeys::Period.GetFName(), 0x34);
Map.Add(EKeys::Slash.GetFName(), 0x35);
return Map;
}();
return ScanCodes;
}
#endif // PLATFORM_WINDOWS
FKey RemapKeyToActiveLayout(const FKey& ReferenceKey)
{
#if PLATFORM_WINDOWS
const uint32* ScanCode = GetReferenceScanCodes().Find(ReferenceKey.GetFName());
if (!ScanCode)
{
// Touche insensible a la disposition : espace, modificateur, manette.
return ReferenceKey;
}
// GetKeyboardLayout(0) = la disposition du thread appelant, donc celle que
// le joueur a reellement active -- et pas celle par defaut du systeme, qui
// differe des qu'il en a plusieurs et bascule au clavier.
const HKL ActiveLayout = ::GetKeyboardLayout(0);
const UINT VirtualKey = ::MapVirtualKeyExW(*ScanCode, MAPVK_VSC_TO_VK_EX, ActiveLayout);
if (VirtualKey == 0)
{
// La disposition n'a rien a cette position. Rare, mais possible sur les
// dispositions non latines : on garde la touche d'origine.
return ReferenceKey;
}
// On REPRODUIT ici, a l'identique, le couple que le moteur recevra au
// runtime : FWindowsApplication passe le virtual key ET le caractere non
// modifie rendu par MAPVK_VK_TO_CHAR. Deduire le CharCode du virtual key
// serait faux des que la touche ne porte pas le caractere de son code --
// et c'est precisement le cas de la rangee des chiffres en AZERTY, ou
// Windows garde VK_1 pour une touche qui produit "&". Le moteur y repond
// EKeys::Ampersand ; simuler EKeys::One nous ferait conclure qu'il n'y a
// rien a convertir, et les dix touches de la barre resteraient muettes.
//
// Le bit de poids fort signale une touche morte (l'accent circonflexe d'un
// AZERTY, par exemple) : on le masque, seul le caractere nous interesse.
const UINT CharCode = ::MapVirtualKeyExW(VirtualKey, MAPVK_VK_TO_CHAR, ActiveLayout) & 0x7FFFFFFF;
const FKey MappedKey = FInputKeyManager::Get().GetKeyFromCodes(VirtualKey, CharCode);
// Une touche introuvable dans les deux tables est SYNTHETISEE par le moteur,
// et elle sort marquee non bindable -- l'assigner donnerait une action
// definitivement muette. C'est le seul cas ou l'on prefere une touche fausse
// a une touche morte.
if (!MappedKey.IsValid() || !MappedKey.IsBindableToActions())
{
return ReferenceKey;
}
return MappedKey;
#else
return ReferenceKey;
#endif
}
FString GetActiveLayoutId()
{
#if PLATFORM_WINDOWS
// 16 et pas KL_NAMELENGTH : l'API en ecrit 9 au plus, et un buffer en dur
// evite de dependre d'une macro que WIN32_LEAN_AND_MEAN pourrait retirer.
WCHAR LayoutName[16] = {};
if (::GetKeyboardLayoutNameW(LayoutName))
{
return FString(LayoutName);
}
return TEXT("inconnue");
#else
return TEXT("non gere sur cette plateforme");
#endif
}
}
@@ -9,6 +9,8 @@
#include "Kismet/GameplayStatics.h"
#include "MusicPlaylistDataAsset.h"
#include "Sound/SoundBase.h"
#include "Sound/SoundClass.h"
#include "SurvivalAudioSettings.h"
#include "UObject/UObjectGlobals.h"
UMusicSubsystem* UMusicSubsystem::Get(const UObject* WorldContextObject)
@@ -298,6 +300,20 @@ void UMusicSubsystem::PlayTrackAtCursor(float FadeInDuration)
return;
}
// Un curseur de volume ne doit RIEN faire d'autre que changer le volume.
//
// Sans PlayWhenSilent, une source devenue inaudible -- curseur Musique a zero --
// est declaree TERMINEE par le moteur : OnAudioFinished part, HandleTrackFinished
// enchaîne, et le silence entre deux morceaux (MinGapBetweenTracks) demarre. Le
// joueur qui remonte son volume n'entend alors plus rien jusqu'a la piste
// suivante, alors qu'il n'a fait que bouger un reglage. Avec ce mode le morceau
// continue d'avancer sans etre rendu, et redevient simplement audible.
//
// Pose ICI et pas sur les assets, pour la meme raison que la SoundClass dans
// CreateMusicComponent : ajouter une piste a une playlist ne doit rien demander
// de plus. Ecrire ce champ ne salit pas l'asset -- rien n'appelle MarkPackageDirty.
Track->VirtualizationMode = EVirtualizationMode::PlayWhenSilent;
if (!MusicComponent)
{
MusicComponent = CreateMusicComponent(Track);
@@ -353,6 +369,14 @@ UAudioComponent* UMusicSubsystem::CreateMusicComponent(USoundBase* Sound)
return nullptr;
}
// La classe est imposee ICI plutot que sur chaque asset de morceau : ajouter une
// piste a une playlist ne doit pas demander de penser a la taguer, sinon le
// curseur Musique cesse d'agir sur elle sans le moindre signe.
if (USoundClass* MusicClass = USurvivalAudioSettings::Get()->GetMusicSoundClass())
{
Component->SoundClassOverride = MusicClass;
}
Component->bIsMusic = true;
Component->OnAudioFinished.AddDynamic(this, &UMusicSubsystem::HandleTrackFinished);
return Component;
@@ -0,0 +1,181 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsAudioWidget.h"
#include "SettingRowWidget.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
namespace SettingsAudioTab
{
/** La seule liste de choix de cet onglet. */
TArray<FText> MakeMuteAudibleLabels()
{
return { LOCTEXT("Muet", "Muet"), LOCTEXT("Audible", "Audible") };
}
}
void USettingsAudioWidget::NativeConstruct()
{
Super::NativeConstruct();
using namespace SurvivalSettingsRange;
using namespace SettingsAudioTab;
const FText PercentSuffix = LOCTEXT("Pourcent", " %");
if (MasterVolumeRow)
{
MasterVolumeRow->SetupAsNumber(LOCTEXT("VolumeGeneral", "Volume général"),
VolumeMin, VolumeMax, VolumeStep, VolumeArrowStep, VolumeMin, PercentSuffix);
MasterVolumeRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleMasterVolumeChanged);
}
if (MusicVolumeRow)
{
MusicVolumeRow->SetupAsNumber(LOCTEXT("Musique", "Musique"),
VolumeMin, VolumeMax, VolumeStep, VolumeArrowStep, VolumeMin, PercentSuffix);
MusicVolumeRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleMusicVolumeChanged);
}
if (EffectsVolumeRow)
{
// Cle "EffetsSonores" et pas "Effets" : l'onglet Graphismes utilise deja
// cette derniere pour la qualite des effets visuels, dans le meme namespace.
EffectsVolumeRow->SetupAsNumber(LOCTEXT("EffetsSonores", "Effets sonores"),
VolumeMin, VolumeMax, VolumeStep, VolumeArrowStep, VolumeMin, PercentSuffix);
EffectsVolumeRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleEffectsVolumeChanged);
}
if (UiVolumeRow)
{
UiVolumeRow->SetupAsNumber(LOCTEXT("Interface", "Interface"),
VolumeMin, VolumeMax, VolumeStep, VolumeArrowStep, VolumeMin, PercentSuffix);
UiVolumeRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleUiVolumeChanged);
}
if (BackgroundSoundRow)
{
BackgroundSoundRow->SetupAsChoice(LOCTEXT("SonEnArrierePlan", "Jeu en arrière-plan"),
MakeMuteAudibleLabels(), 0);
BackgroundSoundRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleBackgroundSoundChanged);
}
RefreshFromSettings();
}
void USettingsAudioWidget::RefreshFromSettings()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
UE_LOG(LogTemp, Error, TEXT("USettingsAudioWidget : GameUserSettingsClassName n'est pas configure dans DefaultEngine.ini, l'onglet restera inerte."));
return;
}
// SetNumericValueSilently : diffuser ici ferait boucler l'onglet sur lui-meme.
if (MasterVolumeRow)
{
MasterVolumeRow->SetNumericValueSilently(Settings->GetMasterVolume());
}
if (MusicVolumeRow)
{
MusicVolumeRow->SetNumericValueSilently(Settings->GetMusicVolume());
}
if (EffectsVolumeRow)
{
EffectsVolumeRow->SetNumericValueSilently(Settings->GetEffectsVolume());
}
if (UiVolumeRow)
{
UiVolumeRow->SetNumericValueSilently(Settings->GetUiVolume());
}
if (BackgroundSoundRow)
{
BackgroundSoundRow->SetIndexSilently(Settings->GetPlayInBackground() ? 1 : 0);
}
}
void USettingsAudioWidget::ResetToDefaults()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
Settings->SetSurvivalAudioToDefaults();
Settings->ApplyAudioSettings();
RefreshFromSettings();
}
void USettingsAudioWidget::HandleMasterVolumeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !MasterVolumeRow)
{
return;
}
Settings->SetMasterVolume(MasterVolumeRow->GetNumericValue());
// Pas de SaveSettings ici : c'est l'ecran de reglages qui ecrit le .ini en se
// fermant. Meme regle que les autres onglets.
Settings->ApplyAudioSettings();
}
void USettingsAudioWidget::HandleMusicVolumeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !MusicVolumeRow)
{
return;
}
Settings->SetMusicVolume(MusicVolumeRow->GetNumericValue());
Settings->ApplyAudioSettings();
}
void USettingsAudioWidget::HandleEffectsVolumeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !EffectsVolumeRow)
{
return;
}
Settings->SetEffectsVolume(EffectsVolumeRow->GetNumericValue());
Settings->ApplyAudioSettings();
}
void USettingsAudioWidget::HandleUiVolumeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !UiVolumeRow)
{
return;
}
// Aucun son de test a jouer : les fleches de la ligne portent deja le son de
// clic de l'interface, donc le joueur entend le reglage qu'il est en train de
// faire, sur le son exact qu'il regle.
Settings->SetUiVolume(UiVolumeRow->GetNumericValue());
Settings->ApplyAudioSettings();
}
void USettingsAudioWidget::HandleBackgroundSoundChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetPlayInBackground(NewIndex == 1);
Settings->ApplyAudioSettings();
}
}
#undef LOCTEXT_NAMESPACE
@@ -186,6 +186,16 @@ void USettingsControlsWidget::ResetToDefaults()
}
Settings->ApplySettings();
// Reinitialiser rend les touches de l'IMC, donc du QWERTY : sur un clavier
// AZERTY le joueur se retrouverait avec un WASD qu'il ne peut pas atteindre.
// On repose donc la disposition juste apres. "Par defaut" veut dire "par defaut
// POUR TON CLAVIER", pas "par defaut pour le clavier du developpeur".
if (USurvivalGameInstance* GameInstance = GetGameInstance<USurvivalGameInstance>())
{
GameInstance->ApplyKeyboardLayoutToDefaultKeys();
}
RefreshFromSettings();
}
@@ -18,8 +18,8 @@
namespace
{
// Ordre des pages dans le WidgetSwitcher. Les trois dernieres n'existent pas
// encore : leur bouton se grise tout seul tant que la page est absente.
// Ordre des pages dans le WidgetSwitcher. Un bouton dont la page manque se
// grise tout seul, ce qui reste vrai si un onglet vient a etre retire.
constexpr int32 TabIndexGameplay = 0;
constexpr int32 TabIndexDisplay = 1;
constexpr int32 TabIndexGraphics = 2;
@@ -0,0 +1,61 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SurvivalAudioSettings.h"
#include "Sound/SoundClass.h"
FName USurvivalAudioSettings::GetCategoryName() const
{
return TEXT("Game");
}
USurvivalAudioSettings* USurvivalAudioSettings::Get()
{
// GetMutableDefault et non GetDefault : les champs Cached* sont remplis a la
// demande, donc les accesseurs ne peuvent pas etre const. Ce ne sont ni des
// UPROPERTY ni des donnees serialisees -- muter un CDO n'est un probleme que
// pour ce qui part sur le disque.
return GetMutableDefault<USurvivalAudioSettings>();
}
USoundClass* USurvivalAudioSettings::Resolve(const TSoftObjectPtr<USoundClass>& Asset, TStrongObjectPtr<USoundClass>& Cache)
{
if (Cache.IsValid())
{
return Cache.Get();
}
if (Asset.IsNull())
{
return nullptr;
}
// Synchrone assume : une SoundClass est un objet minuscule, et le chargement
// n'a lieu qu'une fois par lancement.
//
// Reset et pas une affectation : c'est ce Reset qui pose l'ancre GC, et la
// raison pour laquelle ces caches ne sont pas des UPROPERTY (voir le header).
Cache.Reset(Asset.LoadSynchronous());
return Cache.Get();
}
USoundClass* USurvivalAudioSettings::GetMasterSoundClass()
{
return Resolve(MasterSoundClass, CachedMasterSoundClass);
}
USoundClass* USurvivalAudioSettings::GetMusicSoundClass()
{
return Resolve(MusicSoundClass, CachedMusicSoundClass);
}
USoundClass* USurvivalAudioSettings::GetEffectsSoundClass()
{
return Resolve(EffectsSoundClass, CachedEffectsSoundClass);
}
USoundClass* USurvivalAudioSettings::GetUiSoundClass()
{
return Resolve(UiSoundClass, CachedUiSoundClass);
}
@@ -10,6 +10,7 @@
#include "Engine/LocalPlayer.h"
#include "GameMapsSettings.h"
#include "InputMappingContext.h"
#include "KeyboardLayout.h"
#include "Kismet/GameplayStatics.h"
#include "SessionSubsystem.h"
#include "SurvivalUserSettings.h"
@@ -233,13 +234,131 @@ void USurvivalGameInstance::RegisterMappableContexts()
return;
}
bool bRegisteredAnyContext = false;
for (const TObjectPtr<UInputMappingContext>& Context : MappableContexts)
{
if (Context)
{
bRegisteredAnyContext = true;
// Idempotent : le moteur ignore un contexte deja connu, on peut donc
// rappeler cette fonction sans precaution.
InputSettings->RegisterInputMappingContext(Context);
}
}
// APRES l'enregistrement et jamais avant : le profil de touches ne connait un
// mapping -- donc sa touche par defaut -- qu'une fois son IMC declare. Appeler
// la conversion plus tot la ferait tourner sur un profil vide, sans erreur ni
// effet, ce qui est le pire des cas a diagnostiquer.
//
// UNE SEULE FOIS par session, en revanche. Cette fonction est aussi le point
// d'entree du rafraichissement de l'onglet Touches, appele apres CHAQUE
// remappage : y laisser la conversion reprendrait sa touche au joueur qui a
// choisi celle de l'IMC -- la lettre W sur un AZERTY est identique au defaut,
// donc indiscernable d'un mapping auquel personne n'a touche.
if (bRegisteredAnyContext && !bKeyboardLayoutApplied)
{
bKeyboardLayoutApplied = true;
ApplyKeyboardLayoutToDefaultKeys();
}
}
void USurvivalGameInstance::ApplyKeyboardLayoutToDefaultKeys()
{
const ULocalPlayer* LocalPlayer = GetFirstGamePlayer();
if (!LocalPlayer)
{
return;
}
UEnhancedInputLocalPlayerSubsystem* Subsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
UEnhancedInputUserSettings* InputSettings = Subsystem ? Subsystem->GetUserSettings() : nullptr;
const UEnhancedPlayerMappableKeyProfile* Profile = InputSettings ? InputSettings->GetActiveKeyProfile() : nullptr;
if (!Profile)
{
return;
}
// On COLLECTE avant d'appliquer, au lieu de mapper au fil de la lecture.
// MapPlayerKey ecrit dans les memes lignes que celles qu'on parcourt : muter un
// conteneur pendant son iteration invalide les iterateurs des que le stockage
// est reorganise. Le bug qui en sort est intermittent et ne ressemble a rien.
struct FPendingRemap
{
FName MappingName;
EPlayerMappableKeySlot Slot;
FKey LayoutKey;
};
TArray<FPendingRemap> PendingRemaps;
for (const TPair<FName, FKeyMappingRow>& Row : Profile->GetPlayerMappingRows())
{
for (const FPlayerKeyMapping& Mapping : Row.Value.Mappings)
{
// Le joueur a deja choisi cette touche : son choix fait autorite, meme
// s'il est incoherent avec sa disposition. C'est aussi ce qui rend la
// fonction idempotente -- notre propre conversion compte comme un choix
// des qu'elle est posee, donc on ne repasse jamais dessus.
if (Mapping.IsCustomized())
{
continue;
}
const FKey DefaultKey = Mapping.GetDefaultKey();
const FKey LayoutKey = KeyboardLayout::RemapKeyToActiveLayout(DefaultKey);
// Clavier QWERTY, touche insensible a la disposition, ou manette : la
// conversion rend la touche d'origine et il n'y a rien a ecrire. Sans ce
// test on poserait une surcharge identique au defaut sur chaque mapping,
// et "Reinitialiser" n'aurait plus rien a reinitialiser.
if (LayoutKey == DefaultKey)
{
continue;
}
PendingRemaps.Add({ Row.Key, Mapping.GetSlot(), LayoutKey });
}
}
// Journalise AVANT de sortir, et donc meme quand il n'y a rien a faire. C'est
// la seule trace lisible quand un testeur signale des touches inattendues, et
// le cas "0 touche" est justement celui qu'il faut pouvoir distinguer du
// silence : sans cette ligne, un clavier QWERTY et un systeme qui n'a pas
// tourne du tout produisent exactement le meme log, c'est-a-dire aucun.
//
// Un KLID se lit en deux temps : "0000040C" est un AZERTY francais, "00000409"
// un QWERTY US. Un utilisateur francais sur clavier QWERTY rend bien 00000409,
// parce que c'est la DISPOSITION qui est journalisee, pas la langue -- et c'est
// exactement la distinction sur laquelle une detection par pays se tromperait.
UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : disposition clavier [%s], %d touche(s) a traduire depuis le QWERTY de reference."),
*KeyboardLayout::GetActiveLayoutId(), PendingRemaps.Num());
if (PendingRemaps.IsEmpty())
{
return;
}
for (const FPendingRemap& Remap : PendingRemaps)
{
FMapPlayerKeyArgs Args;
Args.MappingName = Remap.MappingName;
Args.Slot = Remap.Slot;
Args.NewKey = Remap.LayoutKey;
FGameplayTagContainer FailureReason;
InputSettings->MapPlayerKey(Args, FailureReason);
if (!FailureReason.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : conversion de '%s' vers '%s' refusee (%s)."),
*Remap.MappingName.ToString(), *Remap.LayoutKey.ToString(), *FailureReason.ToString());
}
}
// Pousse les surcharges dans les contextes deja actifs : le pawn n'a rien a
// rebrancher, les touches sont bonnes des la premiere image.
InputSettings->ApplySettings();
}
@@ -3,7 +3,14 @@
#include "SurvivalUserSettings.h"
#include "AudioDevice.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "Misc/App.h"
#include "Sound/AudioSettings.h"
#include "Sound/SoundClass.h"
#include "Sound/SoundMix.h"
#include "SurvivalAudioSettings.h"
namespace
{
@@ -13,6 +20,88 @@ namespace
const float Snapped = Min + FMath::RoundToFloat((Value - Min) / Step) * Step;
return FMath::Clamp(Snapped, Min, Max);
}
/** Pourcentage affiche -> amplitude reelle, courbe perceptive comprise. */
float VolumePercentToAmplitude(float Percent)
{
using namespace SurvivalSettingsRange;
const float Normalized = FMath::Clamp(Percent, VolumeMin, VolumeMax) / 100.f;
return FMath::Pow(Normalized, VolumeCurveExponent);
}
/**
* Le peripherique audio a qui parler.
*
* En PIE le monde de jeu peut avoir le sien, distinct de celui de l'editeur :
* viser systematiquement le peripherique principal ferait regler le volume de
* l'editeur pendant que le jeu, lui, ne bouge pas.
*/
FAudioDevice* GetSettingsAudioDevice()
{
if (!GEngine)
{
return nullptr;
}
if (const UWorld* PlayWorld = GEngine->GetCurrentPlayWorld())
{
if (FAudioDevice* WorldDevice = PlayWorld->GetAudioDeviceRaw())
{
return WorldDevice;
}
}
return GEngine->GetMainAudioDeviceRaw();
}
/**
* Le mix par lequel passent les volumes du joueur.
*
* C'est celui de Project Settings > Audio > Default Base Sound Mix, et pas une
* reference a nous. Deux raisons : le moteur le REPOSE a chaque chargement de
* map (UWorld::UpdateWorldComponents -> SetDefaultBaseSoundMix), donc il est
* toujours actif sans qu'on ait a le pousser nous-memes ; et un mix de base est
* evalue a plein regime en permanence, alors qu'un mix pousse a la main
* s'eteindrait au bout de sa duree.
*/
USoundMix* GetSettingsSoundMix()
{
const FSoftObjectPath& MixPath = GetDefault<UAudioSettings>()->DefaultBaseSoundMix;
return MixPath.IsValid() ? Cast<USoundMix>(MixPath.TryLoad()) : nullptr;
}
/**
* Pose le volume d'une SoundClass, en pourcentage.
*
* bApplyToChildren merite une explication, parce que se tromper la donne un
* volume maitre qui n'agit que sur les sons non tagues : le moteur propage les
* proprietes du parent vers les enfants (ParseSoundClasses) AVANT d'appliquer
* les ajusteurs du mix (ApplyClassAdjusters). Un ajusteur pose sur la classe
* maitre arrive donc trop tard pour redescendre tout seul -- il faut demander
* explicitement la recursion. Les feuilles, elles, gardent false : leur
* ajusteur n'a personne a qui redescendre.
*
* Aucun fondu non plus, et ce n'est pas un oubli : toute duree non nulle rend le
* curseur inutilisable. Quand on repose un override alors que le precedent n'a
* pas fini son fondu, UpdateClassAdjustorOverrideEntry relance l'interpolation
* (FDynamicParameter::Set remet CurrTimeSec a zero) mais n'appelle PAS
* Update(DeltaTime) cette frame-la. Un curseur qu'on glisse repose l'override a
* chaque image : la rampe repart indefiniment de zero et GetValue() rend toujours
* la valeur precedente. Le volume ne rattrape son retard qu'une fois le geste
* ARRETE. A zero, Set() ecrit directement la valeur -- et le mixeur lisse deja le
* gain de chaque source dans son buffer de rendu, donc pas de clic pour autant.
*/
void ApplyClassVolume(FAudioDevice& AudioDevice, USoundMix& Mix, USoundClass* SoundClass, float Percent, bool bApplyToChildren)
{
if (!SoundClass)
{
return;
}
AudioDevice.SetSoundMixClassOverride(&Mix, SoundClass,
VolumePercentToAmplitude(Percent), /*Pitch*/ 1.f, /*FadeInTime*/ 0.f, bApplyToChildren);
}
}
USurvivalUserSettings::USurvivalUserSettings()
@@ -22,6 +111,7 @@ USurvivalUserSettings::USurvivalUserSettings()
// fichier s'il existe.
SetSurvivalGameplayToDefaults();
SetSurvivalDisplayToDefaults();
SetSurvivalAudioToDefaults();
}
USurvivalUserSettings* USurvivalUserSettings::Get()
@@ -46,6 +136,22 @@ void USurvivalUserSettings::SetSurvivalDisplayToDefaults()
Brightness = 100.f;
}
void USurvivalUserSettings::SetSurvivalAudioToDefaults()
{
// 80 et non 100 sur le maitre : il faut laisser au joueur de la marge vers le
// haut, sinon le seul reglage possible est de baisser.
MasterVolume = 80.f;
// La musique sous les effets, comme partout : elle est continue, eux sont des
// evenements, et un evenement doit passer par-dessus le fond.
MusicVolume = 60.f;
EffectsVolume = 100.f;
UiVolume = 80.f;
// Le defaut du moteur, qu'on se contente d'exposer.
bPlayInBackground = false;
}
void USurvivalUserSettings::SetToDefaults()
{
// Le "Reinitialiser" global du moteur doit aussi remettre NOS valeurs :
@@ -54,6 +160,7 @@ void USurvivalUserSettings::SetToDefaults()
SetSurvivalGameplayToDefaults();
SetSurvivalDisplayToDefaults();
SetSurvivalAudioToDefaults();
}
void USurvivalUserSettings::SetBrightness(float Value)
@@ -85,6 +192,40 @@ void USurvivalUserSettings::ApplySurvivalSettings()
OnSurvivalSettingsApplied.Broadcast();
}
void USurvivalUserSettings::ApplyAudioSettings()
{
// Ce reglage-la ne passe pas par le mix : c'est l'application entiere qui est
// baissee quand la fenetre perd le focus, bien en amont des SoundClass. Le
// moteur le stocke dans son propre .ini, d'ou une valeur reposee a chaque
// application plutot que memorisee ailleurs.
FApp::SetUnfocusedVolumeMultiplier(bPlayInBackground ? 1.f : 0.f);
FAudioDevice* AudioDevice = GetSettingsAudioDevice();
if (!AudioDevice)
{
// Normal tres tot au demarrage, ou en serveur dedie : les valeurs sont
// deja enregistrees, le prochain passage les posera.
return;
}
USoundMix* Mix = GetSettingsSoundMix();
if (!Mix)
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalUserSettings : aucun Default Base Sound Mix dans Project Settings > Audio, les curseurs de volume resteront sans effet."));
return;
}
USurvivalAudioSettings* AudioAssets = USurvivalAudioSettings::Get();
// Le maitre EN PREMIER et avec recursion : il redescend sur les trois autres,
// qui reappliquent ensuite la leur par-dessus. Les deux se multiplient, ce qui
// est exactement ce qu'on attend d'un volume general.
ApplyClassVolume(*AudioDevice, *Mix, AudioAssets->GetMasterSoundClass(), MasterVolume, /*bApplyToChildren*/ true);
ApplyClassVolume(*AudioDevice, *Mix, AudioAssets->GetMusicSoundClass(), MusicVolume, /*bApplyToChildren*/ false);
ApplyClassVolume(*AudioDevice, *Mix, AudioAssets->GetEffectsSoundClass(), EffectsVolume, /*bApplyToChildren*/ false);
ApplyClassVolume(*AudioDevice, *Mix, AudioAssets->GetUiSoundClass(), UiVolume, /*bApplyToChildren*/ false);
}
void USurvivalUserSettings::ApplyNonResolutionSettings()
{
Super::ApplyNonResolutionSettings();
@@ -92,6 +233,7 @@ void USurvivalUserSettings::ApplyNonResolutionSettings()
// ApplySettings() passe forcement par ici : brancher la diffusion a ce
// niveau garantit que nos reglages suivent, quel que soit le chemin.
ApplySurvivalSettings();
ApplyAudioSettings();
}
float USurvivalUserSettings::GetLookMultiplierX() const
@@ -173,3 +315,32 @@ void USurvivalUserSettings::SetCrouchToggle(bool bValue)
{
bCrouchToggle = bValue;
}
void USurvivalUserSettings::SetMasterVolume(float Value)
{
using namespace SurvivalSettingsRange;
MasterVolume = SnapToStep(Value, VolumeMin, VolumeMax, VolumeStep);
}
void USurvivalUserSettings::SetMusicVolume(float Value)
{
using namespace SurvivalSettingsRange;
MusicVolume = SnapToStep(Value, VolumeMin, VolumeMax, VolumeStep);
}
void USurvivalUserSettings::SetEffectsVolume(float Value)
{
using namespace SurvivalSettingsRange;
EffectsVolume = SnapToStep(Value, VolumeMin, VolumeMax, VolumeStep);
}
void USurvivalUserSettings::SetUiVolume(float Value)
{
using namespace SurvivalSettingsRange;
UiVolume = SnapToStep(Value, VolumeMin, VolumeMax, VolumeStep);
}
void USurvivalUserSettings::SetPlayInBackground(bool bValue)
{
bPlayInBackground = bValue;
}
@@ -0,0 +1,112 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Animation/AnimInstance.h"
#include "FpsArmsAnimInstance.generated.h"
class AFpsPlayer;
/**
* AnimInstance des bras en vue premiere personne.
*
* Elle ne DECIDE de rien : elle lit l'etat du pawn une fois par frame et le
* republie sous forme de variables. C'est la machine a etats du Blueprint enfant
* (ABP_FpsArms) qui choisit l'animation. La separation est volontaire -- une
* transition d'animation se regle a l'oeil, avec des durees de fondu, et ca n'a
* rien a faire dans du C++ qu'il faut recompiler.
*
* Corollaire : ces variables ne sont JAMAIS repliquees. Les bras ne sont vus que
* par leur proprietaire, donc chaque machine calcule les siens a partir d'un etat
* de pawn que la replication a deja synchronise (vitesse, sprint, accroupi).
*/
UCLASS()
class SURVIVAL_PROJET_API UFpsArmsAnimInstance : public UAnimInstance
{
GENERATED_BODY()
public:
virtual void NativeInitializeAnimation() override;
virtual void NativeUpdateAnimation(float DeltaSeconds) override;
protected:
// ------------------------------------------------------------------
// Etat publie vers le Blueprint d'animation
// ------------------------------------------------------------------
/** Vitesse horizontale en cm/s. Sert aussi a moduler le PlayRate de la course. */
UPROPERTY(BlueprintReadOnly, Category = "Movement")
float GroundSpeed = 0.f;
/** Vrai quand le joueur se deplace REELLEMENT, freinage exclu. */
UPROPERTY(BlueprintReadOnly, Category = "Movement")
bool bIsMoving = false;
UPROPERTY(BlueprintReadOnly, Category = "Movement")
bool bIsSprinting = false;
UPROPERTY(BlueprintReadOnly, Category = "Movement")
bool bIsFalling = false;
UPROPERTY(BlueprintReadOnly, Category = "Movement")
bool bIsCrouching = false;
/**
* Vitesse de lecture du cycle de deplacement, a brancher sur la broche
* Play Rate du noeud d'animation.
*
* Le projet a trois vitesses de jeu (accroupi 200, marche 400, sprint 650)
* pour un seul cycle d'animation. Plutot qu'un etat par vitesse -- trois
* seuils, donc trois occasions de claquer quand on les franchit -- on etire
* le meme cycle. La cadence des bras suit alors la vitesse en continu, y
* compris pendant l'acceleration.
*
* Un vrai etat Sprint n'aura de sens que le jour ou l'animateur livrera une
* animation de sprint a la POSTURE differente. Le meme cycle joue plus vite
* n'est pas une animation de sprint.
*/
UPROPERTY(BlueprintReadOnly, Category = "Movement")
float LocomotionPlayRate = 1.f;
/**
* En dessous de cette vitesse (cm/s) on considere le joueur immobile.
*
* Un seuil strictement a zero ferait clignoter la transition course/repos
* pendant le freinage, ou la vitesse decroit en passant par des valeurs
* minuscules mais non nulles.
*/
UPROPERTY(EditDefaultsOnly, Category = "Movement", meta = (ClampMin = "0"))
float MovingSpeedThreshold = 10.f;
/**
* Vitesse (cm/s) a laquelle le cycle tourne a sa cadence d'origine.
*
* A regler a l'oeil dans ABP_FpsArms : c'est la vitesse ou les bras ont l'air
* naturels sans etirement. Par defaut la vitesse de marche du pawn, donc le
* sprint accelere d'environ 60 %.
*/
UPROPERTY(EditDefaultsOnly, Category = "Movement", meta = (ClampMin = "1"))
float ReferenceLocomotionSpeed = 400.f;
/**
* Bornes du Play Rate. Sans le plancher, un joueur qui rampe a 20 cm/s
* regarderait des bras quasi immobiles ; sans le plafond, une future vitesse
* bonus donnerait des bras en accelere qui sortent du registre credible.
*/
UPROPERTY(EditDefaultsOnly, Category = "Movement", meta = (ClampMin = "0.1"))
float MinLocomotionPlayRate = 0.6f;
UPROPERTY(EditDefaultsOnly, Category = "Movement", meta = (ClampMin = "0.1"))
float MaxLocomotionPlayRate = 1.8f;
private:
/**
* Cache du pawn. TryGetPawnOwner() fait un Cast a chaque appel, et cette
* fonction tourne a chaque frame sur un mesh anime.
*
* TWeakObjectPtr et pas un pointeur nu : apres un respawn le pawn est detruit,
* et cet objet peut lui survivre d'une frame.
*/
TWeakObjectPtr<AFpsPlayer> OwningPlayer;
};
+56
View File
@@ -7,8 +7,10 @@
#include "FpsPlayer.generated.h"
class APickupItem;
class UAnimMontage;
class UCameraComponent;
class UCraftingComponent;
class UHeadBobComponent;
class UInputAction;
class UInputMappingContext;
class UInteractionComponent;
@@ -88,6 +90,26 @@ public:
UFUNCTION(BlueprintPure, Category = "Camera")
UCameraComponent* GetFirstPersonCamera() const { return FirstPersonCamera; }
UFUNCTION(BlueprintPure, Category = "Camera")
UHeadBobComponent* GetHeadBob() const { return HeadBob; }
/** Les bras vus par le joueur local. C'est ici que viendra l'objet en main. */
UFUNCTION(BlueprintPure, Category = "Animation")
USkeletalMeshComponent* GetFirstPersonArms() const { return FirstPersonArms; }
/**
* Joue un montage sur les bras du joueur LOCAL, et seulement lui.
*
* Rien n'est replique et c'est voulu : les bras sont en OnlyOwnerSee, donc
* les animer chez le serveur ou chez un ami ferait tourner un squelette que
* personne ne rend. Le geste vu par les autres joueurs viendra du mesh 3e
* personne, avec ses propres animations, le jour ou l'animateur les livrera.
*
* @return la duree du montage en secondes, 0 s'il n'a pas pu etre joue.
*/
UFUNCTION(BlueprintCallable, Category = "Animation")
float PlayArmsMontage(UAnimMontage* Montage, float PlayRate = 1.f);
UFUNCTION(BlueprintPure, Category = "Interaction")
UInteractionComponent* GetInteractionComponent() const { return InteractionComponent; }
@@ -198,6 +220,40 @@ protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UCameraComponent> FirstPersonCamera;
/**
* Bras vue première personne, visibles du seul propriétaire.
*
* Attachés à la CAMÉRA et non à la capsule : ils doivent suivre le tangage du
* regard, sinon lever les yeux les laisserait à l'horizontale. Bénéfice
* gratuit au passage — ils héritent du ressort d'impact et de la bascule
* d'accroupissement, donc les bras encaissent l'atterrissage avec la caméra
* sans une ligne de code de plus.
*
* Sa transform relative se règle À L'ŒIL dans BP_FpsPlayer : les valeurs du
* constructeur ne sont qu'un point de départ, elles dépendent entièrement de
* l'origine du FBX livré par l'animateur.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Animation")
TObjectPtr<USkeletalMeshComponent> FirstPersonArms;
/**
* Montage joué sur les bras au ramassage d'un objet (MTG_GrabItem).
* Vide = aucun geste, le ramassage fonctionne quand même.
*/
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Animation")
TObjectPtr<UAnimMontage> GrabMontage;
/**
* Balancement de tête procédural : respiration à l'arrêt, pas à la marche,
* foulée au sprint. Tous ses réglages sont dans le Details panel du composant,
* pas ici : cinq profils de cinq valeurs noieraient la catégorie "Camera".
*
* Il ne touche pas la caméra lui-même, il expose deux décalages que
* ApplyCameraTransform additionne — cette fonction reste le seul écrivain.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UHeadBobComponent> HeadBob;
/** Détecte et déclenche les interactions avec le monde. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Interaction")
TObjectPtr<UInteractionComponent> InteractionComponent;
@@ -0,0 +1,234 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "HeadBobComponent.generated.h"
class ACharacter;
class UCharacterMovementComponent;
/**
* Les reglages d'un etat de deplacement : immobile, marche, sprint, accroupi, en l'air.
*
* Regroupes dans une struct plutot qu'a plat sur le composant pour deux raisons.
* D'abord le Details panel : cinq etats x cinq valeurs a plat feraient vingt-cinq
* lignes melangees, alors que la struct donne un repli par etat. Ensuite le code :
* passer d'un etat a l'autre est une interpolation champ a champ, et une struct
* permet de l'ecrire une seule fois au lieu d'une par valeur.
*/
USTRUCT(BlueprintType)
struct FHeadBobProfile
{
GENERATED_BODY()
FHeadBobProfile() = default;
FHeadBobProfile(float InStrideFrequency, float InVerticalAmount, float InLateralAmount, float InRollAmount, float InPitchAmount)
: StrideFrequency(InStrideFrequency)
, VerticalAmount(InVerticalAmount)
, LateralAmount(InLateralAmount)
, RollAmount(InRollAmount)
, PitchAmount(InPitchAmount)
{
}
/**
* Foulees par seconde. Une foulee compte DEUX pas : l'oscillation verticale
* bat donc au double de cette valeur, une bosse par pied qui touche le sol.
* A 1.1, les pas tombent a 2.2 Hz, ce qui correspond a une marche normale.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0", UIMax = "4"))
float StrideFrequency = 1.1f;
/** Amplitude verticale en cm, mesuree du repos au sommet (le debattement total vaut le double). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0", UIMax = "10"))
float VerticalAmount = 1.6f;
/** Amplitude laterale en cm : le balancement gauche-droite du buste. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0", UIMax = "10"))
float LateralAmount = 1.2f;
/** Roulis en degres au pic du balancement lateral. La tete penche dans le sens du pas. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (UIMin = "-3", UIMax = "3"))
float RollAmount = 0.4f;
/**
* Tangage en degres au pic vertical. A 0 par defaut, et ce n'est pas un oubli :
* le tangage deplace le reticule, donc il degrade la visee. A n'ouvrir que si
* l'on assume un jeu ou l'on ne vise pas au pixel.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (UIMin = "-3", UIMax = "3"))
float PitchAmount = 0.f;
};
/**
* Balancement de tete procedural, sans la moindre animation authoree.
*
* Le principe tient en une phase unique, avancee chaque frame par la frequence de
* l'etat courant, et une amplitude qui glisse d'un etat a l'autre. C'est ce qui
* rend le passage marche -> sprint continu : ce n'est pas une animation qu'on
* coupe pour en lancer une autre, ce sont deux jeux de coefficients entre lesquels
* on interpole pendant que la phase, elle, ne s'interrompt jamais. Remettre la
* phase a zero a chaque changement d'etat produirait exactement le "clac" qu'on
* cherche a eviter.
*
* Le composant ne touche PAS a la camera. Il expose un decalage de position et un
* decalage de rotation, que AFpsPlayer::ApplyCameraTransform additionne aux siens.
* C'est la condition pour que cette fonction reste le seul endroit du projet qui
* ecrit la transform de la camera -- elle est rappelee depuis CalcCamera, et deux
* ecrivains se marcheraient dessus une frame sur deux.
*
* Rien n'est replique et rien n'a a l'etre : le balancement n'existe que dans les
* yeux du joueur local. Les bras suivent gratuitement, puisqu'ils sont attaches a
* la camera.
*/
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
class SURVIVAL_PROJET_API UHeadBobComponent : public UActorComponent
{
GENERATED_BODY()
public:
UHeadBobComponent();
/**
* Fait avancer la phase et recalcule les decalages.
*
* Appelee par le pawn depuis son Tick, et surtout PAS depuis un TickComponent a
* lui : l'ordre de tick entre un acteur et ses composants n'est pas garanti sans
* dependance explicite, et le resultat serait un decalage lu une frame apres
* avoir ete calcule -- invisible a 120 fps, franchement mou a 30.
*/
void UpdateBob(float DeltaTime);
/** Decalage a ajouter a la position RELATIVE de la camera (X avant, Y droite, Z haut), en cm. */
UFUNCTION(BlueprintPure, Category = "Head Bob")
FVector GetLocationOffset() const { return LocationOffset; }
/** Decalage a ajouter a la rotation de la camera, en degres. */
UFUNCTION(BlueprintPure, Category = "Head Bob")
FRotator GetRotationOffset() const { return RotationOffset; }
/**
* Remet le balancement au repos sans transition.
* Pour une teleportation ou une prise de controle : on ne veut pas que la tete
* finisse le pas d'avant a l'autre bout de la carte.
*/
UFUNCTION(BlueprintCallable, Category = "Head Bob")
void ResetBob();
/**
* Facteur global pousse par les reglages joueur (curseur "secousses de camera").
* A 0 la tete ne bouge plus du tout : c'est un confort d'accessibilite, pas une
* option cosmetique, et il doit valoir pour le balancement comme pour les chocs.
*/
UFUNCTION(BlueprintCallable, Category = "Head Bob")
void SetIntensityScale(float NewScale);
protected:
virtual void BeginPlay() override;
// ------------------------------------------------------------------
// Reglages generaux
// ------------------------------------------------------------------
/** Coupe entierement l'effet, sans avoir a remettre les cinq profils a zero. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob")
bool bEnableHeadBob = true;
/**
* Multiplicateur d'amplitude commun aux cinq profils.
* C'est le curseur a bouger pour un reglage d'ensemble ("un peu moins de tout")
* sans avoir a retoucher vingt valeurs une par une.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0", UIMax = "3"))
float GlobalIntensity = 1.f;
/**
* Vitesse de fondu d'un profil vers un autre (facteur d'interpolation
* exponentielle, par seconde). Plus haut = le sprint prend son amplitude plus
* vite. Trop haut, le changement d'etat redevient une cassure.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0.1", UIMax = "20"))
float ProfileBlendSpeed = 6.f;
// ------------------------------------------------------------------
// Seuils de choix d'etat
// ------------------------------------------------------------------
/**
* En dessous de cette vitesse horizontale (cm/s), on est considere immobile.
* Le seuil se lit sur la vitesse REELLE et non sur la touche enfoncee : marcher
* dans un mur laisse l'input actif alors que le corps ne bouge plus, et sans ce
* detail la tete continuerait de gigoter face a la cloison.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Seuils", meta = (ClampMin = "0"))
float IdleSpeedThreshold = 10.f;
/**
* Au-dessus de cette vitesse horizontale (cm/s), on bascule sur le profil de
* sprint. A regler entre WalkSpeed et SprintSpeed du pawn -- 400 et 650 par
* defaut, d'ou ces 520 : assez haut pour que la phase d'acceleration ne compte
* pas comme du sprint, assez bas pour que le sprint reel soit toujours detecte.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Seuils", meta = (ClampMin = "0"))
float SprintSpeedThreshold = 520.f;
// ------------------------------------------------------------------
// Profils, un par etat. Valeurs par defaut dans le constructeur.
// ------------------------------------------------------------------
/** Immobile : une respiration lente, quasi purement verticale. C'est ce qui evite la statue. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
FHeadBobProfile IdleProfile;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
FHeadBobProfile WalkProfile;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
FHeadBobProfile SprintProfile;
/** Accroupi : plus lent et plus discret que la marche, le corps est ramasse. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
FHeadBobProfile CrouchProfile;
/**
* En l'air : tout a zero, y compris la frequence -- la phase se fige donc en vol
* et repart d'ou elle en etait a la reception, ce qui evite que le premier pas
* apres un saut tombe sur un pied arbitraire. Le profil reste editable malgre
* tout, au cas ou l'on veuille un flottement pendant la chute.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
FHeadBobProfile AirProfile;
private:
/** Choisit le profil vise a partir de l'etat reel du CharacterMovement. */
const FHeadBobProfile& SelectTargetProfile() const;
/** Rapproche BlendedProfile de Target, champ par champ. */
void BlendTowards(const FHeadBobProfile& Target, float Step);
/**
* Position dans le cycle de foulee, en radians, ramenee dans [0, 2*PI[.
* Le repli n'est pas cosmetique : sans lui la valeur grossit indefiniment et la
* precision du float se degrade, jusqu'a un balancement qui devient saccade au
* bout d'une longue partie.
*/
float StridePhase = 0.f;
/** Profil courant, resultat du fondu entre les profils d'etat. */
FHeadBobProfile BlendedProfile;
FVector LocationOffset = FVector::ZeroVector;
FRotator RotationOffset = FRotator::ZeroRotator;
/** Pousse par les reglages joueur, se multiplie a GlobalIntensity. */
float IntensityScale = 1.f;
/** Caches : ces deux pointeurs sont lus a chaque frame, pas question d'y caster. */
TObjectPtr<ACharacter> OwnerCharacter;
TObjectPtr<UCharacterMovementComponent> OwnerMovement;
};
@@ -0,0 +1,54 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "InputCoreTypes.h"
/**
* Traduction d'une touche de reference vers la disposition clavier du systeme.
*
* LE PROBLEME. Un IMC stocke des FKey -- EKeys::W --, c'est-a-dire des LETTRES.
* Or Windows livre au moteur un virtual key code deja traduit par la disposition
* active : sur un clavier AZERTY, la touche en haut a gauche du bloc de
* deplacement envoie VK_Z, qu'Unreal lit donc comme EKeys::Z. Le mapping "W" ne
* se declenche jamais, et le joueur pousse son personnage en arriere en croyant
* avancer. Rien de tout ca n'est visible en developpant sur un clavier QWERTY.
*
* LA REPONSE. On ne raisonne plus en lettres mais en POSITIONS. Chaque touche
* par defaut de l'IMC est lue comme une position physique sur un clavier QWERTY
* US -- c'est le referentiel dans lequel les IMC sont ecrits -- et l'on demande
* a l'OS quelle touche occupe cette position sur la disposition courante. La
* geometrie de la main est preservee : le bloc WASD devient ZQSD en AZERTY,
* reste WASD en QWERTZ (seuls Y et Z y sont echanges), et suit les positions en
* Dvorak ou Colemak. Aucune table par pays a maintenir, et les dispositions
* qu'on n'a jamais vues sont traitees comme les autres.
*
* PORTEE. Seule la zone alphanumerique bouge d'une disposition a l'autre. Les
* modificateurs, la barre d'espace, Echap, les fonctions, les fleches, le pave
* numerique et la manette occupent la meme position partout : ils ne figurent
* pas dans la table de reference et ressortent donc inchanges. C'est aussi ce
* qui garantit qu'on ne casse rien -- une touche inconnue est rendue telle quelle.
*
* WINDOWS UNIQUEMENT pour l'instant. Sur les autres plateformes la touche est
* rendue inchangee, soit exactement le comportement d'avant.
*/
namespace KeyboardLayout
{
/**
* Rend la touche qui occupe, sur la disposition active, la position que
* ReferenceKey occupe sur un clavier QWERTY US.
*
* Rend ReferenceKey inchangee si la touche n'est pas concernee par la
* disposition (espace, modificateurs, manette...), si la plateforme n'est pas
* geree, ou si l'OS ne sait pas repondre. Jamais de touche invalide en sortie.
*/
SURVIVAL_PROJET_API FKey RemapKeyToActiveLayout(const FKey& ReferenceKey);
/**
* L'identifiant de la disposition active, pour le journal : "0000040C" pour
* le francais, "00000409" pour l'anglais US. Sert au diagnostic quand un
* testeur signale des touches inattendues -- c'est la premiere chose a lire.
*/
SURVIVAL_PROJET_API FString GetActiveLayoutId();
}
@@ -114,7 +114,15 @@ public:
UFUNCTION(BlueprintPure, Category = "Musique")
EMusicLevelChangeMode GetLevelChangeMode() const { return LevelChangeMode; }
/** Volume joueur, 0 a 1. A brancher sur l'onglet Audio des reglages plus tard. */
/**
* Attenuation propre au LECTEUR, 0 a 1. Ce n'est PAS le reglage du joueur.
*
* Le volume choisi dans les reglages passe par la SoundClass de musique (voir
* USurvivalAudioSettings), pas par ici : deux chemins vers le meme volume
* finiraient par se multiplier sans que personne ne s'en souvienne. Celui-ci
* reste disponible pour ce qui est de l'ordre du gameplay -- baisser la musique
* pendant un dialogue, par exemple.
*/
UFUNCTION(BlueprintCallable, Category = "Musique")
void SetMusicVolume(float NewVolume);
@@ -0,0 +1,65 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "SettingsTabWidget.h"
#include "SettingsAudioWidget.generated.h"
class USettingRowWidget;
/**
* L'onglet "Audio" : quatre volumes et le son en arriere-plan.
*
* Meme forme que USettingsGameplayWidget -- l'onglet ne stocke rien, chaque ligne
* ecrit dans USurvivalUserSettings puis demande l'application immediate. La
* difference tient au verbe appele : ApplyAudioSettings et non
* ApplySurvivalSettings, parce que les volumes n'interessent pas le pawn et que
* rediffuser aux widgets a chaque cran de curseur ne servirait a rien.
*
* Classe Abstract : on cree un WBP_SettingsAudio qui en herite, avec un
* WBP_SettingRow par ligne, nomme exactement comme le BindWidget correspondant.
*/
UCLASS(Abstract)
class SURVIVAL_PROJET_API USettingsAudioWidget : public USettingsTabWidget
{
GENERATED_BODY()
public:
virtual void NativeConstruct() override;
virtual void RefreshFromSettings() override;
virtual void ResetToDefaults() override;
protected:
UPROPERTY(meta = (BindWidget))
TObjectPtr<USettingRowWidget> MasterVolumeRow;
UPROPERTY(meta = (BindWidget))
TObjectPtr<USettingRowWidget> MusicVolumeRow;
UPROPERTY(meta = (BindWidget))
TObjectPtr<USettingRowWidget> EffectsVolumeRow;
UPROPERTY(meta = (BindWidget))
TObjectPtr<USettingRowWidget> UiVolumeRow;
UPROPERTY(meta = (BindWidget))
TObjectPtr<USettingRowWidget> BackgroundSoundRow;
private:
UFUNCTION()
void HandleMasterVolumeChanged(int32 NewIndex);
UFUNCTION()
void HandleMusicVolumeChanged(int32 NewIndex);
UFUNCTION()
void HandleEffectsVolumeChanged(int32 NewIndex);
UFUNCTION()
void HandleUiVolumeChanged(int32 NewIndex);
UFUNCTION()
void HandleBackgroundSoundChanged(int32 NewIndex);
};
@@ -124,8 +124,7 @@ protected:
TObjectPtr<UButton> ResetButton;
/**
* Les trois onglets qui existent sont OBLIGATOIRES : un WBP_Settings sans eux
* ne compile pas.
* Les cinq onglets sont OBLIGATOIRES : un WBP_Settings sans eux ne compile pas.
*
* BindWidgetOptional echoue en silence -- un nom mal orthographie donnait un
* bouton parfaitement inerte, sans le moindre message. Pour un element dont
@@ -143,12 +142,7 @@ protected:
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> ControlsTabButton;
/**
* Celui-la reste optionnel : sa page n'existe pas encore. Il passera en
* BindWidget le jour ou l'onglet Audio sera ecrit -- en attendant, il se
* grise tout seul si tu le poses.
*/
UPROPERTY(meta = (BindWidgetOptional))
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> AudioTabButton;
/**
@@ -0,0 +1,120 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DeveloperSettings.h"
// Sound/SoundClass.h en entier, et pas une simple declaration anticipee :
// TStrongObjectPtr exige un type COMPLET, la ou TObjectPtr s'en passait.
#include "Sound/SoundClass.h"
#include "UObject/StrongObjectPtr.h"
#include "SurvivalAudioSettings.generated.h"
/**
* Ou le C++ trouve les SoundClass du jeu. Reglable dans Project Settings, section
* "Survival Audio".
*
* UDeveloperSettings et pas des chemins codes en dur dans le .cpp : renommer ou
* deplacer SC_Music casserait silencieusement le curseur Musique, sans la moindre
* erreur de compilation. Ici la reference est un asset comme un autre, suivie par
* le redirecteur de l'editeur et par le cooker.
*
* Ces references sont SOFT : un UDeveloperSettings est construit tres tot, bien
* avant que le gestionnaire d'assets ne soit pret. Une reference dure forcerait le
* chargement des SoundClass au demarrage du moteur, ce qui est exactement le genre
* de dependance qui fait echouer un cook.
*
* A NOTER : le SoundMix, lui, n'est PAS ici. Il est deja declare dans
* Project Settings > Audio > Default Base Sound Mix, d'ou le moteur le reactive a
* chaque chargement de map -- c'est precisement ce qu'on veut, et le redeclarer ici
* ouvrirait la porte a deux references qui divergent.
*/
UCLASS(config = Game, defaultconfig, meta = (DisplayName = "Survival Audio"))
class SURVIVAL_PROJET_API USurvivalAudioSettings : public UDeveloperSettings
{
GENERATED_BODY()
public:
/** Range la page sous "Game" et non sous "Plugins". */
virtual FName GetCategoryName() const override;
/**
* Le CDO, seul exemplaire qui existe.
*
* Non const alors que rien n'est modifie du point de vue du joueur : les
* accesseurs ci-dessous memorisent l'asset resolu dans un champ Transient, comme
* le fait UAudioSettings pour sa propre SoundClass par defaut.
*/
static USurvivalAudioSettings* Get();
/**
* Le sommet de la hierarchie : toutes les autres classes en descendent, donc son
* volume les multiplie toutes.
*/
UPROPERTY(config, EditAnywhere, Category = "Sound Classes")
TSoftObjectPtr<USoundClass> MasterSoundClass;
/**
* Imposee au lecteur de musique par UMusicSubsystem, donc les morceaux eux-memes
* n'ont rien a declarer -- ajouter une piste a une playlist suffit.
*/
UPROPERTY(config, EditAnywhere, Category = "Sound Classes")
TSoftObjectPtr<USoundClass> MusicSoundClass;
/**
* A declarer aussi comme Default Sound Class dans Project Settings > Audio :
* tout son qui n'annonce pas sa classe atterrit alors ici, ce qui evite qu'un
* bruitage oublie echappe au curseur Effets.
*/
UPROPERTY(config, EditAnywhere, Category = "Sound Classes")
TSoftObjectPtr<USoundClass> EffectsSoundClass;
/** A poser a la main sur les sons d'interface (survol, validation). */
UPROPERTY(config, EditAnywhere, Category = "Sound Classes")
TSoftObjectPtr<USoundClass> UiSoundClass;
USoundClass* GetMasterSoundClass();
USoundClass* GetMusicSoundClass();
USoundClass* GetEffectsSoundClass();
USoundClass* GetUiSoundClass();
private:
/**
* Charge au premier appel, puis rend le pointeur memorise.
*
* Le cache n'est pas qu'une optimisation : il fait aussi office d'ancre pour le
* ramasse-miettes. Une SoundClass chargee par un LoadSynchronous et referencee
* nulle part serait collectee, et le curseur cesserait d'agir en pleine partie.
*/
USoundClass* Resolve(const TSoftObjectPtr<USoundClass>& Asset, TStrongObjectPtr<USoundClass>& Cache);
/**
* TStrongObjectPtr et surtout PAS UPROPERTY(Transient) : c'est ce qui plantait
* le jeu package au demarrage, alors que tout allait bien dans l'editeur.
*
* Le CDO d'un UDeveloperSettings naît avant CloseDisregardForGC, donc il vit dans
* le pool des objets PERMANENTS. Le moteur ne les scanne jamais -- ils sont
* immortels par construction -- et exige en echange qu'ils ne referencent que des
* objets du root set, sans quoi ils garderaient un pointeur sur un objet collecte.
* VerifyGCAssumptions le controle et c'est une erreur FATALE :
*
* Disregard for GC object Default__SurvivalAudioSettings referencing SoundClass
* SC_Master which is not part of root set. Property: CachedMasterSoundClass
* Fatal error: Encountered 3 object(s) breaking Disregard for GC assumptions.
*
* Invisible dans l'editeur, ou les SoundClass sont deja chargees quand le pool se
* ferme, donc permanentes elles aussi. En jeu package elles arrivent apres, et le
* jeu meurt au premier ramassage -- pendant le chargement de la premiere map.
*
* TStrongObjectPtr ancre l'asset exactement comme le faisait la UPROPERTY, mais
* range sa reference dans GGCObjectReferencer, le seul objet que VerifyGCAssumptions
* saute explicitement. Le CDO ne reference donc plus rien, et l'asset reste vivant.
*/
TStrongObjectPtr<USoundClass> CachedMasterSoundClass;
TStrongObjectPtr<USoundClass> CachedMusicSoundClass;
TStrongObjectPtr<USoundClass> CachedEffectsSoundClass;
TStrongObjectPtr<USoundClass> CachedUiSoundClass;
};
@@ -51,6 +51,31 @@ public:
UFUNCTION(BlueprintCallable, Category = "Input")
void RegisterMappableContexts();
/**
* Traduit les touches par defaut vers la disposition clavier du joueur.
*
* Les IMC sont ecrits en QWERTY US -- EKeys::W pour avancer -- et Windows livre
* au moteur une touche DEJA traduite par la disposition : en AZERTY, la touche
* du haut du bloc de deplacement arrive comme EKeys::Z et le mapping "W" ne se
* declenche jamais. On repose donc chaque touche a la position equivalente.
* Le detail du mecanisme est dans KeyboardLayout.h.
*
* Ne touche QUE les mappings que le joueur n'a pas remappes lui-meme : un
* choix explicite l'emporte toujours sur une deduction. Et sur un clavier
* QWERTY la conversion rend la touche d'origine, donc rien n'est ecrit du tout
* et le profil reste vierge.
*
* Declenchee UNE FOIS par RegisterMappableContexts(), au premier enregistrement
* des contextes ; ensuite seul "Reinitialiser" la redemande. Elle appartient a
* l'initialisation, pas au rafraichissement d'ecran -- l'onglet Touches se
* rafraichit apres chaque remappage, et repasser dessus reprendrait sa touche
* au joueur qui aurait justement choisi celle de l'IMC (la lettre W sur un
* AZERTY, par exemple : identique au defaut, donc indiscernable d'un mapping
* jamais touche).
*/
UFUNCTION(BlueprintCallable, Category = "Input")
void ApplyKeyboardLayoutToDefaultKeys();
/**
* Un depart VOULU vers le menu commence : le filet de secours reseau doit
* se taire.
@@ -208,6 +233,16 @@ private:
/** Un retour au menu est deja en cours, voulu ou non. */
bool bReturningToMainMenu = false;
/**
* La disposition clavier a deja ete appliquee dans cette session.
*
* Pas de UPROPERTY(config) : ce n'est PAS "l'a-t-on deja fait un jour". Les
* surcharges posees, elles, sont persistees par Enhanced Input, et elles
* suffisent a rendre l'operation sans effet au lancement suivant. Ce drapeau
* ne protege que du re-declenchement en cours de session.
*/
bool bKeyboardLayoutApplied = false;
/**
* Stocke un NOM et pas un index : les index changent des qu'on ajoute ou
* supprime une camera dans la map, et on retomberait sur autre chose.
@@ -74,6 +74,23 @@ namespace SurvivalSettingsRange
*/
constexpr float LookMultiplierAtMin = 0.05f;
constexpr float LookMultiplierAtMax = 1.2f;
/** Les quatre volumes partagent la meme echelle : 0 a 100 %. */
constexpr float VolumeMin = 0.f;
constexpr float VolumeMax = 100.f;
constexpr float VolumeStep = 1.f;
constexpr float VolumeArrowStep = 10.f;
/**
* Exposant applique au pourcentage avant d'en faire une amplitude.
*
* L'oreille repond au logarithme de l'amplitude, pas a l'amplitude : un
* curseur envoye tel quel donne un reglage inutilisable, ou tout se joue dans
* les vingt derniers pourcents et ou la moitie basse ne s'entend deja plus.
* Elever au carre est l'approximation habituelle -- 50 % rendent 0,25 en
* amplitude, soit environ la moitie du volume PERCU. 0 reste le silence exact.
*/
constexpr float VolumeCurveExponent = 2.f;
}
/**
@@ -128,10 +145,25 @@ public:
UFUNCTION(BlueprintCallable, Category = "Reglages")
void ApplySurvivalSettings();
/**
* Pousse les volumes sur le peripherique audio.
*
* Volontairement SEPAREE d'ApplySurvivalSettings : cinq commandes vers le
* thread audio a chaque clic de fleche sur le champ de vision seraient du
* bruit pur. Les deux sont appelees ensemble par ApplyNonResolutionSettings,
* donc le chemin de demarrage n'en oublie aucune.
*/
UFUNCTION(BlueprintCallable, Category = "Reglages|Audio")
void ApplyAudioSettings();
/** Remet les seuls reglages de jeu a leur valeur d'usine. */
UFUNCTION(BlueprintCallable, Category = "Reglages")
void SetSurvivalGameplayToDefaults();
/** Remet a l'usine les seuls reglages de l'onglet Audio. */
UFUNCTION(BlueprintCallable, Category = "Reglages|Audio")
void SetSurvivalAudioToDefaults();
virtual void SetToDefaults() override;
virtual void ApplyNonResolutionSettings() override;
@@ -215,6 +247,45 @@ public:
UFUNCTION(BlueprintCallable, Category = "Reglages|Affichage")
void SetSurvivalDisplayToDefaults();
// ------------------------------------------------------------------
// Audio
//
// Les quatre volumes sont des POURCENTAGES, comme le bouge de camera : c'est
// ce que la ligne de reglage affiche, et la conversion en amplitude (courbe
// comprise) n'appartient qu'a ApplyAudioSettings.
// ------------------------------------------------------------------
UFUNCTION(BlueprintPure, Category = "Reglages|Audio")
float GetMasterVolume() const { return MasterVolume; }
UFUNCTION(BlueprintCallable, Category = "Reglages|Audio")
void SetMasterVolume(float Value);
UFUNCTION(BlueprintPure, Category = "Reglages|Audio")
float GetMusicVolume() const { return MusicVolume; }
UFUNCTION(BlueprintCallable, Category = "Reglages|Audio")
void SetMusicVolume(float Value);
UFUNCTION(BlueprintPure, Category = "Reglages|Audio")
float GetEffectsVolume() const { return EffectsVolume; }
UFUNCTION(BlueprintCallable, Category = "Reglages|Audio")
void SetEffectsVolume(float Value);
UFUNCTION(BlueprintPure, Category = "Reglages|Audio")
float GetUiVolume() const { return UiVolume; }
UFUNCTION(BlueprintCallable, Category = "Reglages|Audio")
void SetUiVolume(float Value);
UFUNCTION(BlueprintPure, Category = "Reglages|Audio")
bool GetPlayInBackground() const { return bPlayInBackground; }
/** Vrai = le jeu continue de s'entendre quand la fenetre n'a plus le focus. */
UFUNCTION(BlueprintCallable, Category = "Reglages|Audio")
void SetPlayInBackground(bool bValue);
private:
/** Echelle lisible (5 a 100), convertie a la lecture. Jamais brute. */
UPROPERTY(config)
@@ -245,4 +316,24 @@ private:
/** Pourcentage, converti en gamma a l'application. */
UPROPERTY(config)
float Brightness;
/** Pourcentages. Le maitre multiplie les trois autres via la hierarchie des SoundClass. */
UPROPERTY(config)
float MasterVolume;
UPROPERTY(config)
float MusicVolume;
UPROPERTY(config)
float EffectsVolume;
UPROPERTY(config)
float UiVolume;
/**
* Le moteur coupe le son des qu'il perd le focus, et c'est son defaut d'usine.
* En coop c'est justement le mauvais defaut pour qui bascule sur Discord.
*/
UPROPERTY(config)
bool bPlayInBackground;
};
@@ -15,7 +15,10 @@ public class Survival_projet : ModuleRules
// extinction). Passer par un evenement Blueprint marcherait aussi, mais
// l'etat du feu appartient au C++ -- le Blueprint ne doit brancher que
// l'asset, pas decider quand il s'allume.
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GameplayTags", "UMG", "Niagara" });
// DeveloperSettings : USurvivalAudioSettings derive de UDeveloperSettings dans
// un header public, donc la dependance doit l'etre aussi. C'est ce qui donne
// la page "Survival Audio" dans les Project Settings.
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GameplayTags", "UMG", "Niagara", "DeveloperSettings" });
// EngineSettings : UGameMapsSettings::GetGameDefaultMap(), qui evite de
// redeclarer le chemin de la map de menu dans le C++ alors qu'il est