(Feat) Fix Fade Loading

This commit is contained in:
2026-08-03 21:10:45 +02:00
parent 7595df6862
commit 1456ed91c7
11 changed files with 925 additions and 28 deletions
+111 -17
View File
@@ -129,6 +129,7 @@ qu'une compilation propre l'est vraiment.
| Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` | | Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` |
| Fondus, boîtes de dialogue | `ScreenFadeComponent`, `ConfirmDialogWidget` | | Fondus, boîtes de dialogue | `ScreenFadeComponent`, `ConfirmDialogWidget` |
| Coop Steam (sessions) | `SessionSubsystem`, `SurvivalGameMode`, `SurvivalGameState`, `SurvivalPlayerState` | | Coop Steam (sessions) | `SessionSubsystem`, `SurvivalGameMode`, `SurvivalGameState`, `SurvivalPlayerState` |
| Pseudo au-dessus des joueurs | `PlayerNameplateComponent`, `PlayerNameplateWidget` |
### Coop Steam — décisions figées ### 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 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. 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 | | Système | État |
|---|---| |---|---|
| Déplacement, crouch | ✅ gratuit via `ACharacter` / `CharacterMovement` | | 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 ## État actuel et suite
Boucle complète : récolter → **fabriquer****cuire** → ranger → consommer → survivre Boucle complète **en coop jusqu'à 4 joueurs** : récolter → fabriquer → cuire → ranger
mourir → réapparaître. Autour : menu principal, menu de pause, et un écran de réglages dont **seul consommer → survivre → mourir → réapparaître. Autour : menu principal, menu de pause, écran de
l'onglet « Jeu & Contrôles » est rempli**. réglages, étiquettes de pseudo Steam.
Le craft est en place de bout en bout : `DA_RecipeBook`, onglets Inventaire / Fabrication, Le portage réseau est terminé pour tout le gameplay existant — voir *Réplication* plus haut pour
grille de recettes filtrée par poste accessible, panneau de détail avec compteurs colorés. le détail et les pièges. Les transitions de map sont propres de bout en bout : plus de flash, plus
Restent à faire quand le besoin viendra : l'**acteur établi** qui appellera de pop-in, départ groupé quand l'hôte quitte.
`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`.
Suite immédiate sur les réglages, dans l'ordre : onglet **Affichage** (le moteur fait tout, Les vraies recettes restent à écrire : celles en place sont deux `DA_Craft_Test_*` bâties sur
prévoir la confirmation à rebours de 10 s sur la résolution), onglet **Graphismes** (les dix `DA_Branch` et `DA_Rocher`. Les **boutons de filtre** par poste ne sont pas faits non plus
curseurs de `UGameUserSettings` ; végétation et distance d'affichage comptent double avec (`SetStationFilter` existe déjà, `ECraftingStation::Count` sert de sentinelle « tout »).
SoStylized), onglet **Audio** quand des `SoundClass` existeront, et enfin le **remappage des
touches** via `UEnhancedInputUserSettings` — le seul morceau réellement coûteux.
Prochaine étape gameplay recommandée : **spawner de ressources avec repousse** 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 (`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, Ensuite, par ordre de valeur : objet visible en main, sauvegarde, cycle jour/nuit.
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.
@@ -8,6 +8,7 @@
#include "EnhancedInputComponent.h" #include "EnhancedInputComponent.h"
#include "FpsPlayer.h" #include "FpsPlayer.h"
#include "GameFramework/GameModeBase.h" #include "GameFramework/GameModeBase.h"
#include "SurvivalGameInstance.h"
#include "SurvivalStatsComponent.h" #include "SurvivalStatsComponent.h"
#include "HotbarWidget.h" #include "HotbarWidget.h"
#include "InputActionValue.h" #include "InputActionValue.h"
@@ -83,6 +84,7 @@ void AFpsPlayerController::BeginPlay()
{ {
Super::BeginPlay(); Super::BeginPlay();
// Le mode d'input vit sur le VIEWPORT, pas sur le controller. OpenLevel // Le mode d'input vit sur le VIEWPORT, pas sur le controller. OpenLevel
// detruit le controller du menu principal mais pas la fenetre : son // detruit le controller du menu principal mais pas la fenetre : son
// GameAndUI et son curseur visible restent en place, et la camera ne // 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. // bQuittingToMainMenu qui distingue le fondu de sortie du reste.
ScreenFade->OnFadeFinished.AddDynamic(this, &AFpsPlayerController::HandleFadeFinished); 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<USessionSubsystem>() : nullptr)
{
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<USurvivalGameInstance>())
{
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<USurvivalGameInstance>();
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); ScreenFade->FadeIn(StartupFadeDuration);
} }
}
void AFpsPlayerController::OnPossess(APawn* InPawn) void AFpsPlayerController::OnPossess(APawn* InPawn)
{ {
Super::OnPossess(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 // 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 // garanti possede, et le prompt comme l'inventaire ont besoin de lui pour
// trouver leurs composants des leur NativeConstruct. // 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 // Apres un respawn les widgets existent deja, mais pointent vers les
// composants du cadavre. On les rebranche sur le nouveau pawn. // composants du cadavre. On les rebranche sur le nouveau pawn.
if (Crosshair) { /* pas d'abonnement */ }
if (InteractionPrompt) { InteractionPrompt->BindToOwningPawn(); } if (InteractionPrompt) { InteractionPrompt->BindToOwningPawn(); }
if (Hotbar) { Hotbar->BindToOwningPawn(); } if (Hotbar) { Hotbar->BindToOwningPawn(); }
if (InventoryScreen) { InventoryScreen->BindToOwningPawn(); } if (InventoryScreen) { InventoryScreen->BindToOwningPawn(); }
@@ -142,9 +262,16 @@ void AFpsPlayerController::OnPossess(APawn* InPawn)
{ {
if (USurvivalStatsComponent* Stats = PlayerPawn->GetSurvivalStats()) 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() void AFpsPlayerController::HandlePawnDied()
@@ -1091,8 +1218,88 @@ void AFpsPlayerController::RequestQuitToMainMenu()
void AFpsPlayerController::HandleQuitToMenuConfirmed() void AFpsPlayerController::HandleQuitToMenuConfirmed()
{ {
bQuitToMenuDialogOpen = false; 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<USessionSubsystem>() : 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<AFpsPlayerController>(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; 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<USurvivalGameInstance>())
{
SurvivalGameInstance->BeginReturnToMainMenu();
}
if (QuitToMenuDialog) if (QuitToMenuDialog)
{ {
QuitToMenuDialog->Close(); QuitToMenuDialog->Close();
@@ -1103,10 +1310,13 @@ void AFpsPlayerController::HandleQuitToMenuConfirmed()
PauseMenu->SetVisibility(ESlateVisibility::Collapsed); PauseMenu->SetVisibility(ESlateVisibility::Collapsed);
} }
// On reste en pause pendant le fondu : relancer le monde une seconde pour // L'ecran d'inventaire ou de coffre resterait affiche par-dessus le noir,
// l'abandonner ensuite laisserait le temps a la faim ou a une chute de tuer // curseur compris. Sa fermeture rend le coffre au passage.
// un joueur qui a deja quitte. C'est UScreenFadeComponent qui continue de if (bInventoryOpen)
// ticker en pause, pas le monde. {
SetInventoryScreenOpen(false, /*bShowCraftingTab=*/false);
}
ScreenFade->FadeOut(QuitToMenuFadeDuration); ScreenFade->FadeOut(QuitToMenuFadeDuration);
} }
@@ -1132,6 +1342,17 @@ void AFpsPlayerController::HandleFadeFinished()
return; 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<USessionSubsystem>() : nullptr)
{
Sessions->LeaveSession();
}
UGameplayStatics::OpenLevelBySoftObjectPtr(this, MainMenuLevel); UGameplayStatics::OpenLevelBySoftObjectPtr(this, MainMenuLevel);
} }
@@ -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.")); 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<USurvivalGameInstance>();
if (SurvivalGameInstance)
{
SurvivalGameInstance->ResumeWorldRendering();
}
if (MainMenuClass) if (MainMenuClass)
{ {
MainMenu = CreateWidget<UMainMenuWidget>(this, MainMenuClass); MainMenu = CreateWidget<UMainMenuWidget>(this, MainMenuClass);
@@ -78,12 +87,31 @@ void AMainMenuPlayerController::BeginPlay()
SetShowMouseCursor(true); SetShowMouseCursor(true);
ScreenFade->OnFadeFinished.AddDynamic(this, &AMainMenuPlayerController::HandleFadeFinished); 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); ScreenFade->FadeIn(OpeningFadeDuration);
if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr) if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr)
{ {
Sessions->OnHostFailed.AddDynamic(this, &AMainMenuPlayerController::HandleHostFailed); Sessions->OnHostFailed.AddDynamic(this, &AMainMenuPlayerController::HandleHostFailed);
Sessions->OnJoinFailed.AddDynamic(this, &AMainMenuPlayerController::HandleJoinFailed); Sessions->OnJoinFailed.AddDynamic(this, &AMainMenuPlayerController::HandleJoinFailed);
Sessions->OnTravelPending.AddDynamic(this, &AMainMenuPlayerController::HandleTravelPending);
// Revenir au menu depuis une partie laisse la session ouverte : elle // Revenir au menu depuis une partie laisse la session ouverte : elle
// resterait annoncee a tes amis alors que plus personne n'est dedans. // 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()); 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<USurvivalGameInstance>())
{
SurvivalGameInstance->ClearReturnToMainMenu();
}
} }
AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot() AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot()
@@ -185,6 +221,31 @@ void AMainMenuPlayerController::HandleHostFailed(FText Reason)
UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel, /*bAbsolute=*/true, TEXT("listen")); UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel, /*bAbsolute=*/true, TEXT("listen"));
} }
void AMainMenuPlayerController::HandleTravelPending()
{
USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : 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) void AMainMenuPlayerController::HandleJoinFailed(FText Reason)
{ {
UE_LOG(LogTemp, Error, TEXT("AMainMenuPlayerController : connexion impossible -- %s"), *Reason.ToString()); UE_LOG(LogTemp, Error, TEXT("AMainMenuPlayerController : connexion impossible -- %s"), *Reason.ToString());
@@ -59,6 +59,9 @@ void UScreenFadeComponent::EnsureWidget()
// Un widget n'existe que sur la machine du joueur local : inutile et // Un widget n'existe que sur la machine du joueur local : inutile et
// incorrect d'en creer un sur un serveur dedie. // 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()) if (!Controller->IsLocalController())
{ {
return; return;
@@ -380,6 +380,37 @@ void USessionSubsystem::HandleJoinSessionComplete(FName SessionName, EOnJoinSess
return; 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; APlayerController* Controller = GetGameInstance() ? GetGameInstance()->GetFirstLocalPlayerController() : nullptr;
if (!Controller) if (!Controller)
{ {
@@ -387,8 +418,8 @@ void USessionSubsystem::HandleJoinSessionComplete(FName SessionName, EOnJoinSess
return; return;
} }
UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : voyage vers [%s]."), *ConnectString); UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : voyage vers [%s]."), *TravelURL);
Controller->ClientTravel(ConnectString, TRAVEL_Absolute); Controller->ClientTravel(TravelURL, TRAVEL_Absolute);
} }
void USessionSubsystem::HandleInviteAccepted(const bool bWasSuccessful, const int32 ControllerId, FUniqueNetIdPtr UserId, const FOnlineSessionSearchResult& InviteResult) void USessionSubsystem::HandleInviteAccepted(const bool bWasSuccessful, const int32 ControllerId, FUniqueNetIdPtr UserId, const FOnlineSessionSearchResult& InviteResult)
@@ -3,9 +3,15 @@
#include "SurvivalGameInstance.h" #include "SurvivalGameInstance.h"
#include "ContentStreaming.h"
#include "EnhancedInputSubsystems.h" #include "EnhancedInputSubsystems.h"
#include "Engine/Engine.h"
#include "Engine/GameViewportClient.h"
#include "Engine/LocalPlayer.h" #include "Engine/LocalPlayer.h"
#include "GameMapsSettings.h"
#include "InputMappingContext.h" #include "InputMappingContext.h"
#include "Kismet/GameplayStatics.h"
#include "SessionSubsystem.h"
#include "SurvivalUserSettings.h" #include "SurvivalUserSettings.h"
#include "UserSettings/EnhancedInputUserSettings.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.")); 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<USessionSubsystem>())
{
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() void USurvivalGameInstance::RegisterMappableContexts()
@@ -121,7 +121,21 @@ public:
protected: protected:
virtual void BeginPlay() override; virtual void BeginPlay() override;
/** Serveur uniquement. Chez un client, c'est AcknowledgePossession qui arrive. */
virtual void OnPossess(APawn* InPawn) override; 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 SetupInputComponent() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
@@ -141,6 +155,16 @@ protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu") UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu")
bool bFadeInOnStart = true; 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")) UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu", meta = (ClampMin = "0.1"))
float StartupFadeDuration = 1.5f; float StartupFadeDuration = 1.5f;
@@ -307,6 +331,45 @@ protected:
private: private:
void CreateHudWidgets(); 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 : * Echap. Une seule touche pour trois usages, d'ou une priorite explicite :
* boite de confirmation, puis inventaire, puis pause. Sans cet ordre on se * boite de confirmation, puis inventaire, puis pause. Sans cet ordre on se
@@ -320,6 +383,37 @@ private:
UFUNCTION() UFUNCTION()
void HandleQuitToMenuConfirmed(); 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. */ /** Non : on referme la boite et on rend la main au menu de pause. */
UFUNCTION() UFUNCTION()
void HandleQuitToMenuCancelled(); void HandleQuitToMenuCancelled();
@@ -328,6 +422,17 @@ private:
UFUNCTION() UFUNCTION()
void HandleFadeFinished(); 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. */ /** L'ecran de reglages demande a se fermer : on rend la main au menu de pause. */
UFUNCTION() UFUNCTION()
void HandleSettingsCloseRequested(); void HandleSettingsCloseRequested();
@@ -469,6 +574,18 @@ private:
FTimerHandle RespawnTimerHandle; 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. */ /** Surveillance de l'eloignement, actif seulement pendant qu'un coffre est ouvert. */
FTimerHandle StorageDistanceTimerHandle; FTimerHandle StorageDistanceTimerHandle;
@@ -127,6 +127,9 @@ private:
/** Tire un angle de vue au hasard, en evitant celui du dernier passage. */ /** Tire un angle de vue au hasard, en evitant celui du dernier passage. */
AMenuCameraSpot* PickCameraSpot(); 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. */ /** Le fondu vient de se terminer : on execute l'action mise de cote. */
UFUNCTION() UFUNCTION()
void HandleFadeFinished(); void HandleFadeFinished();
@@ -162,6 +165,14 @@ private:
UFUNCTION() UFUNCTION()
void HandleJoinFailed(FText Reason); 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. */ /** Ferme le jeu sans rien demander. */
void CloseGame(); void CloseGame();
@@ -20,6 +20,16 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionError, FText, Reason);
/** Fin d'une recherche. SessionCount vaut 0 si personne n'heberge. */ /** Fin d'une recherche. SessionCount vaut 0 si personne n'heberge. */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSessionSearchFinished, bool, bWasSuccessful, int32, SessionCount); 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. * Hebergement et connexion : creer une partie, la trouver, la rejoindre.
* *
@@ -135,6 +145,22 @@ public:
UPROPERTY(BlueprintAssignable, Category = "Session") UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionSearchFinished OnSearchFinished; 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: private:
void HandleCreateSessionComplete(FName SessionName, bool bWasSuccessful); void HandleCreateSessionComplete(FName SessionName, bool bWasSuccessful);
void HandleFindSessionsComplete(bool bWasSuccessful); void HandleFindSessionsComplete(bool bWasSuccessful);
@@ -173,6 +199,16 @@ private:
/** Coupe proprement un delegue encore branche. Idempotent. */ /** Coupe proprement un delegue encore branche. Idempotent. */
void ClearPendingDelegates(); 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; IOnlineSessionPtr SessionInterface;
TSharedPtr<FOnlineSessionSearch> SearchSettings; TSharedPtr<FOnlineSessionSearch> SearchSettings;
@@ -28,6 +28,7 @@ class SURVIVAL_PROJET_API USurvivalGameInstance : public UGameInstance
public: public:
virtual void Init() override; virtual void Init() override;
virtual void Shutdown() override;
/** Nom de la derniere camera de menu utilisee. NAME_None au premier lancement. */ /** Nom de la derniere camera de menu utilisee. NAME_None au premier lancement. */
UFUNCTION(BlueprintPure, Category = "Menu") UFUNCTION(BlueprintPure, Category = "Menu")
@@ -50,7 +51,87 @@ public:
UFUNCTION(BlueprintCallable, Category = "Input") UFUNCTION(BlueprintCallable, Category = "Input")
void RegisterMappableContexts(); 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: 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. * Les mapping contexts dont les touches sont remappables. Mets-y IMC_Default.
* *
@@ -63,6 +144,70 @@ protected:
TArray<TObjectPtr<UInputMappingContext>> MappableContexts; TArray<TObjectPtr<UInputMappingContext>> MappableContexts;
private: 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 * 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. * supprime une camera dans la map, et on retomberait sur autre chose.
@@ -17,7 +17,10 @@ public class Survival_projet : ModuleRules
// l'asset, pas decider quand il s'allume. // l'asset, pas decider quand il s'allume.
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GameplayTags", "UMG", "Niagara" }); 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 // Coop Steam : on ne depend QUE de l'API generique (OnlineSubsystem) et de
// ses helpers (OnlineSubsystemUtils, qui porte le resolveur d'adresses). // ses helpers (OnlineSubsystemUtils, qui porte le resolveur d'adresses).