// Fill out your copyright notice in the Description page of Project Settings. #include "FpsPlayerController.h" #include "Blueprint/UserWidget.h" #include "ConfirmDialogWidget.h" #include "EnhancedInputComponent.h" #include "FpsPlayer.h" #include "GameFramework/GameModeBase.h" #include "SurvivalStatsComponent.h" #include "HotbarWidget.h" #include "InputActionValue.h" #include "InputCoreTypes.h" #include "InventoryComponent.h" #include "InteractionPromptWidget.h" #include "InventoryScreenWidget.h" #include "Kismet/GameplayStatics.h" #include "PauseMenuWidget.h" #include "ScreenFadeComponent.h" #include "SettingsMenuWidget.h" #include "SurvivalStatsWidget.h" #define LOCTEXT_NAMESPACE "PauseMenu" namespace { // Ordre d'empilement des widgets plein ecran, du fond vers l'avant. // // La barre rapide passe AU-DESSUS de l'inventaire, et pas l'inverse : le // Background Blur de l'inventaire floute tout ce qui est rendu en dessous // de lui. Avec un ZOrder inferieur, la barre se retrouverait floutee et // inutilisable des l'ouverture de l'inventaire. // // Bonus : etant devant, elle reste une cible de depot valide -- on peut // glisser un objet de la grille directement dans la barre. // // Pas de 10 pour laisser de la place aux widgets a venir. constexpr int32 ZOrderCrosshair = 0; constexpr int32 ZOrderSurvivalStats = 5; constexpr int32 ZOrderInteractionPrompt = 10; constexpr int32 ZOrderInventory = 20; constexpr int32 ZOrderHotbar = 30; // Le menu de pause passe devant tout le HUD, et la boite de confirmation // devant lui. Meme echelle que dans le menu principal. // // Les noms doivent rester uniques dans TOUT le module : le build unity // concatene les .cpp, et un namespace anonyme n'isole plus rien. D'ou le // prefixe InGame, qui evite la collision avec le menu principal. constexpr int32 ZOrderPauseMenu = 100; constexpr int32 ZOrderInGameSettingsMenu = 150; constexpr int32 ZOrderQuitToMenuDialog = 200; } AFpsPlayerController::AFpsPlayerController() { // Le fondu est un systeme a part entiere, donc un composant -- et le menu // principal reutilise exactement le meme. // // Rappel Unreal : ce constructeur tourne aussi dans l'editeur, sur le CDO. // Il ne doit contenir que de la construction, jamais de logique de jeu. ScreenFade = CreateDefaultSubobject(TEXT("ScreenFade")); QuitToMenuMessage = LOCTEXT("QuitToMenuConfirm", "Retourner au menu principal ?\nTa progression sera perdue."); } void AFpsPlayerController::BeginPlay() { Super::BeginPlay(); // Le mode d'input vit sur le VIEWPORT, pas sur le controller. OpenLevel // detruit le controller du menu principal mais pas la fenetre : son // GameAndUI et son curseur visible restent en place, et la camera ne // repondrait qu'au clic droit maintenu. On ne suppose donc jamais l'etat // laisse par la map precedente, on le repose. if (IsLocalController()) { SetInputMode(FInputModeGameOnly()); SetShowMouseCursor(false); } // Le delegue se declenche a la fin de CHAQUE fondu : c'est // bQuittingToMainMenu qui distingue le fondu de sortie du reste. ScreenFade->OnFadeFinished.AddDynamic(this, &AFpsPlayerController::HandleFadeFinished); if (bFadeInOnStart) { ScreenFade->FadeIn(StartupFadeDuration); } } void AFpsPlayerController::OnPossess(APawn* InPawn) { Super::OnPossess(InPawn); // On cree les widgets ici et pas dans BeginPlay : a ce stade le pawn est // garanti possede, et le prompt comme l'inventaire ont besoin de lui pour // trouver leurs composants des leur NativeConstruct. CreateHudWidgets(); // Apres un respawn les widgets existent deja, mais pointent vers les // composants du cadavre. On les rebranche sur le nouveau pawn. if (Crosshair) { /* pas d'abonnement */ } if (InteractionPrompt) { InteractionPrompt->BindToOwningPawn(); } if (Hotbar) { Hotbar->BindToOwningPawn(); } if (InventoryScreen) { InventoryScreen->BindToOwningPawn(); } if (SurvivalStatsWidget) { SurvivalStatsWidget->BindToOwningPawn(); } if (AFpsPlayer* PlayerPawn = Cast(InPawn)) { if (USurvivalStatsComponent* Stats = PlayerPawn->GetSurvivalStats()) { Stats->OnDied.AddDynamic(this, &AFpsPlayerController::HandlePawnDied); } } } void AFpsPlayerController::HandlePawnDied() { // L'inventaire ouvert au moment de la mort resterait affiche par-dessus // l'ecran noir, curseur compris. if (bInventoryOpen) { ToggleInventory(); } ScreenFade->FadeOut(DeathFadeDuration); GetWorldTimerManager().SetTimer( RespawnTimerHandle, this, &AFpsPlayerController::RespawnPlayer, FMath::Max(RespawnDelay, DeathFadeDuration), /*bLoop=*/false); } void AFpsPlayerController::RespawnPlayer() { // On depossede AVANT de detruire : detruire un pawn encore possede laisse // le controller dans un etat incoherent le temps d'une frame. APawn* PreviousPawn = GetPawn(); UnPossess(); if (IsValid(PreviousPawn)) { PreviousPawn->Destroy(); } // RestartPlayer fait apparaitre un nouveau pawn sur un PlayerStart et le // possede. Le nouveau USurvivalStatsComponent repart plein tout seul dans // son BeginPlay : rien a reinitialiser a la main. if (AGameModeBase* GameMode = GetWorld()->GetAuthGameMode()) { GameMode->RestartPlayer(this); } else { UE_LOG(LogTemp, Error, TEXT("AFpsPlayerController : aucun GameMode, impossible de reapparaitre.")); } ScreenFade->FadeIn(StartupFadeDuration); } void AFpsPlayerController::SetupInputComponent() { Super::SetupInputComponent(); UEnhancedInputComponent* Input = Cast(InputComponent); if (!Input) { return; } if (ToggleInventoryAction) { Input->BindAction(ToggleInventoryAction, ETriggerEvent::Started, this, &AFpsPlayerController::ToggleInventory); } if (ToggleCraftingAction) { Input->BindAction(ToggleCraftingAction, ETriggerEvent::Started, this, &AFpsPlayerController::ToggleCrafting); } if (DropItemAction) { Input->BindAction(DropItemAction, ETriggerEvent::Started, this, &AFpsPlayerController::DropHoveredItem); } // La barre rapide est bindee sur le controller et non sur le pawn : ainsi // changer d'objet actif reste possible meme quand l'inventaire est ouvert // et que les inputs du pawn sont coupes. if (HotbarSelectAction) { Input->BindAction(HotbarSelectAction, ETriggerEvent::Started, this, &AFpsPlayerController::HandleHotbarSelect); } if (HotbarScrollAction) { Input->BindAction(HotbarScrollAction, ETriggerEvent::Triggered, this, &AFpsPlayerController::HandleHotbarScroll); } if (PauseAction) { Input->BindAction(PauseAction, ETriggerEvent::Started, this, &AFpsPlayerController::HandlePauseInput); } else { UE_LOG(LogTemp, Warning, TEXT("AFpsPlayerController : PauseAction n'est pas assignee, le menu de pause sera inaccessible.")); } } UInventoryComponent* AFpsPlayerController::GetPawnInventory() const { AFpsPlayer* PlayerPawn = Cast(GetPawn()); return PlayerPawn ? PlayerPawn->GetInventoryComponent() : nullptr; } void AFpsPlayerController::HandleHotbarSelect(const FInputActionValue& Value) { UInventoryComponent* Inventory = GetPawnInventory(); if (!Inventory) { return; } // La valeur vient du modificateur Scalar de la touche : 1 pour la touche 1, // 2 pour la touche 2, etc. On repasse en index a partir de zero. const int32 Number = FMath::RoundToInt(Value.Get()); Inventory->SelectHotbarSlot(Number - 1); } void AFpsPlayerController::HandleHotbarScroll(const FInputActionValue& Value) { const float Axis = Value.Get(); if (FMath::IsNearlyZero(Axis)) { return; } if (UInventoryComponent* Inventory = GetPawnInventory()) { // Molette vers le haut = slot precedent, comme dans la plupart des FPS. Inventory->CycleHotbarSelection(Axis > 0.f ? -1 : 1); } } void AFpsPlayerController::HandleInventoryTabChanged(bool bInventoryTabActive) { bCraftingTabActive = !bInventoryTabActive; UpdateHotbarVisibility(); } void AFpsPlayerController::UpdateHotbarVisibility() { if (!Hotbar) { return; } // La barre rapide reste affichee sur l'onglet Inventaire : elle en fait // partie, on y glisse des objets. Elle n'a en revanche aucun sens sous la // page de craft, ou elle ne ferait qu'encombrer. // Surtout pas "bHidden" : AActor en a deja un, le masquer donne une erreur // de compilation (C4458) plutot qu'un simple avertissement. const bool bHideHotbar = bInventoryOpen && bCraftingTabActive; Hotbar->SetVisibility(bHideHotbar ? ESlateVisibility::Collapsed : HotbarDefaultVisibility); } void AFpsPlayerController::DropHoveredItem() { if (!bInventoryOpen || !InventoryScreen) { return; } // Renvoie INDEX_NONE quand l'onglet Fabrication est affiche : la touche ne // doit pas agir sur une case cachee derriere la grille de craft. const int32 SlotIndex = InventoryScreen->GetHoveredSlotIndex(); if (SlotIndex == INDEX_NONE) { return; } AFpsPlayer* PlayerPawn = Cast(GetPawn()); if (!PlayerPawn) { return; } // Ctrl enfonce : un seul exemplaire, comme pour le glisser. On lit l'etat // de la touche plutot que d'en faire une action separee : le geste est // modal, pas une commande distincte. const bool bSingle = IsInputKeyDown(EKeys::LeftControl) || IsInputKeyDown(EKeys::RightControl); // MAX_int32 pour "tout" : DropSlot borne de lui-meme a ce que contient le slot. PlayerPawn->DropSlot(SlotIndex, bSingle ? 1 : MAX_int32); } void AFpsPlayerController::CreateHudWidgets() { // Un widget n'existe que sur la machine du joueur local : inutile et // incorrect d'en creer un sur un serveur dedie. if (!IsLocalController()) { return; } if (!Crosshair && CrosshairClass) { Crosshair = CreateWidget(this, CrosshairClass); if (Crosshair) { Crosshair->AddToViewport(ZOrderCrosshair); } } if (!InteractionPrompt) { if (InteractionPromptClass) { InteractionPrompt = CreateWidget(this, InteractionPromptClass); if (InteractionPrompt) { InteractionPrompt->AddToViewport(ZOrderInteractionPrompt); } } else { UE_LOG(LogTemp, Warning, TEXT("AFpsPlayerController : InteractionPromptClass n'est pas assignee, aucun prompt ne s'affichera.")); } } // Le voile de fondu n'est plus cree ici : UScreenFadeComponent s'en charge // dans son propre BeginPlay, et son ZOrder de 1000 le garde devant tout le // HUD quel que soit l'ordre d'ajout. if (!SurvivalStatsWidget && SurvivalStatsClass) { SurvivalStatsWidget = CreateWidget(this, SurvivalStatsClass); if (SurvivalStatsWidget) { SurvivalStatsWidget->AddToViewport(ZOrderSurvivalStats); } } if (!Hotbar && HotbarClass) { Hotbar = CreateWidget(this, HotbarClass); if (Hotbar) { // Relevee avant tout masquage : c'est la valeur a laquelle on // reviendra en quittant l'onglet Fabrication. HotbarDefaultVisibility = Hotbar->GetVisibility(); Hotbar->AddToViewport(ZOrderHotbar); } } if (!InventoryScreen) { if (InventoryScreenClass) { InventoryScreen = CreateWidget(this, InventoryScreenClass); if (InventoryScreen) { // Abonnement AVANT l'ajout au viewport : c'est lui qui declenche // NativeConstruct, donc la premiere diffusion d'onglet. S'abonner // apres, on la manquerait. InventoryScreen->OnTabChanged.AddDynamic(this, &AFpsPlayerController::HandleInventoryTabChanged); InventoryScreen->AddToViewport(ZOrderInventory); // Cree des le depart pour que les abonnements de ses deux pages // soient actifs, mais cache : ainsi l'ecran reste a jour meme // ferme, et l'ouverture est instantanee. InventoryScreen->SetVisibility(ESlateVisibility::Collapsed); } } else { UE_LOG(LogTemp, Warning, TEXT("AFpsPlayerController : InventoryScreenClass n'est pas assignee, l'inventaire ne s'ouvrira pas.")); } } if (!PauseMenu) { if (PauseMenuClass) { PauseMenu = CreateWidget(this, PauseMenuClass); if (PauseMenu) { PauseMenu->AddToViewport(ZOrderPauseMenu); // Cree une fois pour toutes et simplement cache, comme // l'inventaire : le menu de pause survit ainsi aux respawns. PauseMenu->SetVisibility(ESlateVisibility::Collapsed); } } else { UE_LOG(LogTemp, Warning, TEXT("AFpsPlayerController : PauseMenuClass n'est pas assignee, le menu de pause ne s'ouvrira pas.")); } } } void AFpsPlayerController::ToggleInventory() { // Tab veut dire "ouvre mon sac". Rouvrir sur l'onglet Fabrication parce // qu'on l'avait laisse la serait une surprise a chaque fois. SetInventoryScreenOpen(!bInventoryOpen, /*bShowCraftingTab=*/false); } void AFpsPlayerController::ToggleCrafting() { // Deja sur l'onglet Fabrication : la meme touche referme, comme Tab le fait // pour l'inventaire. Depuis l'onglet Inventaire, elle bascule sans fermer -- // c'est le geste attendu quand on cherche quoi construire avec ce qu'on vient // de ramasser. if (bInventoryOpen && bCraftingTabActive) { SetInventoryScreenOpen(false, /*bShowCraftingTab=*/false); return; } SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true); } void AFpsPlayerController::SetInventoryScreenOpen(bool bOpen, bool bShowCraftingTab) { if (!InventoryScreen) { return; } // Ecran deja ouvert : on ne fait que changer d'onglet. Repasser par le mode // d'input serait au mieux inutile, au pire nuisible -- SetIgnoreLookInput // gere un compteur qu'un appel en trop desequilibrerait durablement. if (bOpen && bInventoryOpen) { if (bShowCraftingTab) { InventoryScreen->ShowCraftingTab(); } else { InventoryScreen->ShowInventoryTab(); } return; } if (bOpen == bInventoryOpen) { return; } bInventoryOpen = bOpen; if (bInventoryOpen) { if (bShowCraftingTab) { InventoryScreen->ShowCraftingTab(); } else { InventoryScreen->ShowInventoryTab(); } } InventoryScreen->SetVisibility(bInventoryOpen ? ESlateVisibility::Visible : ESlateVisibility::Collapsed); // Fermer l'ecran doit toujours rendre la barre, meme si on l'avait laissee // sur l'onglet Fabrication. UpdateHotbarVisibility(); AFpsPlayer* PlayerPawn = Cast(GetPawn()); if (bInventoryOpen) { // GameAndUI et non UIOnly : en UIOnly, plus aucune action Enhanced // Input ne passerait et on ne pourrait plus refermer avec Tab. FInputModeGameAndUI Mode; Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); Mode.SetHideCursorDuringCapture(false); SetInputMode(Mode); SetShowMouseCursor(true); // Le mode GameAndUI laisse passer le jeu : sans ces deux appels, la // souris ferait tourner la camera pendant qu'on navigue dans la grille. SetIgnoreLookInput(true); SetIgnoreMoveInput(true); // Les deux appels ci-dessus ne couvrent que le deplacement et le regard. // Accroupi, sprint, saut et interaction resteraient actifs -- appuyer // sur Ctrl pour glisser un objet ferait s'accroupir le personnage. // DisableInput retire l'input component du PAWN de la pile ; celui du // controller reste, donc Tab continue de repondre. if (PlayerPawn) { PlayerPawn->ClearTransientInputStates(); PlayerPawn->DisableInput(this); } } else { SetInputMode(FInputModeGameOnly()); SetShowMouseCursor(false); // Reset et non SetIgnoreXxx(false) : ces fonctions gerent un compteur, // et un desequilibre finirait par bloquer definitivement les inputs. ResetIgnoreLookInput(); ResetIgnoreMoveInput(); if (PlayerPawn) { PlayerPawn->EnableInput(this); } } } void AFpsPlayerController::HandlePauseInput() { // La boite a le focus et consomme deja Echap elle-meme. Si elle l'a perdu, // on l'ignore quand meme : repondre ici refermerait le menu en laissant la // question posee. if (bQuitToMenuDialogOpen) { return; } // L'ecran de reglages consomme Echap lui-meme tant qu'il a le focus. Comme // pour la boite, on ne repond ici que s'il l'a perdu. if (bSettingsOpen) { HandleSettingsCloseRequested(); return; } // Echap sert d'abord a annuler ce qui est ouvert. Fermer l'inventaire est // ce que le joueur attend en premier. if (bInventoryOpen) { ToggleInventory(); return; } if (bPauseMenuOpen) { ResumeGame(); return; } OpenPauseMenu(); } void AFpsPlayerController::OpenPauseMenu() { if (!PauseMenu || bPauseMenuOpen) { return; } // Mettre en pause pendant l'ecran de mort figerait le timer de respawn : le // joueur resterait devant un ecran noir dont plus rien ne le sortirait. if (GetWorldTimerManager().IsTimerActive(RespawnTimerHandle)) { return; } bPauseMenuOpen = true; PauseMenu->SetVisibility(ESlateVisibility::Visible); // GameAndUI et non UIOnly, meme raison que l'inventaire : en UIOnly plus // aucune action Enhanced Input ne passe, et Echap ne refermerait rien. FInputModeGameAndUI Mode; Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); Mode.SetHideCursorDuringCapture(false); SetInputMode(Mode); SetShowMouseCursor(true); // Pas de DisableInput sur le pawn ici, contrairement a l'inventaire : le // monde est reellement fige, et les actions du pawn n'ont pas // "Trigger When Paused" -- elles ne se declenchent donc plus d'elles-memes. SetPause(true); } void AFpsPlayerController::ResumeGame() { if (!bPauseMenuOpen) { return; } // Reprendre alors qu'une boite ou les reglages sont encore ouverts les // laisserait affiches par-dessus la partie relancee. if (bQuitToMenuDialogOpen) { HandleQuitToMenuCancelled(); } if (bSettingsOpen) { HandleSettingsCloseRequested(); } bPauseMenuOpen = false; if (PauseMenu) { PauseMenu->SetVisibility(ESlateVisibility::Collapsed); } SetPause(false); SetInputMode(FInputModeGameOnly()); SetShowMouseCursor(false); } void AFpsPlayerController::OpenSettings() { if (bSettingsOpen) { return; } if (!SettingsMenuClass) { UE_LOG(LogTemp, Warning, TEXT("AFpsPlayerController : SettingsMenuClass n'est pas assignee, les reglages ne s'ouvriront pas.")); return; } // Creee a la premiere ouverture seulement : un ecran de reglages qu'on ne // visite peut-etre jamais n'a pas a peser sur chaque demarrage de partie. if (!SettingsMenu) { SettingsMenu = CreateWidget(this, SettingsMenuClass); if (!SettingsMenu) { return; } 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; SettingsMenu->Open(); // Le menu de pause reste lisible derriere mais cesse de repondre : sans ca // on pourrait cliquer Reprendre a travers l'ecran de reglages. if (PauseMenu) { PauseMenu->SetVisibility(ESlateVisibility::HitTestInvisible); } } void AFpsPlayerController::HandleSettingsCloseRequested() { bSettingsOpen = false; if (SettingsMenu) { SettingsMenu->Close(); } if (PauseMenu) { PauseMenu->SetVisibility(ESlateVisibility::Visible); } } void AFpsPlayerController::RequestQuitToMainMenu() { // Le fondu de sortie est deja lance : on ignore le second clic plutot que // d'empiler deux chargements. if (bQuittingToMainMenu) { return; } if (MainMenuLevel.IsNull()) { UE_LOG(LogTemp, Error, TEXT("AFpsPlayerController : MainMenuLevel n'est pas assignee, impossible de retourner au menu.")); return; } // Sans boite assignee on ne bloque pas le joueur dans sa partie : on part. if (!QuitToMenuDialogClass) { UE_LOG(LogTemp, Warning, TEXT("AFpsPlayerController : QuitToMenuDialogClass n'est pas assignee, retour au menu sans confirmation.")); HandleQuitToMenuConfirmed(); return; } // Creee au premier clic seulement, puis reutilisee. if (!QuitToMenuDialog) { QuitToMenuDialog = CreateWidget(this, QuitToMenuDialogClass); if (!QuitToMenuDialog) { return; } QuitToMenuDialog->OnConfirmed.AddDynamic(this, &AFpsPlayerController::HandleQuitToMenuConfirmed); QuitToMenuDialog->OnCancelled.AddDynamic(this, &AFpsPlayerController::HandleQuitToMenuCancelled); QuitToMenuDialog->AddToViewport(ZOrderQuitToMenuDialog); } bQuitToMenuDialogOpen = true; QuitToMenuDialog->Open(QuitToMenuMessage); // Le menu reste lisible derriere mais cesse de repondre : sans ca, on // pourrait cliquer Reprendre alors que la question est encore posee. if (PauseMenu) { PauseMenu->SetVisibility(ESlateVisibility::HitTestInvisible); } } void AFpsPlayerController::HandleQuitToMenuConfirmed() { bQuitToMenuDialogOpen = false; bQuittingToMainMenu = true; if (QuitToMenuDialog) { QuitToMenuDialog->Close(); } if (PauseMenu) { PauseMenu->SetVisibility(ESlateVisibility::Collapsed); } // On reste en pause pendant le fondu : relancer le monde une seconde pour // l'abandonner ensuite laisserait le temps a la faim ou a une chute de tuer // un joueur qui a deja quitte. C'est UScreenFadeComponent qui continue de // ticker en pause, pas le monde. ScreenFade->FadeOut(QuitToMenuFadeDuration); } void AFpsPlayerController::HandleQuitToMenuCancelled() { bQuitToMenuDialogOpen = false; if (QuitToMenuDialog) { QuitToMenuDialog->Close(); } if (PauseMenu) { PauseMenu->SetVisibility(ESlateVisibility::Visible); } } void AFpsPlayerController::HandleFadeFinished() { if (!bQuittingToMainMenu) { return; } // La pause vit sur les WorldSettings, que OpenLevel detruit avec le monde. // On la leve quand meme : un monde qu'on quitte en etat de pause est un // piege si le chargement echoue. SetPause(false); UGameplayStatics::OpenLevelBySoftObjectPtr(this, MainMenuLevel); } void AFpsPlayerController::EndPlay(const EEndPlayReason::Type EndPlayReason) { if (SettingsMenu) { SettingsMenu->RemoveFromParent(); SettingsMenu = nullptr; } if (QuitToMenuDialog) { QuitToMenuDialog->RemoveFromParent(); QuitToMenuDialog = nullptr; } if (PauseMenu) { PauseMenu->RemoveFromParent(); PauseMenu = nullptr; } if (InventoryScreen) { InventoryScreen->RemoveFromParent(); InventoryScreen = nullptr; } if (InteractionPrompt) { InteractionPrompt->RemoveFromParent(); InteractionPrompt = nullptr; } if (Hotbar) { Hotbar->RemoveFromParent(); Hotbar = nullptr; } if (SurvivalStatsWidget) { SurvivalStatsWidget->RemoveFromParent(); SurvivalStatsWidget = nullptr; } if (Crosshair) { Crosshair->RemoveFromParent(); Crosshair = nullptr; } // Le voile de fondu est nettoye par UScreenFadeComponent::EndPlay. Super::EndPlay(EndPlayReason); } #undef LOCTEXT_NAMESPACE