diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 96c15b3..6c2cbf4 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,22 +4,49 @@ Jeu de survie solo à la première personne, gameplay écrit en **C++**, Bluepri câblage d'assets. Inspirations assumées : Raft, Aloft, Valheim, esthétique solarpunk. Assets d'environnement : pack **SoStylized** (sa végétation n'est pas à l'échelle humaine). -## Interlocuteur +## Rôle -L'auteur vient de **Unity** et découvre Unreal. Il écrit en français — **répondre en français**. +Tu es un **développeur Unreal Engine senior**. Tu connais le moteur en profondeur : framework +Gameplay, UMG/Slate, Enhanced Input, cycle de vie des objets et GC, coût réel des choses au +runtime. L'auteur écrit en français — **répondre en français**. -Faire systématiquement le parallèle Unity quand un concept nouveau apparaît -(Blueprint ≈ prefab, DataAsset ≈ ScriptableObject, ActorComponent ≈ MonoBehaviour, -BeginPlay ≈ Start, Trace Channel ≈ LayerMask). Il raisonne déjà très bien en composants, -inutile de réexpliquer les bases. +Le code que tu produis est du code de production, pas du prototype : -Signaler en revanche les endroits où le modèle **diverge réellement** de Unity, ce sont ceux -qui le piègent : framework GameMode/Controller/Pawn imposé, `UPROPERTY` obligatoire pour le GC, -constructeur qui tourne dans l'éditeur (CDO), pas de matrice de collision globale. +- **Propre** : responsabilités séparées, pas de logique dupliquée, pas de valeur en dur qui + devrait être exposée, noms explicites, `const` et références là où c'est correct. +- **Optimisé** : pas de `Tick` quand un délégué ou un timer suffit, pas de `FindComponent` ni de + cast dans une boucle, cache des pointeurs, `TArray::Reserve`, pas d'allocation par frame. + Signaler quand une approche coûte cher et proposer l'alternative. +- **Commenté en français**, sur le *pourquoi* et les pièges — jamais de commentaire qui + paraphrase la ligne en dessous. -Il valide les décisions de design lui-même. Proposer **une recommandation argumentée**, pas un -catalogue d'options. Quand un choix change matériellement le code (modèle d'inventaire, règles -de mort), demander avant de coder plutôt que de deviner. +### Guider pas à pas dans l'éditeur + +C'est le point le plus important. **Le code seul ne suffit jamais** : presque tout ce qu'on +écrit demande ensuite un branchement côté éditeur, et c'est là que l'auteur a besoin d'être +guidé. Donc pour chaque tâche : + +1. Écrire et compiler le C++. +2. Puis donner la marche à suivre dans l'éditeur, **étape numérotée par étape numérotée**, en + nommant les choses telles qu'elles apparaissent à l'écran : quel Blueprint créer et depuis + quelle classe parente, où le ranger, quels widgets poser dans la Designer, leur nom exact + (les `BindWidget` échouent au moindre écart), quelle propriété régler dans le Details panel, + quel asset assigner où, quelle case cocher. +3. Terminer par **comment vérifier que ça marche** : ce qu'on doit voir en PIE, et le symptôme + typique si une étape a été ratée. + +Exemple : pour un menu de réglages, livrer la classe `USettingsMenuWidget`, puis expliquer la +création de `WBP_Settings`, la hiérarchie de widgets à construire, les noms à respecter, le +branchement dans le PlayerController, et le test final. + +Ne jamais supposer qu'une manipulation éditeur est évidente ou déjà faite. En cas de doute sur +l'état d'un asset, demander plutôt que d'écrire du code qui suppose un branchement inexistant. + +### Décisions + +L'auteur valide les décisions de design lui-même. Proposer **une recommandation argumentée**, +pas un catalogue d'options. Quand un choix change matériellement le code (modèle d'inventaire, +règles de mort), demander avant de coder plutôt que de deviner. ## Compiler @@ -123,7 +150,9 @@ qu'une compilation propre l'est vraiment. - **Réglages** : `USurvivalUserSettings` fait autorité, jamais le CDO du pawn. Application immédiate, aucun bouton « Appliquer » ; la sauvegarde a lieu à la fermeture de l'écran. L'écran ignore qui l'ouvre (`OnCloseRequested`), d'où un seul `WBP_Settings` pour le menu - principal et la pause. Une ligne = un index dans une liste de paliers, y compris les nombres : + principal et la pause — et il reçoit même le ZOrder de sa boîte de dialogue par + `SetDialogZOrder()` : **le contrôleur reste seul maître de son empilement**, un widget + réutilisable n'a pas à connaître la pile de son hôte. Une ligne = un index dans une liste de paliers, y compris les nombres : d'où un seul `WBP_SettingRow` pour tout le menu. ## Conventions diff --git a/Config/DefaultEngine.ini b/Config/DefaultEngine.ini index 072e288..f3c4908 100644 --- a/Config/DefaultEngine.ini +++ b/Config/DefaultEngine.ini @@ -4,6 +4,7 @@ GameDefaultMap=/Game/Game/Maps/MenuScene.MenuScene GlobalDefaultGameMode=/Game/Game/GameMode/GM_FpsPlayer.GM_FpsPlayer_C EditorStartupMap=/Game/Game/Maps/GameScene.GameScene +GameInstanceClass=/Game/Game/Game-Instance/BP_SurvivalGameInstance.BP_SurvivalGameInstance_C [/Script/Engine.RendererSettings] r.AllowStaticLighting=False diff --git a/Content/Game/Audio/Music/echonn-the-wandererx27s-song-220954.uasset b/Content/Game/Audio/Music/echonn-the-wandererx27s-song-220954.uasset new file mode 100644 index 0000000..c562fd5 --- /dev/null +++ b/Content/Game/Audio/Music/echonn-the-wandererx27s-song-220954.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af4b0724c6fe90e5eff787cde40a2e4a74afb97d4da5923230648d8f1209f764 +size 21411384 diff --git a/Content/Game/Audio/Music/grand_project-mystical-forest-384201.uasset b/Content/Game/Audio/Music/grand_project-mystical-forest-384201.uasset new file mode 100644 index 0000000..4fda4f8 --- /dev/null +++ b/Content/Game/Audio/Music/grand_project-mystical-forest-384201.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6cf2192b426af0bc4551551e2631b0f5923164d14f4c33dbee2851bdc3a462df +size 10888716 diff --git a/Content/Game/Audio/Music/grand_project-wonders-of-the-earth-550792.uasset b/Content/Game/Audio/Music/grand_project-wonders-of-the-earth-550792.uasset new file mode 100644 index 0000000..f130348 --- /dev/null +++ b/Content/Game/Audio/Music/grand_project-wonders-of-the-earth-550792.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8716134a0966a3618a8deb78b397b47845aa58375760ea2339d7d5699d876473 +size 17312587 diff --git a/Content/Game/Audio/Music/viramiller-nature-investigation-255161.uasset b/Content/Game/Audio/Music/viramiller-nature-investigation-255161.uasset new file mode 100644 index 0000000..edd7355 --- /dev/null +++ b/Content/Game/Audio/Music/viramiller-nature-investigation-255161.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ac1b1969f61639a0095a02168366ab610f68603bfb2a3d328308867dc4417a2 +size 6743336 diff --git a/Content/Game/Audio/Playlists/DA_Playlist_Ambient.uasset b/Content/Game/Audio/Playlists/DA_Playlist_Ambient.uasset new file mode 100644 index 0000000..5c7c820 --- /dev/null +++ b/Content/Game/Audio/Playlists/DA_Playlist_Ambient.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eacbf919ba68e2baa8a1d7614177ad9f4eae9eaed5da06280ed5cce8c4d4c53 +size 2797 diff --git a/Content/Game/Game-Instance/BP_SurvivalGameInstance.uasset b/Content/Game/Game-Instance/BP_SurvivalGameInstance.uasset new file mode 100644 index 0000000..0d224ce --- /dev/null +++ b/Content/Game/Game-Instance/BP_SurvivalGameInstance.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7454b6b38c4edb99e1ffd6073c3748bf0fc9df0627bd01e7d17dd5d5b223f7b4 +size 6223 diff --git a/Content/Game/Input/IMC_Default.uasset b/Content/Game/Input/IMC_Default.uasset index 75cc6c8..2afc0f7 100644 --- a/Content/Game/Input/IMC_Default.uasset +++ b/Content/Game/Input/IMC_Default.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d0e53defd95013051ca7c366ae7a9fa8f40bf9a767a607371ac32f2ec4870a7d -size 14046 +oid sha256:d45a37d28ba1d448435a17382940fff77ff0cdff269313ab1648653bad93e7ab +size 16135 diff --git a/Content/Game/Maps/MenuScene.umap b/Content/Game/Maps/MenuScene.umap index ab90650..e30e3b7 100644 --- a/Content/Game/Maps/MenuScene.umap +++ b/Content/Game/Maps/MenuScene.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b64543ad68f42b323a2bf74d90e6daa05210acf6c34c2b24562dc810c0819f8 -size 15118967 +oid sha256:e04ac2e0903c8e8f937c57e1f86ba9606ed0e1732824b98b8e63c73e5e6fe2ef +size 15120728 diff --git a/Content/Game/Menu/BP_MainMenuPlayerController.uasset b/Content/Game/Menu/BP_MainMenuPlayerController.uasset index b571913..795756f 100644 --- a/Content/Game/Menu/BP_MainMenuPlayerController.uasset +++ b/Content/Game/Menu/BP_MainMenuPlayerController.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb4d61c1c168fa3ca474158ec1c7ee9ca3b090f9b574c43bd396ebc02faef244 -size 21705 +oid sha256:95a1714281e371e38897d5efcac84c13b4d7d34aad54ac8ae427cf4c269798ab +size 21906 diff --git a/Content/Game/Player/BP_FpsPlayer.uasset b/Content/Game/Player/BP_FpsPlayer.uasset index 9d34e18..c623993 100644 --- a/Content/Game/Player/BP_FpsPlayer.uasset +++ b/Content/Game/Player/BP_FpsPlayer.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d1701d9fbf6bad1af217173e8a2fb85abac01fa8be4295c8f5bff9cb3fea4a9 -size 27887 +oid sha256:b9aa15c231ba13c9201656ea9ddde189abbaba2e53cdba6ac16e7ebba73fd2b0 +size 27829 diff --git a/Content/Game/Player/BP_FpsPlayerController.uasset b/Content/Game/Player/BP_FpsPlayerController.uasset index 4a00cba..5b8b4c1 100644 --- a/Content/Game/Player/BP_FpsPlayerController.uasset +++ b/Content/Game/Player/BP_FpsPlayerController.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:337a2b2d0fd89d5bb9aa1ec73d9b0881de10628ac9d9a414d49e8a9945f185ad -size 23795 +oid sha256:86ceba18e93b5b0aa266a1715ef146f5c7ad1102c2c7e2289bb2f6ab55c06ab4 +size 23996 diff --git a/Content/Game/UI/Setting/Input/WBP_KeyCapture.uasset b/Content/Game/UI/Setting/Input/WBP_KeyCapture.uasset new file mode 100644 index 0000000..d79762a --- /dev/null +++ b/Content/Game/UI/Setting/Input/WBP_KeyCapture.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b555a26ee16b01a1527c76e593f499e7cde067b7024c119e63540d48b42b720 +size 37492 diff --git a/Content/Game/UI/Setting/Input/WBP_SettingKeyRow.uasset b/Content/Game/UI/Setting/Input/WBP_SettingKeyRow.uasset new file mode 100644 index 0000000..8f0cc6e --- /dev/null +++ b/Content/Game/UI/Setting/Input/WBP_SettingKeyRow.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fdf670d7bf030941d855332a1e987cd124f65c9c1787f31d32a3a13be6fdf09 +size 31036 diff --git a/Content/Game/UI/Setting/Input/WBP_SettingsControls.uasset b/Content/Game/UI/Setting/Input/WBP_SettingsControls.uasset new file mode 100644 index 0000000..a696964 --- /dev/null +++ b/Content/Game/UI/Setting/Input/WBP_SettingsControls.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4a205e42bc83abf84756f63b7fa90314fb40f10554c16169ea76c7ad40e5e87 +size 38586 diff --git a/Content/Game/UI/Setting/WBP_SettingRow.uasset b/Content/Game/UI/Setting/WBP_SettingRow.uasset new file mode 100644 index 0000000..40aac7b --- /dev/null +++ b/Content/Game/UI/Setting/WBP_SettingRow.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:903e3bfdf37b47283935ba3a0f468c65ab1fc430b5e653d072a40e6776e39f73 +size 41722 diff --git a/Content/Game/UI/Setting/WBP_Settings.uasset b/Content/Game/UI/Setting/WBP_Settings.uasset new file mode 100644 index 0000000..03b0ecb --- /dev/null +++ b/Content/Game/UI/Setting/WBP_Settings.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d99dae1ac8a99ce499a49d968cf05d5a641cdedb338cd25c19c90ee6c573544d +size 53466 diff --git a/Content/Game/UI/Setting/WBP_SettingsDisplay.uasset b/Content/Game/UI/Setting/WBP_SettingsDisplay.uasset new file mode 100644 index 0000000..98c295f --- /dev/null +++ b/Content/Game/UI/Setting/WBP_SettingsDisplay.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:884db68c80a9905a7b98806a3d12183bb8b52b0d4ddca6444d53937464c2c9cf +size 32097 diff --git a/Content/Game/UI/Setting/WBP_SettingsGameplay.uasset b/Content/Game/UI/Setting/WBP_SettingsGameplay.uasset new file mode 100644 index 0000000..800f236 --- /dev/null +++ b/Content/Game/UI/Setting/WBP_SettingsGameplay.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd2d1a45f2d775f06daa29c43c631f2535056936bcaaea89defc767cc6d960ec +size 34894 diff --git a/Content/Game/UI/Setting/WBP_SettingsGraphics.uasset b/Content/Game/UI/Setting/WBP_SettingsGraphics.uasset new file mode 100644 index 0000000..c39743f --- /dev/null +++ b/Content/Game/UI/Setting/WBP_SettingsGraphics.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39c46e91c208ee600370f390c36dfb884cace917fa7ed8653e8567913f0e1a9f +size 39426 diff --git a/Content/Game/UI/WBP_MainMenu.uasset b/Content/Game/UI/WBP_MainMenu.uasset index e2409b3..f72693b 100644 --- a/Content/Game/UI/WBP_MainMenu.uasset +++ b/Content/Game/UI/WBP_MainMenu.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96d130d4c3e17517e303ea6d64ef53c194f9912037e16e56ee3232eaddce3547 -size 217933 +oid sha256:7d365a133589ccbba1a26c6e3658e11dfc93cddede410773a470dda7be6f6e71 +size 211169 diff --git a/Content/Game/UI/WBP_PauseMenu.uasset b/Content/Game/UI/WBP_PauseMenu.uasset index b95d68e..3db5540 100644 --- a/Content/Game/UI/WBP_PauseMenu.uasset +++ b/Content/Game/UI/WBP_PauseMenu.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07aef1bf35873673ae324f3bc62022adfc540e870719edc494c4feeea700f339 -size 50325 +oid sha256:c4325390c6e21709f9b329a06c983c8a5110eadbe8695da448f9bdb88b267b7b +size 48953 diff --git a/Source/Survival_projet/Private/ConfirmDialogWidget.cpp b/Source/Survival_projet/Private/ConfirmDialogWidget.cpp index d6ba8de..6dd8231 100644 --- a/Source/Survival_projet/Private/ConfirmDialogWidget.cpp +++ b/Source/Survival_projet/Private/ConfirmDialogWidget.cpp @@ -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); diff --git a/Source/Survival_projet/Private/FpsPlayer.cpp b/Source/Survival_projet/Private/FpsPlayer.cpp index 0414e1b..c500efb 100644 --- a/Source/Survival_projet/Private/FpsPlayer.cpp +++ b/Source/Survival_projet/Private/FpsPlayer.cpp @@ -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 diff --git a/Source/Survival_projet/Private/FpsPlayerController.cpp b/Source/Survival_projet/Private/FpsPlayerController.cpp index 26e60e8..d9cfac7 100644 --- a/Source/Survival_projet/Private/FpsPlayerController.cpp +++ b/Source/Survival_projet/Private/FpsPlayerController.cpp @@ -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; diff --git a/Source/Survival_projet/Private/KeyCaptureWidget.cpp b/Source/Survival_projet/Private/KeyCaptureWidget.cpp new file mode 100644 index 0000000..48ea364 --- /dev/null +++ b/Source/Survival_projet/Private/KeyCaptureWidget.cpp @@ -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 diff --git a/Source/Survival_projet/Private/MainMenuPlayerController.cpp b/Source/Survival_projet/Private/MainMenuPlayerController.cpp index 697548b..218cc07 100644 --- a/Source/Survival_projet/Private/MainMenuPlayerController.cpp +++ b/Source/Survival_projet/Private/MainMenuPlayerController.cpp @@ -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(); diff --git a/Source/Survival_projet/Private/MusicDirector.cpp b/Source/Survival_projet/Private/MusicDirector.cpp new file mode 100644 index 0000000..07e2563 --- /dev/null +++ b/Source/Survival_projet/Private/MusicDirector.cpp @@ -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); + } +} diff --git a/Source/Survival_projet/Private/MusicSubsystem.cpp b/Source/Survival_projet/Private/MusicSubsystem.cpp new file mode 100644 index 0000000..8889288 --- /dev/null +++ b/Source/Survival_projet/Private/MusicSubsystem.cpp @@ -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() : 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& 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 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); +} diff --git a/Source/Survival_projet/Private/SettingKeyRowWidget.cpp b/Source/Survival_projet/Private/SettingKeyRowWidget.cpp new file mode 100644 index 0000000..97ffce6 --- /dev/null +++ b/Source/Survival_projet/Private/SettingKeyRowWidget.cpp @@ -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 diff --git a/Source/Survival_projet/Private/SettingRowWidget.cpp b/Source/Survival_projet/Private/SettingRowWidget.cpp index 6c28f64..b0be91c 100644 --- a/Source/Survival_projet/Private/SettingRowWidget.cpp +++ b/Source/Survival_projet/Private/SettingRowWidget.cpp @@ -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& Options, int32 Index) @@ -31,6 +46,8 @@ void USettingRowWidget::SetupAsChoice(const FText& Label, const TArray& 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& 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(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(CurrentIndex) / static_cast(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(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()); } } diff --git a/Source/Survival_projet/Private/SettingsControlsWidget.cpp b/Source/Survival_projet/Private/SettingsControlsWidget.cpp new file mode 100644 index 0000000..4c3a898 --- /dev/null +++ b/Source/Survival_projet/Private/SettingsControlsWidget.cpp @@ -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(); + return Subsystem ? Subsystem->GetUserSettings() : nullptr; +} + +TArray USettingsControlsWidget::GetAllRows() const +{ + // L'ordre est celui de l'affichage : il sert aussi a la reinitialisation. + TArray 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()) + { + 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& 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 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 diff --git a/Source/Survival_projet/Private/SettingsDisplayWidget.cpp b/Source/Survival_projet/Private/SettingsDisplayWidget.cpp new file mode 100644 index 0000000..0627bd7 --- /dev/null +++ b/Source/Survival_projet/Private/SettingsDisplayWidget.cpp @@ -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& FrameRateLimits() + { + static const TArray 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& 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 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 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 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(Resolution.X) / static_cast(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(A.X) * A.Y < static_cast(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(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 diff --git a/Source/Survival_projet/Private/SettingsGameplayWidget.cpp b/Source/Survival_projet/Private/SettingsGameplayWidget.cpp index 85e4d43..c675b0e 100644 --- a/Source/Survival_projet/Private/SettingsGameplayWidget.cpp +++ b/Source/Survival_projet/Private/SettingsGameplayWidget.cpp @@ -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); } diff --git a/Source/Survival_projet/Private/SettingsGraphicsWidget.cpp b/Source/Survival_projet/Private/SettingsGraphicsWidget.cpp new file mode 100644 index 0000000..6515194 --- /dev/null +++ b/Source/Survival_projet/Private/SettingsGraphicsWidget.cpp @@ -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 diff --git a/Source/Survival_projet/Private/SettingsMenuWidget.cpp b/Source/Survival_projet/Private/SettingsMenuWidget.cpp index b43bcdf..d4becdd 100644 --- a/Source/Survival_projet/Private/SettingsMenuWidget.cpp +++ b/Source/Survival_projet/Private/SettingsMenuWidget.cpp @@ -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(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()) + { + 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(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(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 diff --git a/Source/Survival_projet/Private/SettingsTabWidget.cpp b/Source/Survival_projet/Private/SettingsTabWidget.cpp new file mode 100644 index 0000000..4fdbe68 --- /dev/null +++ b/Source/Survival_projet/Private/SettingsTabWidget.cpp @@ -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; +} diff --git a/Source/Survival_projet/Private/SurvivalGameInstance.cpp b/Source/Survival_projet/Private/SurvivalGameInstance.cpp index 1e5ef06..a2d9bff 100644 --- a/Source/Survival_projet/Private/SurvivalGameInstance.cpp +++ b/Source/Survival_projet/Private/SurvivalGameInstance.cpp @@ -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(); + 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& Context : MappableContexts) + { + if (Context) + { + // Idempotent : le moteur ignore un contexte deja connu, on peut donc + // rappeler cette fonction sans precaution. + InputSettings->RegisterInputMappingContext(Context); + } + } +} diff --git a/Source/Survival_projet/Private/SurvivalUserSettings.cpp b/Source/Survival_projet/Private/SurvivalUserSettings.cpp index b6552c2..c71f926 100644 --- a/Source/Survival_projet/Private/SurvivalUserSettings.cpp +++ b/Source/Survival_projet/Private/SurvivalUserSettings.cpp @@ -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(); } diff --git a/Source/Survival_projet/Public/ConfirmDialogWidget.h b/Source/Survival_projet/Public/ConfirmDialogWidget.h index 0f73ed0..8e944fb 100644 --- a/Source/Survival_projet/Public/ConfirmDialogWidget.h +++ b/Source/Survival_projet/Public/ConfirmDialogWidget.h @@ -4,6 +4,7 @@ #include "CoreMinimal.h" #include "Blueprint/UserWidget.h" +#include "Containers/Ticker.h" #include "ConfirmDialogWidget.generated.h" class UButton; @@ -48,6 +49,20 @@ public: UFUNCTION(BlueprintCallable, Category = "Dialogue") void Open(const FText& InMessage); + /** + * Meme chose, mais avec un retour automatique passe le delai. + * + * @param InMessageFormat doit contenir {0}, remplace par les secondes + * restantes. Exemple : "Garder ce mode ?\nRetour + * automatique dans {0} s." + * + * A l'expiration, OnCancelled est diffuse -- comme si le joueur avait + * refuse. C'est le seul filet quand la question porte sur un changement qui + * peut rendre l'ecran illisible. + */ + UFUNCTION(BlueprintCallable, Category = "Dialogue") + void OpenWithCountdown(const FText& InMessageFormat, float Seconds); + /** Referme la boite. Ne previent personne : l'appelant sait ce qu'il fait. */ UFUNCTION(BlueprintCallable, Category = "Dialogue") void Close(); @@ -82,4 +97,27 @@ private: UFUNCTION() void HandleCancelClicked(); + + /** Un tick par frame tant que le rebours tourne. Renvoie false pour s'arreter. */ + bool TickCountdown(float DeltaTime); + + void StopCountdown(); + + void RefreshCountdownText(); + + /** + * Le rebours passe par le ticker du moteur et non par un timer du monde. + * + * Deux raisons : l'ecran de reglages s'ouvre depuis le menu de PAUSE, ou les + * timers du monde sont geles -- le rebours ne repartirait jamais ; et un + * changement de resolution rate doit se defaire meme si le jeu bloque. + */ + FTSTicker::FDelegateHandle CountdownTickerHandle; + + FText CountdownMessageFormat; + + float CountdownRemaining = 0.f; + + /** Evite de reecrire le texte 60 fois par seconde pour rien. */ + int32 LastDisplayedSeconds = -1; }; diff --git a/Source/Survival_projet/Public/KeyCaptureWidget.h b/Source/Survival_projet/Public/KeyCaptureWidget.h new file mode 100644 index 0000000..29e9d3f --- /dev/null +++ b/Source/Survival_projet/Public/KeyCaptureWidget.h @@ -0,0 +1,115 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "InputCoreTypes.h" +#include "KeyCaptureWidget.generated.h" + +class UButton; +class UTextBlock; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnKeyCaptured, FKey, CapturedKey); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnKeyCaptureCancelled); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnKeyConflictAnswered, bool, bConfirmed); + +/** Ce que le voile attend a un instant donne. */ +UENUM() +enum class EKeyCaptureState : uint8 +{ + /** Ferme. */ + Idle, + + /** Ouvert, en attente d'une touche. Les boutons sont replies. */ + Capturing, + + /** Ouvert, la touche est prise : on demande quoi faire. Les boutons apparaissent. */ + Conflict +}; + +/** + * Le voile de remappage, en deux temps : "Appuie sur une touche", puis + * eventuellement "cette touche est deja prise, on la vole ?". + * + * Les deux etapes vivent dans le MEME widget et non dans deux ecrans successifs. + * Le conflit fait partie du geste de remappage : refermer le voile pour ouvrir + * une boite par-dessus produirait un clignotement et ferait perdre le fil de ce + * qu'on etait en train de modifier. UConfirmDialogWidget garde les questions + * generiques -- reinitialiser, abandonner une partie, valider une resolution. + * + * Classe Abstract : on cree un WBP_KeyCapture qui en herite. + */ +UCLASS(Abstract) +class SURVIVAL_PROJET_API UKeyCaptureWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + UKeyCaptureWidget(const FObjectInitializer& ObjectInitializer); + + virtual void NativeOnInitialized() override; + virtual FReply NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent) override; + virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override; + + UPROPERTY(BlueprintAssignable, Category = "Reglages") + FOnKeyCaptured OnKeyCaptured; + + UPROPERTY(BlueprintAssignable, Category = "Reglages") + FOnKeyCaptureCancelled OnCaptureCancelled; + + UPROPERTY(BlueprintAssignable, Category = "Reglages") + FOnKeyConflictAnswered OnConflictAnswered; + + /** Affiche le voile, ecrit la consigne, replie les boutons et prend le focus. */ + UFUNCTION(BlueprintCallable, Category = "Reglages") + void BeginCapture(const FText& ActionLabel); + + /** + * Bascule le voile deja ouvert sur la question de conflit. + * + * Ne referme rien : c'est tout l'interet d'avoir garde un seul widget. + */ + UFUNCTION(BlueprintCallable, Category = "Reglages") + void ShowConflict(const FText& Message); + + UFUNCTION(BlueprintCallable, Category = "Reglages") + void EndCapture(); + + UFUNCTION(BlueprintPure, Category = "Reglages") + EKeyCaptureState GetCaptureState() const { return State; } + +protected: + /** Optionnel : un voile dont le texte est fige dans le Blueprint reste valide. */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr PromptText; + + /** + * Boutons de la seule etape de conflit. Replies pendant la capture : les + * afficher inertes inviterait a cliquer alors qu'on attend une touche. + */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr ConfirmButton; + + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr CancelButton; + + /** Doit contenir {0}, remplace par le nom de l'action en cours de remappage. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages") + FText PromptFormat; + +private: + UFUNCTION() + void HandleConfirmClicked(); + + UFUNCTION() + void HandleCancelClicked(); + + /** Facteur commun a NativeOnKeyDown et NativeOnMouseButtonDown. */ + FReply CaptureKey(const FKey& Key); + + /** Affiche ou replie les deux boutons selon l'etat. */ + void UpdateButtonVisibility(); + + EKeyCaptureState State = EKeyCaptureState::Idle; +}; diff --git a/Source/Survival_projet/Public/MusicDirector.h b/Source/Survival_projet/Public/MusicDirector.h new file mode 100644 index 0000000..6fefede --- /dev/null +++ b/Source/Survival_projet/Public/MusicDirector.h @@ -0,0 +1,63 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Info.h" +#include "MusicSubsystem.h" +#include "MusicDirector.generated.h" + +class UMusicPlaylistDataAsset; + +/** + * Declare la musique du jeu. UN SEUL exemplaire suffit, dans la premiere map + * chargee -- typiquement la scene de menu. + * + * Il ne joue rien lui-meme et ne survit pas a sa map : il transmet sa consigne a + * UMusicSubsystem, qui, lui, vit sur le GameInstance et traverse tous les + * changements de niveau. C'est pour ca que les maps suivantes n'ont besoin de + * rien : la musique continue toute seule, et la regle de transition a deja ete + * posee ici. + * + * En poser un second dans une autre map reste possible : il change alors de + * playlist a l'arrivee sur celle-la. + * + * Derive d'AInfo : aucun visuel, aucun tick, et AInfo se declare non + * spatialement charge, donc il est present des le demarrage meme en World + * Partition -- un acteur ordinaire hors de la zone de streaming ne ferait jamais + * son BeginPlay. + */ +UCLASS(ClassGroup = "Audio", meta = (DisplayName = "Music Director")) +class SURVIVAL_PROJET_API AMusicDirector : public AInfo +{ + GENERATED_BODY() + +public: + AMusicDirector(); + +protected: + virtual void BeginPlay() override; + + /** + * La playlist a lancer en arrivant sur cette map. + * + * Si c'est deja elle qui tourne, elle n'est pas relancee. La laisser vide veut + * dire "je ne change pas la musique en cours", et n'a d'interet que sur un + * second Director pose uniquement pour changer la regle de transition. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Musique") + TObjectPtr Playlist; + + /** + * Ce que devient le morceau en cours a CHAQUE changement de scene suivant. + * + * Continue : il poursuit sa route par-dessus le chargement. + * New : il s'eteint et le lecteur en demarre un autre -- tirage aleatoire + * ou morceau suivant, selon le mode de la playlist. + * + * La consigne est envoyee au lecteur, qui la garde : les autres maps n'ont + * rien a declarer. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Musique") + EMusicLevelChangeMode LevelChangeMode = EMusicLevelChangeMode::Continue; +}; diff --git a/Source/Survival_projet/Public/MusicPlaylistDataAsset.h b/Source/Survival_projet/Public/MusicPlaylistDataAsset.h new file mode 100644 index 0000000..a6f2f6b --- /dev/null +++ b/Source/Survival_projet/Public/MusicPlaylistDataAsset.h @@ -0,0 +1,85 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "MusicPlaylistDataAsset.generated.h" + +class USoundBase; + +/** + * Une liste de morceaux et la facon de les enchainer. + * + * DataAsset et pas une variable sur un acteur : c'est de la DONNEE, au meme titre + * qu'un UItemDataAsset. On en cree autant qu'on veut (menu, exploration, nuit, + * combat) sans toucher au code, et la meme playlist peut etre referencee par + * plusieurs maps -- ce qui est exactement ce qui permet au menu et au jeu de + * partager une musique qui ne se coupe pas entre les deux. + * + * Aucun etat ici : l'index en cours, l'ordre tire au sort et le volume vivent + * dans UMusicSubsystem. Un DataAsset est partage par toutes les references, y + * mettre de l'etat le partagerait aussi. + */ +UCLASS(BlueprintType) +class SURVIVAL_PROJET_API UMusicPlaylistDataAsset : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + /** Nom lisible, pour un futur affichage "en cours de lecture". */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist") + FText DisplayName; + + /** + * Les morceaux, dans l'ordre de lecture quand l'aleatoire est desactive. + * + * Les cases vides sont ignorees a la lecture : une reference oubliee ne coupe + * pas la musique, elle est simplement sautee. + * + * IMPORTANT : aucun de ces sons ne doit boucler sur lui-meme (ni "Looping" sur + * la SoundWave, ni un noeud Looping dans la SoundCue). L'enchainement repose + * sur OnAudioFinished, qui n'est jamais diffuse par un son qui boucle. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist") + TArray> Tracks; + + /** + * Mode par defaut au demarrage de CETTE playlist. + * + * Le joueur peut ensuite basculer en cours de route via + * UMusicSubsystem::SetShuffleEnabled ; cette valeur n'est relue qu'au + * prochain demarrage de playlist. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist") + bool bShuffle = true; + + /** Faux = la musique s'arrete quand tous les morceaux ont ete joues une fois. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist") + bool bLoop = true; + + /** + * Silence entre deux morceaux, tire au hasard dans l'intervalle. + * + * Une musique d'ambiance qui respire quelques secondes se remarque beaucoup + * moins qu'un flux continu, et c'est ce qui evite la lassitude sur une longue + * session. A zero, l'enchainement est immediat. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist", meta = (ClampMin = "0.0", Units = "s")) + float MinGapBetweenTracks = 0.f; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist", meta = (ClampMin = "0.0", Units = "s")) + float MaxGapBetweenTracks = 0.f; + + /** Correction de niveau propre a cette playlist, par-dessus le volume joueur. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist", meta = (ClampMin = "0.0", ClampMax = "2.0")) + float VolumeMultiplier = 1.f; + + /** Fondu entrant au demarrage de la playlist (pas entre deux morceaux). */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist", meta = (ClampMin = "0.0", Units = "s")) + float FadeInDuration = 2.f; + + /** Fondu sortant quand une autre playlist prend la main, ou a l'arret. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Playlist", meta = (ClampMin = "0.0", Units = "s")) + float FadeOutDuration = 2.f; +}; diff --git a/Source/Survival_projet/Public/MusicSubsystem.h b/Source/Survival_projet/Public/MusicSubsystem.h new file mode 100644 index 0000000..161a3a6 --- /dev/null +++ b/Source/Survival_projet/Public/MusicSubsystem.h @@ -0,0 +1,204 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Containers/Ticker.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "MusicSubsystem.generated.h" + +class UAudioComponent; +class UMusicPlaylistDataAsset; +class USoundBase; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMusicTrackChanged, USoundBase*, NewTrack); + +/** + * Ce qui arrive au morceau en cours quand on change de scene. + * + * La question ne se pose que parce que le lecteur, lui, ne s'arrete jamais : + * sans consigne, la musique du menu se poursuit telle quelle dans la partie, au + * milieu du morceau ou elle en etait. + */ +UENUM(BlueprintType) +enum class EMusicLevelChangeMode : uint8 +{ + /** Le morceau en cours poursuit sa route, comme si de rien n'etait. */ + Continue UMETA(DisplayName = "Continue"), + + /** + * Le morceau en cours s'eteint et le suivant demarre : nouveau tirage si + * l'aleatoire est actif, morceau suivant de la liste sinon. La playlist, elle, + * ne change pas. + */ + New UMETA(DisplayName = "New") +}; + +/** + * Le lecteur de musique du jeu. Un seul, pour toute la duree de l'application. + * + * UGameInstanceSubsystem et pas un acteur : il naît avec le GameInstance et meurt + * avec le jeu, exactement comme USurvivalGameInstance. Aucune map ne le possede, + * donc aucun OpenLevel ne le detruit -- c'est le seul endroit d'ou une musique + * peut traverser le passage menu -> partie sans se couper. Le moteur l'instancie + * tout seul, il n'y a rien a poser dans une scene ni a cocher nulle part. + * + * Le composant audio lui-meme est cree via CreateSound2D avec + * bPersistAcrossLevelTransition : le moteur le construit alors SANS monde et sans + * proprietaire, et lui pose bIgnoreForFlushing -- le drapeau que le vidage du + * peripherique audio, declenche a chaque changement de niveau, saute + * explicitement. Sans ce parametre le son s'arreterait net au chargement, meme si + * l'objet C++, lui, survivait. + * + * Effet de bord voulu : ces composants sont marques bIsUISound, donc ils + * continuent de jouer quand le jeu est en pause. Le menu de pause garde sa + * musique sans une ligne de plus. + */ +UCLASS() +class SURVIVAL_PROJET_API UMusicSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** Raccourci depuis n'importe quel objet ayant un monde. */ + UFUNCTION(BlueprintPure, Category = "Musique", meta = (WorldContext = "WorldContextObject", DisplayName = "Get Music Subsystem")) + static UMusicSubsystem* Get(const UObject* WorldContextObject); + + /** + * Demarre une playlist. + * + * Si c'est DEJA celle qui joue, ne fait rien : c'est ce comportement qui rend + * la transition menu -> jeu inaudible quand les deux maps designent la meme + * playlist. Si c'en est une autre, croisement entre l'ancienne qui s'eteint et + * la nouvelle qui entre. + * + * @param bForceRestart Repart du debut meme si la playlist est deja en cours. + */ + UFUNCTION(BlueprintCallable, Category = "Musique") + void PlayPlaylist(UMusicPlaylistDataAsset* Playlist, bool bForceRestart = false); + + /** @param FadeOutDuration Negatif = utiliser le fondu declare par la playlist. */ + UFUNCTION(BlueprintCallable, Category = "Musique") + void StopMusic(float FadeOutDuration = -1.f); + + /** Passe au morceau suivant, dans l'ordre courant (aleatoire ou non). */ + UFUNCTION(BlueprintCallable, Category = "Musique") + void SkipToNextTrack(); + + /** + * Bascule aleatoire / ordre de la liste. + * + * Ne coupe jamais le morceau en cours : seul l'enchainement change. Le curseur + * se recale sur le morceau qui joue, donc en repassant en mode ordonne la + * lecture reprend juste apres lui au lieu de sauter au debut de la liste. + */ + UFUNCTION(BlueprintCallable, Category = "Musique") + void SetShuffleEnabled(bool bEnabled); + + UFUNCTION(BlueprintPure, Category = "Musique") + bool IsShuffleEnabled() const { return bShuffleEnabled; } + + /** + * Ce que devient le morceau en cours au prochain changement de scene. + * + * La consigne vit ICI et pas sur l'acteur qui l'a posee : l'acteur meurt avec + * sa map, alors que le changement de scene se produit justement apres. Une + * seule map -- le menu -- suffit donc a la declarer pour tout le jeu. + */ + UFUNCTION(BlueprintCallable, Category = "Musique") + void SetLevelChangeMode(EMusicLevelChangeMode Mode) { LevelChangeMode = Mode; } + + UFUNCTION(BlueprintPure, Category = "Musique") + EMusicLevelChangeMode GetLevelChangeMode() const { return LevelChangeMode; } + + /** Volume joueur, 0 a 1. A brancher sur l'onglet Audio des reglages plus tard. */ + UFUNCTION(BlueprintCallable, Category = "Musique") + void SetMusicVolume(float NewVolume); + + UFUNCTION(BlueprintPure, Category = "Musique") + float GetMusicVolume() const { return MusicVolume; } + + UFUNCTION(BlueprintPure, Category = "Musique") + bool IsPlaying() const { return bIsPlaying; } + + UFUNCTION(BlueprintPure, Category = "Musique") + USoundBase* GetCurrentTrack() const; + + UFUNCTION(BlueprintPure, Category = "Musique") + UMusicPlaylistDataAsset* GetCurrentPlaylist() const { return CurrentPlaylist; } + + /** Diffuse a chaque changement de morceau, nullptr a l'arret. */ + UPROPERTY(BlueprintAssignable, Category = "Musique") + FOnMusicTrackChanged OnMusicTrackChanged; + +private: + /** Fin NATURELLE d'un morceau uniquement -- voir FadeOutAndRelease. */ + UFUNCTION() + void HandleTrackFinished(); + + /** Applique LevelChangeMode a chaque map chargee. */ + void HandlePostLoadMap(UWorld* LoadedWorld); + + /** (Re)construit l'ordre de lecture du cycle a venir. */ + void BuildPlayOrder(); + + void AdvanceAndPlay(float FadeInDuration); + void PlayTrackAtCursor(float FadeInDuration); + UAudioComponent* CreateMusicComponent(USoundBase* Sound); + + /** + * Detache puis eteint un composant : apres cet appel, plus rien de ce qu'il + * diffuse ne revient vers nous. + */ + void FadeOutAndRelease(TObjectPtr& Component, float FadeOutDuration); + + void ScheduleGap(float DelaySeconds); + void CancelPendingGap(); + float GetEffectiveVolume() const; + + UPROPERTY() + TObjectPtr CurrentPlaylist; + + /** + * UPROPERTY obligatoire : ces composants n'ont ni monde ni acteur proprietaire, + * cette reference est la SEULE qui les tienne hors de portee du ramasse-miettes. + */ + UPROPERTY() + TObjectPtr MusicComponent; + + /** La voie de sortie, le temps d'un croisement. Une seule a la fois. */ + UPROPERTY() + TObjectPtr FadingOutComponent; + + /** + * Index dans Tracks, deja melangee si l'aleatoire est actif. + * + * Un ordre pre-melange plutot qu'un tirage a chaque morceau : le tirage + * independant rejoue le meme titre deux fois de suite une fois sur N, et sur + * une playlist de cinq morceaux ca s'entend tout de suite. + */ + TArray PlayOrder; + + int32 OrderCursor = INDEX_NONE; + int32 LastPlayedTrackIndex = INDEX_NONE; + + bool bShuffleEnabled = true; + bool bIsPlaying = false; + float MusicVolume = 1.f; + + EMusicLevelChangeMode LevelChangeMode = EMusicLevelChangeMode::Continue; + + /** Abonnement au chargement de map, retire a la fermeture du jeu. */ + FDelegateHandle PostLoadMapHandle; + + /** + * Le silence entre deux morceaux passe par le ticker de Core, pas par un + * FTimerHandle : un timer appartient au monde, donc il disparaîtrait au + * chargement de la map suivante -- et il ne tourne pas en pause. Le ticker de + * Core ignore les deux. + */ + FTSTicker::FDelegateHandle GapTickerHandle; +}; diff --git a/Source/Survival_projet/Public/SettingKeyRowWidget.h b/Source/Survival_projet/Public/SettingKeyRowWidget.h new file mode 100644 index 0000000..9ec2a26 --- /dev/null +++ b/Source/Survival_projet/Public/SettingKeyRowWidget.h @@ -0,0 +1,73 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "InputCoreTypes.h" +#include "SettingKeyRowWidget.generated.h" + +class UButton; +class UTextBlock; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSettingKeyRebindRequested, FName, MappingName); + +/** + * Une ligne de remappage : un libelle, et un bouton portant la touche actuelle. + * + * Widget distinct de USettingRowWidget et non un mode de plus : une touche n'est + * pas un index dans une liste de paliers, il n'y a ni fleches ni curseur, et + * l'interaction est d'une autre nature -- on ouvre un voile de capture. + * + * Elle ne sait pas remapper. Elle previent via OnRebindRequested et c'est + * l'onglet qui pilote, comme partout ailleurs dans les reglages. + * + * Classe Abstract : on cree un WBP_SettingKeyRow qui en herite. + */ +UCLASS(Abstract) +class SURVIVAL_PROJET_API USettingKeyRowWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + virtual void NativeOnInitialized() override; + + UPROPERTY(BlueprintAssignable, Category = "Reglages") + FOnSettingKeyRebindRequested OnRebindRequested; + + /** Pose le libelle et la touche. Le nom de mapping est renvoye tel quel au clic. */ + UFUNCTION(BlueprintCallable, Category = "Reglages") + void Setup(FName InMappingName, const FText& Label, const FKey& Key); + + /** Met a jour la seule touche affichee. */ + UFUNCTION(BlueprintCallable, Category = "Reglages") + void SetKey(const FKey& Key); + + UFUNCTION(BlueprintPure, Category = "Reglages") + FName GetMappingName() const { return MappingName; } + +protected: + UPROPERTY(meta = (BindWidget)) + TObjectPtr LabelText; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr KeyButton; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr KeyText; + + /** Couleur du texte quand l'action n'a plus aucune touche. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages") + FLinearColor UnboundColor = FLinearColor(0.9f, 0.2f, 0.2f); + + /** Couleur normale. Reprise telle quelle a la premiere affectation valide. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages") + FLinearColor BoundColor = FLinearColor::White; + +private: + UFUNCTION() + void HandleKeyButtonClicked(); + + /** Identifiant Enhanced Input du mapping, pas le libelle affiche. */ + FName MappingName = NAME_None; +}; diff --git a/Source/Survival_projet/Public/SettingRowWidget.h b/Source/Survival_projet/Public/SettingRowWidget.h index 22b4e4c..d0fd326 100644 --- a/Source/Survival_projet/Public/SettingRowWidget.h +++ b/Source/Survival_projet/Public/SettingRowWidget.h @@ -8,6 +8,7 @@ class UButton; class UProgressBar; +class USlider; class UTextBlock; DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSettingRowIndexChanged, int32, NewIndex); @@ -49,10 +50,17 @@ public: /** * Mode nombre : la valeur affichee est calculee a partir de l'index. - * Suffix sert aux unites ("%", "°") et peut rester vide. + * + * @param Step pas fin, celui du glissement. C'est lui qui definit la + * granularite de l'index. + * @param ArrowStep bond des fleches, forcement un multiple de Step. Les + * fleches se calent sur le multiple suivant plutot que + * d'ajouter betement leur bond : depuis 37 avec un bond de + * 5 on arrive a 40, pas a 42. + * @param Suffix unite ("%", "°"), peut rester vide. */ UFUNCTION(BlueprintCallable, Category = "Reglages") - void SetupAsNumber(const FText& Label, float Min, float Max, float Step, float Value, const FText& Suffix); + void SetupAsNumber(const FText& Label, float Min, float Max, float Step, float ArrowStep, float Value, const FText& Suffix); UFUNCTION(BlueprintPure, Category = "Reglages") int32 GetIndex() const { return CurrentIndex; } @@ -75,6 +83,18 @@ public: UFUNCTION(BlueprintCallable, Category = "Reglages") void SetNumericValueSilently(float Value); + /** + * Derniere valeur que le JOUEUR peut atteindre, les suivantes restant + * affichables par le code. + * + * Sert a "Personnalise" en qualite globale : c'est un etat dans lequel on + * tombe en bougeant un curseur individuel, jamais un choix qu'on fait. + * Sans ca la fleche droite proposerait de le "selectionner", ce qui ne veut + * rien dire. INDEX_NONE pour lever la limite. + */ + UFUNCTION(BlueprintCallable, Category = "Reglages") + void SetMaxSelectableIndex(int32 InMaxSelectableIndex); + protected: UPROPERTY(meta = (BindWidget)) TObjectPtr LabelText; @@ -89,14 +109,29 @@ protected: TObjectPtr RightArrowButton; /** - * Facultative : une ligne de choix (Maintien / Bascule) n'a rien a remplir. - * Volontairement une ProgressBar et non un Slider : la barre montre ou l'on - * en est, ce sont les fleches qui reglent. Un slider glissable produirait des - * valeurs entre deux paliers que le setter refuserait ensuite. + * Le REMPLISSAGE visible, et rien d'autre. + * + * Le Slider d'Unreal ne sait pas dessiner de portion remplie : il rend une + * barre uniforme plus une poignee. On empile donc les deux -- cette + * ProgressBar pour le rendu, le Slider transparent par-dessus pour le + * geste. C'est le montage habituel, pas un bricolage. */ UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr ValueBar; + /** + * Le GESTE, et rien d'autre : a styler entierement transparent dans le WBP. + * + * Sa course est exprimee en INDEX de palier (0 a StepCount-1) et non dans + * l'unite du reglage -- c'est ce qui garantit qu'un glissement ne peut pas + * produire une valeur hors palier, que le setter refuserait ensuite. + * + * Les deux sont facultatifs : une ligne de choix (Maintien / Bascule) les + * replie tous les deux, il n'y a rien a doser. + */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr ValueSlider; + private: UFUNCTION() void HandleLeftClicked(); @@ -104,9 +139,19 @@ private: UFUNCTION() void HandleRightClicked(); + /** Glissement souris : on arrondit sur le palier le plus proche. */ + UFUNCTION() + void HandleSliderValueChanged(float NewValue); + /** Deplace l'index de Delta paliers et diffuse, si le mouvement est possible. */ void StepIndex(int32 Delta); + /** Index vise par une fleche : le multiple de son bond suivant ou precedent. */ + int32 ArrowTargetIndex(int32 Direction) const; + + /** Plus grand index atteignable par le joueur. */ + int32 MaxSelectable() const; + /** Reecrit le texte, la barre et l'etat des deux fleches. */ void Refresh(); @@ -120,8 +165,23 @@ private: int32 CurrentIndex = 0; - /** Nombre de paliers, donc d'index valides. Toujours au moins 1. */ + /** Nombre de paliers fins, donc d'index valides. Toujours au moins 1. */ int32 StepCount = 1; + /** Bond des fleches, exprime en nombre de paliers fins. 1 en mode choix. */ + int32 ArrowIndexDelta = 1; + + /** INDEX_NONE = aucune limite, tous les paliers sont selectionnables. */ + int32 MaxSelectableIndex = INDEX_NONE; + bool bNumeric = false; + + /** + * Vrai pendant qu'on repositionne le curseur depuis le code. + * + * USlider::SetValue ne rediffuse pas son delegue aujourd'hui, mais s'en + * remettre a ce detail d'implementation coute une boucle infinie le jour ou + * il change. + */ + bool bRefreshingSlider = false; }; diff --git a/Source/Survival_projet/Public/SettingsControlsWidget.h b/Source/Survival_projet/Public/SettingsControlsWidget.h new file mode 100644 index 0000000..5dfd78f --- /dev/null +++ b/Source/Survival_projet/Public/SettingsControlsWidget.h @@ -0,0 +1,125 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "InputCoreTypes.h" +#include "SettingsTabWidget.h" +#include "SettingsControlsWidget.generated.h" + +class UEnhancedInputUserSettings; +class USettingKeyRowWidget; + +/** + * L'onglet "Touches". + * + * Le stockage, la persistance et l'application des surcharges appartiennent a + * UEnhancedInputUserSettings, natif depuis 5.3 : on n'ecrit ni serialisation ni + * reconstruction d'IMC. Cet onglet n'est qu'une facade -- afficher les touches + * courantes, capturer la nouvelle, arbitrer les conflits. + * + * PREREQUIS EDITEUR : sans "Enable User Settings" dans Project Settings -> + * Enhanced Input, et sans Player Mappable Key Settings sur chaque mapping de + * IMC_Default, toutes les lignes afficheront "Non assignée". + * + * Classe Abstract : on cree un WBP_SettingsControls qui en herite. + */ +UCLASS(Abstract) +class SURVIVAL_PROJET_API USettingsControlsWidget : public USettingsTabWidget +{ + GENERATED_BODY() + +public: + USettingsControlsWidget(const FObjectInitializer& ObjectInitializer); + + virtual void NativeConstruct() override; + + virtual void RefreshFromSettings() override; + virtual void ResetToDefaults() override; + +protected: + UPROPERTY(meta = (BindWidget)) + TObjectPtr MoveForwardRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr MoveBackwardRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr MoveLeftRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr MoveRightRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr JumpRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr SprintRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr CrouchRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr InteractRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr UseItemRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr ToggleInventoryRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr DropItemRow; + + /** + * Question posee en cas de conflit. Trois emplacements : + * {0} la touche, {1} l'action qui la possede, {2} l'action qui la reclame. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages") + FText ConflictMessage; + +private: + UFUNCTION() + void HandleRebindRequested(FName MappingName); + + UFUNCTION() + void HandleKeyCaptured(FKey CapturedKey); + + UFUNCTION() + void HandleConflictAnswered(bool bConfirmed); + + /** Ecrit la touche en attente sur le mapping en attente, puis rafraichit. */ + void ApplyPendingRebind(); + + /** Le sous-systeme de reglages d'input du joueur local, ou nullptr. */ + UEnhancedInputUserSettings* GetInputSettings() const; + + /** Toutes les lignes, dans l'ordre d'affichage. Ignore les nulles. */ + TArray GetAllRows() const; + + /** La ligne portant ce nom de mapping, ou nullptr. */ + USettingKeyRowWidget* FindRow(FName MappingName) const; + + /** + * Donne son identite a une ligne et retient son libelle. + * + * Le libelle vient du C++ et non du "Display Name" des Player Mappable Key + * Settings : ca t'evite de le saisir onze fois dans l'editeur, et ca garde + * les textes traduisibles au meme endroit que le reste du menu. + */ + void SetupRow(USettingKeyRowWidget* Row, FName MappingName, const FText& Label); + + /** Libelle lisible d'un mapping. Retombe sur l'identifiant si inconnu. */ + FText GetMappingDisplayName(FName MappingName) const; + + /** Libelles retenus a la construction, pour les messages de conflit. */ + TMap MappingLabels; + + /** Mapping en cours de remappage, et touche capturee en attente d'arbitrage. */ + FName PendingMappingName = NAME_None; + + FKey PendingKey; + + /** Mapping a qui l'on volerait la touche si le joueur confirme. */ + FName ConflictingMappingName = NAME_None; +}; diff --git a/Source/Survival_projet/Public/SettingsDisplayWidget.h b/Source/Survival_projet/Public/SettingsDisplayWidget.h new file mode 100644 index 0000000..ecba2c9 --- /dev/null +++ b/Source/Survival_projet/Public/SettingsDisplayWidget.h @@ -0,0 +1,121 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "SettingsTabWidget.h" +#include "SettingsDisplayWidget.generated.h" + +class USettingRowWidget; +class USurvivalUserSettings; + +/** + * L'onglet "Affichage". + * + * Presque tout est deja ecrit dans UGameUserSettings : mode d'affichage, + * resolution, VSync, limite de FPS, echelle de resolution. On ne fait que + * l'habiller. Seule la luminosite nous appartient, parce que le gamma vit sur + * le moteur et non dans les reglages. + * + * Le changement de resolution ou de mode passe par une CONFIRMATION A REBOURS : + * c'est le seul filet contre un mode que l'ecran ne sait pas afficher, situation + * dont on ne peut sinon sortir qu'en supprimant GameUserSettings.ini a la main. + * + * Classe Abstract : on cree un WBP_SettingsDisplay qui en herite. + */ +UCLASS(Abstract) +class SURVIVAL_PROJET_API USettingsDisplayWidget : public USettingsTabWidget +{ + GENERATED_BODY() + +public: + USettingsDisplayWidget(const FObjectInitializer& ObjectInitializer); + + virtual void NativeConstruct() override; + + virtual void RefreshFromSettings() override; + virtual void ResetToDefaults() override; + +protected: + UPROPERTY(meta = (BindWidget)) + TObjectPtr DisplayModeRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr ResolutionRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr FrameRateLimitRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr VSyncRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr ResolutionScaleRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr BrightnessRow; + + /** Doit contenir {0}, remplace par les secondes restantes. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages") + FText VideoConfirmMessage; + + /** Delai avant retour automatique au mode precedent. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages", meta = (ClampMin = "3", ClampMax = "30")) + float VideoConfirmSeconds = 10.f; + + /** + * Proposer aussi les formats ultralarges (21:9) en plus du 16:9. + * + * La liste que renvoie le systeme contient TOUT ce que la carte accepte : + * 4:3, 5:4, 16:10, des modes d'un autre age. On n'en garde que deux familles. + * Le 16:9 est toujours accepte -- il represente plus des trois quarts des + * joueurs Steam a lui seul. Le 21:9 est marginal mais reel, d'ou l'option. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages") + bool bAllowUltrawide = true; + + /** Plus petite resolution proposee. En dessous, le HUD devient illisible. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages") + FIntPoint MinimumResolution = FIntPoint(1280, 720); + +private: + UFUNCTION() + void HandleDisplayModeChanged(int32 NewIndex); + + UFUNCTION() + void HandleResolutionChanged(int32 NewIndex); + + UFUNCTION() + void HandleFrameRateLimitChanged(int32 NewIndex); + + UFUNCTION() + void HandleVSyncChanged(int32 NewIndex); + + UFUNCTION() + void HandleResolutionScaleChanged(int32 NewIndex); + + UFUNCTION() + void HandleBrightnessChanged(int32 NewIndex); + + /** Reponse a la question posee apres un changement de mode ou de resolution. */ + UFUNCTION() + void HandleVideoConfirmAnswered(bool bConfirmed); + + /** Applique le mode video puis demande confirmation. */ + void ApplyVideoModeAndConfirm(); + + /** Recharge la liste des resolutions de l'ecran et repositionne la ligne. */ + void RebuildResolutionList(); + + /** Ecarte les formats non desires. Ne vide jamais la liste completement. */ + void FilterResolutions(const FIntPoint& CurrentResolution); + + /** Index de la resolution courante dans AvailableResolutions, 0 si inconnue. */ + int32 FindCurrentResolutionIndex(const USurvivalUserSettings* Settings) const; + + /** Index de la plus grande resolution proposee, INDEX_NONE si la liste est vide. */ + int32 FindLargestResolutionIndex() const; + + /** Resolutions proposees, dans l'ordre de la ligne. */ + TArray AvailableResolutions; +}; diff --git a/Source/Survival_projet/Public/SettingsGraphicsWidget.h b/Source/Survival_projet/Public/SettingsGraphicsWidget.h new file mode 100644 index 0000000..1cb5688 --- /dev/null +++ b/Source/Survival_projet/Public/SettingsGraphicsWidget.h @@ -0,0 +1,111 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "SettingsTabWidget.h" +#include "SettingsGraphicsWidget.generated.h" + +class USettingRowWidget; +class USurvivalUserSettings; + +/** + * L'onglet "Graphismes" : les dix curseurs de scalability du moteur, plus une + * qualite globale qui les pousse tous. + * + * Zero logique metier ici : chaque ligne appelle un Set...Quality(0-3) de + * UGameUserSettings, le moteur fait le reste. C'est l'onglet qui rapporte le + * plus pour le moins d'effort. + * + * Vegetation et Distance d'affichage sont places EN HAUT et non dans l'ordre du + * moteur : avec SoStylized, ce sont les deux seuls qui decident vraiment si le + * jeu tourne sur une machine moyenne. + * + * Classe Abstract : on cree un WBP_SettingsGraphics qui en herite. + */ +UCLASS(Abstract) +class SURVIVAL_PROJET_API USettingsGraphicsWidget : public USettingsTabWidget +{ + GENERATED_BODY() + +public: + virtual void NativeConstruct() override; + + virtual void RefreshFromSettings() override; + virtual void ResetToDefaults() override; + +protected: + /** Bas / Moyen / Élevé / Épique, plus "Personnalisé" que le joueur ne peut pas choisir. */ + UPROPERTY(meta = (BindWidget)) + TObjectPtr OverallQualityRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr ViewDistanceRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr FoliageRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr ShadowRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr TextureRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr VisualEffectRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr PostProcessRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr AntiAliasingRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr ReflectionRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr GlobalIlluminationRow; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr ShadingRow; + +private: + UFUNCTION() + void HandleOverallQualityChanged(int32 NewIndex); + + UFUNCTION() + void HandleViewDistanceChanged(int32 NewIndex); + + UFUNCTION() + void HandleFoliageChanged(int32 NewIndex); + + UFUNCTION() + void HandleShadowChanged(int32 NewIndex); + + UFUNCTION() + void HandleTextureChanged(int32 NewIndex); + + UFUNCTION() + void HandleVisualEffectChanged(int32 NewIndex); + + UFUNCTION() + void HandlePostProcessChanged(int32 NewIndex); + + UFUNCTION() + void HandleAntiAliasingChanged(int32 NewIndex); + + UFUNCTION() + void HandleReflectionChanged(int32 NewIndex); + + UFUNCTION() + void HandleGlobalIlluminationChanged(int32 NewIndex); + + UFUNCTION() + void HandleShadingChanged(int32 NewIndex); + + /** Prepare une ligne de qualite : memes quatre libelles partout. */ + void SetupQualityRow(USettingRowWidget* Row, const FText& Label); + + /** Pousse la scalability et rafraichit la ligne de qualite globale. */ + void ApplyAndRefreshOverall(); +}; diff --git a/Source/Survival_projet/Public/SettingsMenuWidget.h b/Source/Survival_projet/Public/SettingsMenuWidget.h index 70355f3..b5dea27 100644 --- a/Source/Survival_projet/Public/SettingsMenuWidget.h +++ b/Source/Survival_projet/Public/SettingsMenuWidget.h @@ -8,11 +8,18 @@ class UButton; class UConfirmDialogWidget; +class UKeyCaptureWidget; class USettingsTabWidget; class UWidgetSwitcher; DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSettingsCloseRequested); +/** Reponse a une demande de confirmation. bConfirmed est faux sur un refus ou une expiration. */ +DECLARE_DYNAMIC_DELEGATE_OneParam(FOnSettingsConfirmationResult, bool, bConfirmed); + +/** Touche capturee. Invalide si le joueur a annule avec Echap. */ +DECLARE_DYNAMIC_DELEGATE_OneParam(FOnSettingsKeyCaptureResult, FKey, CapturedKey); + /** * L'ecran de reglages : une barre d'onglets, un WidgetSwitcher, Retour et * Reinitialiser. @@ -55,6 +62,57 @@ public: UFUNCTION(BlueprintCallable, Category = "Reglages") void Close(); + /** + * ZOrder de la boite de confirmation, impose par celui qui ouvre l'ecran. + * + * L'ecran ne le choisit PAS lui-meme : il est ouvert depuis deux contextes + * dont les empilements n'ont rien a voir (50 au menu principal, 150 en jeu). + * Un numero code en dur ici obligerait le widget a savoir qui l'affiche, + * exactement ce qu'on evite avec OnCloseRequested. A appeler AVANT Open(). + */ + UFUNCTION(BlueprintCallable, Category = "Reglages") + void SetDialogZOrder(int32 InZOrder) { DialogZOrder = InZOrder; } + + /** + * Pose une question au joueur pour le compte d'un onglet. + * + * L'ecran est le seul a posseder une boite : un onglet qui creerait la + * sienne devrait a son tour se demander a quel ZOrder la poser, et on + * repartirait pour un tour. + * + * @param MessageOrFormat texte simple, ou format contenant {0} si + * CountdownSeconds est superieur a zero. + * @param CountdownSeconds 0 pour une question sans limite de temps. + * @return false si aucune boite n'est configuree -- l'appelant doit alors + * decider seul, sa reponse n'arrivera jamais. + */ + bool RequestConfirmation(const FText& MessageOrFormat, float CountdownSeconds, const FOnSettingsConfirmationResult& OnResult); + + /** + * Ouvre le voile de capture de touche pour le compte d'un onglet. + * + * Meme raison que la boite : un voile pose DANS l'onglet serait clippe a la + * zone du WidgetSwitcher au lieu de couvrir l'ecran, et devrait a son tour + * se trouver un ZOrder. + * + * @return false si aucun voile n'est configure -- la reponse n'arrivera + * jamais, l'appelant doit renoncer. + */ + bool RequestKeyCapture(const FText& ActionLabel, const FOnSettingsKeyCaptureResult& OnResult); + + /** + * Bascule le voile deja ouvert sur la question de conflit. + * + * Le voile RESTE ouvert entre la capture et la reponse : c'est le meme geste + * pour le joueur, il n'a pas a le voir disparaitre puis revenir. + */ + UFUNCTION(BlueprintCallable, Category = "Reglages") + void ShowKeyCaptureConflict(const FText& Message, const FOnSettingsConfirmationResult& OnAnswer); + + /** Referme le voile. A appeler quand le remappage est termine ou abandonne. */ + UFUNCTION(BlueprintCallable, Category = "Reglages") + void CloseKeyCapture(); + protected: UPROPERTY(meta = (BindWidget)) TObjectPtr TabSwitcher; @@ -62,41 +120,58 @@ protected: UPROPERTY(meta = (BindWidget)) TObjectPtr BackButton; - UPROPERTY(meta = (BindWidgetOptional)) + UPROPERTY(meta = (BindWidget)) TObjectPtr ResetButton; /** - * Les onglets a venir. Grises automatiquement tant que le WidgetSwitcher ne - * contient pas la page correspondante -- meme principe que les boutons de - * coop du menu principal : ils annoncent la couleur sans mener nulle part. + * Les trois onglets qui existent 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 + * l'absence n'a aucun sens, on veut l'erreur a la compilation du Blueprint. */ - UPROPERTY(meta = (BindWidgetOptional)) + UPROPERTY(meta = (BindWidget)) TObjectPtr GameplayTabButton; - UPROPERTY(meta = (BindWidgetOptional)) + UPROPERTY(meta = (BindWidget)) TObjectPtr DisplayTabButton; - UPROPERTY(meta = (BindWidgetOptional)) + UPROPERTY(meta = (BindWidget)) TObjectPtr GraphicsTabButton; + UPROPERTY(meta = (BindWidget)) + TObjectPtr 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)) TObjectPtr AudioTabButton; /** - * Boite de confirmation POSEE DANS le WBP_Settings, et non ajoutee au - * viewport : elle se retrouve ainsi naturellement au-dessus de l'ecran de - * reglages, sans avoir a lui trouver un ZOrder qui n'entre en conflit ni - * avec le menu de pause ni avec la boite d'abandon de partie. - * - * Absente, "Reinitialiser" agit sans rien demander. + * Ton WBP_ConfirmDialog, a renseigner dans les Class Defaults de + * WBP_Settings. Laisse-le vide et "Reinitialiser" agit sans rien demander. */ - UPROPERTY(meta = (BindWidgetOptional)) - TObjectPtr ResetDialog; + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Reglages") + TSubclassOf ResetDialogClass; /** La question posee avant de reinitialiser l'onglet visible. */ UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reglages") FText ResetDialogMessage; + /** Ton WBP_KeyCapture. Sans lui, l'onglet Touches ne peut rien remapper. */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Reglages") + TSubclassOf KeyCaptureClass; + + UPROPERTY(Transient, BlueprintReadOnly, Category = "Reglages") + TObjectPtr ResetDialog; + + UPROPERTY(Transient, BlueprintReadOnly, Category = "Reglages") + TObjectPtr KeyCapture; + private: UFUNCTION() void HandleBackClicked(); @@ -104,11 +179,21 @@ private: UFUNCTION() void HandleResetClicked(); + /** Reponse a la question du bouton Reinitialiser. */ UFUNCTION() - void HandleResetConfirmed(); + void HandleResetAnswered(bool bConfirmed); UFUNCTION() - void HandleResetCancelled(); + void HandleDialogConfirmed(); + + UFUNCTION() + void HandleDialogCancelled(); + + /** Referme la boite et rend la reponse a qui a pose la question. */ + void FinishConfirmation(bool bConfirmed); + + /** Cree la boite au premier besoin. Renvoie nullptr si aucune classe n'est assignee. */ + UConfirmDialogWidget* EnsureDialog(); UFUNCTION() void HandleGameplayTabClicked(); @@ -119,9 +204,24 @@ private: UFUNCTION() void HandleGraphicsTabClicked(); + UFUNCTION() + void HandleControlsTabClicked(); + UFUNCTION() void HandleAudioTabClicked(); + UFUNCTION() + void HandleKeyCaptured(FKey CapturedKey); + + UFUNCTION() + void HandleKeyCaptureCancelled(); + + UFUNCTION() + void HandleKeyConflictAnswered(bool bConfirmed); + + /** A qui rendre la reponse du conflit affiche dans le voile. */ + FOnSettingsConfirmationResult PendingConflictAnswer; + /** Change de page et rafraichit la nouvelle. Ignore un index inexistant. */ void ShowTab(int32 Index); @@ -131,5 +231,16 @@ private: /** Grise les boutons dont la page n'existe pas encore. */ void UpdateTabButtonStates(); + /** A qui rendre la reponse de la boite ouverte. Vide quand rien n'est pose. */ + FOnSettingsConfirmationResult PendingConfirmation; + + /** A qui rendre la touche capturee. Vide quand le voile est ferme. */ + FOnSettingsKeyCaptureResult PendingKeyCapture; + bool bResetDialogOpen = false; + + bool bKeyCaptureOpen = false; + + /** Valeur de repli si l'appelant oublie SetDialogZOrder : devant le fondu exclu. */ + int32 DialogZOrder = 500; }; diff --git a/Source/Survival_projet/Public/SettingsTabWidget.h b/Source/Survival_projet/Public/SettingsTabWidget.h index c134f1d..5ad8700 100644 --- a/Source/Survival_projet/Public/SettingsTabWidget.h +++ b/Source/Survival_projet/Public/SettingsTabWidget.h @@ -6,12 +6,14 @@ #include "Blueprint/UserWidget.h" #include "SettingsTabWidget.generated.h" +class USettingsMenuWidget; + /** * Base commune a tous les onglets de reglages. * * Deux verbes, rien de plus : l'ecran parent rafraichit ou reinitialise l'onglet - * visible sans jamais savoir ce qu'il contient. Ajouter l'onglet Graphismes plus - * tard ne demandera donc aucune ligne dans USettingsMenuWidget. + * visible sans jamais savoir ce qu'il contient. Ajouter un onglet ne demande + * donc aucune ligne dans USettingsMenuWidget. */ UCLASS(Abstract) class SURVIVAL_PROJET_API USettingsTabWidget : public UUserWidget @@ -24,4 +26,22 @@ public: /** Remet a l'usine les seuls reglages de cet onglet, puis se rafraichit. */ virtual void ResetToDefaults() {} + + /** + * L'ecran se presente a ses onglets a la construction. + * + * Pointeur explicite plutot qu'un GetTypedOuter au moment de s'en servir : + * un onglet doit pouvoir demander une confirmation, et faire dependre ca de + * la forme de l'arbre de widgets casserait le jour ou on l'imbrique + * autrement. + * + * Defini dans le .cpp et non inline : TWeakObjectPtr exige un type complet + * pour s'assigner, et inclure l'en-tete de l'ecran ici ferait dependre tous + * les onglets de lui. + */ + void SetOwningMenu(USettingsMenuWidget* InMenu); + +protected: + UPROPERTY(Transient, BlueprintReadOnly, Category = "Reglages") + TWeakObjectPtr OwningMenu; }; diff --git a/Source/Survival_projet/Public/SurvivalGameInstance.h b/Source/Survival_projet/Public/SurvivalGameInstance.h index 3b83bf5..fbc836a 100644 --- a/Source/Survival_projet/Public/SurvivalGameInstance.h +++ b/Source/Survival_projet/Public/SurvivalGameInstance.h @@ -6,6 +6,9 @@ #include "Engine/GameInstance.h" #include "SurvivalGameInstance.generated.h" +class UInputMappingContext; +class ULocalPlayer; + /** * Le seul objet qui survit aux changements de map. * @@ -33,6 +36,32 @@ public: UFUNCTION(BlueprintCallable, Category = "Menu") void SetLastMenuCameraName(FName SpotName) { LastMenuCameraName = SpotName; } + /** + * Declare les IMC remappables aupres des reglages d'input du joueur. + * + * Appelee A LA DEMANDE, par l'ecran de reglages avant de lire les touches, et + * non depuis un evenement de demarrage. Raison : UserSettings n'est cree que + * dans UEnhancedInputLocalPlayerSubsystem::PlayerControllerChanged, et + * ULocalPlayer diffuse ses evenements AVANT de prevenir ses sous-systemes -- + * tout hook precoce arrive donc trop tot, quel qu'il soit. + * + * Idempotente : le moteur ignore un contexte deja connu. + */ + UFUNCTION(BlueprintCallable, Category = "Input") + void RegisterMappableContexts(); + +protected: + /** + * Les mapping contexts dont les touches sont remappables. Mets-y IMC_Default. + * + * Ici et PAS sur le pawn : le menu principal n'a aucun pawn, et l'ecran de + * reglages doit pourtant afficher les touches. C'est le critere habituel -- + * "est-ce que ca doit survivre au changement de map ?" -- qui envoie cette + * liste sur le GameInstance. + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input") + TArray> MappableContexts; + private: /** * Stocke un NOM et pas un index : les index changent des qu'on ajoute ou diff --git a/Source/Survival_projet/Public/SurvivalUserSettings.h b/Source/Survival_projet/Public/SurvivalUserSettings.h index 981621a..2dd9c82 100644 --- a/Source/Survival_projet/Public/SurvivalUserSettings.h +++ b/Source/Survival_projet/Public/SurvivalUserSettings.h @@ -18,17 +18,52 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSurvivalSettingsApplied); */ namespace SurvivalSettingsRange { + // Deux granularites par reglage : + // Step = le pas FIN, celui du glissement au curseur. C'est aussi + // celui sur lequel les setters arrondissent : le stocker plus + // grossier annulerait le reglage fin juste apres. + // ArrowStep = le bond des fleches, pour traverser la plage vite. constexpr float SensitivityMin = 5.f; constexpr float SensitivityMax = 100.f; - constexpr float SensitivityStep = 5.f; + constexpr float SensitivityStep = 1.f; + constexpr float SensitivityArrowStep = 5.f; constexpr float FieldOfViewMin = 70.f; constexpr float FieldOfViewMax = 120.f; - constexpr float FieldOfViewStep = 5.f; + constexpr float FieldOfViewStep = 1.f; + constexpr float FieldOfViewArrowStep = 5.f; constexpr float CameraShakeMin = 0.f; constexpr float CameraShakeMax = 100.f; - constexpr float CameraShakeStep = 10.f; + constexpr float CameraShakeStep = 1.f; + constexpr float CameraShakeArrowStep = 10.f; + + /** + * Luminosite en POURCENTAGE et non en gamma. + * + * Le gamma utile va de 0.8 a 1.4 ; affiche tel quel il tiendrait dans un + * intervalle de 0.6 que la ligne, qui n'affiche que des entiers, arrondirait + * a "1" partout. On stocke donc 80 a 140 et on divise a l'application. + */ + constexpr float BrightnessMin = 80.f; + constexpr float BrightnessMax = 140.f; + constexpr float BrightnessStep = 1.f; + constexpr float BrightnessArrowStep = 10.f; + + /** + * Gamma d'affichage a 100 % de luminosite. + * + * C'est la valeur du moteur (BaseEngine.ini), PAS un neutre a 1.0 : + * GEngine->DisplayGamma est un exposant, pas un multiplicateur. Y ecrire 1.0 + * assombrit tout l'ecran -- et comme GEngine est global, l'editeur avec. + */ + constexpr float BaseDisplayGamma = 2.2f; + + /** Echelle de resolution, en pourcentage de la resolution native. */ + constexpr float ResolutionScaleMin = 50.f; + constexpr float ResolutionScaleMax = 100.f; + constexpr float ResolutionScaleStep = 1.f; + constexpr float ResolutionScaleArrowStep = 10.f; /** * Multiplicateurs reels correspondant aux extremites de l'echelle affichee. @@ -169,6 +204,17 @@ public: UFUNCTION(BlueprintCallable, Category = "Reglages|Controles") void SetCrouchToggle(bool bValue); + UFUNCTION(BlueprintPure, Category = "Reglages|Affichage") + float GetBrightness() const { return Brightness; } + + /** Ne fait qu'enregistrer : c'est ApplySurvivalSettings qui pousse le gamma. */ + UFUNCTION(BlueprintCallable, Category = "Reglages|Affichage") + void SetBrightness(float Value); + + /** Remet a l'usine les seuls reglages de l'onglet Affichage qui nous appartiennent. */ + UFUNCTION(BlueprintCallable, Category = "Reglages|Affichage") + void SetSurvivalDisplayToDefaults(); + private: /** Echelle lisible (5 a 100), convertie a la lecture. Jamais brute. */ UPROPERTY(config) @@ -195,4 +241,8 @@ private: UPROPERTY(config) bool bCrouchToggle; + + /** Pourcentage, converti en gamma a l'application. */ + UPROPERTY(config) + float Brightness; }; diff --git a/Source/Survival_projet/Survival_projet.Build.cs b/Source/Survival_projet/Survival_projet.Build.cs index af44b8a..9f03666 100644 --- a/Source/Survival_projet/Survival_projet.Build.cs +++ b/Source/Survival_projet/Survival_projet.Build.cs @@ -8,7 +8,10 @@ public class Survival_projet : ModuleRules { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; - PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "UMG" }); + // GameplayTags : Enhanced Input rend ses erreurs de remappage sous forme + // de FGameplayTagContainer. Sans ce module on peut declarer le type mais + // pas l'inspecter, et on perdrait la raison d'un echec dans le log. + PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GameplayTags", "UMG" }); PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });