diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 93a7c08..f26723e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -129,6 +129,7 @@ qu'une compilation propre l'est vraiment. | Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` | | Fondus, boîtes de dialogue | `ScreenFadeComponent`, `ConfirmDialogWidget` | | Coop Steam (sessions) | `SessionSubsystem`, `SurvivalGameMode`, `SurvivalGameState`, `SurvivalPlayerState` | +| Pseudo au-dessus des joueurs | `PlayerNameplateComponent`, `PlayerNameplateWidget` | ### Coop Steam — décisions figées @@ -242,6 +243,99 @@ exactement le cas d'`APickupItem::OnRep_ItemData`. ne fait de Tick : il suffit qu'`OnRep_Slots()` rediffuse ce délégué. C'est le dividende de la règle « les widgets s'abonnent à des délégués », et il faut la tenir pour les systèmes à venir. +**Un client arrive en SPECTATEUR, et la caméra y reste un instant.** C'est la cause du « on voit +sous la carte » à la connexion, et elle est contre-intuitive : le pawn est bien créé **et bien +placé**, mais `GetViewTarget()` vaut encore `SpectatorPawn_0` — né au centre du monde, donc sous +le terrain. Mesuré sur une vraie connexion : + +``` +FadeIn démarre ViewTarget=SpectatorPawn_0 PawnLoc=(11319, -16530, 1805) +AcknowledgePossession Pawn=BP_FpsPlayer_C_0 ViewTarget=SpectatorPawn_0 +``` + +La caméra bascule sur le corps **après** l'acquittement de la possession. La bonne condition pour +éclaircir n'est donc pas « ai-je un pawn » mais **`GetViewTarget() == GetPawn()`** — d'où la +boucle de réessai de `TryStartupFadeIn()`, avec `CompleteStartupFadeIn()` comme filet au bout de +`StartupFadeTimeout` (un écran noir définitif se lit comme un plantage). + +**Et rien ne doit être rendu avant.** Entre le chargement de la map et ce moment-là, aucun acteur +n'existe à qui accrocher un voile — le composant de fondu vit justement sur le contrôleur absent. +`USurvivalGameInstance` coupe donc `bDisableWorldRendering` sur le viewport, seul objet qui survive +au changement de map sans appartenir à personne, et c'est le contrôleur qui le rend une fois son +voile noir en place. + +**`PreLoadMap` et surtout PAS `PostLoadMapWithWorld`** pour cette coupure. Le second est diffusé +**après** le `BeginPlay` des acteurs : sur une map chargée localement — le menu, ou la partie chez +l'hôte — le contrôleur rendait la main au rendu, et on la lui reprenait juste après. Écran noir +définitif jusqu'au filet de sécurité. Chez un client ça passait *par accident*, son contrôleur +arrivant plus tard — donc invisible si l'on ne teste que ce cas-là. + +*Note : `ReceivedPlayer()` arrive AVANT `BeginPlay()` chez un client, et `IsLocalController()` y est +déjà vrai — ce n'est donc pas là qu'est le problème, contrairement à ce qu'on pourrait croire.* + +**`IsLocalController()` est indispensable sur tout ce qui touche à l'écran.** Sur la machine de +l'hôte, chaque joueur distant a AUSSI un `AFpsPlayerController` côté serveur. Sans cette garde, le +contrôleur d'un ami qui se connecte va poser un fondu et rallumer le rendu **du viewport de +l'hôte**, à un instant totalement arbitraire. + +### Étiquettes de pseudo + +- **Le serveur ne peut PAS deviner le pseudo Steam d'un client** : l'API d'identité ne connaît que + l'utilisateur local de chaque machine. C'est donc le client qui l'annonce + (`Server_SetPlayerNickname`), et le serveur l'écrit sur le `PlayerState`, déjà répliqué par le + moteur. Sans ça on lit « Player » au-dessus des quatre joueurs — c'est le nom par défaut que le + moteur met dans l'URL de voyage. +- **Espace ÉCRAN, pas Monde** : l'étiquette fait face à la caméra *par construction*, sans calcul + d'orientation, et n'est jamais occultée par le décor. Mais `SWorldWidgetScreenLayer::Tick` + n'applique **aucune échelle de distance** — le pseudo garderait une taille en pixels constante + pendant que le personnage rétrécit, ce que l'œil lit comme une étiquette qui *grossit* en + s'éloignant. D'où l'échelle calculée à la main en 1/d, et l'opacité en fondu sur la dernière + bande avant `MaxDrawDistance`. +- Le composant ne réplique **rien** : le pseudo vit déjà sur le `PlayerState`. + +### Le pop-in au chargement — deux causes, deux remèdes + +**Les requêtes** — textures et LOD de meshes. `IStreamingManager::StreamAllResources()` les traite +toutes (`UpdateResourceStreaming(0, true)` n'a pas besoin de point de vue, ce qui tombe bien : le +rendu est encore coupé) puis bloque jusqu'à ce qu'elles aboutissent. Appelé dans +`ResumeWorldRendering()`, donc **écran noir** : c'est du temps de chargement, pas une saccade. +Mesuré : ~80 ms, 0 requête restante. + +**Ce qui converge par IMAGE** — Nanite, ombres virtuelles, Lumen. Aucun flush ne peut les forcer : +il leur faut des images rendues. D'où `BeginSceneWarmup()`, qui laisse le monde rendre derrière le +voile encore noir avant de l'éclaircir. + +**Il compte des IMAGES, pas des secondes**, et c'est tout l'intérêt : une durée fixe donnerait +quarante images sur une machine rapide et cinq sur une machine lente — c'est-à-dire le moins de +chauffe à celle qui en a le plus besoin. `SetTimerForNextTick` = un tick, une image. +`SceneWarmupMaxSeconds` n'est qu'un **plafond**, pas la mesure ; le log dit « plafond atteint » +quand il s'applique, pour savoir lequel des deux réglages ajuster. + +Ordre non négociable dans le menu : `SetViewTarget` **puis** `ResumeWorldRendering()`. L'inverse +ferait chauffer le décor vu depuis l'angle par défaut, c'est-à-dire pas celui qu'on va montrer. + +**`OnPossess` n'existe QUE sur le serveur.** Chez un client, le pawn arrive par réplication et +`OnPossess` n'est jamais appelée — son pendant est **`AcknowledgePossession`**. Tout ce qui doit +se produire à la prise de contrôle (création du HUD, rebranchement des widgets, abonnement à +`OnDied`) passe donc par `SetupForPossessedPawn()`, appelée des deux côtés et **idempotente** : +chez l'hôte, qui est serveur *et* client local, les deux chemins l'exécutent. D'où +`AddUniqueDynamic` sur `OnDied`, sinon la mort déclencherait deux fondus et deux respawns. +Symptôme quand on l'oublie : un joueur qui rejoint n'a aucune interface, alors que l'hôte a la +sienne — donc invisible en solo comme en test chez soi. + +**Une connexion qui tombe ne ramène personne nulle part.** Le moteur coupe le lien et s'arrête là. +`USurvivalGameInstance` s'abonne donc à `GEngine->OnNetworkFailure()` et `OnTravelFailure()` pour +détruire la session et rouvrir le menu. C'est sur le GameInstance et pas sur le PlayerController +parce que celui-ci est justement détruit par l'échec. + +**Quand l'hôte quitte, tout le monde quitte — et on le dit explicitement.** Le moteur ne prévient +personne : sans RPC, les clients restent dans un monde mort, avec le **pawn fantôme de l'hôte** +encore planté devant eux, jusqu'à un timeout qui peut ne jamais venir. `HandleQuitToMenuConfirmed` +appelle donc `KickRemoteClientsToMainMenu()` **avant** de lancer son propre fondu, pour que les +quatre fondus tournent ensemble. Le filet `OnNetworkFailure` reste pour ce qui n'est pas annoncé — +hôte qui plante, câble arraché — et se tait pendant un départ voulu grâce à +`BeginReturnToMainMenu()`, sinon il chargerait le menu en plein milieu du fondu. + | Système | État | |---|---| | Déplacement, crouch | ✅ gratuit via `ACharacter` / `CharacterMovement` | @@ -463,26 +557,26 @@ Le détail du système de craft — API, câblage éditeur, dépannage — est d ## État actuel et suite -Boucle complète : récolter → **fabriquer** → **cuire** → ranger → consommer → survivre → -mourir → réapparaître. Autour : menu principal, menu de pause, et un écran de réglages dont **seul -l'onglet « Jeu & Contrôles » est rempli**. +Boucle complète **en coop jusqu'à 4 joueurs** : récolter → fabriquer → cuire → ranger → +consommer → survivre → mourir → réapparaître. Autour : menu principal, menu de pause, écran de +réglages, étiquettes de pseudo Steam. -Le craft est en place de bout en bout : `DA_RecipeBook`, onglets Inventaire / Fabrication, -grille de recettes filtrée par poste accessible, panneau de détail avec compteurs colorés. -Restent à faire quand le besoin viendra : l'**acteur établi** qui appellera -`AddAvailableStation(Workbench)`, les **boutons de filtre** par poste (`SetStationFilter` existe -déjà et `ECraftingStation::Count` sert de sentinelle « tout »), et les vraies recettes — celles -en place sont deux `DA_Craft_Test_*` bâties sur `DA_Branch` et `DA_Rocher`. +Le portage réseau est terminé pour tout le gameplay existant — voir *Réplication* plus haut pour +le détail et les pièges. Les transitions de map sont propres de bout en bout : plus de flash, plus +de pop-in, départ groupé quand l'hôte quitte. -Suite immédiate sur les réglages, dans l'ordre : onglet **Affichage** (le moteur fait tout, -prévoir la confirmation à rebours de 10 s sur la résolution), onglet **Graphismes** (les dix -curseurs de `UGameUserSettings` ; végétation et distance d'affichage comptent double avec -SoStylized), onglet **Audio** quand des `SoundClass` existeront, et enfin le **remappage des -touches** via `UEnhancedInputUserSettings` — le seul morceau réellement coûteux. +Les vraies recettes restent à écrire : celles en place sont deux `DA_Craft_Test_*` bâties sur +`DA_Branch` et `DA_Rocher`. Les **boutons de filtre** par poste ne sont pas faits non plus +(`SetStationFilter` existe déjà, `ECraftingStation::Count` sert de sentinelle « tout »). Prochaine étape gameplay recommandée : **spawner de ressources avec repousse** (`AResourceSpawner`, liste pondérée de DataAssets, rayon, densité, délai). Sans lui la carte se -vide en dix minutes. +vide en dix minutes. À écrire **serveur-autoritaire d'emblée** — c'est un acteur du monde. -Ensuite, par ordre de valeur : objet visible en main, établi, coffres, sauvegarde, -cycle jour/nuit. +Ensuite, par ordre de valeur : objet visible en main, sauvegarde, cycle jour/nuit. + +> **Systèmes présents dans `Source/` mais pas encore décrits ici** : `MusicDirector`, +> `MusicSubsystem`, `MusicPlaylistDataAsset`, `SettingsDisplayWidget`, `SettingsGraphicsWidget`, +> `SettingsControlsWidget`, `SettingKeyRowWidget`, `KeyCaptureWidget`. Ils sont arrivés en dehors +> des sessions qui ont produit ce document — à documenter quand on y touchera, plutôt que de +> décrire de mémoire un code qu'on n'a pas lu. diff --git a/Source/Survival_projet/Private/FpsPlayerController.cpp b/Source/Survival_projet/Private/FpsPlayerController.cpp index 4cc6381..de8c0fb 100644 --- a/Source/Survival_projet/Private/FpsPlayerController.cpp +++ b/Source/Survival_projet/Private/FpsPlayerController.cpp @@ -8,6 +8,7 @@ #include "EnhancedInputComponent.h" #include "FpsPlayer.h" #include "GameFramework/GameModeBase.h" +#include "SurvivalGameInstance.h" #include "SurvivalStatsComponent.h" #include "HotbarWidget.h" #include "InputActionValue.h" @@ -83,6 +84,7 @@ 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 @@ -115,16 +117,135 @@ void AFpsPlayerController::BeginPlay() // bQuittingToMainMenu qui distingue le fondu de sortie du reste. ScreenFade->OnFadeFinished.AddDynamic(this, &AFpsPlayerController::HandleFadeFinished); - if (bFadeInOnStart) + // Une invitation peut tomber en pleine partie : sans ce fondu, l'ecran + // sauterait directement dans le monde de l'autre hote. + if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr) { - ScreenFade->FadeIn(StartupFadeDuration); + Sessions->OnTravelPending.AddDynamic(this, &AFpsPlayerController::HandleSessionTravelPending); } + + // IsLocalController est INDISPENSABLE ici : sur la machine de l'hote, chaque + // joueur distant a AUSSI un controller cote serveur. Sans cette garde, le + // controller d'un ami qui se connecte irait poser un fondu et rallumer le + // rendu du viewport de l'HOTE, a un moment totalement arbitraire. + if (bFadeInOnStart && IsLocalController()) + { + // On NOIRCIT et on attend que la camera soit sur notre corps, au lieu + // d'eclaircir tout de suite. Un client demarre sur un SpectatorPawn ne + // au centre du monde -- sous le terrain -- et y reste encore un instant + // apres l'acquittement de la possession. C'est TryStartupFadeIn qui + // decidera du bon moment. + ScreenFade->SnapToBlack(); + bStartupFadePending = true; + + // Filet : si la possession n'arrivait jamais, l'ecran resterait noir a + // vie et le joueur croirait le jeu plante. + // Le filet appelle CompleteStartupFadeIn et non TryStartupFadeIn : sans + // ca il retomberait dans la boucle de reessai et l'ecran resterait noir + // pour toujours. + GetWorldTimerManager().SetTimer( + StartupFadeTimeoutHandle, this, &AFpsPlayerController::CompleteStartupFadeIn, + FMath::Max(StartupFadeTimeout, 1.f), /*bLoop=*/false); + + // L'hote possede deja son pawn ET sa camera est dessus : il n'attendra + // rien. Le test reel est dans TryStartupFadeIn. + TryStartupFadeIn(); + } + else if (IsLocalController()) + { + // Mode sans fondu : personne ne rallumera le rendu, il faut le faire ici + // sous peine d'ecran noir jusqu'au filet de securite. + if (USurvivalGameInstance* SurvivalGameInstance = GetGameInstance()) + { + SurvivalGameInstance->ResumeWorldRendering(); + } + } +} + +void AFpsPlayerController::TryStartupFadeIn() +{ + if (!bStartupFadePending) + { + return; + } + + // Un controller distant vu depuis le serveur n'a pas d'ecran a eclaircir, et + // surtout pas celui de l'hote. + if (!IsLocalController()) + { + return; + } + + // La camera doit deja regarder par NOS yeux. Un client demarre sur un + // SpectatorPawn ne au centre du monde -- sous le terrain -- et y reste + // encore un instant apres l'acquittement de la possession. Eclaircir a cet + // instant montre exactement ca. + const APawn* OwnPawn = GetPawn(); + if (!OwnPawn || GetViewTarget() != OwnPawn) + { + GetWorldTimerManager().SetTimer( + StartupFadeRetryHandle, this, &AFpsPlayerController::TryStartupFadeIn, + StartupFadeRetry, /*bLoop=*/false); + return; + } + + CompleteStartupFadeIn(); +} + +void AFpsPlayerController::CompleteStartupFadeIn() +{ + if (!bStartupFadePending) + { + return; + } + + bStartupFadePending = false; + GetWorldTimerManager().ClearTimer(StartupFadeTimeoutHandle); + GetWorldTimerManager().ClearTimer(StartupFadeRetryHandle); + + // Le rendu du monde est suspendu depuis le chargement de la map : on le + // rend AVANT le fondu, alors que le voile est encore parfaitement noir. + // L'ordre inverse laisserait passer les images qu'on cherche a cacher. + USurvivalGameInstance* SurvivalGameInstance = GetGameInstance(); + if (!SurvivalGameInstance) + { + StartStartupFade(); + return; + } + + SurvivalGameInstance->ResumeWorldRendering(); + + // On laisse le monde rendre quelques images DERRIERE le voile noir avant de + // l'eclaircir : c'est ce qui donne a Nanite, aux ombres virtuelles et a + // Lumen le temps de converger sans que personne le voie. + SurvivalGameInstance->BeginSceneWarmup( + FSimpleDelegate::CreateUObject(this, &AFpsPlayerController::StartStartupFade)); +} + +void AFpsPlayerController::StartStartupFade() +{ + ScreenFade->FadeIn(StartupFadeDuration); } void AFpsPlayerController::OnPossess(APawn* InPawn) { Super::OnPossess(InPawn); + SetupForPossessedPawn(InPawn); +} + +void AFpsPlayerController::AcknowledgePossession(APawn* P) +{ + Super::AcknowledgePossession(P); + + + // Le chemin CLIENT. Sans lui, un joueur qui rejoint n'a aucun HUD : le + // serveur a bien appele OnPossess, mais chez lui, pas chez le client. + SetupForPossessedPawn(P); +} + +void AFpsPlayerController::SetupForPossessedPawn(APawn* 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. @@ -132,7 +253,6 @@ void AFpsPlayerController::OnPossess(APawn* InPawn) // 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(); } @@ -142,9 +262,16 @@ void AFpsPlayerController::OnPossess(APawn* InPawn) { if (USurvivalStatsComponent* Stats = PlayerPawn->GetSurvivalStats()) { - Stats->OnDied.AddDynamic(this, &AFpsPlayerController::HandlePawnDied); + // AddUnique et non Add : chez l'hote, OnPossess ET + // AcknowledgePossession passent tous les deux ici. Un double + // abonnement declencherait deux fondus de mort et deux respawns. + Stats->OnDied.AddUniqueDynamic(this, &AFpsPlayerController::HandlePawnDied); } } + + // Le corps est la : on peut enfin montrer le monde. Sans effet si le fondu + // d'arrivee a deja eu lieu, ce qui est le cas a chaque respawn. + TryStartupFadeIn(); } void AFpsPlayerController::HandlePawnDied() @@ -1091,8 +1218,88 @@ void AFpsPlayerController::RequestQuitToMainMenu() void AFpsPlayerController::HandleQuitToMenuConfirmed() { bQuitToMenuDialogOpen = false; + + // L'hote emmene tout le monde avec lui : sa partie EST la partie. On + // previent avant de fondre, pour que les quatre fondus tournent ensemble et + // que personne ne voie l'ecran se figer. + if (HasAuthority()) + { + KickRemoteClientsToMainMenu(); + } + + BeginQuitToMainMenu(); +} + +void AFpsPlayerController::HandleSessionTravelPending() +{ + USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr; + if (!Sessions || bQuittingToMainMenu) + { + return; + } + + // Les ecrans ouverts resteraient affiches par-dessus le noir, curseur + // compris, et arriveraient tels quels dans la partie de l'ami. + if (bInventoryOpen) + { + SetInventoryScreenOpen(false, /*bShowCraftingTab=*/false); + } + + if (bPauseMenuOpen) + { + ResumeGame(); + } + + ScreenFade->FadeOut(Sessions->GetTravelFadeDuration()); +} + +void AFpsPlayerController::Client_ReturnToMainMenu_Implementation() +{ + UE_LOG(LogTemp, Log, TEXT("AFpsPlayerController : l'hote a quitte la partie, retour au menu.")); + + BeginQuitToMainMenu(); +} + +void AFpsPlayerController::KickRemoteClientsToMainMenu() +{ + UWorld* World = GetWorld(); + if (!World || !HasAuthority()) + { + return; + } + + for (FConstPlayerControllerIterator It = World->GetPlayerControllerIterator(); It; ++It) + { + AFpsPlayerController* Other = Cast(It->Get()); + + // Soi-meme exclu : on a notre propre chemin, et l'hote n'est de toute + // facon pas joignable par une RPC Client_ depuis lui-meme. + if (Other && Other != this) + { + Other->Client_ReturnToMainMenu(); + } + } +} + +void AFpsPlayerController::BeginQuitToMainMenu() +{ + // Un client peut avoir clique Quitter juste avant de recevoir l'ordre de + // l'hote : deux fondus simultanes se marcheraient dessus. + if (bQuittingToMainMenu) + { + return; + } + bQuittingToMainMenu = true; + // Le GameInstance doit savoir que ce depart est VOULU : sans ca, la + // connexion qui tombe pendant le fondu declencherait son filet de secours, + // qui chargerait le menu immediatement et couperait le fondu en deux. + if (USurvivalGameInstance* SurvivalGameInstance = GetGameInstance()) + { + SurvivalGameInstance->BeginReturnToMainMenu(); + } + if (QuitToMenuDialog) { QuitToMenuDialog->Close(); @@ -1103,10 +1310,13 @@ void AFpsPlayerController::HandleQuitToMenuConfirmed() 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. + // L'ecran d'inventaire ou de coffre resterait affiche par-dessus le noir, + // curseur compris. Sa fermeture rend le coffre au passage. + if (bInventoryOpen) + { + SetInventoryScreenOpen(false, /*bShowCraftingTab=*/false); + } + ScreenFade->FadeOut(QuitToMenuFadeDuration); } @@ -1132,6 +1342,17 @@ void AFpsPlayerController::HandleFadeFinished() return; } + // Fermer la session AVANT de voyager. Chez l'hote c'est ce qui la retire de + // la liste de tes amis a l'instant ou tu quittes, plutot qu'a l'arrivee sur + // le menu ; chez un client c'est ce qui libere sa place. + // + // Les autres joueurs, eux, perdront la connexion : c'est + // USurvivalGameInstance::HandleNetworkFailure qui les ramene a leur menu. + if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr) + { + Sessions->LeaveSession(); + } + UGameplayStatics::OpenLevelBySoftObjectPtr(this, MainMenuLevel); } diff --git a/Source/Survival_projet/Private/MainMenuPlayerController.cpp b/Source/Survival_projet/Private/MainMenuPlayerController.cpp index a914e27..1adc433 100644 --- a/Source/Survival_projet/Private/MainMenuPlayerController.cpp +++ b/Source/Survival_projet/Private/MainMenuPlayerController.cpp @@ -55,6 +55,15 @@ void AMainMenuPlayerController::BeginPlay() UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : aucune AMenuCameraSpot active dans la map, le menu s'affichera sur la vue par defaut.")); } + // APRES SetViewTarget, jamais avant : les images de chauffe doivent etre + // rendues depuis l'angle qu'on va reellement montrer, sinon elles raffinent + // un decor qu'on ne verra pas. Le voile est deja noir, donc rien ne fuit. + USurvivalGameInstance* SurvivalGameInstance = GetGameInstance(); + if (SurvivalGameInstance) + { + SurvivalGameInstance->ResumeWorldRendering(); + } + if (MainMenuClass) { MainMenu = CreateWidget(this, MainMenuClass); @@ -78,12 +87,31 @@ void AMainMenuPlayerController::BeginPlay() SetShowMouseCursor(true); ScreenFade->OnFadeFinished.AddDynamic(this, &AMainMenuPlayerController::HandleFadeFinished); + + // Meme raison qu'en jeu : on laisse le decor rendre quelques images derriere + // le voile noir avant de l'eclaircir, le temps que Nanite et les textures + // virtuelles se raffinent. Sans ca on voit le terrain passer de sa version + // grossiere a la vraie, et les modeles apparaitre un a un. + if (SurvivalGameInstance) + { + SurvivalGameInstance->BeginSceneWarmup( + FSimpleDelegate::CreateUObject(this, &AMainMenuPlayerController::StartOpeningFade)); + } + else + { + StartOpeningFade(); + } +} + +void AMainMenuPlayerController::StartOpeningFade() +{ ScreenFade->FadeIn(OpeningFadeDuration); if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr) { Sessions->OnHostFailed.AddDynamic(this, &AMainMenuPlayerController::HandleHostFailed); Sessions->OnJoinFailed.AddDynamic(this, &AMainMenuPlayerController::HandleJoinFailed); + Sessions->OnTravelPending.AddDynamic(this, &AMainMenuPlayerController::HandleTravelPending); // Revenir au menu depuis une partie laisse la session ouverte : elle // resterait annoncee a tes amis alors que plus personne n'est dedans. @@ -91,6 +119,14 @@ void AMainMenuPlayerController::BeginPlay() UE_LOG(LogTemp, Log, TEXT("AMainMenuPlayerController : service en ligne [%s]."), *Sessions->GetOnlineServiceName()); } + + // On est arrive : le filet de secours reseau redevient actif pour la + // prochaine partie. Sans cette remise a zero, un hote qui relance apres + // avoir quitte ne serait plus jamais ramene au menu en cas de coupure. + if (USurvivalGameInstance* SurvivalGameInstance = GetGameInstance()) + { + SurvivalGameInstance->ClearReturnToMainMenu(); + } } AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot() @@ -185,6 +221,31 @@ void AMainMenuPlayerController::HandleHostFailed(FText Reason) UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel, /*bAbsolute=*/true, TEXT("listen")); } +void AMainMenuPlayerController::HandleTravelPending() +{ + USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr; + if (!Sessions) + { + return; + } + + // Le menu reste lisible pendant le fondu mais cesse de repondre : cliquer + // Jouer alors qu'on part deja rejoindre un ami serait un piege. + if (MainMenu) + { + MainMenu->SetVisibility(ESlateVisibility::HitTestInvisible); + } + + // Exactement la duree que le sous-systeme attend : fondre plus lentement + // laisserait voyager avant que l'ecran soit noir, plus vite laisserait un + // ecran noir immobile. + ScreenFade->FadeOut(Sessions->GetTravelFadeDuration()); + + // PendingAction reste a None VOLONTAIREMENT : ce n'est pas nous qui + // chargerons la map, c'est le sous-systeme. HandleFadeFinished doit donc ne + // rien faire quand ce fondu-la se termine. +} + void AMainMenuPlayerController::HandleJoinFailed(FText Reason) { UE_LOG(LogTemp, Error, TEXT("AMainMenuPlayerController : connexion impossible -- %s"), *Reason.ToString()); diff --git a/Source/Survival_projet/Private/ScreenFadeComponent.cpp b/Source/Survival_projet/Private/ScreenFadeComponent.cpp index de28799..2bca8cc 100644 --- a/Source/Survival_projet/Private/ScreenFadeComponent.cpp +++ b/Source/Survival_projet/Private/ScreenFadeComponent.cpp @@ -59,6 +59,9 @@ void UScreenFadeComponent::EnsureWidget() // Un widget n'existe que sur la machine du joueur local : inutile et // incorrect d'en creer un sur un serveur dedie. + // ATTENTION : chez un client, ce test est FAUX au BeginPlay du controller -- + // son ULocalPlayer ne lui est attache qu'ensuite, dans SetPlayer(). Un + // SnapToBlack() fait trop tot noircit donc un voile inexistant, en silence. if (!Controller->IsLocalController()) { return; diff --git a/Source/Survival_projet/Private/SessionSubsystem.cpp b/Source/Survival_projet/Private/SessionSubsystem.cpp index be44dd3..a22446a 100644 --- a/Source/Survival_projet/Private/SessionSubsystem.cpp +++ b/Source/Survival_projet/Private/SessionSubsystem.cpp @@ -380,6 +380,37 @@ void USessionSubsystem::HandleJoinSessionComplete(FName SessionName, EOnJoinSess return; } + // On ne voyage PAS tout de suite : on annonce, on laisse le controller + // courant fondre au noir, puis on part. Sans ce delai, accepter une + // invitation depuis le menu coupe l'image d'un coup -- le seul endroit du + // jeu ou une transition n'etait pas fondue. + PendingTravelURL = ConnectString; + OnTravelPending.Broadcast(); + + UGameInstance* OwningGameInstance = GetGameInstance(); + if (TravelFadeDuration <= 0.f || !OwningGameInstance) + { + CommitPendingTravel(); + return; + } + + // Le TimerManager du GameInstance et non celui du monde : le monde courant + // est justement celui qu'on s'apprete a quitter. + OwningGameInstance->GetTimerManager().SetTimer( + TravelTimer, this, &USessionSubsystem::CommitPendingTravel, + TravelFadeDuration, /*bLoop=*/false); +} + +void USessionSubsystem::CommitPendingTravel() +{ + if (PendingTravelURL.IsEmpty()) + { + return; + } + + const FString TravelURL = MoveTemp(PendingTravelURL); + PendingTravelURL.Reset(); + APlayerController* Controller = GetGameInstance() ? GetGameInstance()->GetFirstLocalPlayerController() : nullptr; if (!Controller) { @@ -387,8 +418,8 @@ void USessionSubsystem::HandleJoinSessionComplete(FName SessionName, EOnJoinSess return; } - UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : voyage vers [%s]."), *ConnectString); - Controller->ClientTravel(ConnectString, TRAVEL_Absolute); + UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : voyage vers [%s]."), *TravelURL); + Controller->ClientTravel(TravelURL, TRAVEL_Absolute); } void USessionSubsystem::HandleInviteAccepted(const bool bWasSuccessful, const int32 ControllerId, FUniqueNetIdPtr UserId, const FOnlineSessionSearchResult& InviteResult) diff --git a/Source/Survival_projet/Private/SurvivalGameInstance.cpp b/Source/Survival_projet/Private/SurvivalGameInstance.cpp index a2d9bff..8a68f8f 100644 --- a/Source/Survival_projet/Private/SurvivalGameInstance.cpp +++ b/Source/Survival_projet/Private/SurvivalGameInstance.cpp @@ -3,9 +3,15 @@ #include "SurvivalGameInstance.h" +#include "ContentStreaming.h" #include "EnhancedInputSubsystems.h" +#include "Engine/Engine.h" +#include "Engine/GameViewportClient.h" #include "Engine/LocalPlayer.h" +#include "GameMapsSettings.h" #include "InputMappingContext.h" +#include "Kismet/GameplayStatics.h" +#include "SessionSubsystem.h" #include "SurvivalUserSettings.h" #include "UserSettings/EnhancedInputUserSettings.h" @@ -32,6 +38,175 @@ void USurvivalGameInstance::Init() { UE_LOG(LogTemp, Error, TEXT("USurvivalGameInstance : GameUserSettingsClassName n'est pas configure dans DefaultEngine.ini, aucun reglage ne sera pris en compte.")); } + + // Filet de securite reseau. On s'abonne ici, une fois pour toutes : ces + // evenements arrivent au pire moment, typiquement quand le monde est en + // train d'etre detruit, et un abonnement porte par un acteur mourrait + // justement avec lui. + if (GEngine) + { + NetworkFailureHandle = GEngine->OnNetworkFailure().AddUObject(this, &USurvivalGameInstance::HandleNetworkFailure); + TravelFailureHandle = GEngine->OnTravelFailure().AddUObject(this, &USurvivalGameInstance::HandleTravelFailure); + } + + PreLoadMapHandle = FCoreUObjectDelegates::PreLoadMap.AddUObject(this, &USurvivalGameInstance::HandlePreLoadMap); +} + +void USurvivalGameInstance::HandlePreLoadMap(const FString& MapName) +{ + // On coupe AVANT que quoi que ce soit du nouveau monde n'existe. C'est le + // premier controller pret qui rallumera, une fois son voile noir en place -- + // et il naitra forcement apres nous, ce qui garantit le bon ordre. + SetWorldRenderingSuspended(true); + UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : chargement de [%s], rendu du monde suspendu."), *MapName); + + // Le timer du GameInstance et non celui du monde : celui-ci est justement en + // train d'etre detruit. + GetTimerManager().SetTimer( + RenderingResumeTimeout, this, &USurvivalGameInstance::ResumeWorldRendering, + 20.f, /*bLoop=*/false); +} + +void USurvivalGameInstance::BeginSceneWarmup(FSimpleDelegate OnComplete) +{ + SceneWarmupCompleted = OnComplete; + RemainingWarmupFrames = FMath::Max(SceneWarmupFrames, 0); + WarmupStartTime = FPlatformTime::Seconds(); + + if (RemainingWarmupFrames <= 0) + { + SceneWarmupCompleted.ExecuteIfBound(); + SceneWarmupCompleted.Unbind(); + return; + } + + TickSceneWarmup(); +} + +void USurvivalGameInstance::TickSceneWarmup() +{ + const double Elapsed = FPlatformTime::Seconds() - WarmupStartTime; + const bool bCapReached = Elapsed >= SceneWarmupMaxSeconds; + + if (RemainingWarmupFrames > 0 && !bCapReached) + { + --RemainingWarmupFrames; + + // SetTimerForNextTick et non un timer en secondes : une image rendue, + // un tick. C'est l'unite dans laquelle Nanite et Lumen progressent. + GetTimerManager().SetTimerForNextTick(this, &USurvivalGameInstance::TickSceneWarmup); + return; + } + + UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : chauffe terminee -- %d images rendues en %.0f ms%s."), + SceneWarmupFrames - RemainingWarmupFrames, Elapsed * 1000.0, + bCapReached ? TEXT(" (plafond atteint)") : TEXT("")); + + // Copie avant execution : le rappel declenche un fondu, qui pourrait + // theoriquement relancer une chauffe et ecraser le delegue en pleine main. + FSimpleDelegate Completed = SceneWarmupCompleted; + SceneWarmupCompleted.Unbind(); + Completed.ExecuteIfBound(); +} + +void USurvivalGameInstance::SetWorldRenderingSuspended(bool bSuspended) +{ + if (UGameViewportClient* Viewport = GetGameViewportClient()) + { + Viewport->bDisableWorldRendering = bSuspended; + } +} + +void USurvivalGameInstance::ResumeWorldRendering() +{ + GetTimerManager().ClearTimer(RenderingResumeTimeout); + + // Streaming vide AVANT de rallumer. UpdateResourceStreaming(0, true) traite + // tout sans avoir besoin d'un point de vue -- ce qui tombe bien, le rendu + // est encore coupe donc personne ne lui en a fourni -- puis on bloque le + // temps que les requetes aboutissent. + // + // Ce blocage a lieu ECRAN NOIR : c'est du temps de chargement, pas une + // saccade. C'est exactement ce que le moteur fait lui-meme a l'ouverture + // d'une map, et c'est ce qui evite de voir le terrain se raffiner et les + // modeles apparaitre un a un. + const double StartTime = FPlatformTime::Seconds(); + const int32 PendingRequests = IStreamingManager::Get().StreamAllResources(FMath::Max(StreamingFlushTimeLimit, 0.f)); + const double ElapsedMs = (FPlatformTime::Seconds() - StartTime) * 1000.0; + + SetWorldRenderingSuspended(false); + + UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : rendu du monde repris apres %.0f ms de streaming (%d requete(s) restante(s))."), + ElapsedMs, PendingRequests); +} + +void USurvivalGameInstance::Shutdown() +{ + if (GEngine) + { + GEngine->OnNetworkFailure().Remove(NetworkFailureHandle); + GEngine->OnTravelFailure().Remove(TravelFailureHandle); + } + + FCoreUObjectDelegates::PreLoadMap.Remove(PreLoadMapHandle); + + Super::Shutdown(); +} + +void USurvivalGameInstance::HandleNetworkFailure(UWorld* World, UNetDriver* NetDriver, ENetworkFailure::Type FailureType, const FString& ErrorString) +{ + UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : echec reseau [%s] -- %s"), + ENetworkFailure::ToString(FailureType), *ErrorString); + + // Depart deja en cours : c'est le cas NORMAL quand l'hote quitte, puisqu'il + // a prevenu tout le monde avant de couper. On laisse les fondus finir. + if (bReturningToMainMenu) + { + UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : depart deja en cours, echec ignore.")); + return; + } + + // Ici en revanche personne n'a prevenu : hote qui plante, cable arrache, + // relais Steam qui lache. C'est le filet de secours, sans fondu -- mieux + // vaut un retour brutal au menu qu'un monde mort. + ReturnToMainMenu(); +} + +void USurvivalGameInstance::HandleTravelFailure(UWorld* World, ETravelFailure::Type FailureType, const FString& ErrorString) +{ + UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : echec de voyage [%s] -- %s"), + ETravelFailure::ToString(FailureType), *ErrorString); + + if (bReturningToMainMenu) + { + return; + } + + ReturnToMainMenu(); +} + +void USurvivalGameInstance::ReturnToMainMenu() +{ + bReturningToMainMenu = true; + + // La session est morte avec la connexion : la detruire ici evite qu'elle + // reste annoncee a tes amis, et surtout qu'elle bloque la creation de la + // suivante. + if (USessionSubsystem* Sessions = GetSubsystem()) + { + Sessions->LeaveSession(); + } + + // GetGameDefaultMap et pas un chemin en dur : c'est deja MenuScene dans + // DefaultEngine.ini, et une seconde copie du chemin finirait par diverger. + const FString MenuMap = UGameMapsSettings::GetGameDefaultMap(); + if (MenuMap.IsEmpty()) + { + UE_LOG(LogTemp, Error, TEXT("USurvivalGameInstance : aucune GameDefaultMap, impossible de revenir au menu.")); + return; + } + + UGameplayStatics::OpenLevel(this, FName(*MenuMap), /*bAbsolute=*/true); } void USurvivalGameInstance::RegisterMappableContexts() diff --git a/Source/Survival_projet/Public/FpsPlayerController.h b/Source/Survival_projet/Public/FpsPlayerController.h index e54f03e..0396bc9 100644 --- a/Source/Survival_projet/Public/FpsPlayerController.h +++ b/Source/Survival_projet/Public/FpsPlayerController.h @@ -121,7 +121,21 @@ public: protected: virtual void BeginPlay() override; + + /** Serveur uniquement. Chez un client, c'est AcknowledgePossession qui arrive. */ virtual void OnPossess(APawn* InPawn) override; + + /** + * Le pendant CLIENT de OnPossess. + * + * OnPossess n'existe QUE sur le serveur : chez un client le pawn arrive par + * replication et cette fonction n'est jamais appelee. Comme c'est elle qui + * construisait le HUD, un joueur qui se connectait n'avait aucune interface -- + * ni viseur, ni barre rapide, ni jauges. Le piege est d'autant plus fourbe + * que l'hote, lui, est bien le serveur et voit son HUD parfaitement. + */ + virtual void AcknowledgePossession(APawn* P) override; + virtual void SetupInputComponent() override; virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; @@ -141,6 +155,16 @@ protected: UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu") bool bFadeInOnStart = true; + /** + * Délai maximal d'attente du pawn avant d'éclaircir quand même. + * + * Filet de sécurité pur : si la possession n'arrivait jamais, l'écran + * resterait noir pour toujours et le joueur croirait le jeu planté. Mieux + * vaut une vue bancale qu'un écran mort. + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu", meta = (ClampMin = "1", Units = "s")) + float StartupFadeTimeout = 10.f; + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu", meta = (ClampMin = "0.1")) float StartupFadeDuration = 1.5f; @@ -307,6 +331,45 @@ protected: private: void CreateHudWidgets(); + /** + * Tout ce qu'il faut faire quand ce controller prend un pawn, quel que soit + * le cote du reseau : creer le HUD s'il manque, rebrancher les widgets sur + * le nouveau corps, ecouter sa mort. + * + * Appelee par OnPossess (serveur) ET AcknowledgePossession (client), donc + * potentiellement deux fois chez l'hote qui est les deux a la fois. Elle + * doit rester idempotente -- d'ou AddUniqueDynamic sur OnDied, et + * CreateHudWidgets qui ne cree que ce qui n'existe pas encore. + */ + void SetupForPossessedPawn(APawn* InPawn); + + /** + * Éclaircit l'écran d'arrivée, une seule fois, et seulement quand le joueur + * a réellement un corps. + * + * La condition n'est PAS « ai-je un pawn » mais « la caméra est-elle sur mon + * pawn ». Chez un client, le contrôleur commence sur un `SpectatorPawn` né + * au centre du monde -- sous le terrain -- et y reste encore un instant + * APRÈS `AcknowledgePossession`. Mesuré sur une vraie connexion : au moment + * où la possession est acquittée, `GetViewTarget()` vaut toujours + * `SpectatorPawn_0`. Éclaircir là montre le dessous de la carte. + * + * Tant que la caméra n'y est pas, on repasse toutes les `StartupFadeRetry` + * secondes. L'écran reste noir pendant ce temps -- le rendu du monde est + * suspendu par le GameInstance depuis le chargement de la map. + */ + void TryStartupFadeIn(); + + /** + * Éclaircit pour de bon, sans vérifier la caméra. + * Appelée par le filet de sécurité : mieux vaut une vue bancale qu'un écran + * noir définitif, qui se lit comme un plantage. + */ + void CompleteStartupFadeIn(); + + /** L'éclaircissement lui-même, après le temps de chauffe du monde. */ + void StartStartupFade(); + /** * Echap. Une seule touche pour trois usages, d'ou une priorite explicite : * boite de confirmation, puis inventaire, puis pause. Sans cet ordre on se @@ -320,6 +383,37 @@ private: UFUNCTION() void HandleQuitToMenuConfirmed(); + /** + * L'hote s'en va : ce client doit partir aussi. + * + * Une RPC explicite et non un simple abandon de connexion. Quand l'hote + * ferme sa partie, le moteur ne previent personne : les clients restent + * dans un monde mort, avec le pawn fantome de l'hote encore plante devant + * eux, jusqu'a un timeout qui peut ne jamais venir. On leur dit donc de + * partir, et ils partent avec leur propre fondu. + */ + UFUNCTION(Client, Reliable) + void Client_ReturnToMainMenu(); + + /** + * Lance le depart : ferme les ecrans, previent le GameInstance, fond au noir. + * + * Idempotente -- un client peut recevoir l'ordre de l'hote alors qu'il avait + * deja clique Quitter de son cote, et deux fondus simultanes se + * marcheraient dessus. + */ + void BeginQuitToMainMenu(); + + /** + * Envoie l'ordre de depart a tous les AUTRES joueurs. Serveur uniquement. + * + * Appele AVANT le fondu de l'hote et non apres : les clients ont ainsi + * toute la duree de ce fondu pour recevoir le message et lancer le leur. + * Prevenir a la fin donnerait un client qui decouvre la coupure sans + * explication, ecran de jeu fige. + */ + void KickRemoteClientsToMainMenu(); + /** Non : on referme la boite et on rend la main au menu de pause. */ UFUNCTION() void HandleQuitToMenuCancelled(); @@ -328,6 +422,17 @@ private: UFUNCTION() void HandleFadeFinished(); + /** + * Le joueur a accepte une invitation EN PLEINE PARTIE : on fond au noir + * avant que le voyage vers l'autre hote parte. + * + * bQuittingToMainMenu reste faux : ce n'est pas nous qui chargeons la map + * suivante, c'est USessionSubsystem. HandleFadeFinished doit donc laisser + * passer ce fondu-la sans rien faire. + */ + UFUNCTION() + void HandleSessionTravelPending(); + /** L'ecran de reglages demande a se fermer : on rend la main au menu de pause. */ UFUNCTION() void HandleSettingsCloseRequested(); @@ -469,6 +574,18 @@ private: FTimerHandle RespawnTimerHandle; + /** Le fondu d'arrivée attend le pawn. */ + bool bStartupFadePending = false; + + FTimerHandle StartupFadeTimeoutHandle; + + /** Réessai tant que la caméra n'est pas sur le pawn. */ + FTimerHandle StartupFadeRetryHandle; + + + /** Période de réessai, en secondes. Assez court pour ne pas se voir. */ + static constexpr float StartupFadeRetry = 0.05f; + /** Surveillance de l'eloignement, actif seulement pendant qu'un coffre est ouvert. */ FTimerHandle StorageDistanceTimerHandle; diff --git a/Source/Survival_projet/Public/MainMenuPlayerController.h b/Source/Survival_projet/Public/MainMenuPlayerController.h index 2e70d0e..c02c6de 100644 --- a/Source/Survival_projet/Public/MainMenuPlayerController.h +++ b/Source/Survival_projet/Public/MainMenuPlayerController.h @@ -127,6 +127,9 @@ private: /** Tire un angle de vue au hasard, en evitant celui du dernier passage. */ AMenuCameraSpot* PickCameraSpot(); + /** L'eclaircissement lui-meme, apres la chauffe du decor. */ + void StartOpeningFade(); + /** Le fondu vient de se terminer : on execute l'action mise de cote. */ UFUNCTION() void HandleFadeFinished(); @@ -162,6 +165,14 @@ private: UFUNCTION() void HandleJoinFailed(FText Reason); + /** + * Une invitation vient d'aboutir : on fond au noir avant que le voyage + * parte. C'est le seul chemin d'entree en partie qui ne passait pas par le + * bouton Jouer, et donc le seul qui coupait l'image d'un coup. + */ + UFUNCTION() + void HandleTravelPending(); + /** Ferme le jeu sans rien demander. */ void CloseGame(); diff --git a/Source/Survival_projet/Public/SessionSubsystem.h b/Source/Survival_projet/Public/SessionSubsystem.h index ffc22c6..0a90762 100644 --- a/Source/Survival_projet/Public/SessionSubsystem.h +++ b/Source/Survival_projet/Public/SessionSubsystem.h @@ -20,6 +20,16 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionError, FText, Reason); /** Fin d'une recherche. SessionCount vaut 0 si personne n'heberge. */ DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSessionSearchFinished, bool, bWasSuccessful, int32, SessionCount); +/** + * La connexion a abouti, le voyage part dans un instant. + * + * Existe uniquement pour laisser le temps d'un fondu : le sous-systeme sait ou + * aller, mais le voile appartient au PlayerController -- et une invitation peut + * etre acceptee depuis le menu comme en pleine partie, donc depuis deux + * controllers differents. Chacun s'abonne et fond a sa maniere. + */ +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSessionTravelPending); + /** * Hebergement et connexion : creer une partie, la trouver, la rejoindre. * @@ -135,6 +145,22 @@ public: UPROPERTY(BlueprintAssignable, Category = "Session") FOnSessionSearchFinished OnSearchFinished; + UPROPERTY(BlueprintAssignable, Category = "Session") + FOnSessionTravelPending OnTravelPending; + + /** + * Duree laissee au fondu avant que le voyage parte. + * + * C'est LE sous-systeme qui la porte et non chaque controller : les deux + * doivent fondre exactement le temps qu'il attend, sinon l'ecran voyage + * avant d'etre noir. Une seule valeur, lue par tous. + * + * Pas de UPROPERTY editable : un GameInstanceSubsystem n'a aucun asset ou + * la regler. Si elle doit bouger un jour, ce sera ici. + */ + UFUNCTION(BlueprintPure, Category = "Session") + float GetTravelFadeDuration() const { return TravelFadeDuration; } + private: void HandleCreateSessionComplete(FName SessionName, bool bWasSuccessful); void HandleFindSessionsComplete(bool bWasSuccessful); @@ -173,6 +199,16 @@ private: /** Coupe proprement un delegue encore branche. Idempotent. */ void ClearPendingDelegates(); + /** Le fondu a eu son temps : on voyage pour de bon. */ + void CommitPendingTravel(); + + /** Adresse resolue de l'hote, en attente du fondu. */ + FString PendingTravelURL; + + FTimerHandle TravelTimer; + + float TravelFadeDuration = 1.f; + IOnlineSessionPtr SessionInterface; TSharedPtr SearchSettings; diff --git a/Source/Survival_projet/Public/SurvivalGameInstance.h b/Source/Survival_projet/Public/SurvivalGameInstance.h index fbc836a..4b8ee16 100644 --- a/Source/Survival_projet/Public/SurvivalGameInstance.h +++ b/Source/Survival_projet/Public/SurvivalGameInstance.h @@ -28,6 +28,7 @@ class SURVIVAL_PROJET_API USurvivalGameInstance : public UGameInstance public: virtual void Init() override; + virtual void Shutdown() override; /** Nom de la derniere camera de menu utilisee. NAME_None au premier lancement. */ UFUNCTION(BlueprintPure, Category = "Menu") @@ -50,7 +51,87 @@ public: UFUNCTION(BlueprintCallable, Category = "Input") void RegisterMappableContexts(); + /** + * Un depart VOULU vers le menu commence : le filet de secours reseau doit + * se taire. + * + * Sans ce drapeau, la connexion qui tombe pendant le fondu de sortie + * declencherait HandleNetworkFailure, qui chargerait le menu sur-le-champ + * et couperait le fondu en plein milieu -- exactement le saut d'image qu'on + * cherchait a eviter en fondant. + */ + void BeginReturnToMainMenu() { bReturningToMainMenu = true; } + + /** On est arrive au menu : le filet redevient actif pour la partie suivante. */ + void ClearReturnToMainMenu() { bReturningToMainMenu = false; } + + /** + * Rend la main au rendu du monde, coupe depuis le chargement de la map. + * + * A n'appeler QUE lorsque le voile de fondu est deja noir : le monde + * reapparait alors derriere lui, invisible. + * + * Pourquoi couper le rendu plutot que poser un widget. Mesure sur une vraie + * connexion : 2,4 secondes s'ecoulent entre `LoadMap` chez le client et la + * naissance de son PlayerController, que le serveur lui envoie. Pendant ce + * temps la map est chargee et le moteur la rend depuis une vue par defaut -- + * sous le terrain. Il n'existe alors AUCUN acteur a qui accrocher un voile, + * et le composant de fondu vit justement sur ce controller absent. Le + * viewport, lui, survit aux changements de map et n'appartient a personne. + * + * Attend aussi la fin du streaming avant de rendre la main : c'est le seul + * instant du chargement ou l'on sait que rien ne sera montre tant qu'on n'a + * pas fini. Sans ca, le joueur voit le terrain passer de sa version + * grossiere a la vraie, et les modeles apparaitre un a un. + */ + UFUNCTION(BlueprintCallable, Category = "Chargement") + void ResumeWorldRendering(); + + /** + * Laisse le monde rendre SceneWarmupFrames images derriere le voile noir, + * puis rappelle OnComplete. + * + * Compte des IMAGES et non des secondes, et c'est tout l'interet. Ce qui + * reste a se raffiner apres le flush de streaming -- Nanite, les ombres + * virtuelles, la convergence de Lumen -- progresse a chaque image RENDUE, + * pas avec le temps qui passe. Une duree fixe donnerait quarante images sur + * une machine rapide et cinq sur une machine lente : exactement celle qui en + * aurait eu le plus besoin. + */ + void BeginSceneWarmup(FSimpleDelegate OnComplete); + protected: + /** + * Temps maximal accordé au streaming avant de montrer le monde, en secondes. + * + * Ce blocage se produit ECRAN NOIR, donc il ne se voit pas -- c'est du temps + * de chargement, pas une saccade. Le dépasser vaut mieux que de montrer un + * terrain qui se raffine sous les yeux du joueur, mais on le borne quand + * même : une machine lente ne doit pas rester bloquée indéfiniment. + * + * Réglable sur BP_SurvivalGameInstance. + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Chargement", meta = (ClampMin = "0", Units = "s")) + float StreamingFlushTimeLimit = 5.f; + + /** + * Nombre d'images rendues derriere le voile noir avant de l'eclaircir. + * Voir BeginSceneWarmup(). Reglable sur BP_SurvivalGameInstance. + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Chargement", meta = (ClampMin = "0")) + int32 SceneWarmupFrames = 45; + + /** + * Plafond de la chauffe, en secondes. + * + * Le compte d'images reste la mesure PRINCIPALE -- c'est la bonne unite. + * Ceci n'est qu'un garde-fou : sur une machine tres lente, 45 images + * pourraient prendre plusieurs secondes d'ecran noir, et mieux vaut un + * decor pas tout a fait converge qu'une attente qui inquiete. + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Chargement", meta = (ClampMin = "0.1", Units = "s")) + float SceneWarmupMaxSeconds = 2.f; + /** * Les mapping contexts dont les touches sont remappables. Mets-y IMC_Default. * @@ -63,6 +144,70 @@ protected: TArray> MappableContexts; private: + /** + * L'hote a quitte, ou la connexion est tombee. + * + * Sans ce filet, un client dont l'hote ferme le jeu reste planté dans un + * monde qui ne repond plus : le moteur coupe la connexion, mais rien ne le + * ramene nulle part. C'est ici et pas sur le PlayerController parce que + * celui-ci est justement detruit par l'echec -- le GameInstance est le seul + * objet dont on soit certain qu'il vive encore a cet instant. + */ + void HandleNetworkFailure(UWorld* World, UNetDriver* NetDriver, ENetworkFailure::Type FailureType, const FString& ErrorString); + + /** Meme filet pour un voyage qui echoue en route. */ + void HandleTravelFailure(UWorld* World, ETravelFailure::Type FailureType, const FString& ErrorString); + + /** + * Ferme la session en cours et rouvre le menu principal. + * + * La map du menu n'est PAS une propriete de plus : c'est deja la + * GameDefaultMap du projet. En dupliquer le chemin ici donnerait deux + * valeurs a tenir en phase, et celle-ci ne serait jamais testee. + */ + void ReturnToMainMenu(); + + /** + * Une map va etre chargee : on coupe le rendu jusqu'a ce qu'un controller + * soit pret. + * + * PreLoadMap et surtout PAS PostLoadMapWithWorld : ce dernier est diffuse + * APRES le BeginPlay des acteurs. Sur une map chargee localement -- le menu, + * ou la partie chez l'hote -- le controller rendait donc la main au rendu, + * et on la lui reprenait juste apres. Ecran noir definitif, jusqu'au filet + * de securite. Chez un client ca passait par accident, son controller + * arrivant plus tard. + */ + void HandlePreLoadMap(const FString& MapName); + + void SetWorldRenderingSuspended(bool bSuspended); + + /** Une image de chauffe vient de passer : on decompte, ou on rappelle. */ + void TickSceneWarmup(); + + /** Images restantes a rendre avant d'eclaircir. */ + int32 RemainingWarmupFrames = 0; + + /** Debut de la chauffe, pour appliquer le plafond en secondes. */ + double WarmupStartTime = 0.0; + + /** Ce qu'on fait une fois la chauffe finie -- eclaircir, cote appelant. */ + FSimpleDelegate SceneWarmupCompleted; + + FDelegateHandle NetworkFailureHandle; + FDelegateHandle TravelFailureHandle; + FDelegateHandle PreLoadMapHandle; + + /** + * Filet : si aucun controller ne rend la main -- connexion qui n'aboutit + * pas, spawn rate -- on rallume quand meme. Un ecran noir definitif se lit + * comme un plantage, et c'est pire que le defaut qu'on corrige. + */ + FTimerHandle RenderingResumeTimeout; + + /** Un retour au menu est deja en cours, voulu ou non. */ + bool bReturningToMainMenu = false; + /** * Stocke un NOM et pas un index : les index changent des qu'on ajoute ou * supprime une camera dans la map, et on retomberait sur autre chose. diff --git a/Source/Survival_projet/Survival_projet.Build.cs b/Source/Survival_projet/Survival_projet.Build.cs index 5cf211a..aa7aafe 100644 --- a/Source/Survival_projet/Survival_projet.Build.cs +++ b/Source/Survival_projet/Survival_projet.Build.cs @@ -17,7 +17,10 @@ public class Survival_projet : ModuleRules // l'asset, pas decider quand il s'allume. PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GameplayTags", "UMG", "Niagara" }); - PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" }); + // EngineSettings : UGameMapsSettings::GetGameDefaultMap(), qui evite de + // redeclarer le chemin de la map de menu dans le C++ alors qu'il est + // deja dans DefaultEngine.ini. + PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "EngineSettings" }); // Coop Steam : on ne depend QUE de l'API generique (OnlineSubsystem) et de // ses helpers (OnlineSubsystemUtils, qui porte le resolveur d'adresses).