(Feat) Add mulitplayer
This commit is contained in:
+171
-3
@@ -100,6 +100,9 @@ l'oublie, en Standalone seulement : la caméra ne répond qu'au clic droit maint
|
||||
**Enhanced Input en pause** : un jeu en pause ne fait qu'un tick d'input réduit, où seules les
|
||||
actions cochées **« Trigger When Paused »** sont évaluées. C'est une case sur l'asset `IA_`,
|
||||
pas du C++ — et son corollaire est qu'aucune action du pawn ne se déclenche en pause.
|
||||
*Le projet ne met plus jamais le jeu en pause (voir le menu de pause plus bas), donc la case sur
|
||||
`IA_Pause` ne sert plus à rien. La laisser cochée est sans effet ; la note reste ici parce que le
|
||||
piège ressortira au premier `SetPause` réintroduit.*
|
||||
|
||||
**Le `namespace` anonyme n'isole rien.** Le build unity concatène les `.cpp` du module : deux
|
||||
constantes de même nom dans deux fichiers différents deviennent une redéfinition (C2374). Les
|
||||
@@ -125,6 +128,166 @@ qu'une compilation propre l'est vraiment.
|
||||
| Menu de pause | `PauseMenuWidget` + la partie pause de `FpsPlayerController` |
|
||||
| Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` |
|
||||
| Fondus, boîtes de dialogue | `ScreenFadeComponent`, `ConfirmDialogWidget` |
|
||||
| Coop Steam (sessions) | `SessionSubsystem`, `SurvivalGameMode`, `SurvivalGameState`, `SurvivalPlayerState` |
|
||||
|
||||
### Coop Steam — décisions figées
|
||||
|
||||
- **Cible : 4 joueurs, serveur d'écoute, P2P par le relais Valve.** Plugins `OnlineSubsystemSteam`
|
||||
et `SteamSockets`, AppID **4282850** (Emberwild). Le C++ ne parle **jamais** de Steam : tout passe
|
||||
par `IOnlineSubsystem`, dont l'implémentation est choisie dans `DefaultEngine.ini`. Corollaire
|
||||
précieux : sans Steam le moteur retombe sur `OnlineSubsystemNULL` et tout le code bascule en LAN
|
||||
(`USessionSubsystem::IsLanMode`), ce qui rend le test à deux instances possible sur une machine.
|
||||
- **L'OSS Steam est désactivé de force dans l'éditeur** (`IsEnabled()` teste `IsRunningGame()`).
|
||||
Steam ne s'initialise donc **jamais en PIE** — c'est voulu par Epic, pas un bug de config. Pour
|
||||
le tester : **Play > Standalone Game**, ou un build packagé.
|
||||
- **Toute partie est une partie en ligne.** Un seul bouton *Jouer*, qui crée la session puis ouvre
|
||||
la map en `?listen`. Pas de « Héberger » vs « Rejoindre » : le joueur devrait choisir avant de
|
||||
savoir si un ami le rejoindra, et devrait relancer sa partie pour changer d'avis.
|
||||
- **On rejoint UNIQUEMENT par invitation Steam**, depuis le bouton *Inviter* du menu de pause.
|
||||
L'acceptation est écoutée par `USessionSubsystem` dès son `Initialize` et jamais coupée : une
|
||||
invitation tombe à n'importe quel instant, y compris en pleine partie, et le sous-système est le
|
||||
seul objet dont on soit certain qu'il vive à ce moment-là. La commande console **`EmberJoin`**
|
||||
existe pour tester à deux instances, sans aucun bouton pour y mener.
|
||||
- **Un échec de session ne bloque pas le lancement** : la map s'ouvre quand même en serveur
|
||||
d'écoute, seul *Inviter* est grisé. Un joueur qui clique Jouer veut jouer ; le renvoyer à son
|
||||
menu pour une panne de Steam serait une punition qui n'est pas la sienne.
|
||||
- **`bUseLobbiesIfAvailable = true` est LE drapeau à ne pas oublier** : Steam ne route le P2P que
|
||||
par un lobby. Sans lui la session se crée, apparaît même dans une recherche, mais aucune
|
||||
connexion n'aboutit hors du LAN.
|
||||
- **`AGameMode` n'existe que sur le serveur.** `GetGameMode()` rend `nullptr` sur un client,
|
||||
toujours. Tout appel écrit côté client doit devenir un RPC serveur ou une lecture du GameState —
|
||||
c'est notamment le cas du respawn dans `AFpsPlayerController`, **encore à faire**.
|
||||
- **Autorité : le serveur, pour tout ce qui touche au monde.** Pickups, coffres, feux, établis et
|
||||
inventaires sont modifiés **uniquement** côté serveur ; les clients demandent par RPC et
|
||||
reçoivent le résultat par réplication. Coop entre amis, donc aucune anti-triche : les
|
||||
validations serveur sont des garde-fous contre la désynchronisation, pas contre le joueur.
|
||||
- **`Slots` de `UInventoryComponent` est répliqué à TOUT LE MONDE**, pas au seul propriétaire.
|
||||
Ce n'est pas un raccourci : les conditions de `GetLifetimeReplicatedProps` sont mises en cache
|
||||
**par classe**, pas par instance. Un `COND_OwnerOnly` frapperait donc aussi le composant d'un
|
||||
coffre — or un coffre n'appartient à aucune connexion, et sa grille n'arriverait chez personne.
|
||||
Le même composant sert le sac et le contenant, et les coffres sont à accès simultané. Coût réel :
|
||||
4 × 30 slots de trois champs, envoyés seulement quand ils changent.
|
||||
- **Coffres : accès simultané**, pas de verrou. `TransferSlot` diffuse déjà `OnInventoryChanged`
|
||||
des deux côtés, l'ossature est là.
|
||||
|
||||
### Tester la coop — ça ne se teste QUE en build Steam
|
||||
|
||||
**PIE ne teste pas la coop de ce projet.** L'OSS Steam y est désactivé de force par Epic
|
||||
(`IsEnabled()` teste `IsRunningGame()`), donc pas de session, pas d'invitation, pas de relais.
|
||||
PIE en *Play As Listen Server* reste utile pour la réplication pure (voir l'autre joueur bouger,
|
||||
un pickup disparaître), mais **tout ce qui touche à la session se teste en build packagé**,
|
||||
distribué par le dépôt Steam. C'est la procédure de référence.
|
||||
|
||||
**1. Packager** — éditeur fermé :
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\Epic Games\UE_5.8\Engine\Build\BatchFiles\RunUAT.bat" BuildCookRun `
|
||||
-project="C:\Users\Mathew\Documents\Perso\Unreal\Unreal_EmberWild\Survival_projet.uproject" `
|
||||
-noP4 -platform=Win64 -clientconfig=Development `
|
||||
-build -cook -stage -pak -compressed -prereqs `
|
||||
-archive -archivedirectory="...\Build\Package" -utf8output -nocompileeditor
|
||||
```
|
||||
|
||||
`Development` et pas `Shipping` : on garde la console (`~`), les logs dans `Saved/Logs` et la
|
||||
commande `EmberJoin`. Sans eux, un échec chez le testeur ne dit rien. Premier cook ≈ 18 min,
|
||||
les suivants sont bien plus courts (DDC chaud).
|
||||
|
||||
**2. Copier dans le dépôt** — le contenu de `Build\Package\Windows\` va **à la racine** de
|
||||
`C:\sdk\tools\ContentBuilder\content\`, en excluant `steam_appid.txt` : Steam fournit lui-même
|
||||
l'AppID au lancement, et le fichier l'écraserait.
|
||||
|
||||
**3. Uploader** — `C:\sdk\tools\ContentBuilder\upload_emberwild.bat` (App **4282850**, dépôt
|
||||
**4282851**). Le `.vdf` n'a **pas** de `SetLive`, donc le build monte sans devenir live.
|
||||
|
||||
**4. Publier sur la branche `develop`**, jamais sur `default`. Et vérifier que *Launch Options*
|
||||
pointe sur `Survival_projet.exe` — puis **publier les changements**, sinon rien n'est visible
|
||||
côté client.
|
||||
|
||||
**5. Tester à deux** : les deux lancent le jeu **depuis Steam**, l'hôte clique *Jouer*, puis
|
||||
Échap → *Inviter des amis*. Les logs à lire sont dans `Survival_projet/Saved/Logs/` de chaque
|
||||
côté — `ShowInviteUI` chez l'hôte, `invitation acceptee` puis `voyage vers [...]` chez l'invité.
|
||||
|
||||
**Deux réglages qui cassent tout si on les oublie** : la *List of Maps to Include* dans les
|
||||
Packaging Settings doit contenir `MenuScene` **et** `GameScene` — cette dernière n'est référencée
|
||||
que par un `TSoftObjectPtr`, et une référence soft n'est pas suivie par le cooker ; sans elle le
|
||||
build démarre sur le menu et *Jouer* ne charge rien. Et sans liste du tout, le cook embarque les
|
||||
~25 maps de démo SoStylized.
|
||||
|
||||
**Se tester seul** reste possible malgré l'invitation obligatoire : deux instances lancées avec
|
||||
`-nosteam` retombent sur `OnlineSubsystemNULL`, donc en LAN, et la commande console `EmberJoin`
|
||||
fait recherche + connexion sur la seconde.
|
||||
|
||||
### Réplication — la règle, et où on en est
|
||||
|
||||
**La règle : rien de ce qui touche au monde ne s'exécute sur un client.** Un client trace, vise,
|
||||
affiche, glisse — mais dès qu'il faut *changer* quelque chose, il envoie un RPC serveur et attend
|
||||
la réplication. Le symptôme quand on l'oublie est toujours le même et il est fourbe : ça marche
|
||||
parfaitement en solo et chez l'hôte, et l'objet réapparaît chez le client à la réplication suivante.
|
||||
|
||||
**Le point de passage unique : `IInteractable::Execute_Interact`.** Les quatre acteurs du monde
|
||||
(`PickupItem`, `CookingStation`, `CraftingStation`, `StorageContainer`) passent tous par là.
|
||||
`UInteractionComponent::TryInteract()` route donc vers `Server_Interact` quand on n'a pas
|
||||
l'autorité — une seule RPC rend les quatre autoritaires. Ne jamais appeler `Execute_Interact`
|
||||
ailleurs sans se poser la question de qui exécute.
|
||||
|
||||
**`SetIsReplicatedByDefault(true)` est obligatoire sur un composant qui porte un RPC serveur**,
|
||||
même s'il ne réplique aucune propriété : sans lui le moteur **jette silencieusement** l'appel.
|
||||
|
||||
**`OnConstruction` s'exécute avant l'arrivée des propriétés répliquées.** Un acteur dont le
|
||||
visuel dépend d'une `UPROPERTY` répliquée doit la déclarer `ReplicatedUsing` et reposer son
|
||||
visuel dans l'`OnRep` — sinon l'objet est correct chez l'hôte et nu chez les clients. C'est
|
||||
exactement le cas d'`APickupItem::OnRep_ItemData`.
|
||||
|
||||
**L'UI se recâble toute seule.** Tous les widgets sont abonnés à `OnInventoryChanged` et aucun
|
||||
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.
|
||||
|
||||
| Système | État |
|
||||
|---|---|
|
||||
| Déplacement, crouch | ✅ gratuit via `ACharacter` / `CharacterMovement` |
|
||||
| Sprint (`bIsSprinting` + RPC vitesse) | ✅ fait |
|
||||
| Interaction (RPC serveur + validation de distance) | ✅ fait |
|
||||
| `PickupItem` (ramassage, visuel, quantité) | ✅ fait |
|
||||
| `InventoryComponent` — lecture (`Slots` répliqué) | ✅ fait |
|
||||
| `InventoryComponent` — écriture (transferts, consommation) | ✅ fait |
|
||||
| Jeter au sol (`DropFromInventory`, `DropAllItems`) | ✅ fait |
|
||||
| `SurvivalStatsComponent` (simulation serveur, mort par `OnRep_Dead`) | ✅ fait |
|
||||
| Respawn (`Server_RequestRespawn`) | ✅ fait |
|
||||
| `StorageContainer` (contenu, couvercle) | ✅ fait |
|
||||
| `CraftingComponent` (`Server_Craft`) | ✅ fait |
|
||||
| `CookingStation` (grill, bûches, flamme) | ✅ fait |
|
||||
|
||||
**Un acteur du monde qui ouvre une interface doit RENVOYER l'ordre au client.**
|
||||
`Interact_Implementation` s'exécute sur le serveur : `PlayerController->OpenStorage(...)` y créerait
|
||||
un widget dans le vide et l'écran du joueur ne s'ouvrirait jamais. D'où `Client_OpenStorage` et
|
||||
`Client_OpenCraftingAtStation`. C'est le piège le plus contre-intuitif du portage, parce que le
|
||||
code *semble* correct et fonctionne parfaitement chez l'hôte.
|
||||
|
||||
**L'établi n'a rien à répliquer.** `ACraftingStation` est un meuble : un mesh, un prompt, aucun
|
||||
état. Ce qui devait changer, c'est le sens de l'appel (ci-dessus) et `Craft()`, qui touche
|
||||
l'inventaire autoritaire. Le poste accessible est tenu **des deux côtés** — le client en a besoin
|
||||
pour filtrer sa grille de recettes, le serveur pour valider `Server_Craft`. N'en tenir qu'un
|
||||
donnerait soit un onglet vide devant un établi, soit un établi utilisable depuis la forêt.
|
||||
|
||||
**La cuisson ne se compte que sur le serveur.** Les `FTimerHandle` ne sont pas des `UPROPERTY` et
|
||||
ne partent jamais sur le réseau : les clients ne reçoivent que le *résultat* — l'objet devenu
|
||||
cuit, la bûche disparue, la flamme allumée. Quatre clients qui compteraient chacun leur cuisson
|
||||
divergeraient, et le steak serait brûlé chez l'un et cru chez l'autre.
|
||||
|
||||
**Le sprint est le piège qui ne se voit pas chez l'hôte.** `MaxWalkSpeed` posé en local
|
||||
seulement laisse le serveur croire le client à `WalkSpeed` : il corrige alors sa position à
|
||||
chaque pas, et c'est le rubber-banding. D'où `Server_SetSprinting`, appelée **après** avoir
|
||||
appliqué en local — l'inverse donnerait un sprint qui démarre en retard.
|
||||
|
||||
**Pas de prédiction sur l'inventaire.** Le client envoie sa RPC et attend `OnRep_Slots`.
|
||||
Quelques dizaines de millisecondes sur un relais Steam, invisibles pour un glisser-déposer,
|
||||
alors qu'une prédiction fausse donnerait une pile qui saute.
|
||||
|
||||
**`FindNetProxy` est le passage obligé des transferts vers un coffre.** Un `UInventoryComponent`
|
||||
de conteneur n'appartient à aucune connexion : une RPC émise depuis lui est **silencieusement
|
||||
jetée**. On route donc toujours par l'inventaire du pawn local, et la source comme la destination
|
||||
sont passées en paramètres — jamais déduites du récepteur, sinon « Tout prendre » viderait le sac
|
||||
du joueur dans lui-même.
|
||||
|
||||
### Décisions de gameplay figées
|
||||
|
||||
@@ -148,9 +311,14 @@ qu'une compilation propre l'est vraiment.
|
||||
**relit** à son `BeginPlay` et s'abonne à `OnSurvivalSettingsApplied`.
|
||||
- **Échap** a une priorité explicite dans `HandlePauseInput()` : boîte de confirmation →
|
||||
réglages → inventaire → pause. Sans cet ordre, deux écrans se retrouvent ouverts en même temps.
|
||||
- **Menu de pause** : `SetPause(true)` fige le monde, mais **pas Slate** — les animations UMG et
|
||||
`ScreenFadeComponent` continuent (d'où son `bTickEvenWhenPaused`). Pas de bouton « Quitter le
|
||||
jeu » : la sortie se fait depuis le menu principal.
|
||||
- **Menu de pause : il ne met plus rien en pause.** `SetPause()` a disparu du projet. En coop la
|
||||
pause est impossible par nature — côté client l'appel est ignoré (elle appartient à l'autorité),
|
||||
côté hôte elle figerait le monde des quatre joueurs parce qu'un seul a ouvert son menu. Le monde
|
||||
continue donc de tourner, comme dans Valheim et Raft : ouvrir son menu ne met pas à l'abri.
|
||||
Corollaire **non négociable** : c'est la pause qui coupait gratuitement l'input du pawn, il faut
|
||||
donc passer par `SetUiInputMode(true)` — le même que l'inventaire et le coffre. L'oublier laisse
|
||||
la caméra tourner derrière le menu. Pas de bouton « Quitter le jeu » : la sortie se fait depuis
|
||||
le menu principal.
|
||||
- **Réglages** : `USurvivalUserSettings` fait autorité, jamais le CDO du pawn. Application
|
||||
immédiate, aucun bouton « Appliquer » ; la sauvegarde a lieu à la fermeture de l'écran.
|
||||
L'écran ignore qui l'ouvre (`OnCloseRequested`), d'où un seul `WBP_Settings` pour le menu
|
||||
|
||||
Reference in New Issue
Block a user