(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` |
| 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.