(Feat) Add Main Menu + Music Manger

This commit is contained in:
2026-07-29 20:18:51 +02:00
parent 956f48816b
commit 9b5ad7c8a4
55 changed files with 3727 additions and 125 deletions
@@ -33,6 +33,9 @@ void UConfirmDialogWidget::NativeConstruct()
void UConfirmDialogWidget::Open(const FText& InMessage)
{
// Une ouverture simple annule un rebours laisse par la precedente.
StopCountdown();
if (MessageText)
{
MessageText->SetText(InMessage);
@@ -45,11 +48,79 @@ void UConfirmDialogWidget::Open(const FText& InMessage)
SetKeyboardFocus();
}
void UConfirmDialogWidget::OpenWithCountdown(const FText& InMessageFormat, float Seconds)
{
StopCountdown();
CountdownMessageFormat = InMessageFormat;
CountdownRemaining = FMath::Max(Seconds, 1.f);
LastDisplayedSeconds = -1;
RefreshCountdownText();
SetVisibility(ESlateVisibility::Visible);
SetKeyboardFocus();
CountdownTickerHandle = FTSTicker::GetCoreTicker().AddTicker(
FTickerDelegate::CreateUObject(this, &UConfirmDialogWidget::TickCountdown));
}
void UConfirmDialogWidget::Close()
{
StopCountdown();
SetVisibility(ESlateVisibility::Collapsed);
}
void UConfirmDialogWidget::StopCountdown()
{
if (CountdownTickerHandle.IsValid())
{
FTSTicker::GetCoreTicker().RemoveTicker(CountdownTickerHandle);
CountdownTickerHandle.Reset();
}
}
void UConfirmDialogWidget::RefreshCountdownText()
{
if (!MessageText)
{
return;
}
// CeilToInt : on affiche "10" pendant la premiere seconde et "1" pendant la
// derniere. Avec un arrondi classique on afficherait "0" une demi-seconde
// avant que ca se declenche, ce qui donne l'impression d'un bug.
const int32 Seconds = FMath::CeilToInt(CountdownRemaining);
if (Seconds == LastDisplayedSeconds)
{
return;
}
LastDisplayedSeconds = Seconds;
MessageText->SetText(FText::Format(CountdownMessageFormat, FText::AsNumber(Seconds)));
}
bool UConfirmDialogWidget::TickCountdown(float DeltaTime)
{
CountdownRemaining -= DeltaTime;
if (CountdownRemaining > 0.f)
{
RefreshCountdownText();
// true : on veut etre rappele a la frame suivante.
return true;
}
// On coupe le ticker AVANT de diffuser : un abonne qui rouvre une boite
// dans son handler doit pouvoir relancer un rebours.
StopCountdown();
OnCancelled.Broadcast();
return false;
}
FReply UConfirmDialogWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
const FKey Key = InKeyEvent.GetKey();
@@ -64,6 +135,7 @@ FReply UConfirmDialogWidget::NativeOnKeyDown(const FGeometry& InGeometry, const
// reellement branchee.
if (bCancelOnEscape && (Key == EKeys::Escape || Key == EKeys::Virtual_Gamepad_Back.GetVirtualKey()))
{
StopCountdown();
OnCancelled.Broadcast();
// Handled et non Unhandled : sans ca la touche continue de remonter et
@@ -73,6 +145,7 @@ FReply UConfirmDialogWidget::NativeOnKeyDown(const FGeometry& InGeometry, const
if (bConfirmOnEnter && (Key == EKeys::Enter || Key == EKeys::Virtual_Gamepad_Accept.GetVirtualKey()))
{
StopCountdown();
OnConfirmed.Broadcast();
return FReply::Handled();
}
@@ -82,16 +155,26 @@ FReply UConfirmDialogWidget::NativeOnKeyDown(const FGeometry& InGeometry, const
void UConfirmDialogWidget::HandleConfirmClicked()
{
// Le joueur a repondu : le rebours n'a plus lieu d'etre, et le laisser
// tourner ferait arriver un OnCancelled apres coup.
StopCountdown();
OnConfirmed.Broadcast();
}
void UConfirmDialogWidget::HandleCancelClicked()
{
StopCountdown();
OnCancelled.Broadcast();
}
void UConfirmDialogWidget::NativeDestruct()
{
// Le ticker du moteur survit au widget : sans ce retrait, il rappellerait
// une methode sur un objet detruit.
StopCountdown();
if (ConfirmButton)
{
ConfirmButton->OnClicked.RemoveDynamic(this, &UConfirmDialogWidget::HandleConfirmClicked);
@@ -97,6 +97,10 @@ 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.
Subsystem->AddMappingContext(DefaultMappingContext, 0);
}
else
@@ -539,6 +539,12 @@ void AFpsPlayerController::OpenSettings()
SettingsMenu->OnCloseRequested.AddDynamic(this, &AFpsPlayerController::HandleSettingsCloseRequested);
SettingsMenu->AddToViewport(ZOrderInGameSettingsMenu);
// L'ecran de reglages est partage avec le menu principal, dont
// l'empilement n'a rien a voir avec celui du jeu : c'est a nous, qui
// possedons la pile, de lui dire ou poser sa boite. +10 la place devant
// lui sans empieter sur la boite d'abandon de partie a 200.
SettingsMenu->SetDialogZOrder(ZOrderInGameSettingsMenu + 10);
}
bSettingsOpen = true;
@@ -0,0 +1,173 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "KeyCaptureWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#define LOCTEXT_NAMESPACE "Reglages"
UKeyCaptureWidget::UKeyCaptureWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Sans ce drapeau le widget ne peut pas prendre le focus clavier, et
// NativeOnKeyDown ne serait jamais appele -- le voile resterait affiche sans
// jamais rien capturer. Meme piege que UConfirmDialogWidget.
SetIsFocusable(true);
PromptFormat = LOCTEXT("CapturePrompt", "Appuie sur une touche pour « {0} »\nÉchap pour annuler");
}
void UKeyCaptureWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (ConfirmButton)
{
ConfirmButton->OnClicked.AddDynamic(this, &UKeyCaptureWidget::HandleConfirmClicked);
}
if (CancelButton)
{
CancelButton->OnClicked.AddDynamic(this, &UKeyCaptureWidget::HandleCancelClicked);
}
}
void UKeyCaptureWidget::BeginCapture(const FText& ActionLabel)
{
State = EKeyCaptureState::Capturing;
if (PromptText)
{
PromptText->SetText(FText::Format(PromptFormat, ActionLabel));
}
UpdateButtonVisibility();
SetVisibility(ESlateVisibility::Visible);
// Le focus se reprend a CHAQUE ouverture : le refermer le rend a l'ecran de
// reglages, et un voile visible mais sans focus ignorerait le clavier.
SetKeyboardFocus();
}
void UKeyCaptureWidget::ShowConflict(const FText& Message)
{
State = EKeyCaptureState::Conflict;
if (PromptText)
{
PromptText->SetText(Message);
}
UpdateButtonVisibility();
// Le voile n'a pas bouge, mais le focus a pu partir sur un bouton qui vient
// d'apparaitre : on le reprend pour qu'Echap continue de repondre.
SetKeyboardFocus();
}
void UKeyCaptureWidget::EndCapture()
{
State = EKeyCaptureState::Idle;
UpdateButtonVisibility();
SetVisibility(ESlateVisibility::Collapsed);
}
void UKeyCaptureWidget::UpdateButtonVisibility()
{
// Collapsed et non Hidden : replies, les boutons ne doivent pas reserver la
// place qu'ils occuperont, sinon la consigne de capture serait decentree.
const ESlateVisibility ButtonVisibility = State == EKeyCaptureState::Conflict
? ESlateVisibility::Visible
: ESlateVisibility::Collapsed;
if (ConfirmButton)
{
ConfirmButton->SetVisibility(ButtonVisibility);
}
if (CancelButton)
{
CancelButton->SetVisibility(ButtonVisibility);
}
}
FReply UKeyCaptureWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
const FKey Key = InKeyEvent.GetKey();
if (State == EKeyCaptureState::Conflict)
{
// Echap vaut Annuler, comme partout ailleurs dans les menus.
if (Key == EKeys::Escape)
{
OnConflictAnswered.Broadcast(false);
}
// Aucune autre touche n'est lue : on ne capture plus, on attend une
// reponse. Sans ce garde-fou, appuyer sur une touche pour "repondre"
// relancerait un remappage a l'insu du joueur.
return FReply::Handled();
}
return CaptureKey(Key);
}
FReply UKeyCaptureWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (State == EKeyCaptureState::Conflict)
{
// Les clics sur les boutons ne remontent pas jusqu'ici, ils sont traites
// avant. Tout le reste est avale pour garder le voile modal.
return FReply::Handled();
}
// La souris passe par le meme chemin que le clavier : Souris 4 et Souris 5
// sont des touches comme les autres pour Enhanced Input.
return CaptureKey(InMouseEvent.GetEffectingButton());
}
FReply UKeyCaptureWidget::CaptureKey(const FKey& Key)
{
if (Key == EKeys::Escape)
{
OnCaptureCancelled.Broadcast();
return FReply::Handled();
}
// Les touches de modification seules ne sont pas capturees : Enhanced Input
// les traite comme des touches a part entiere, mais assigner "Maj" tout court
// rendrait inutilisable toute combinaison la contenant.
if (Key.IsModifierKey())
{
return FReply::Handled();
}
// Un axe analogique n'est pas assignable a une action Digital : on ignore
// plutot que de laisser MapPlayerKey echouer plus loin sans explication.
if (Key.IsAxis1D() || Key.IsAxis2D() || Key.IsAxis3D())
{
return FReply::Handled();
}
OnKeyCaptured.Broadcast(Key);
// Handled dans tous les cas : le voile est modal, rien ne doit passer a
// travers pendant qu'il attend une touche.
return FReply::Handled();
}
void UKeyCaptureWidget::HandleConfirmClicked()
{
OnConflictAnswered.Broadcast(true);
}
void UKeyCaptureWidget::HandleCancelClicked()
{
OnConflictAnswered.Broadcast(false);
}
#undef LOCTEXT_NAMESPACE
@@ -229,6 +229,10 @@ void AMainMenuPlayerController::OpenSettings()
SettingsMenu->OnCloseRequested.AddDynamic(this, &AMainMenuPlayerController::HandleSettingsCloseRequested);
SettingsMenu->AddToViewport(ZOrderMainMenuSettings);
// Meme ecran qu'en jeu, mais notre pile est plus courte : sa boite se
// pose a 60, devant les reglages et derriere celle du bouton Quitter.
SettingsMenu->SetDialogZOrder(ZOrderMainMenuSettings + 10);
}
SettingsMenu->Open();
@@ -0,0 +1,30 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "MusicDirector.h"
#include "MusicSubsystem.h"
AMusicDirector::AMusicDirector()
{
PrimaryActorTick.bCanEverTick = false;
}
void AMusicDirector::BeginPlay()
{
Super::BeginPlay();
UMusicSubsystem* Music = UMusicSubsystem::Get(this);
if (!Music)
{
return;
}
// La consigne d'abord : elle doit etre posee meme si cette map ne change pas
// de playlist, puisque c'est elle qui vaudra pour toutes les scenes suivantes.
Music->SetLevelChangeMode(LevelChangeMode);
if (Playlist)
{
Music->PlayPlaylist(Playlist);
}
}
@@ -0,0 +1,423 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "MusicSubsystem.h"
#include "Components/AudioComponent.h"
#include "Engine/Engine.h"
#include "Engine/GameInstance.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "MusicPlaylistDataAsset.h"
#include "Sound/SoundBase.h"
#include "UObject/UObjectGlobals.h"
UMusicSubsystem* UMusicSubsystem::Get(const UObject* WorldContextObject)
{
if (!GEngine || !WorldContextObject)
{
return nullptr;
}
const UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull);
UGameInstance* GameInstance = World ? World->GetGameInstance() : nullptr;
return GameInstance ? GameInstance->GetSubsystem<UMusicSubsystem>() : nullptr;
}
void UMusicSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
// Le seul endroit d'ou l'on voit passer un changement de scene : le lecteur
// survit aux maps, donc aucun BeginPlay ni aucun EndPlay ne peut jouer ce
// role -- et la map d'arrivee n'a justement pas a connaître le sujet.
PostLoadMapHandle = FCoreUObjectDelegates::PostLoadMapWithWorld.AddUObject(this, &UMusicSubsystem::HandlePostLoadMap);
}
void UMusicSubsystem::Deinitialize()
{
if (PostLoadMapHandle.IsValid())
{
FCoreUObjectDelegates::PostLoadMapWithWorld.Remove(PostLoadMapHandle);
PostLoadMapHandle.Reset();
}
CancelPendingGap();
// Fondu a zero : on quitte le jeu, plus personne n'ecoute.
FadeOutAndRelease(MusicComponent, 0.f);
if (FadingOutComponent)
{
FadingOutComponent->Stop();
FadingOutComponent = nullptr;
}
Super::Deinitialize();
}
void UMusicSubsystem::PlayPlaylist(UMusicPlaylistDataAsset* Playlist, bool bForceRestart)
{
if (!Playlist || Playlist->Tracks.IsEmpty())
{
StopMusic();
return;
}
// Deja en cours : on la laisse tranquille. C'est tout l'interet de faire
// pointer la map du menu et celle du jeu vers le meme asset -- le chargement
// devient inaudible.
if (CurrentPlaylist == Playlist && bIsPlaying && !bForceRestart)
{
return;
}
CancelPendingGap();
// L'ancienne musique s'eteint sur sa propre voie pendant que la nouvelle
// entre : un composant audio ne peut pas se croiser avec lui-meme.
FadeOutAndRelease(MusicComponent, CurrentPlaylist ? CurrentPlaylist->FadeOutDuration : Playlist->FadeOutDuration);
CurrentPlaylist = Playlist;
bShuffleEnabled = Playlist->bShuffle;
LastPlayedTrackIndex = INDEX_NONE;
bIsPlaying = true;
BuildPlayOrder();
if (PlayOrder.IsEmpty())
{
// Que des cases vides dans la liste.
bIsPlaying = false;
return;
}
PlayTrackAtCursor(Playlist->FadeInDuration);
}
void UMusicSubsystem::StopMusic(float FadeOutDuration)
{
CancelPendingGap();
const float Duration = (FadeOutDuration >= 0.f)
? FadeOutDuration
: (CurrentPlaylist ? CurrentPlaylist->FadeOutDuration : 0.f);
FadeOutAndRelease(MusicComponent, Duration);
bIsPlaying = false;
CurrentPlaylist = nullptr;
PlayOrder.Reset();
OrderCursor = INDEX_NONE;
OnMusicTrackChanged.Broadcast(nullptr);
}
void UMusicSubsystem::SkipToNextTrack()
{
if (!bIsPlaying || !CurrentPlaylist)
{
return;
}
CancelPendingGap();
// On LIBERE le composant au lieu de le reutiliser. Stop() et FadeOut()
// rediffusent OnAudioFinished, et cette diffusion transite par le thread audio :
// elle peut donc arriver une frame plus tard, soit APRES le demarrage du morceau
// suivant, ce qui en sauterait un second. Un composant neuf, celui d'avant
// detache, rend ce retour tardif sans effet.
const float SkipFade = FMath::Min(CurrentPlaylist->FadeOutDuration, 1.f);
FadeOutAndRelease(MusicComponent, SkipFade);
AdvanceAndPlay(SkipFade);
}
void UMusicSubsystem::SetShuffleEnabled(bool bEnabled)
{
if (bShuffleEnabled == bEnabled)
{
return;
}
bShuffleEnabled = bEnabled;
if (!CurrentPlaylist)
{
return;
}
// Le morceau en cours continue ; seule la SUITE change. On recale le curseur
// dessus pour qu'en mode ordonne la lecture reprenne juste apres lui, et non
// au debut de la liste.
BuildPlayOrder();
const int32 Found = PlayOrder.IndexOfByKey(LastPlayedTrackIndex);
OrderCursor = (Found != INDEX_NONE) ? Found : 0;
}
void UMusicSubsystem::SetMusicVolume(float NewVolume)
{
MusicVolume = FMath::Clamp(NewVolume, 0.f, 1.f);
if (MusicComponent)
{
// SetVolumeMultiplier est independant du fader utilise par FadeIn/FadeOut :
// regler le volume au milieu d'un fondu ne l'interrompt pas.
MusicComponent->SetVolumeMultiplier(GetEffectiveVolume());
}
}
USoundBase* UMusicSubsystem::GetCurrentTrack() const
{
return MusicComponent ? MusicComponent->Sound : nullptr;
}
void UMusicSubsystem::HandleTrackFinished()
{
// N'est atteint que par une fin naturelle : toute coupure manuelle passe par
// FadeOutAndRelease, qui detache le delegue avant d'arreter le son.
if (!bIsPlaying || !CurrentPlaylist)
{
return;
}
const float MinGap = FMath::Max(0.f, CurrentPlaylist->MinGapBetweenTracks);
const float MaxGap = FMath::Max(MinGap, CurrentPlaylist->MaxGapBetweenTracks);
const float Gap = FMath::FRandRange(MinGap, MaxGap);
if (Gap > KINDA_SMALL_NUMBER)
{
ScheduleGap(Gap);
}
else
{
AdvanceAndPlay(0.f);
}
}
void UMusicSubsystem::HandlePostLoadMap(UWorld* LoadedWorld)
{
// En PIE plusieurs instances peuvent charger des maps en parallele : on ne
// reagit qu'a la nôtre.
if (!LoadedWorld || LoadedWorld->GetGameInstance() != GetGameInstance())
{
return;
}
if (LevelChangeMode != EMusicLevelChangeMode::New || !bIsPlaying)
{
return;
}
// Volontairement apres le chargement et non avant : le nouveau morceau entre
// en meme temps que la nouvelle scene s'affiche, pas pendant l'ecran de
// chargement ou personne ne l'associe a quoi que ce soit.
SkipToNextTrack();
}
void UMusicSubsystem::BuildPlayOrder()
{
PlayOrder.Reset();
OrderCursor = 0;
if (!CurrentPlaylist)
{
return;
}
PlayOrder.Reserve(CurrentPlaylist->Tracks.Num());
for (int32 Index = 0; Index < CurrentPlaylist->Tracks.Num(); ++Index)
{
// Filtre en amont : une case vide ne doit jamais devenir un morceau a jouer,
// sinon il faudrait la sauter au moment de la lecture -- et une liste
// entierement vide y bouclerait a l'infini.
if (CurrentPlaylist->Tracks[Index])
{
PlayOrder.Add(Index);
}
}
if (!bShuffleEnabled || PlayOrder.Num() < 2)
{
return;
}
// Fisher-Yates : chaque permutation equiprobable, une seule passe.
for (int32 Index = PlayOrder.Num() - 1; Index > 0; --Index)
{
PlayOrder.Swap(Index, FMath::RandRange(0, Index));
}
// Un nouveau cycle ne redemarre jamais sur le morceau qui vient de finir.
// C'est la difference entre "aleatoire" et "aleatoire agreable a ecouter".
if (PlayOrder[0] == LastPlayedTrackIndex)
{
PlayOrder.Swap(0, FMath::RandRange(1, PlayOrder.Num() - 1));
}
}
void UMusicSubsystem::AdvanceAndPlay(float FadeInDuration)
{
if (!CurrentPlaylist)
{
return;
}
++OrderCursor;
if (!PlayOrder.IsValidIndex(OrderCursor))
{
if (!CurrentPlaylist->bLoop)
{
StopMusic(CurrentPlaylist->FadeOutDuration);
return;
}
BuildPlayOrder();
if (PlayOrder.IsEmpty())
{
StopMusic(0.f);
return;
}
}
PlayTrackAtCursor(FadeInDuration);
}
void UMusicSubsystem::PlayTrackAtCursor(float FadeInDuration)
{
if (!CurrentPlaylist || !PlayOrder.IsValidIndex(OrderCursor))
{
return;
}
const int32 TrackIndex = PlayOrder[OrderCursor];
USoundBase* Track = CurrentPlaylist->Tracks.IsValidIndex(TrackIndex) ? CurrentPlaylist->Tracks[TrackIndex].Get() : nullptr;
if (!Track)
{
// La playlist a ete editee pendant la lecture : on reconstruit plutot que
// de jouer un index perime.
BuildPlayOrder();
return;
}
if (!MusicComponent)
{
MusicComponent = CreateMusicComponent(Track);
if (!MusicComponent)
{
bIsPlaying = false;
return;
}
}
else
{
MusicComponent->SetSound(Track);
}
MusicComponent->SetVolumeMultiplier(GetEffectiveVolume());
if (FadeInDuration > 0.f)
{
MusicComponent->FadeIn(FadeInDuration, 1.f);
}
else
{
MusicComponent->Play();
}
LastPlayedTrackIndex = TrackIndex;
OnMusicTrackChanged.Broadcast(Track);
}
UAudioComponent* UMusicSubsystem::CreateMusicComponent(USoundBase* Sound)
{
const UGameInstance* GameInstance = GetGameInstance();
UWorld* World = GameInstance ? GameInstance->GetWorld() : nullptr;
if (!World || !Sound)
{
return nullptr;
}
// bPersistAcrossLevelTransition : le composant est cree sans monde ni acteur
// proprietaire et marque bIgnoreForFlushing, le seul moyen de traverser un
// OpenLevel sans que le peripherique audio ne le coupe.
// bAutoDestroy a faux : on veut le reutiliser d'un morceau au suivant, et
// surtout recevoir son OnAudioFinished plutot que de le voir disparaître.
UAudioComponent* Component = UGameplayStatics::CreateSound2D(
World, Sound,
/*VolumeMultiplier*/ 1.f, /*PitchMultiplier*/ 1.f, /*StartTime*/ 0.f,
/*ConcurrencySettings*/ nullptr,
/*bPersistAcrossLevelTransition*/ true,
/*bAutoDestroy*/ false);
if (!Component)
{
return nullptr;
}
Component->bIsMusic = true;
Component->OnAudioFinished.AddDynamic(this, &UMusicSubsystem::HandleTrackFinished);
return Component;
}
void UMusicSubsystem::FadeOutAndRelease(TObjectPtr<UAudioComponent>& Component, float FadeOutDuration)
{
if (!Component)
{
return;
}
// Detacher AVANT d'arreter : Stop() comme FadeOut() finissent par rediffuser
// OnAudioFinished, qui enchainerait sur le morceau suivant alors qu'on cherche
// justement a couper.
Component->OnAudioFinished.RemoveDynamic(this, &UMusicSubsystem::HandleTrackFinished);
// Une seule voie de sortie : si un croisement precedent n'est pas fini, il est
// coupe net. Empiler les fondus ferait monter le nombre de voix pour rien.
if (FadingOutComponent && FadingOutComponent != Component)
{
FadingOutComponent->Stop();
FadingOutComponent = nullptr;
}
if (FadeOutDuration > 0.f && Component->IsPlaying())
{
Component->FadeOut(FadeOutDuration, 0.f);
FadingOutComponent = Component;
}
else
{
Component->Stop();
}
Component = nullptr;
}
void UMusicSubsystem::ScheduleGap(float DelaySeconds)
{
CancelPendingGap();
TWeakObjectPtr<UMusicSubsystem> WeakThis(this);
GapTickerHandle = FTSTicker::GetCoreTicker().AddTicker(TEXT("MusicSubsystem.Gap"), DelaySeconds,
[WeakThis](float /*DeltaTime*/)
{
if (UMusicSubsystem* Self = WeakThis.Get())
{
Self->GapTickerHandle.Reset();
Self->AdvanceAndPlay(0.f);
}
return false; // un seul declenchement
});
}
void UMusicSubsystem::CancelPendingGap()
{
if (GapTickerHandle.IsValid())
{
FTSTicker::RemoveTicker(GapTickerHandle);
GapTickerHandle.Reset();
}
}
float UMusicSubsystem::GetEffectiveVolume() const
{
return MusicVolume * (CurrentPlaylist ? CurrentPlaylist->VolumeMultiplier : 1.f);
}
@@ -0,0 +1,62 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingKeyRowWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#define LOCTEXT_NAMESPACE "Reglages"
void USettingKeyRowWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
// Comme USettingRowWidget : l'onglet appelle Setup depuis SON NativeConstruct,
// et rien ne garantit l'ordre entre le construct du parent et celui de ses
// enfants. Initialize, lui, tourne une fois a la creation de l'arbre.
if (KeyButton)
{
KeyButton->OnClicked.AddDynamic(this, &USettingKeyRowWidget::HandleKeyButtonClicked);
}
}
void USettingKeyRowWidget::Setup(FName InMappingName, const FText& Label, const FKey& Key)
{
MappingName = InMappingName;
if (LabelText)
{
LabelText->SetText(Label);
}
SetKey(Key);
}
void USettingKeyRowWidget::SetKey(const FKey& Key)
{
if (!KeyText)
{
return;
}
// Une touche invalide n'est pas une erreur : c'est l'etat normal d'une action
// dont on vient de voler la touche. Le rouge est le signal qu'il reste
// quelque chose a reparer avant de quitter l'ecran.
const bool bBound = Key.IsValid();
KeyText->SetText(bBound
? Key.GetDisplayName()
: LOCTEXT("ToucheNonAssignee", "Non assignée"));
KeyText->SetColorAndOpacity(FSlateColor(bBound ? BoundColor : UnboundColor));
}
void USettingKeyRowWidget::HandleKeyButtonClicked()
{
// On ne fait rien de plus que transmettre : la ligne ignore ce qu'est un
// profil de touches, et c'est tres bien ainsi.
OnRebindRequested.Broadcast(MappingName);
}
#undef LOCTEXT_NAMESPACE
@@ -5,6 +5,7 @@
#include "Components/Button.h"
#include "Components/ProgressBar.h"
#include "Components/Slider.h"
#include "Components/TextBlock.h"
void USettingRowWidget::NativeOnInitialized()
@@ -24,6 +25,20 @@ void USettingRowWidget::NativeOnInitialized()
{
RightArrowButton->OnClicked.AddDynamic(this, &USettingRowWidget::HandleRightClicked);
}
if (ValueSlider)
{
ValueSlider->OnValueChanged.AddDynamic(this, &USettingRowWidget::HandleSliderValueChanged);
}
// La valeur est POSEE SUR le curseur : si elle restait cliquable, elle
// avalerait le clic au milieu de la course et le glissement ne partirait
// jamais. Force ici plutot que dans le WBP : c'est le genre de case qu'on
// oublie et dont le symptome (une zone morte au centre) est illisible.
if (ValueText)
{
ValueText->SetVisibility(ESlateVisibility::HitTestInvisible);
}
}
void USettingRowWidget::SetupAsChoice(const FText& Label, const TArray<FText>& Options, int32 Index)
@@ -31,6 +46,8 @@ void USettingRowWidget::SetupAsChoice(const FText& Label, const TArray<FText>& O
bNumeric = false;
ChoiceLabels = Options;
StepCount = FMath::Max(Options.Num(), 1);
ArrowIndexDelta = 1;
MaxSelectableIndex = INDEX_NONE;
CurrentIndex = FMath::Clamp(Index, 0, StepCount - 1);
if (LabelText)
@@ -38,18 +55,22 @@ void USettingRowWidget::SetupAsChoice(const FText& Label, const TArray<FText>& O
LabelText->SetText(Label);
}
// Une barre de remplissage sur "Maintien / Bascule" ne veut rien dire : elle
// serait vide ou pleine sans que ca informe de quoi que ce soit. On la
// masque plutot que d'imposer un second WBP juste pour les choix.
// Une jauge sur "Maintien / Bascule" ne veut rien dire : deux positions et
// rien a doser. On replie l'empilement plutot que d'imposer un second WBP.
if (ValueBar)
{
ValueBar->SetVisibility(ESlateVisibility::Collapsed);
}
if (ValueSlider)
{
ValueSlider->SetVisibility(ESlateVisibility::Collapsed);
}
Refresh();
}
void USettingRowWidget::SetupAsNumber(const FText& Label, float Min, float Max, float Step, float Value, const FText& Suffix)
void USettingRowWidget::SetupAsNumber(const FText& Label, float Min, float Max, float Step, float ArrowStep, float Value, const FText& Suffix)
{
bNumeric = true;
ChoiceLabels.Reset();
@@ -57,21 +78,39 @@ void USettingRowWidget::SetupAsNumber(const FText& Label, float Min, float Max,
NumericStep = FMath::Max(Step, KINDA_SMALL_NUMBER);
NumericSuffix = Suffix;
// +1 : les deux extremites comptent. 70 a 120 par 5 fait 11 paliers, pas 10.
// +1 : les deux extremites comptent. 70 a 120 par 1 fait 51 paliers, pas 50.
StepCount = FMath::Max(FMath::FloorToInt((Max - Min) / NumericStep) + 1, 1);
// Le bond des fleches ne vit qu'en nombre de paliers fins : tout le reste du
// widget raisonne en index, y compris le curseur.
ArrowIndexDelta = FMath::Max(FMath::RoundToInt(ArrowStep / NumericStep), 1);
MaxSelectableIndex = INDEX_NONE;
if (LabelText)
{
LabelText->SetText(Label);
}
// HitTestInvisible et non Visible : la barre est un decor, elle ne doit pas
// intercepter les clics destines aux fleches qui l'encadrent.
// HitTestInvisible : le remplissage est un decor pose SOUS le curseur, il ne
// doit surtout pas intercepter le clic qui devrait lancer le glissement.
if (ValueBar)
{
ValueBar->SetVisibility(ESlateVisibility::HitTestInvisible);
}
if (ValueSlider)
{
ValueSlider->SetVisibility(ESlateVisibility::Visible);
// La course du curseur est graduee en INDEX, pas dans l'unite du
// reglage : 0 a 10 pour un FOV de 70 a 120 par pas de 5. Le glissement
// ne peut donc produire qu'un palier valide, et le meme curseur sert a
// n'importe quelle plage sans conversion.
ValueSlider->SetMinValue(0.f);
ValueSlider->SetMaxValue(static_cast<float>(FMath::Max(StepCount - 1, 1)));
ValueSlider->SetStepSize(1.f);
}
SetNumericValueSilently(Value);
}
@@ -82,10 +121,25 @@ float USettingRowWidget::GetNumericValue() const
void USettingRowWidget::SetIndexSilently(int32 NewIndex)
{
// Borne sur StepCount et non sur MaxSelectable : le code a le droit
// d'afficher un etat que le joueur ne peut pas choisir.
CurrentIndex = FMath::Clamp(NewIndex, 0, StepCount - 1);
Refresh();
}
void USettingRowWidget::SetMaxSelectableIndex(int32 InMaxSelectableIndex)
{
MaxSelectableIndex = InMaxSelectableIndex;
Refresh();
}
int32 USettingRowWidget::MaxSelectable() const
{
return MaxSelectableIndex == INDEX_NONE
? StepCount - 1
: FMath::Clamp(MaxSelectableIndex, 0, StepCount - 1);
}
void USettingRowWidget::SetNumericValueSilently(float Value)
{
SetIndexSilently(FMath::RoundToInt((Value - NumericMin) / NumericStep));
@@ -93,17 +147,57 @@ void USettingRowWidget::SetNumericValueSilently(float Value)
void USettingRowWidget::HandleLeftClicked()
{
StepIndex(-1);
StepIndex(ArrowTargetIndex(-1) - CurrentIndex);
}
void USettingRowWidget::HandleRightClicked()
{
StepIndex(1);
StepIndex(ArrowTargetIndex(1) - CurrentIndex);
}
int32 USettingRowWidget::ArrowTargetIndex(int32 Direction) const
{
// Pas de bond a arrondir : une ligne de choix, ou un reglage dont les deux
// pas sont identiques.
if (ArrowIndexDelta <= 1)
{
return CurrentIndex + Direction;
}
// On vise le multiple du bond suivant, pas CurrentIndex + bond : arrive a 37
// au curseur, la fleche droite doit donner 40 et la gauche 35. Sinon les
// fleches perdent leur reperage des qu'on a glisse une fois.
if (Direction > 0)
{
return ((CurrentIndex / ArrowIndexDelta) + 1) * ArrowIndexDelta;
}
// Division entiere arrondie au superieur, pour que 35 redescende bien a 30
// et pas a 35 (ce qui n'aurait rien fait).
return ((CurrentIndex + ArrowIndexDelta - 1) / ArrowIndexDelta - 1) * ArrowIndexDelta;
}
void USettingRowWidget::HandleSliderValueChanged(float NewValue)
{
if (bRefreshingSlider)
{
return;
}
// On passe par StepIndex plutot que d'ecrire CurrentIndex : le glissement
// et les fleches doivent produire exactement le meme effet, y compris le
// grisage des fleches et la diffusion du delegue.
StepIndex(FMath::RoundToInt(NewValue) - CurrentIndex);
// Puis on recale la poignee sur le cran, meme si l'index n'a pas bouge :
// StepIndex sort sans rien faire dans ce cas, et la poignee resterait
// plantee entre deux paliers.
Refresh();
}
void USettingRowWidget::StepIndex(int32 Delta)
{
const int32 NewIndex = FMath::Clamp(CurrentIndex + Delta, 0, StepCount - 1);
const int32 NewIndex = FMath::Clamp(CurrentIndex + Delta, 0, MaxSelectable());
// Pas de bouclage, volontairement : passer de 100 a 5 de sensibilite d'un
// clic de trop rendrait le jeu injouable sans qu'on comprenne pourquoi.
@@ -143,13 +237,22 @@ void USettingRowWidget::Refresh()
if (ValueBar)
{
// Un seul palier : la barre n'a aucun sens, on la remplit entierement
// Un seul palier : la jauge n'a aucun sens, on la remplit entierement
// plutot que de diviser par zero.
ValueBar->SetPercent(StepCount > 1
? static_cast<float>(CurrentIndex) / static_cast<float>(StepCount - 1)
: 1.f);
}
if (ValueSlider && bNumeric)
{
// Le drapeau evite qu'un repositionnement venant du code soit relu comme
// un geste du joueur.
bRefreshingSlider = true;
ValueSlider->SetValue(static_cast<float>(CurrentIndex));
bRefreshingSlider = false;
}
// Les fleches se grisent aux extremites : le joueur voit qu'il est au bout
// au lieu de cliquer dans le vide.
if (LeftArrowButton)
@@ -159,6 +262,6 @@ void USettingRowWidget::Refresh()
if (RightArrowButton)
{
RightArrowButton->SetIsEnabled(CurrentIndex < StepCount - 1);
RightArrowButton->SetIsEnabled(CurrentIndex < MaxSelectable());
}
}
@@ -0,0 +1,344 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsControlsWidget.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/LocalPlayer.h"
#include "GameFramework/PlayerController.h"
#include "SettingKeyRowWidget.h"
#include "SettingsMenuWidget.h"
#include "SurvivalGameInstance.h"
#include "UserSettings/EnhancedInputUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
USettingsControlsWidget::USettingsControlsWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Dans le CONSTRUCTEUR et non dans NativeConstruct : ce dernier tourne a
// chaque ouverture et ecraserait le texte que tu aurais regle dans les Class
// Defaults de WBP_SettingsControls, rendant la UPROPERTY inutile.
//
// Trois informations, pas deux : la touche, qui la possede aujourd'hui, et a
// qui on la donne. Sans la troisieme, un joueur qui a ouvert le voile puis
// hesite ne sait plus quelle ligne il etait en train de modifier.
ConflictMessage = LOCTEXT("ConflitTouche",
"« {0} » est déjà utilisée par « {1} ».\nL'assigner à « {2} » ?\n\n« {1} » n'aura plus de touche.");
}
void USettingsControlsWidget::NativeConstruct()
{
Super::NativeConstruct();
// Les identifiants doivent correspondre EXACTEMENT au champ "Name" des
// Player Mappable Key Settings de IMC_Default. Une faute de frappe donne une
// ligne perpetuellement "Non assignée" -- c'est le seul point de contact
// entre le C++ et l'editeur, et il n'est verifiable qu'au runtime.
SetupRow(MoveForwardRow, TEXT("MoveForward"), LOCTEXT("Avancer", "Avancer"));
SetupRow(MoveBackwardRow, TEXT("MoveBackward"), LOCTEXT("Reculer", "Reculer"));
SetupRow(MoveLeftRow, TEXT("MoveLeft"), LOCTEXT("AllerGauche", "Aller à gauche"));
SetupRow(MoveRightRow, TEXT("MoveRight"), LOCTEXT("AllerDroite", "Aller à droite"));
SetupRow(JumpRow, TEXT("Jump"), LOCTEXT("Sauter", "Sauter"));
SetupRow(SprintRow, TEXT("Sprint"), LOCTEXT("Sprinter", "Sprinter"));
SetupRow(CrouchRow, TEXT("Crouch"), LOCTEXT("SAccroupir", "S'accroupir"));
SetupRow(InteractRow, TEXT("Interact"), LOCTEXT("Interagir", "Interagir"));
SetupRow(UseItemRow, TEXT("UseItem"), LOCTEXT("UtiliserObjet", "Utiliser l'objet"));
SetupRow(ToggleInventoryRow, TEXT("ToggleInventory"), LOCTEXT("Inventaire", "Inventaire"));
SetupRow(DropItemRow, TEXT("DropItem"), LOCTEXT("Jeter", "Jeter"));
RefreshFromSettings();
}
void USettingsControlsWidget::SetupRow(USettingKeyRowWidget* Row, FName MappingName, const FText& Label)
{
if (!Row)
{
return;
}
MappingLabels.Add(MappingName, Label);
// Touche invalide au depart : RefreshFromSettings ira chercher la vraie juste
// apres. Poser une valeur en dur ici mentirait le temps d'une frame.
Row->Setup(MappingName, Label, FKey());
// Un seul handler pour les onze lignes : chacune renvoie son propre nom de
// mapping, il n'y a rien a distinguer ici.
Row->OnRebindRequested.AddDynamic(this, &USettingsControlsWidget::HandleRebindRequested);
}
UEnhancedInputUserSettings* USettingsControlsWidget::GetInputSettings() const
{
const ULocalPlayer* LocalPlayer = GetOwningLocalPlayer();
if (!LocalPlayer)
{
return nullptr;
}
UEnhancedInputLocalPlayerSubsystem* Subsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
return Subsystem ? Subsystem->GetUserSettings() : nullptr;
}
TArray<USettingKeyRowWidget*> USettingsControlsWidget::GetAllRows() const
{
// L'ordre est celui de l'affichage : il sert aussi a la reinitialisation.
TArray<USettingKeyRowWidget*> Rows;
Rows.Reserve(11);
for (USettingKeyRowWidget* Row : { MoveForwardRow.Get(), MoveBackwardRow.Get(), MoveLeftRow.Get(),
MoveRightRow.Get(), JumpRow.Get(), SprintRow.Get(), CrouchRow.Get(), InteractRow.Get(),
UseItemRow.Get(), ToggleInventoryRow.Get(), DropItemRow.Get() })
{
if (Row)
{
Rows.Add(Row);
}
}
return Rows;
}
USettingKeyRowWidget* USettingsControlsWidget::FindRow(FName MappingName) const
{
for (USettingKeyRowWidget* Row : GetAllRows())
{
if (Row->GetMappingName() == MappingName)
{
return Row;
}
}
return nullptr;
}
FText USettingsControlsWidget::GetMappingDisplayName(FName MappingName) const
{
if (const FText* Label = MappingLabels.Find(MappingName))
{
return *Label;
}
// Repli sur l'identifiant technique : moins joli, mais toujours plus utile
// qu'un libelle vide dans un message de conflit.
return FText::FromName(MappingName);
}
void USettingsControlsWidget::RefreshFromSettings()
{
// On declare les contextes JUSTE AVANT de lire : c'est le seul moment ou
// l'on est certain que le joueur local a son PlayerController, donc que le
// sous-systeme a cree ses UserSettings. Tout enregistrement plus precoce
// tombe dans le vide -- ULocalPlayer previent ses abonnes avant ses
// sous-systemes. L'appel est idempotent, le repeter ne coute rien.
if (USurvivalGameInstance* GameInstance = GetGameInstance<USurvivalGameInstance>())
{
GameInstance->RegisterMappableContexts();
}
const UEnhancedInputUserSettings* Settings = GetInputSettings();
const UEnhancedPlayerMappableKeyProfile* Profile = Settings ? Settings->GetActiveKeyProfile() : nullptr;
if (!Profile)
{
UE_LOG(LogTemp, Warning, TEXT("USettingsControlsWidget : aucun profil de touches. Verifie 'Enable User Settings' dans Project Settings > Enhanced Input."));
return;
}
// On parcourt les lignes DECLAREES et on va chercher leur mapping, plutot que
// l'inverse : le profil contient aussi des mappings qu'on n'expose pas, et
// l'ordre d'affichage est le notre, pas le sien.
for (const TPair<FName, FKeyMappingRow>& Pair : Profile->GetPlayerMappingRows())
{
USettingKeyRowWidget* Row = FindRow(Pair.Key);
if (!Row)
{
continue;
}
FKey CurrentKey;
for (const FPlayerKeyMapping& Mapping : Pair.Value.Mappings)
{
CurrentKey = Mapping.GetCurrentKey();
break;
}
Row->SetKey(CurrentKey);
}
}
void USettingsControlsWidget::ResetToDefaults()
{
UEnhancedInputUserSettings* Settings = GetInputSettings();
if (!Settings)
{
return;
}
for (const USettingKeyRowWidget* Row : GetAllRows())
{
FMapPlayerKeyArgs Args;
Args.MappingName = Row->GetMappingName();
FGameplayTagContainer FailureReason;
Settings->ResetAllPlayerKeysInRow(Args, FailureReason);
}
Settings->ApplySettings();
RefreshFromSettings();
}
void USettingsControlsWidget::HandleRebindRequested(FName MappingName)
{
if (!OwningMenu.IsValid())
{
return;
}
PendingMappingName = MappingName;
PendingKey = FKey();
ConflictingMappingName = NAME_None;
FOnSettingsKeyCaptureResult Callback;
Callback.BindDynamic(this, &USettingsControlsWidget::HandleKeyCaptured);
OwningMenu->RequestKeyCapture(GetMappingDisplayName(MappingName), Callback);
}
void USettingsControlsWidget::HandleKeyCaptured(FKey CapturedKey)
{
UEnhancedInputUserSettings* Settings = GetInputSettings();
// Touche invalide = le joueur a annule avec Echap.
if (!CapturedKey.IsValid() || PendingMappingName.IsNone() || !Settings)
{
PendingMappingName = NAME_None;
return;
}
PendingKey = CapturedKey;
const UEnhancedPlayerMappableKeyProfile* Profile = Settings->GetActiveKeyProfile();
if (!Profile)
{
return;
}
// Le moteur sait deja repondre a "qui utilise cette touche ?" : pas de
// comparaison a la main sur toutes les lignes.
TArray<FName> Owners;
Profile->GetMappingNamesForKey(CapturedKey, Owners);
ConflictingMappingName = NAME_None;
for (const FName& Owner : Owners)
{
// Se voler sa propre touche n'a rien d'un conflit.
if (Owner != PendingMappingName && FindRow(Owner))
{
ConflictingMappingName = Owner;
break;
}
}
if (ConflictingMappingName.IsNone())
{
// Rien a arbitrer : on applique et on referme le voile.
ApplyPendingRebind();
if (OwningMenu.IsValid())
{
OwningMenu->CloseKeyCapture();
}
return;
}
if (!OwningMenu.IsValid())
{
return;
}
FOnSettingsConfirmationResult Callback;
Callback.BindDynamic(this, &USettingsControlsWidget::HandleConflictAnswered);
const FText Message = FText::Format(ConflictMessage,
PendingKey.GetDisplayName(),
GetMappingDisplayName(ConflictingMappingName),
GetMappingDisplayName(PendingMappingName));
// Le voile reste ouvert et change simplement d'etat : pour le joueur c'est
// le meme geste qui continue, pas un second ecran qui apparait.
OwningMenu->ShowKeyCaptureConflict(Message, Callback);
}
void USettingsControlsWidget::HandleConflictAnswered(bool bConfirmed)
{
// Le voile s'est deja referme de lui-meme en repondant : il n'y a plus qu'a
// appliquer, ou a oublier ce qu'on avait mis de cote.
if (!bConfirmed)
{
PendingMappingName = NAME_None;
ConflictingMappingName = NAME_None;
PendingKey = FKey();
return;
}
ApplyPendingRebind();
}
void USettingsControlsWidget::ApplyPendingRebind()
{
UEnhancedInputUserSettings* Settings = GetInputSettings();
if (!Settings || PendingMappingName.IsNone() || !PendingKey.IsValid())
{
return;
}
FGameplayTagContainer FailureReason;
// L'ancien proprietaire perd sa touche AVANT qu'on assigne la notre : deux
// actions sur la meme touche laisseraient un doublon actif, et le joueur
// declencherait les deux d'un seul appui.
//
// On lui assigne une touche VIDE au lieu d'appeler UnMapPlayerKey : ce
// dernier fait un ResetToDefault(), donc il rendrait a l'action sa touche
// d'origine -- exactement celle qu'on est en train de lui prendre. Le nom
// trompe, il annule la surcharge du joueur, il ne libere pas la touche.
if (!ConflictingMappingName.IsNone())
{
FMapPlayerKeyArgs ClearArgs;
ClearArgs.MappingName = ConflictingMappingName;
ClearArgs.Slot = EPlayerMappableKeySlot::First;
ClearArgs.NewKey = FKey();
Settings->MapPlayerKey(ClearArgs, FailureReason);
}
FMapPlayerKeyArgs MapArgs;
MapArgs.MappingName = PendingMappingName;
MapArgs.Slot = EPlayerMappableKeySlot::First;
MapArgs.NewKey = PendingKey;
Settings->MapPlayerKey(MapArgs, FailureReason);
if (!FailureReason.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("USettingsControlsWidget : remappage de '%s' refuse (%s)."),
*PendingMappingName.ToString(), *FailureReason.ToString());
}
// ApplySettings pousse les surcharges dans les mapping contexts deja
// enregistres : le pawn n'a rien a rebrancher, la touche est active tout de
// suite. La sauvegarde, elle, attend la fermeture de l'ecran.
Settings->ApplySettings();
PendingMappingName = NAME_None;
ConflictingMappingName = NAME_None;
PendingKey = FKey();
// Les deux lignes concernees ont bouge : on relit tout plutot que de tenir
// une comptabilite qui finirait par diverger.
RefreshFromSettings();
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,506 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsDisplayWidget.h"
#include "Kismet/KismetSystemLibrary.h"
#include "SettingRowWidget.h"
#include "SettingsMenuWidget.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
namespace SettingsDisplayTab
{
// Ordre des modes dans la ligne. Il doit correspondre a MakeWindowMode et a
// FindDisplayModeIndex : trois listes a garder alignees, d'ou leur voisinage.
constexpr int32 ModeFullscreen = 0;
constexpr int32 ModeBorderless = 1;
constexpr int32 ModeWindowed = 2;
EWindowMode::Type MakeWindowMode(int32 Index)
{
switch (Index)
{
case ModeBorderless: return EWindowMode::WindowedFullscreen;
case ModeWindowed: return EWindowMode::Windowed;
default: return EWindowMode::Fullscreen;
}
}
int32 FindDisplayModeIndex(EWindowMode::Type Mode)
{
switch (Mode)
{
case EWindowMode::WindowedFullscreen: return ModeBorderless;
case EWindowMode::Windowed: return ModeWindowed;
default: return ModeFullscreen;
}
}
/**
* Limites de FPS proposees. 0 = illimitee, c'est la convention de
* SetFrameRateLimit et non une valeur sentinelle a nous.
*/
const TArray<float>& FrameRateLimits()
{
static const TArray<float> Limits = { 30.f, 60.f, 90.f, 120.f, 144.f, 165.f, 240.f, 0.f };
return Limits;
}
int32 FindFrameRateIndex(float Limit)
{
const TArray<float>& Limits = FrameRateLimits();
// Comparaison a la tolerance pres : la valeur relue du .ini a pu passer
// par une conversion et ne plus etre strictement egale.
for (int32 Index = 0; Index < Limits.Num(); ++Index)
{
if (FMath::IsNearlyEqual(Limits[Index], Limit, 0.5f))
{
return Index;
}
}
// Valeur exotique posee a la main dans le .ini : on retombe sur
// "Illimitee" plutot que d'afficher n'importe quoi.
return Limits.Num() - 1;
}
}
USettingsDisplayWidget::USettingsDisplayWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Dans le constructeur, pour la meme raison que ConflictMessage : ecrit dans
// NativeConstruct, ce texte ecraserait a chaque ouverture celui regle dans le
// Blueprint.
VideoConfirmMessage = LOCTEXT("VideoConfirm",
"Garder ce mode d'affichage ?\nRetour automatique dans {0} s.");
}
void USettingsDisplayWidget::NativeConstruct()
{
Super::NativeConstruct();
using namespace SurvivalSettingsRange;
if (DisplayModeRow)
{
DisplayModeRow->SetupAsChoice(LOCTEXT("ModeAffichage", "Mode d'affichage"),
{ LOCTEXT("PleinEcran", "Plein écran"),
LOCTEXT("SansBordure", "Sans bordure"),
LOCTEXT("Fenetre", "Fenêtré") }, 0);
DisplayModeRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleDisplayModeChanged);
}
if (ResolutionRow)
{
ResolutionRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleResolutionChanged);
}
if (FrameRateLimitRow)
{
TArray<FText> Labels;
Labels.Reserve(SettingsDisplayTab::FrameRateLimits().Num());
for (const float Limit : SettingsDisplayTab::FrameRateLimits())
{
Labels.Add(FMath::IsNearlyZero(Limit)
? LOCTEXT("Illimitee", "Illimitée")
: FText::AsNumber(FMath::RoundToInt(Limit)));
}
FrameRateLimitRow->SetupAsChoice(LOCTEXT("LimiteFPS", "Limite de FPS"), Labels, 0);
FrameRateLimitRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleFrameRateLimitChanged);
}
if (VSyncRow)
{
VSyncRow->SetupAsChoice(LOCTEXT("VSync", "Synchronisation verticale"),
{ LOCTEXT("Non", "Non"), LOCTEXT("Oui", "Oui") }, 0);
VSyncRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleVSyncChanged);
}
if (ResolutionScaleRow)
{
ResolutionScaleRow->SetupAsNumber(LOCTEXT("EchelleResolution", "Échelle de résolution"),
ResolutionScaleMin, ResolutionScaleMax, ResolutionScaleStep, ResolutionScaleArrowStep,
ResolutionScaleMax, LOCTEXT("Pourcent", " %"));
ResolutionScaleRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleResolutionScaleChanged);
}
if (BrightnessRow)
{
BrightnessRow->SetupAsNumber(LOCTEXT("Luminosite", "Luminosité"),
BrightnessMin, BrightnessMax, BrightnessStep, BrightnessArrowStep,
BrightnessMin, LOCTEXT("Pourcent", " %"));
BrightnessRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleBrightnessChanged);
}
RebuildResolutionList();
RefreshFromSettings();
}
void USettingsDisplayWidget::RebuildResolutionList()
{
AvailableResolutions.Reset();
// La liste depend du mode : en fenetre on veut des tailles qui tiennent dans
// l'ecran, pas les modes exclusifs de la carte graphique.
const USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
const bool bWindowed = Settings && Settings->GetFullscreenMode() == EWindowMode::Windowed;
if (bWindowed)
{
UKismetSystemLibrary::GetConvenientWindowedResolutions(AvailableResolutions);
}
else
{
UKismetSystemLibrary::GetSupportedFullscreenResolutions(AvailableResolutions);
}
if (AvailableResolutions.Num() == 0)
{
// Ecran non interroge (editeur, machine sans mode expose) : on propose au
// moins la resolution courante pour ne pas afficher une ligne vide.
UE_LOG(LogTemp, Warning, TEXT("USettingsDisplayWidget : aucune resolution retournee par le systeme, repli sur la resolution courante."));
AvailableResolutions.Add(Settings ? Settings->GetScreenResolution() : FIntPoint(1920, 1080));
}
FilterResolutions(Settings ? Settings->GetScreenResolution() : FIntPoint(1920, 1080));
if (!ResolutionRow)
{
return;
}
TArray<FText> Labels;
Labels.Reserve(AvailableResolutions.Num());
for (const FIntPoint& Resolution : AvailableResolutions)
{
// FString::Printf et non FText::AsNumber : ce dernier formate selon la
// locale et insererait un separateur de milliers -- "1 920 x 1 080".
// Une resolution s'ecrit "1920x1080", sans espace ni separateur : c'est
// une reference technique, pas une quantite.
Labels.Add(FText::FromString(FString::Printf(TEXT("%dx%d"), Resolution.X, Resolution.Y)));
}
ResolutionRow->SetupAsChoice(LOCTEXT("Resolution", "Résolution"),
Labels, FindCurrentResolutionIndex(Settings));
}
void USettingsDisplayWidget::FilterResolutions(const FIntPoint& CurrentResolution)
{
// Des PLAGES et non un rapport exact : les resolutions commerciales ne
// tombent pas juste. En 16:9, 1366x768 vaut 1.779 ; en "21:9", 2560x1080
// vaut 2.370, 3440x1440 vaut 2.389 et 3840x1600 vaut 2.400 -- trois valeurs
// differentes pour un format qu'on annonce pourtant comme un seul.
constexpr float WideMin = 1.74f;
constexpr float WideMax = 1.81f;
constexpr float UltrawideMin = 2.30f;
constexpr float UltrawideMax = 2.42f;
TArray<FIntPoint> Kept;
Kept.Reserve(AvailableResolutions.Num());
for (const FIntPoint& Resolution : AvailableResolutions)
{
if (Resolution.X < MinimumResolution.X || Resolution.Y < MinimumResolution.Y)
{
continue;
}
const float Ratio = static_cast<float>(Resolution.X) / static_cast<float>(Resolution.Y);
const bool bIsWide = Ratio >= WideMin && Ratio <= WideMax;
const bool bIsUltrawide = bAllowUltrawide && Ratio >= UltrawideMin && Ratio <= UltrawideMax;
// Tout le reste degage : 4:3, 5:4, 16:10, et le 32:9 des ecrans doubles,
// qui deformerait le HUD sans qu'on ait rien prevu pour lui.
if (!bIsWide && !bIsUltrawide)
{
continue;
}
Kept.Add(Resolution);
}
// La resolution courante doit rester selectionnable meme si elle ne passe pas
// le filtre : sinon un joueur en 16:10 verrait la ligne afficher autre chose
// que ce qu'il utilise vraiment.
if (!Kept.Contains(CurrentResolution) && CurrentResolution.X > 0 && CurrentResolution.Y > 0)
{
Kept.Add(CurrentResolution);
}
// Un ecran qui ne proposerait aucun 16:9 vaut mieux non filtre que vide.
if (Kept.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("USettingsDisplayWidget : aucune resolution ne passe le filtre, la liste complete est conservee."));
return;
}
// Tri croissant par surface : la plus petite en haut, la native en bas.
Kept.Sort([](const FIntPoint& A, const FIntPoint& B)
{
return static_cast<int64>(A.X) * A.Y < static_cast<int64>(B.X) * B.Y;
});
AvailableResolutions = MoveTemp(Kept);
}
int32 USettingsDisplayWidget::FindCurrentResolutionIndex(const USurvivalUserSettings* Settings) const
{
if (!Settings)
{
return 0;
}
const FIntPoint Current = Settings->GetScreenResolution();
const int32 Found = AvailableResolutions.IndexOfByKey(Current);
// Resolution absente de la liste (fenetre redimensionnee a la main) : on
// retombe sur la plus grande, en fin de liste.
return Found != INDEX_NONE ? Found : AvailableResolutions.Num() - 1;
}
int32 USettingsDisplayWidget::FindLargestResolutionIndex() const
{
int32 BestIndex = INDEX_NONE;
int64 BestArea = -1;
// On compare la SURFACE et non l'index : rien ne garantit que le systeme
// renvoie ses modes triés, et un 2560x1080 ultralarge ne doit pas passer
// devant un 1920x1200.
for (int32 Index = 0; Index < AvailableResolutions.Num(); ++Index)
{
const int64 Area = static_cast<int64>(AvailableResolutions[Index].X) * AvailableResolutions[Index].Y;
if (Area > BestArea)
{
BestArea = Area;
BestIndex = Index;
}
}
return BestIndex;
}
void USettingsDisplayWidget::RefreshFromSettings()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
if (DisplayModeRow)
{
DisplayModeRow->SetIndexSilently(SettingsDisplayTab::FindDisplayModeIndex(Settings->GetFullscreenMode()));
}
if (ResolutionRow)
{
ResolutionRow->SetIndexSilently(FindCurrentResolutionIndex(Settings));
}
if (FrameRateLimitRow)
{
FrameRateLimitRow->SetIndexSilently(SettingsDisplayTab::FindFrameRateIndex(Settings->GetFrameRateLimit()));
}
if (VSyncRow)
{
VSyncRow->SetIndexSilently(Settings->IsVSyncEnabled() ? 1 : 0);
}
if (ResolutionScaleRow)
{
// On lit la valeur BRUTE en pourcentage et non la normalisee : la plage
// autorisee par le moteur ne commence pas a 50 %, une valeur normalisee
// ne correspondrait donc pas a ce que la ligne affiche.
float CurrentNormalized = 0.f;
float CurrentValue = 100.f;
float MinValue = 0.f;
float MaxValue = 100.f;
Settings->GetResolutionScaleInformationEx(CurrentNormalized, CurrentValue, MinValue, MaxValue);
ResolutionScaleRow->SetNumericValueSilently(CurrentValue);
}
if (BrightnessRow)
{
BrightnessRow->SetNumericValueSilently(Settings->GetBrightness());
}
}
void USettingsDisplayWidget::ResetToDefaults()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
// On ne touche PAS a la resolution ni au mode d'affichage : les remettre
// "par defaut" pourrait basculer sur un mode que l'ecran ne gere pas, et le
// joueur n'a pas demande ca en cliquant Reinitialiser.
Settings->SetFrameRateLimit(0.f);
Settings->SetVSyncEnabled(false);
Settings->SetResolutionScaleNormalized(1.f);
Settings->SetSurvivalDisplayToDefaults();
Settings->ApplyNonResolutionSettings();
RefreshFromSettings();
}
void USettingsDisplayWidget::HandleDisplayModeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
Settings->SetFullscreenMode(SettingsDisplayTab::MakeWindowMode(NewIndex));
// Passer en fenetre change la liste des tailles proposables.
RebuildResolutionList();
// En plein ecran ou sans bordure, on bascule d'office sur la plus grande
// resolution disponible -- c'est-a-dire celle de l'ecran.
//
// Sans ca, un jeu lance en Standalone dans une fenetre de 1280x720 garderait
// cette resolution en plein ecran : l'image serait etiree sur tout l'ecran et
// visiblement floue, alors que le joueur vient de demander le contraire. En
// fenetre on ne touche a rien, la taille choisie est celle qu'il veut.
if (NewIndex != SettingsDisplayTab::ModeWindowed)
{
const int32 LargestIndex = FindLargestResolutionIndex();
if (AvailableResolutions.IsValidIndex(LargestIndex))
{
Settings->SetScreenResolution(AvailableResolutions[LargestIndex]);
if (ResolutionRow)
{
ResolutionRow->SetIndexSilently(LargestIndex);
}
}
}
ApplyVideoModeAndConfirm();
}
void USettingsDisplayWidget::HandleResolutionChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !AvailableResolutions.IsValidIndex(NewIndex))
{
return;
}
Settings->SetScreenResolution(AvailableResolutions[NewIndex]);
ApplyVideoModeAndConfirm();
}
void USettingsDisplayWidget::ApplyVideoModeAndConfirm()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
// ApplyResolutionSettings et non ApplySettings : on ne veut surtout pas
// repousser la qualite graphique au passage, ce serait une saccade a chaque
// clic de fleche.
Settings->ApplyResolutionSettings(/*bCheckForCommandLineOverrides=*/false);
if (!OwningMenu.IsValid())
{
// Sans ecran parent, personne ne peut poser la question. On valide, au
// prix du filet de securite -- mieux que de laisser un etat en suspens.
Settings->ConfirmVideoMode();
return;
}
FOnSettingsConfirmationResult Callback;
Callback.BindDynamic(this, &USettingsDisplayWidget::HandleVideoConfirmAnswered);
if (!OwningMenu->RequestConfirmation(VideoConfirmMessage, VideoConfirmSeconds, Callback))
{
Settings->ConfirmVideoMode();
}
}
void USettingsDisplayWidget::HandleVideoConfirmAnswered(bool bConfirmed)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
if (bConfirmed)
{
// Ce mode devient la reference vers laquelle RevertVideoMode ramenera la
// prochaine fois.
Settings->ConfirmVideoMode();
}
else
{
Settings->RevertVideoMode();
Settings->ApplyResolutionSettings(/*bCheckForCommandLineOverrides=*/false);
RebuildResolutionList();
}
RefreshFromSettings();
}
void USettingsDisplayWidget::HandleFrameRateLimitChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !SettingsDisplayTab::FrameRateLimits().IsValidIndex(NewIndex))
{
return;
}
Settings->SetFrameRateLimit(SettingsDisplayTab::FrameRateLimits()[NewIndex]);
Settings->ApplyNonResolutionSettings();
}
void USettingsDisplayWidget::HandleVSyncChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetVSyncEnabled(NewIndex == 1);
Settings->ApplyNonResolutionSettings();
}
}
void USettingsDisplayWidget::HandleResolutionScaleChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !ResolutionScaleRow)
{
return;
}
Settings->SetResolutionScaleValueEx(ResolutionScaleRow->GetNumericValue());
Settings->ApplyNonResolutionSettings();
}
void USettingsDisplayWidget::HandleBrightnessChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !BrightnessRow)
{
return;
}
Settings->SetBrightness(BrightnessRow->GetNumericValue());
// ApplySurvivalSettings et non ApplyNonResolutionSettings : la luminosite
// est a nous, elle ne passe pas par le volet video du moteur.
Settings->ApplySurvivalSettings();
}
#undef LOCTEXT_NAMESPACE
@@ -33,7 +33,7 @@ void USettingsGameplayWidget::NativeConstruct()
if (SensitivityRow)
{
SensitivityRow->SetupAsNumber(LOCTEXT("Sensibilite", "Sensibilité"),
SensitivityMin, SensitivityMax, SensitivityStep, SensitivityMin, FText::GetEmpty());
SensitivityMin, SensitivityMax, SensitivityStep, SensitivityArrowStep, SensitivityMin, FText::GetEmpty());
SensitivityRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleSensitivityChanged);
}
@@ -47,7 +47,7 @@ void USettingsGameplayWidget::NativeConstruct()
if (SensitivityVerticalRow)
{
SensitivityVerticalRow->SetupAsNumber(LOCTEXT("SensibiliteVerticale", "Sensibilité verticale"),
SensitivityMin, SensitivityMax, SensitivityStep, SensitivityMin, FText::GetEmpty());
SensitivityMin, SensitivityMax, SensitivityStep, SensitivityArrowStep, SensitivityMin, FText::GetEmpty());
SensitivityVerticalRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleSensitivityVerticalChanged);
}
@@ -60,14 +60,14 @@ void USettingsGameplayWidget::NativeConstruct()
if (FieldOfViewRow)
{
FieldOfViewRow->SetupAsNumber(LOCTEXT("ChampDeVision", "Champ de vision"),
FieldOfViewMin, FieldOfViewMax, FieldOfViewStep, FieldOfViewMin, LOCTEXT("Degres", "°"));
FieldOfViewMin, FieldOfViewMax, FieldOfViewStep, FieldOfViewArrowStep, FieldOfViewMin, LOCTEXT("Degres", "°"));
FieldOfViewRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleFieldOfViewChanged);
}
if (CameraShakeRow)
{
CameraShakeRow->SetupAsNumber(LOCTEXT("BougeCamera", "Bougé de caméra"),
CameraShakeMin, CameraShakeMax, CameraShakeStep, CameraShakeMin, LOCTEXT("Pourcent", " %"));
CameraShakeMin, CameraShakeMax, CameraShakeStep, CameraShakeArrowStep, CameraShakeMin, LOCTEXT("Pourcent", " %"));
CameraShakeRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleCameraShakeChanged);
}
@@ -0,0 +1,250 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsGraphicsWidget.h"
#include "SettingRowWidget.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
namespace SettingsGraphicsTab
{
/**
* Index de "Personnalise" dans la ligne de qualite globale.
*
* Le moteur renvoie -1 quand les curseurs ne correspondent a aucun preset.
* On l'affiche comme un cinquieme choix, mais SetMaxSelectableIndex(3)
* empeche le joueur de le selectionner : c'est un etat dans lequel on tombe,
* pas un reglage qu'on choisit.
*/
constexpr int32 CustomQualityIndex = 4;
/** Bas / Moyen / Eleve / Epique. Le moteur accepte aussi 4 (cinematique), qu'on n'expose pas. */
constexpr int32 MaxQualityLevel = 3;
}
void USettingsGraphicsWidget::SetupQualityRow(USettingRowWidget* Row, const FText& Label)
{
if (!Row)
{
return;
}
Row->SetupAsChoice(Label,
{ LOCTEXT("QualiteBas", "Bas"),
LOCTEXT("QualiteMoyen", "Moyen"),
LOCTEXT("QualiteEleve", "Élevé"),
LOCTEXT("QualiteEpique", "Épique") }, 0);
}
void USettingsGraphicsWidget::NativeConstruct()
{
Super::NativeConstruct();
if (OverallQualityRow)
{
OverallQualityRow->SetupAsChoice(LOCTEXT("QualiteGlobale", "Qualité globale"),
{ LOCTEXT("QualiteBas", "Bas"),
LOCTEXT("QualiteMoyen", "Moyen"),
LOCTEXT("QualiteEleve", "Élevé"),
LOCTEXT("QualiteEpique", "Épique"),
LOCTEXT("QualitePerso", "Personnalisé") }, 0);
OverallQualityRow->SetMaxSelectableIndex(SettingsGraphicsTab::MaxQualityLevel);
OverallQualityRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleOverallQualityChanged);
}
SetupQualityRow(ViewDistanceRow, LOCTEXT("DistanceAffichage", "Distance d'affichage"));
SetupQualityRow(FoliageRow, LOCTEXT("Vegetation", "Végétation"));
SetupQualityRow(ShadowRow, LOCTEXT("Ombres", "Ombres"));
SetupQualityRow(TextureRow, LOCTEXT("Textures", "Textures"));
SetupQualityRow(VisualEffectRow, LOCTEXT("Effets", "Effets"));
SetupQualityRow(PostProcessRow, LOCTEXT("PostProcess", "Post-process"));
SetupQualityRow(AntiAliasingRow, LOCTEXT("AntiAliasing", "Anti-aliasing"));
SetupQualityRow(ReflectionRow, LOCTEXT("Reflexions", "Réflexions"));
SetupQualityRow(GlobalIlluminationRow, LOCTEXT("EclairageGlobal", "Éclairage global"));
SetupQualityRow(ShadingRow, LOCTEXT("Ombrage", "Ombrage"));
if (ViewDistanceRow) { ViewDistanceRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleViewDistanceChanged); }
if (FoliageRow) { FoliageRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleFoliageChanged); }
if (ShadowRow) { ShadowRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleShadowChanged); }
if (TextureRow) { TextureRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleTextureChanged); }
if (VisualEffectRow) { VisualEffectRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleVisualEffectChanged); }
if (PostProcessRow) { PostProcessRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandlePostProcessChanged); }
if (AntiAliasingRow) { AntiAliasingRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleAntiAliasingChanged); }
if (ReflectionRow) { ReflectionRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleReflectionChanged); }
if (GlobalIlluminationRow) { GlobalIlluminationRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleGlobalIlluminationChanged); }
if (ShadingRow) { ShadingRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleShadingChanged); }
RefreshFromSettings();
}
void USettingsGraphicsWidget::RefreshFromSettings()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
if (OverallQualityRow)
{
// -1 quand les curseurs ne collent a aucun preset : c'est la que
// "Personnalise" s'affiche.
const int32 Overall = Settings->GetOverallScalabilityLevel();
OverallQualityRow->SetIndexSilently(Overall >= 0 ? Overall : SettingsGraphicsTab::CustomQualityIndex);
}
if (ViewDistanceRow) { ViewDistanceRow->SetIndexSilently(Settings->GetViewDistanceQuality()); }
if (FoliageRow) { FoliageRow->SetIndexSilently(Settings->GetFoliageQuality()); }
if (ShadowRow) { ShadowRow->SetIndexSilently(Settings->GetShadowQuality()); }
if (TextureRow) { TextureRow->SetIndexSilently(Settings->GetTextureQuality()); }
if (VisualEffectRow) { VisualEffectRow->SetIndexSilently(Settings->GetVisualEffectQuality()); }
if (PostProcessRow) { PostProcessRow->SetIndexSilently(Settings->GetPostProcessingQuality()); }
if (AntiAliasingRow) { AntiAliasingRow->SetIndexSilently(Settings->GetAntiAliasingQuality()); }
if (ReflectionRow) { ReflectionRow->SetIndexSilently(Settings->GetReflectionQuality()); }
if (GlobalIlluminationRow) { GlobalIlluminationRow->SetIndexSilently(Settings->GetGlobalIlluminationQuality()); }
if (ShadingRow) { ShadingRow->SetIndexSilently(Settings->GetShadingQuality()); }
}
void USettingsGraphicsWidget::ResetToDefaults()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
// "Eleve" plutot que le benchmark automatique : sur une machine modeste,
// RunHardwareBenchmark peut descendre tres bas et le joueur ne comprendrait
// pas pourquoi "Reinitialiser" a degrade son jeu.
Settings->SetOverallScalabilityLevel(2);
Settings->ApplyNonResolutionSettings();
RefreshFromSettings();
}
void USettingsGraphicsWidget::ApplyAndRefreshOverall()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !OverallQualityRow)
{
return;
}
Settings->ApplyNonResolutionSettings();
// Toucher un curseur individuel fait presque toujours basculer la qualite
// globale sur "Personnalise" : la ligne du haut doit suivre immediatement,
// sinon elle affiche un preset qui ne correspond plus a rien.
const int32 Overall = Settings->GetOverallScalabilityLevel();
OverallQualityRow->SetIndexSilently(Overall >= 0 ? Overall : SettingsGraphicsTab::CustomQualityIndex);
}
void USettingsGraphicsWidget::HandleOverallQualityChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
Settings->SetOverallScalabilityLevel(FMath::Clamp(NewIndex, 0, SettingsGraphicsTab::MaxQualityLevel));
Settings->ApplyNonResolutionSettings();
// Un preset repositionne les dix autres lignes d'un coup.
RefreshFromSettings();
}
void USettingsGraphicsWidget::HandleViewDistanceChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetViewDistanceQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleFoliageChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetFoliageQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleShadowChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetShadowQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleTextureChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetTextureQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleVisualEffectChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetVisualEffectQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandlePostProcessChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetPostProcessingQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleAntiAliasingChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetAntiAliasingQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleReflectionChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetReflectionQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleGlobalIlluminationChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetGlobalIlluminationQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleShadingChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetShadingQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
#undef LOCTEXT_NAMESPACE
@@ -6,7 +6,11 @@
#include "Components/Button.h"
#include "Components/WidgetSwitcher.h"
#include "ConfirmDialogWidget.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/LocalPlayer.h"
#include "InputCoreTypes.h"
#include "KeyCaptureWidget.h"
#include "UserSettings/EnhancedInputUserSettings.h"
#include "SettingsTabWidget.h"
#include "SurvivalUserSettings.h"
@@ -19,7 +23,8 @@ namespace
constexpr int32 TabIndexGameplay = 0;
constexpr int32 TabIndexDisplay = 1;
constexpr int32 TabIndexGraphics = 2;
constexpr int32 TabIndexAudio = 3;
constexpr int32 TabIndexControls = 3;
constexpr int32 TabIndexAudio = 4;
}
USettingsMenuWidget::USettingsMenuWidget(const FObjectInitializer& ObjectInitializer)
@@ -62,16 +67,27 @@ void USettingsMenuWidget::NativeConstruct()
GraphicsTabButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleGraphicsTabClicked);
}
if (ControlsTabButton)
{
ControlsTabButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleControlsTabClicked);
}
if (AudioTabButton)
{
AudioTabButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleAudioTabClicked);
}
if (ResetDialog)
// Chaque onglet recoit un pointeur vers nous : c'est par la qu'il demandera
// une confirmation, sans avoir a fabriquer sa propre boite.
if (TabSwitcher)
{
ResetDialog->OnConfirmed.AddDynamic(this, &USettingsMenuWidget::HandleResetConfirmed);
ResetDialog->OnCancelled.AddDynamic(this, &USettingsMenuWidget::HandleResetCancelled);
ResetDialog->Close();
for (int32 Index = 0; Index < TabSwitcher->GetNumWidgets(); ++Index)
{
if (USettingsTabWidget* Tab = Cast<USettingsTabWidget>(TabSwitcher->GetWidgetAtIndex(Index)))
{
Tab->SetOwningMenu(this);
}
}
}
UpdateTabButtonStates();
@@ -89,6 +105,20 @@ void USettingsMenuWidget::NativeDestruct()
ResetButton->OnClicked.RemoveDynamic(this, &USettingsMenuWidget::HandleResetClicked);
}
// La boite et le voile vivent au viewport, pas dans notre arbre : ils ne
// disparaitraient pas avec nous.
if (ResetDialog)
{
ResetDialog->RemoveFromParent();
ResetDialog = nullptr;
}
if (KeyCapture)
{
KeyCapture->RemoveFromParent();
KeyCapture = nullptr;
}
Super::NativeDestruct();
}
@@ -96,6 +126,9 @@ void USettingsMenuWidget::Open()
{
SetVisibility(ESlateVisibility::Visible);
// L'ecran a pu etre referme sur un autre onglet que celui d'ouverture.
UpdateTabButtonStates();
// L'ecran a pu etre ouvert, referme, puis rouvert apres un changement fait
// ailleurs : on relit toujours plutot que de faire confiance a l'affichage.
if (USettingsTabWidget* ActiveTab = GetActiveTab())
@@ -109,12 +142,18 @@ void USettingsMenuWidget::Open()
void USettingsMenuWidget::Close()
{
if (ResetDialog)
// Fermer l'ecran avec une question en suspens vaut un refus : sinon le
// demandeur -- typiquement un changement de resolution en attente de
// confirmation -- resterait dans un etat non tranche.
if (bResetDialogOpen)
{
ResetDialog->Close();
FinishConfirmation(false);
}
bResetDialogOpen = false;
if (bKeyCaptureOpen)
{
CloseKeyCapture();
}
SetVisibility(ESlateVisibility::Collapsed);
@@ -124,6 +163,20 @@ void USettingsMenuWidget::Close()
{
Settings->SaveSettings();
}
// Les touches remappees vivent ailleurs : Enhanced Input les serialise pour
// son compte, dans son propre fichier. Deux systemes de persistance, mais un
// seul moment ou l'on ecrit.
if (const ULocalPlayer* LocalPlayer = GetOwningLocalPlayer())
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
{
if (UEnhancedInputUserSettings* InputSettings = Subsystem->GetUserSettings())
{
InputSettings->SaveSettings();
}
}
}
}
FReply USettingsMenuWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
@@ -132,12 +185,18 @@ FReply USettingsMenuWidget::NativeOnKeyDown(const FGeometry& InGeometry, const F
if (Key == EKeys::Escape)
{
// La boite de confirmation gere Echap elle-meme quand elle a le focus.
// Si elle l'a perdu, on annule ici plutot que de fermer tout l'ecran en
// laissant la question affichee.
// La boite et le voile gerent Echap eux-memes quand ils ont le focus.
// S'ils l'ont perdu, on annule ici plutot que de fermer tout l'ecran en
// laissant la question ou le voile affiche.
if (bKeyCaptureOpen)
{
CloseKeyCapture();
return FReply::Handled();
}
if (bResetDialogOpen)
{
HandleResetCancelled();
FinishConfirmation(false);
return FReply::Handled();
}
@@ -165,6 +224,9 @@ void USettingsMenuWidget::ShowTab(int32 Index)
TabSwitcher->SetActiveWidgetIndex(Index);
// L'onglet a change, donc le bouton a griser aussi.
UpdateTabButtonStates();
if (USettingsTabWidget* ActiveTab = GetActiveTab())
{
ActiveTab->RefreshFromSettings();
@@ -174,26 +236,24 @@ void USettingsMenuWidget::ShowTab(int32 Index)
void USettingsMenuWidget::UpdateTabButtonStates()
{
const int32 PageCount = TabSwitcher ? TabSwitcher->GetNumWidgets() : 0;
const int32 ActiveIndex = TabSwitcher ? TabSwitcher->GetActiveWidgetIndex() : INDEX_NONE;
if (GameplayTabButton)
// Un bouton est cliquable si sa page existe ET qu'on n'y est pas deja.
// Griser l'onglet courant lui sert d'indicateur de position : sans ca, rien
// ne distingue visuellement l'onglet affiche des autres.
auto ApplyState = [PageCount, ActiveIndex](UButton* Button, int32 TabIndex)
{
GameplayTabButton->SetIsEnabled(PageCount > TabIndexGameplay);
}
if (Button)
{
Button->SetIsEnabled(PageCount > TabIndex && ActiveIndex != TabIndex);
}
};
if (DisplayTabButton)
{
DisplayTabButton->SetIsEnabled(PageCount > TabIndexDisplay);
}
if (GraphicsTabButton)
{
GraphicsTabButton->SetIsEnabled(PageCount > TabIndexGraphics);
}
if (AudioTabButton)
{
AudioTabButton->SetIsEnabled(PageCount > TabIndexAudio);
}
ApplyState(GameplayTabButton, TabIndexGameplay);
ApplyState(DisplayTabButton, TabIndexDisplay);
ApplyState(GraphicsTabButton, TabIndexGraphics);
ApplyState(ControlsTabButton, TabIndexControls);
ApplyState(AudioTabButton, TabIndexAudio);
}
void USettingsMenuWidget::HandleBackClicked()
@@ -201,20 +261,66 @@ void USettingsMenuWidget::HandleBackClicked()
OnCloseRequested.Broadcast();
}
void USettingsMenuWidget::HandleResetClicked()
UConfirmDialogWidget* USettingsMenuWidget::EnsureDialog()
{
// Sans boite assignee on ne bloque pas le joueur : on reinitialise.
if (!ResetDialog)
if (ResetDialog)
{
HandleResetConfirmed();
return;
return ResetDialog;
}
bResetDialogOpen = true;
ResetDialog->Open(ResetDialogMessage);
if (!ResetDialogClass)
{
return nullptr;
}
ResetDialog = CreateWidget<UConfirmDialogWidget>(GetOwningPlayer(), ResetDialogClass);
if (!ResetDialog)
{
return nullptr;
}
ResetDialog->OnConfirmed.AddDynamic(this, &USettingsMenuWidget::HandleDialogConfirmed);
ResetDialog->OnCancelled.AddDynamic(this, &USettingsMenuWidget::HandleDialogCancelled);
ResetDialog->AddToViewport(DialogZOrder);
return ResetDialog;
}
void USettingsMenuWidget::HandleResetConfirmed()
bool USettingsMenuWidget::RequestConfirmation(const FText& MessageOrFormat, float CountdownSeconds, const FOnSettingsConfirmationResult& OnResult)
{
UConfirmDialogWidget* Dialog = EnsureDialog();
if (!Dialog)
{
UE_LOG(LogTemp, Warning, TEXT("USettingsMenuWidget : ResetDialogClass n'est pas assignee, la question ne peut pas etre posee."));
return false;
}
// Une seule boite pour tout l'ecran : si une question etait deja posee, la
// nouvelle l'ecrase. On repond "non" a l'ancienne pour que son demandeur ne
// reste pas suspendu a une reponse qui n'arrivera plus.
if (PendingConfirmation.IsBound())
{
const FOnSettingsConfirmationResult Superseded = PendingConfirmation;
PendingConfirmation.Unbind();
Superseded.ExecuteIfBound(false);
}
PendingConfirmation = OnResult;
bResetDialogOpen = true;
if (CountdownSeconds > 0.f)
{
Dialog->OpenWithCountdown(MessageOrFormat, CountdownSeconds);
}
else
{
Dialog->Open(MessageOrFormat);
}
return true;
}
void USettingsMenuWidget::FinishConfirmation(bool bConfirmed)
{
bResetDialogOpen = false;
@@ -223,26 +329,51 @@ void USettingsMenuWidget::HandleResetConfirmed()
ResetDialog->Close();
}
// Detache AVANT d'appeler : le demandeur a parfaitement le droit de reposer
// une question dans son propre handler.
const FOnSettingsConfirmationResult Answered = PendingConfirmation;
PendingConfirmation.Unbind();
SetKeyboardFocus();
Answered.ExecuteIfBound(bConfirmed);
}
void USettingsMenuWidget::HandleDialogConfirmed()
{
FinishConfirmation(true);
}
void USettingsMenuWidget::HandleDialogCancelled()
{
FinishConfirmation(false);
}
void USettingsMenuWidget::HandleResetClicked()
{
FOnSettingsConfirmationResult Callback;
Callback.BindDynamic(this, &USettingsMenuWidget::HandleResetAnswered);
// Sans boite configuree on ne bloque pas le joueur : on reinitialise.
if (!RequestConfirmation(ResetDialogMessage, 0.f, Callback))
{
HandleResetAnswered(true);
}
}
void USettingsMenuWidget::HandleResetAnswered(bool bConfirmed)
{
if (!bConfirmed)
{
return;
}
// Uniquement l'onglet visible : un "Reinitialiser" qui remettrait aussi la
// resolution et le volume serait une mauvaise surprise.
if (USettingsTabWidget* ActiveTab = GetActiveTab())
{
ActiveTab->ResetToDefaults();
}
SetKeyboardFocus();
}
void USettingsMenuWidget::HandleResetCancelled()
{
bResetDialogOpen = false;
if (ResetDialog)
{
ResetDialog->Close();
}
SetKeyboardFocus();
}
void USettingsMenuWidget::HandleGameplayTabClicked()
@@ -260,9 +391,127 @@ void USettingsMenuWidget::HandleGraphicsTabClicked()
ShowTab(TabIndexGraphics);
}
void USettingsMenuWidget::HandleControlsTabClicked()
{
ShowTab(TabIndexControls);
}
void USettingsMenuWidget::HandleAudioTabClicked()
{
ShowTab(TabIndexAudio);
}
bool USettingsMenuWidget::RequestKeyCapture(const FText& ActionLabel, const FOnSettingsKeyCaptureResult& OnResult)
{
if (!KeyCaptureClass)
{
UE_LOG(LogTemp, Warning, TEXT("USettingsMenuWidget : KeyCaptureClass n'est pas assignee, aucun remappage possible."));
return false;
}
// Cree au premier remappage seulement, puis reutilise -- comme la boite.
if (!KeyCapture)
{
KeyCapture = CreateWidget<UKeyCaptureWidget>(GetOwningPlayer(), KeyCaptureClass);
if (!KeyCapture)
{
return false;
}
KeyCapture->OnKeyCaptured.AddDynamic(this, &USettingsMenuWidget::HandleKeyCaptured);
KeyCapture->OnCaptureCancelled.AddDynamic(this, &USettingsMenuWidget::HandleKeyCaptureCancelled);
KeyCapture->OnConflictAnswered.AddDynamic(this, &USettingsMenuWidget::HandleKeyConflictAnswered);
// Au-dessus de la boite : une confirmation de conflit s'ouvre APRES la
// capture, mais le voile doit couvrir l'ecran pendant qu'il attend.
KeyCapture->AddToViewport(DialogZOrder + 1);
}
// Une capture deja en cours est abandonnee : son demandeur recoit une touche
// invalide plutot que de rester suspendu.
if (PendingKeyCapture.IsBound())
{
const FOnSettingsKeyCaptureResult Superseded = PendingKeyCapture;
PendingKeyCapture.Unbind();
Superseded.ExecuteIfBound(FKey());
}
PendingKeyCapture = OnResult;
bKeyCaptureOpen = true;
KeyCapture->BeginCapture(ActionLabel);
return true;
}
void USettingsMenuWidget::ShowKeyCaptureConflict(const FText& Message, const FOnSettingsConfirmationResult& OnAnswer)
{
if (!KeyCapture || !bKeyCaptureOpen)
{
// Le voile a ete referme entre-temps : on repond "non" plutot que de
// laisser le demandeur suspendu.
OnAnswer.ExecuteIfBound(false);
return;
}
PendingConflictAnswer = OnAnswer;
KeyCapture->ShowConflict(Message);
}
void USettingsMenuWidget::CloseKeyCapture()
{
bKeyCaptureOpen = false;
if (KeyCapture)
{
KeyCapture->EndCapture();
}
// On detache tout : si quelqu'un attendait encore une reponse, il la recoit
// negative plutot que jamais.
const FOnSettingsKeyCaptureResult AbandonedCapture = PendingKeyCapture;
const FOnSettingsConfirmationResult AbandonedConflict = PendingConflictAnswer;
PendingKeyCapture.Unbind();
PendingConflictAnswer.Unbind();
SetKeyboardFocus();
AbandonedCapture.ExecuteIfBound(FKey());
AbandonedConflict.ExecuteIfBound(false);
}
void USettingsMenuWidget::HandleKeyCaptured(FKey CapturedKey)
{
// Le voile RESTE ouvert : c'est l'onglet qui decide s'il y a conflit, et
// donc s'il faut le basculer sur la question ou le refermer.
const FOnSettingsKeyCaptureResult Answered = PendingKeyCapture;
PendingKeyCapture.Unbind();
Answered.ExecuteIfBound(CapturedKey);
}
void USettingsMenuWidget::HandleKeyCaptureCancelled()
{
// Echap pendant la capture : rien a arbitrer, on referme.
CloseKeyCapture();
}
void USettingsMenuWidget::HandleKeyConflictAnswered(bool bConfirmed)
{
// Detache AVANT d'appeler : le demandeur applique le remappage dans son
// handler et pourrait vouloir rouvrir une capture derriere.
const FOnSettingsConfirmationResult Answered = PendingConflictAnswer;
PendingConflictAnswer.Unbind();
bKeyCaptureOpen = false;
if (KeyCapture)
{
KeyCapture->EndCapture();
}
SetKeyboardFocus();
Answered.ExecuteIfBound(bConfirmed);
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,11 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsTabWidget.h"
#include "SettingsMenuWidget.h"
void USettingsTabWidget::SetOwningMenu(USettingsMenuWidget* InMenu)
{
OwningMenu = InMenu;
}
@@ -3,12 +3,20 @@
#include "SurvivalGameInstance.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/LocalPlayer.h"
#include "InputMappingContext.h"
#include "SurvivalUserSettings.h"
#include "UserSettings/EnhancedInputUserSettings.h"
void USurvivalGameInstance::Init()
{
Super::Init();
// Pas d'enregistrement des IMC ici : a ce stade il n'y a ni joueur local ni
// PlayerController, donc pas encore de UserSettings a qui parler.
// RegisterMappableContexts() est appelee par l'ecran de reglages.
// Les reglages sont deja LUS depuis le .ini par le moteur, mais rien ne les
// a encore APPLIQUES : sans cet appel, la resolution et la qualite
// choisies au dernier lancement resteraient lettre morte jusqu'au premier
@@ -25,3 +33,38 @@ void USurvivalGameInstance::Init()
UE_LOG(LogTemp, Error, TEXT("USurvivalGameInstance : GameUserSettingsClassName n'est pas configure dans DefaultEngine.ini, aucun reglage ne sera pris en compte."));
}
}
void USurvivalGameInstance::RegisterMappableContexts()
{
ULocalPlayer* LocalPlayer = GetFirstGamePlayer();
if (!LocalPlayer)
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : aucun joueur local, enregistrement des touches remis a plus tard."));
return;
}
if (MappableContexts.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : MappableContexts est vide, l'onglet Touches affichera 'Non assignee' partout. Ajoute IMC_Default dans le Blueprint de GameInstance."));
return;
}
UEnhancedInputLocalPlayerSubsystem* Subsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
UEnhancedInputUserSettings* InputSettings = Subsystem ? Subsystem->GetUserSettings() : nullptr;
if (!InputSettings)
{
UE_LOG(LogTemp, Error, TEXT("USurvivalGameInstance : reglages d'input introuvables. Coche 'Enable User Settings' dans Project Settings > Enhanced Input."));
return;
}
for (const TObjectPtr<UInputMappingContext>& Context : MappableContexts)
{
if (Context)
{
// Idempotent : le moteur ignore un contexte deja connu, on peut donc
// rappeler cette fonction sans precaution.
InputSettings->RegisterInputMappingContext(Context);
}
}
}
@@ -21,6 +21,7 @@ USurvivalUserSettings::USurvivalUserSettings()
// valeurs sont donc bien les defauts d'usine, ecrasees ensuite par le
// fichier s'il existe.
SetSurvivalGameplayToDefaults();
SetSurvivalDisplayToDefaults();
}
USurvivalUserSettings* USurvivalUserSettings::Get()
@@ -40,17 +41,47 @@ void USurvivalUserSettings::SetSurvivalGameplayToDefaults()
bCrouchToggle = false;
}
void USurvivalUserSettings::SetSurvivalDisplayToDefaults()
{
Brightness = 100.f;
}
void USurvivalUserSettings::SetToDefaults()
{
// Le "Reinitialiser" global du moteur doit aussi remettre NOS valeurs :
// sans cette surcharge, l'onglet Jeu resterait seul inchange.
// sans cette surcharge, elles resteraient seules inchangees.
Super::SetToDefaults();
SetSurvivalGameplayToDefaults();
SetSurvivalDisplayToDefaults();
}
void USurvivalUserSettings::SetBrightness(float Value)
{
using namespace SurvivalSettingsRange;
Brightness = SnapToStep(Value, BrightnessMin, BrightnessMax, BrightnessStep);
}
void USurvivalUserSettings::ApplySurvivalSettings()
{
// Le gamma n'appartient pas a UGameUserSettings : il vit sur GEngine, donc
// sur un objet GLOBAL au processus. Deux consequences :
//
// 1. DisplayGamma est un EXPOSANT dont la valeur moteur est 2.2, pas un
// multiplicateur neutre a 1. On module la reference, on ne la remplace
// pas.
// 2. En editeur, cet objet est celui de l'editeur : y toucher assombrit
// toute l'interface, y compris apres avoir quitte le PIE. On ne l'applique
// donc qu'en jeu. La luminosite ne se teste pas en PIE, mais en
// Standalone -- qui est un processus separe.
if (GEngine && !GIsEditor)
{
using namespace SurvivalSettingsRange;
const float Normalized = FMath::Clamp(Brightness, BrightnessMin, BrightnessMax) / 100.f;
GEngine->DisplayGamma = BaseDisplayGamma * Normalized;
}
OnSurvivalSettingsApplied.Broadcast();
}