diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4c25fdd..93a7c08 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 diff --git a/Config/DefaultEngine.ini b/Config/DefaultEngine.ini index cb19120..14aed2f 100644 --- a/Config/DefaultEngine.ini +++ b/Config/DefaultEngine.ini @@ -92,6 +92,46 @@ FontDPI=72 ; n'existent tout simplement pas au runtime. GameUserSettingsClassName=/Script/Survival_projet.SurvivalUserSettings +; ================================================================== +; Coop Steam (4 joueurs, listen server, P2P par le relais Valve) +; ================================================================== + +; Le NetDriver par defaut ouvre un socket UDP et attend une IP : inutilisable +; derriere un NAT. SteamSocketsNetDriver fait transiter la connexion par le +; relais de Valve -- aucun port a ouvrir, aucune IP echangee entre joueurs. +; Le ClearArray est obligatoire : sans lui on empile une seconde definition +; "GameNetDriver" et c'est celle du moteur qui gagne. +; Le Fallback sert au PIE et aux tests hors Steam : sans client Steam lance, +; le moteur retombe sur l'IpNetDriver classique au lieu de refuser de demarrer. +[/Script/Engine.GameEngine] +!NetDriverDefinitions=ClearArray ++NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/SteamSockets.SteamSocketsNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver") + +[/Script/SteamSockets.SteamSocketsNetDriver] +NetConnectionClassName="/Script/SteamSockets.SteamSocketsNetConnection" + +[OnlineSubsystem] +DefaultPlatformService=Steam + +; SteamDevAppId = notre AppID reel (Emberwild). Le classique 480 (Spacewar) +; ne sert que si des testeurs ne possedent pas encore le jeu sur leur compte. +; +; PAS de bInitServerOnClient : il force l'initialisation de l'API GameServer +; de Steam en plus de l'API client, dans le meme process. C'est fait pour +; tester du code de serveur dedie sur une machine de dev. Un listen server +; n'en a pas besoin -- il cree un LOBBY Steam via l'API client. Pire, si +; l'init serveur echoue alors que le client a reussi, OnlineSubsystemSteam +; detruit TOUT (voir le Shutdown() de son Init) : on ajouterait un mode +; d'echec sans rien gagner. +; +; Rappel qui fait perdre du temps : dans l'EDITEUR, IsEnabled() rend toujours +; faux (IsRunningGame() est faux), donc Steam ne s'initialise jamais en PIE et +; le moteur retombe sur OnlineSubsystemNULL. C'est voulu par Epic. Pour tester +; Steam il faut Play > Standalone Game, ou un build package. +[OnlineSubsystemSteam] +bEnabled=true +SteamDevAppId=4282850 + [/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings] bEnablePlugin=True bAllowNetworkConnection=True diff --git a/Config/DefaultGame.ini b/Config/DefaultGame.ini index 15beceb..f544f5b 100644 --- a/Config/DefaultGame.ini +++ b/Config/DefaultGame.ini @@ -13,3 +13,15 @@ ProjectID=4774BE68499C24D0A70C6E87B49DA419 [/Script/DiscordRichPresence.DiscordRichPresenceSettings] ClientId=1432045921620725933 +; Sans cette liste le cooker embarque TOUTES les maps du dossier Content, donc +; les ~25 maps de demonstration de SoStylized : build enorme et cook interminable. +; +; MenuScene doit y figurer meme si c'est deja la GameDefaultMap, et GameScene +; SURTOUT : elle n'est referencee que par un TSoftObjectPtr (GameLevel sur +; BP_MainMenuPlayerController). Une reference soft n'est pas suivie par le +; cooker comme une reference dure -- sans cette ligne le build demarre sur le +; menu, on clique Jouer, et rien ne se charge. +[/Script/UnrealEd.ProjectPackagingSettings] ++MapsToCook=(FilePath="/Game/Game/Maps/MenuScene") ++MapsToCook=(FilePath="/Game/Game/Maps/GameScene") + diff --git a/Content/Game/GameMode/GM_FpsPlayer.uasset b/Content/Game/GameMode/GM_FpsPlayer.uasset index 23a6738..f706461 100644 --- a/Content/Game/GameMode/GM_FpsPlayer.uasset +++ b/Content/Game/GameMode/GM_FpsPlayer.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50f356f971fa4b2844f913b91edc3d2cc458fabccc9f625b9dd171350b4ce73f -size 21577 +oid sha256:9e3a316570cbc674f42561fc3a112faa449aac7bcb859976f54a94449fb9b954 +size 21767 diff --git a/Content/Game/Maps/MenuScene.umap b/Content/Game/Maps/MenuScene.umap index 2ba5ab1..83825a2 100644 --- a/Content/Game/Maps/MenuScene.umap +++ b/Content/Game/Maps/MenuScene.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eadc04a1254e35997b200bd2301e599406ba1b085374249979289e88f3456924 -size 15120886 +oid sha256:8748372e1c97adbce152e816b693281ff9c4980b6d5c02abf11a657f63aec9cd +size 15120890 diff --git a/Content/Game/UI/Pause/WBP_PauseMenu.uasset b/Content/Game/UI/Pause/WBP_PauseMenu.uasset index e4cf96e..1d44c7a 100644 --- a/Content/Game/UI/Pause/WBP_PauseMenu.uasset +++ b/Content/Game/UI/Pause/WBP_PauseMenu.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:19566d39e61af42bf5198fc20f0e38c08cbd336da9839447a943f22dbf7d7cdc -size 48971 +oid sha256:93c2757b39d36d62ed067c739edf99b836bdbdf9e826e32cf1a0149ec43a29dc +size 55760 diff --git a/Source/Survival_projet/Private/CookingStation.cpp b/Source/Survival_projet/Private/CookingStation.cpp index 78998a3..ab36384 100644 --- a/Source/Survival_projet/Private/CookingStation.cpp +++ b/Source/Survival_projet/Private/CookingStation.cpp @@ -11,6 +11,7 @@ #include "ItemDataAsset.h" #include "Kismet/GameplayStatics.h" #include "NiagaraComponent.h" +#include "Net/UnrealNetwork.h" #include "TimerManager.h" #define LOCTEXT_NAMESPACE "CookingStation" @@ -20,6 +21,14 @@ ACookingStation::ACookingStation() // Tout passe par des timers : la station n'a rien a faire chaque frame. PrimaryActorTick.bCanEverTick = false; + // Acteur du monde. La cuisson et la combustion ne sont comptees QUE par le + // serveur : les timers ne partent jamais sur le reseau, seuls leurs + // resultats -- l'objet devenu cuit, la buche disparue -- sont repliques. + // Quatre clients qui compteraient chacun leur cuisson divergeraient, et le + // steak serait brule chez l'un et cru chez l'autre. + bReplicates = true; + SetReplicateMovement(false); + BaseMesh = CreateDefaultSubobject(TEXT("BaseMesh")); SetRootComponent(BaseMesh); // Le foyer est un obstacle physique, contrairement a un ramassable : on @@ -77,40 +86,83 @@ ACookingStation::ACookingStation() } } +void ACookingStation::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + DOREPLIFETIME(ACookingStation, Slots); + DOREPLIFETIME(ACookingStation, Fuel); + DOREPLIFETIME(ACookingStation, bIsLit); +} + +void ACookingStation::OnRep_Slots() +{ + // Le mesh d'un emplacement suit son contenu : c'est TOUT l'affichage de la + // cuisson, il n'y a aucune interface. Cru, cuit, brule -- le joueur ne lit + // que ca. + for (int32 Index = 0; Index < Slots.Num(); ++Index) + { + RefreshSlotMesh(Index); + } +} + +void ACookingStation::OnRep_Fuel() +{ + RefreshFuelIndicators(); +} + +void ACookingStation::OnRep_IsLit() +{ + ApplyFireVisuals(bIsLit); +} + void ACookingStation::BeginPlay() { Super::BeginPlay(); - Slots.SetNum(MaxCookingSlots); + // Recensement des buches AVANT tout : il fixe la capacite, et il est purement + // local -- ce sont des composants du Blueprint, identiques partout. + CacheFuelIndicators(); + + // Le contenu initial est de l'etat de monde : seul le serveur le pose, les + // clients le recevront par replication. Sans cette garde, chaque client se + // donnerait ses propres buches de depart en plus de celles du serveur. + if (HasAuthority()) + { + Slots.SetNum(MaxCookingSlots); + + if (StartingFuelItem && StartingFuelItem->bIsFuel) + { + const int32 Count = FMath::Min(StartingFuelCount, GetFuelCapacity()); + for (int32 Index = 0; Index < Count; ++Index) + { + FFuelSlotState& NewFuel = Fuel.AddDefaulted_GetRef(); + NewFuel.Item = StartingFuelItem; + NewFuel.RemainingSeconds = FMath::Max(1.f, StartingFuelItem->FuelDuration); + } + } + } // Les meshes de reperage poses dans le Blueprint n'ont plus rien a faire la : // RefreshSlotMesh remet chaque emplacement sur son contenu reel, donc rien. - for (int32 Index = 0; Index < MaxCookingSlots; ++Index) + // Chez un client Slots est encore vide a cet instant ; OnRep_Slots repassera + // des que le contenu reel arrivera. + for (int32 Index = 0; Index < Slots.Num(); ++Index) { RefreshSlotMesh(Index); } - // Avant tout usage du carburant : c'est ce recensement qui fixe la capacite. - CacheFuelIndicators(); - - if (StartingFuelItem && StartingFuelItem->bIsFuel) - { - const int32 Count = FMath::Min(StartingFuelCount, GetFuelCapacity()); - for (int32 Index = 0; Index < Count; ++Index) - { - FFuelSlotState& NewFuel = Fuel.AddDefaulted_GetRef(); - NewFuel.Item = StartingFuelItem; - NewFuel.RemainingSeconds = FMath::Max(1.f, StartingFuelItem->FuelDuration); - } - } - RefreshFuelIndicators(); // Le grill est vide au demarrage, donc le feu reste eteint quoi qu'il // arrive. On passe quand meme par le point de decision unique plutot que // d'ecrire un cas particulier de plus. ApplyFireVisuals(false); - UpdateFireState(); + + if (HasAuthority()) + { + UpdateFireState(); + } } void ACookingStation::EndPlay(const EEndPlayReason::Type EndPlayReason) @@ -245,6 +297,14 @@ bool ACookingStation::HasPendingWork() const void ACookingStation::UpdateFireState() { + // Point de decision unique du feu, donc point de garde unique : bIsLit et + // les timers de combustion appartiennent au serveur. Chez un client c'est + // OnRep_IsLit qui allume la flamme, sans jamais rien decider. + if (!HasAuthority()) + { + return; + } + // Un objet DEJA cuit qui peut encore bruler compte comme du travail : sans // ca le feu s'arreterait pile a la fin de la cuisson et la sur-cuisson // n'arriverait jamais. Oublier sa viande doit rester punitif. diff --git a/Source/Survival_projet/Private/CookingStation.cpp.tmp.23276.004bf5974d1a b/Source/Survival_projet/Private/CookingStation.cpp.tmp.23276.004bf5974d1a new file mode 100644 index 0000000..d17bb3c --- /dev/null +++ b/Source/Survival_projet/Private/CookingStation.cpp.tmp.23276.004bf5974d1a @@ -0,0 +1,892 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "CookingStation.h" + +#include "Components/PointLightComponent.h" +#include "Components/SphereComponent.h" +#include "Components/StaticMeshComponent.h" +#include "Engine/World.h" +#include "InventoryComponent.h" +#include "ItemDataAsset.h" +#include "Kismet/GameplayStatics.h" +#include "NiagaraComponent.h" +#include "Net/UnrealNetwork.h" +#include "TimerManager.h" + +#define LOCTEXT_NAMESPACE "CookingStation" + +ACookingStation::ACookingStation() +{ + // Tout passe par des timers : la station n'a rien a faire chaque frame. + PrimaryActorTick.bCanEverTick = false; + + // Acteur du monde. La cuisson et la combustion ne sont comptees QUE par le + // serveur : les timers ne partent jamais sur le reseau, seuls leurs + // resultats -- l'objet devenu cuit, la buche disparue -- sont repliques. + // Quatre clients qui compteraient chacun leur cuisson divergeraient, et le + // steak serait brule chez l'un et cru chez l'autre. + bReplicates = true; + SetReplicateMovement(false); + + BaseMesh = CreateDefaultSubobject(TEXT("BaseMesh")); + SetRootComponent(BaseMesh); + // Le foyer est un obstacle physique, contrairement a un ramassable : on + // n'entre pas dans un feu de camp. + BaseMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); + + InteractionSphere = CreateDefaultSubobject(TEXT("InteractionSphere")); + InteractionSphere->SetupAttachment(BaseMesh); + InteractionSphere->InitSphereRadius(90.f); + InteractionSphere->SetCollisionEnabled(ECollisionEnabled::QueryOnly); + InteractionSphere->SetCollisionResponseToAllChannels(ECR_Ignore); + InteractionSphere->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block); + + FireEffect = CreateDefaultSubobject(TEXT("FireEffect")); + FireEffect->SetupAttachment(BaseMesh); + // Surtout pas d'auto-activation : un feu neuf n'a pas de bois, il ne doit + // pas flamber une frame avant que BeginPlay ne le coupe. + FireEffect->SetAutoActivate(false); + + FireLight = CreateDefaultSubobject(TEXT("FireLight")); + FireLight->SetupAttachment(BaseMesh); + FireLight->SetRelativeLocation(FVector(0.f, 0.f, 40.f)); + + // Movable, sinon rien ne fonctionne : une lumiere Static -- le defaut d'un + // composant lumiere -- est cuite dans le lightmap, et l'allumer ou + // l'eteindre a l'execution n'a strictement aucun effet a l'ecran. + FireLight->SetMobility(EComponentMobility::Movable); + + FireLight->SetIntensity(3000.f); + FireLight->SetAttenuationRadius(600.f); + FireLight->SetLightColor(FLinearColor(1.f, 0.55f, 0.2f)); + + // Une lumiere ponctuelle projette ses ombres sur SIX faces de cubemap, a + // chaque frame et pour chaque objet a portee. Sur un feu de camp qu'on + // posera par dizaines, c'est le poste le plus cher du systeme, et de loin. + // Coche-la sur le feu principal du campement si tu la veux, pas partout. + FireLight->CastShadows = false; + + FireLight->SetVisibility(false); + + SlotMeshes.Reserve(MaxCookingSlots); + for (int32 Index = 0; Index < MaxCookingSlots; ++Index) + { + const FName ComponentName(*FString::Printf(TEXT("CookSlot_%d"), Index)); + + UStaticMeshComponent* SlotMesh = CreateDefaultSubobject(ComponentName); + SlotMesh->SetupAttachment(BaseMesh); + SlotMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision); + // Visible dans l'editeur pour que tu puisses positionner l'emplacement a + // la souris avec un mesh de reperage ; le jeu le cache au demarrage tant + // qu'il n'y a rien dessus. + SlotMesh->SetHiddenInGame(false); + + SlotMeshes.Add(SlotMesh); + } +} + +void ACookingStation::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + DOREPLIFETIME(ACookingStation, Slots); + DOREPLIFETIME(ACookingStation, Fuel); + DOREPLIFETIME(ACookingStation, bIsLit); +} + +void ACookingStation::OnRep_Slots() +{ + // Le mesh d'un emplacement suit son contenu : c'est TOUT l'affichage de la + // cuisson, il n'y a aucune interface. Cru, cuit, brule -- le joueur ne lit + // que ca. + for (int32 Index = 0; Index < Slots.Num(); ++Index) + { + RefreshSlotMesh(Index); + } +} + +void ACookingStation::OnRep_Fuel() +{ + RefreshFuelIndicators(); +} + +void ACookingStation::OnRep_IsLit() +{ + ApplyFireVisuals(bIsLit); +} + +void ACookingStation::BeginPlay() +{ + Super::BeginPlay(); + + // Recensement des buches AVANT tout : il fixe la capacite, et il est purement + // local -- ce sont des composants du Blueprint, identiques partout. + CacheFuelIndicators(); + + // Le contenu initial est de l'etat de monde : seul le serveur le pose, les + // clients le recevront par replication. Sans cette garde, chaque client se + // donnerait ses propres buches de depart en plus de celles du serveur. + if (HasAuthority()) + { + Slots.SetNum(MaxCookingSlots); + + if (StartingFuelItem && StartingFuelItem->bIsFuel) + { + const int32 Count = FMath::Min(StartingFuelCount, GetFuelCapacity()); + for (int32 Index = 0; Index < Count; ++Index) + { + FFuelSlotState& NewFuel = Fuel.AddDefaulted_GetRef(); + NewFuel.Item = StartingFuelItem; + NewFuel.RemainingSeconds = FMath::Max(1.f, StartingFuelItem->FuelDuration); + } + } + } + + // Les meshes de reperage poses dans le Blueprint n'ont plus rien a faire la : + // RefreshSlotMesh remet chaque emplacement sur son contenu reel, donc rien. + // Chez un client Slots est encore vide a cet instant ; OnRep_Slots repassera + // des que le contenu reel arrivera. + for (int32 Index = 0; Index < Slots.Num(); ++Index) + { + RefreshSlotMesh(Index); + } + + RefreshFuelIndicators(); + + // Le grill est vide au demarrage, donc le feu reste eteint quoi qu'il + // arrive. On passe quand meme par le point de decision unique plutot que + // d'ecrire un cas particulier de plus. + ApplyFireVisuals(false); + + if (HasAuthority()) + { + UpdateFireState(); + } +} + +void ACookingStation::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // Les timers portent un delegue vers this. Les laisser tourner apres la + // destruction de l'acteur est le genre de detail qui ne se voit qu'au + // changement de niveau, sous la forme d'un crash sans pile lisible. + if (UWorld* World = GetWorld()) + { + FTimerManager& TimerManager = World->GetTimerManager(); + TimerManager.ClearTimer(FuelTimer); + + for (FCookingSlotState& Slot : Slots) + { + TimerManager.ClearTimer(Slot.Timer); + } + } + + Super::EndPlay(EndPlayReason); +} + +// ---------------------------------------------------------------------- +// Carburant et allumage +// ---------------------------------------------------------------------- + +float ACookingStation::GetFuelRemaining() const +{ + float Total = 0.f; + + for (int32 Index = 0; Index < Fuel.Num(); ++Index) + { + // Feu allume, c'est le timer qui fait autorite pour la buche en cours : + // son RemainingSeconds n'est rafraichi qu'a l'extinction. + if (Index == 0 && bIsLit) + { + Total += FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(FuelTimer)); + } + else + { + Total += Fuel[Index].RemainingSeconds; + } + } + + return Total; +} + +void ACookingStation::SyncFuelFromTimer() +{ + if (bIsLit && Fuel.Num() > 0) + { + Fuel[0].RemainingSeconds = FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(FuelTimer)); + } +} + +void ACookingStation::StartFuelTimer() +{ + GetWorldTimerManager().ClearTimer(FuelTimer); + + if (!bIsLit || Fuel.Num() == 0) + { + return; + } + + const float Duration = FMath::Max(0.05f, Fuel[0].RemainingSeconds); + GetWorldTimerManager().SetTimer(FuelTimer, this, &ACookingStation::HandleFuelConsumed, Duration, false); +} + +bool ACookingStation::Light() +{ + if (bIsLit) + { + return true; + } + + if (Fuel.Num() == 0) + { + return false; + } + + bIsLit = true; + StartFuelTimer(); + + // Reprise, pas redemarrage : chaque cuisson repart exactement la ou elle + // s'etait figee quand le feu s'est eteint. + for (const FCookingSlotState& Slot : Slots) + { + GetWorldTimerManager().UnPauseTimer(Slot.Timer); + } + + ApplyFireVisuals(true); + UGameplayStatics::PlaySoundAtLocation(this, LightSound, GetActorLocation()); + + OnStationChanged.Broadcast(); + return true; +} + +void ACookingStation::Extinguish() +{ + if (!bIsLit) + { + return; + } + + SyncFuelFromTimer(); + GetWorldTimerManager().ClearTimer(FuelTimer); + bIsLit = false; + + for (const FCookingSlotState& Slot : Slots) + { + GetWorldTimerManager().PauseTimer(Slot.Timer); + } + + ApplyFireVisuals(false); + UGameplayStatics::PlaySoundAtLocation(this, ExtinguishSound, GetActorLocation()); + + OnStationChanged.Broadcast(); +} + +bool ACookingStation::HasPendingWork() const +{ + for (const FCookingSlotState& Slot : Slots) + { + float Unused = 0.f; + if (!Slot.IsEmpty() && GetNextStage(Slot.Item, Unused)) + { + return true; + } + } + + return false; +} + +void ACookingStation::UpdateFireState() +{ + // Un objet DEJA cuit qui peut encore bruler compte comme du travail : sans + // ca le feu s'arreterait pile a la fin de la cuisson et la sur-cuisson + // n'arriverait jamais. Oublier sa viande doit rester punitif. + if (Fuel.Num() > 0 && HasPendingWork()) + { + Light(); + } + else + { + Extinguish(); + } +} + +void ACookingStation::HandleFuelConsumed() +{ + if (Fuel.Num() > 0) + { + // RemoveAt et non un index de lecture qui avance : le foyer doit + // toujours se lire "les N premieres buches sont pleines", c'est ce qui + // rend l'affichage trivial et sans etat supplementaire. + Fuel.RemoveAt(0); + } + + RefreshFuelIndicators(); + + if (Fuel.Num() == 0) + { + Extinguish(); + return; + } + + StartFuelTimer(); + OnStationChanged.Broadcast(); +} + +int32 ACookingStation::FindFuelSource(const UInventoryComponent* Inventory, UItemDataAsset*& OutItem) +{ + OutItem = nullptr; + + if (!Inventory) + { + return INDEX_NONE; + } + + // La main d'abord : c'est le seul moyen pour le joueur de designer ce qu'il + // accepte de bruler. Sans cette priorite, remplir le feu piocherait la + // premiere chose venue et ses planches partiraient avant ses branches. + const FInventorySlot Selected = Inventory->GetSelectedSlot(); + if (Selected.Item && Selected.Item->bIsFuel) + { + OutItem = Selected.Item; + return Inventory->GetSelectedHotbarIndex(); + } + + const TArray& InventorySlots = Inventory->GetSlots(); + for (int32 Index = 0; Index < InventorySlots.Num(); ++Index) + { + if (!InventorySlots[Index].IsEmpty() && InventorySlots[Index].Item->bIsFuel) + { + OutItem = InventorySlots[Index].Item; + return Index; + } + } + + return INDEX_NONE; +} + +bool ACookingStation::AddFuelFrom(int32 InventorySlotIndex, AActor* Interactor) +{ + UInventoryComponent* Inventory = GetInventoryOf(Interactor); + if (!Inventory || Fuel.Num() >= GetFuelCapacity()) + { + return false; + } + + // Un seul exemplaire, jamais la pile : c'est le joueur qui decide combien + // de bois il engage, buche par buche. + FInventorySlot Taken; + if (!Inventory->TakeFromSlot(InventorySlotIndex, 1, Taken) || Taken.IsEmpty()) + { + return false; + } + + FFuelSlotState& NewFuel = Fuel.AddDefaulted_GetRef(); + NewFuel.Item = Taken.Item; + NewFuel.RemainingSeconds = FMath::Max(1.f, Taken.Item->FuelDuration); + + RefreshFuelIndicators(); + + // Du bois arrive alors qu'une cuisson attendait : c'est ce qui relance le + // feu. Si rien n'attend, la buche patiente sans se consumer. + UpdateFireState(); + + UGameplayStatics::PlaySoundAtLocation(this, PlaceItemSound, GetActorLocation()); + + OnStationChanged.Broadcast(); + return true; +} + +void ACookingStation::ApplyFireVisuals(bool bActive) +{ + if (bActive) + { + FireEffect->Activate(/*bReset=*/true); + } + else + { + FireEffect->Deactivate(); + } + + FireLight->SetVisibility(bActive); +} + +void ACookingStation::CacheFuelIndicators() +{ + TArray Found; + GetComponents(Found); + + FuelIndicators.Reset(); + for (UStaticMeshComponent* Component : Found) + { + if (Component->ComponentHasTag(FuelIndicatorTag)) + { + FuelIndicators.Add(Component); + } + } + + // GetComponents ne promet aucun ordre. Sans tri, la buche qui disparait en + // premier changerait d'une compilation du Blueprint a l'autre -- un bug + // qu'on ne reproduit jamais deux fois pareil. + // + // TArray::Sort DEREFERENCE les elements des qu'il s'agit de pointeurs, + // TObjectPtr compris : le predicat recoit les composants, pas les pointeurs. + // + // Tri alphabetique, donc Log_10 passerait AVANT Log_2 : au-dela de neuf + // buches, nomme-les Log_01, Log_02... + FuelIndicators.Sort([](const UStaticMeshComponent& A, const UStaticMeshComponent& B) + { + return A.GetName().Compare(B.GetName()) < 0; + }); +} + +void ACookingStation::RefreshFuelIndicators() +{ + for (int32 Index = 0; Index < FuelIndicators.Num(); ++Index) + { + const bool bOccupied = Fuel.IsValidIndex(Index); + FuelIndicators[Index]->SetVisibility(bOccupied); + + if (bOccupied && bFuelIndicatorsUseItemMesh && Fuel[Index].Item) + { + Fuel[Index].Item->ApplyWorldVisualsTo(FuelIndicators[Index]); + } + } +} + +// ---------------------------------------------------------------------- +// Cuisson +// ---------------------------------------------------------------------- + +UItemDataAsset* ACookingStation::GetNextStage(const UItemDataAsset* Item, float& OutDuration) +{ + OutDuration = 0.f; + + if (!Item) + { + return nullptr; + } + + // Le test "!= Item" n'est pas de la paranoia : un resultat qui pointe sur + // l'objet lui-meme fait tourner le timer indefiniment sans que rien ne + // change a l'ecran. La viande "cuit" en boucle, reste crue, et le feu ne + // s'arrete jamais puisqu'il croit avoir du travail. Vu en jeu, ca ressemble + // a un timer casse, et on cherche du cote du code pendant une heure. + // + // La cuisson d'abord : un objet cru qui porterait aussi bCanBurn doit + // devenir cuit avant de se gacher, jamais l'inverse. + if (Item->bCanBeCooked && Item->CookedResult && Item->CookedResult != Item) + { + OutDuration = Item->CookDuration; + return Item->CookedResult; + } + + if (Item->bCanBurn && Item->BurntResult && Item->BurntResult != Item) + { + OutDuration = Item->BurnDuration; + return Item->BurntResult; + } + + return nullptr; +} + +void ACookingStation::StartSlotTimer(int32 SlotIndex) +{ + FCookingSlotState& Slot = Slots[SlotIndex]; + + GetWorldTimerManager().ClearTimer(Slot.Timer); + + if (Slot.IsEmpty()) + { + return; + } + + float Duration = 0.f; + if (!GetNextStage(Slot.Item, Duration)) + { + // Un objet qui se DIT transformable sans avoir d'etape suivante + // exploitable n'est pas une fin de chaine, c'est une donnee cassee. + // Sans ce message il resterait inerte sur le grill, et rien a l'ecran + // ne dirait que le probleme est dans l'asset et pas dans le feu. + if (Slot.Item->bCanBeCooked || Slot.Item->bCanBurn) + { + UE_LOG(LogTemp, Warning, TEXT("ACookingStation : %s se declare transformable mais n'a aucune etape suivante valide (resultat vide, ou pointant sur lui-meme). Il restera inerte sur le feu."), + *GetNameSafe(Slot.Item)); + } + + // Maillon final -- un steak brule -- : il reste sur le grill sans plus + // rien faire, et sans consommer un timer pour ne rien faire. + return; + } + + Duration = FMath::Max(0.05f, Duration / CookSpeedMultiplier); + + const FTimerDelegate Finished = FTimerDelegate::CreateUObject(this, &ACookingStation::HandleSlotFinished, SlotIndex); + GetWorldTimerManager().SetTimer(Slot.Timer, Finished, Duration, false); + + // Poser quelque chose sur un feu eteint est autorise : le compte a rebours + // existe, il attend simplement qu'on allume. + if (!bIsLit) + { + GetWorldTimerManager().PauseTimer(Slot.Timer); + } +} + +void ACookingStation::HandleSlotFinished(int32 SlotIndex) +{ + if (!Slots.IsValidIndex(SlotIndex)) + { + return; + } + + FCookingSlotState& Slot = Slots[SlotIndex]; + + float Unused = 0.f; + UItemDataAsset* NextItem = GetNextStage(Slot.Item, Unused); + if (!NextItem) + { + return; + } + + Slot.Item = NextItem; + RefreshSlotMesh(SlotIndex); + + // Enchainement automatique : le steak cuit qui porte bCanBurn se remet a + // compter tout seul vers le brule. C'est ce qui fait que le joueur doit + // revenir chercher sa viande au lieu de l'oublier sur le feu. + StartSlotTimer(SlotIndex); + + // Apres StartSlotTimer : si ce maillon etait le dernier, il n'y a plus rien + // a transformer et le feu s'arrete de lui-meme. + UpdateFireState(); + + OnStationChanged.Broadcast(); +} + +float ACookingStation::GetSlotTimeRemaining(int32 SlotIndex) const +{ + if (!Slots.IsValidIndex(SlotIndex)) + { + return 0.f; + } + + // GetTimerRemaining renvoie -1 quand le handle ne designe aucun timer actif. + return FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(Slots[SlotIndex].Timer)); +} + +bool ACookingStation::CanTakeFromSlot(int32 SlotIndex) const +{ + if (!Slots.IsValidIndex(SlotIndex) || Slots[SlotIndex].IsEmpty()) + { + return false; + } + + if (bAllowTakingRawItems) + { + return true; + } + + const UItemDataAsset* Item = Slots[SlotIndex].Item; + + // Critere porte par l'objet lui-meme, sans etat a maintenir : tant qu'il + // sait encore CUIRE, il est cru, donc engage. Des qu'il ne sait plus que + // bruler, c'est qu'il a fini sa cuisson et qu'on peut le sauver. + // + // Consequence voulue : reposer une viande cuite sur le feu ne la reverrouille + // pas, alors qu'un booleen "a deja transforme" l'aurait fait. + if (!Item->bCanBeCooked) + { + return true; + } + + // Second filet : un objet dont la chaine est cassee ne cuira jamais. Le + // laisser verrouille le sequestrerait pour toujours sur le grill, avec pour + // seul recours de recharger la partie. + float Unused = 0.f; + return GetNextStage(Item, Unused) == nullptr; +} + +void ACookingStation::RefreshSlotMesh(int32 SlotIndex) +{ + UStaticMeshComponent* SlotMesh = SlotMeshes[SlotIndex]; + const FCookingSlotState& Slot = Slots[SlotIndex]; + + if (Slot.IsEmpty() || !Slot.Item->WorldMesh) + { + SlotMesh->SetStaticMesh(nullptr); + SlotMesh->SetVisibility(false); + return; + } + + // Mesh ET materiau : c'est souvent le materiau seul qui distingue le cru du + // cuit quand les deux etapes partagent la meme geometrie. + Slot.Item->ApplyWorldVisualsTo(SlotMesh); + SlotMesh->SetRelativeScale3D(FVector(CookingMeshScale)); + SlotMesh->SetVisibility(true); +} + +// ---------------------------------------------------------------------- +// Visee et decision +// ---------------------------------------------------------------------- + +int32 ACookingStation::ResolveAimedSlot(const AActor* Interactor) const +{ + if (!Interactor) + { + return INDEX_NONE; + } + + FVector ViewLocation; + FRotator ViewRotation; + // GetActorEyesViewPoint : sur un pawn possede, il renvoie le point de vue du + // controller, donc exactement ce que le joueur voit. On evite ainsi de + // dependre d'AFpsPlayer -- n'importe quel acteur pourra cuisiner. + Interactor->GetActorEyesViewPoint(ViewLocation, ViewRotation); + + const FVector ViewDirection = ViewRotation.Vector(); + const float MinDot = FMath::Cos(FMath::DegreesToRadians(SlotAimHalfAngle)); + + int32 BestIndex = INDEX_NONE; + float BestDot = MinDot; + + const int32 UsedSlots = FMath::Min(CookingSlotCount, MaxCookingSlots); + for (int32 Index = 0; Index < UsedSlots; ++Index) + { + const FVector ToSlot = SlotMeshes[Index]->GetComponentLocation() - ViewLocation; + if (ToSlot.IsNearlyZero()) + { + continue; + } + + // Produit scalaire et non distance a l'ecran : c'est l'ecart ANGULAIRE + // au centre du viseur qui compte, et il ne demande ni projection ni + // acces au viewport. Sous le cone, aucun emplacement n'est vise et le + // joueur s'adresse au foyer lui-meme. + const float Dot = FVector::DotProduct(ToSlot.GetSafeNormal(), ViewDirection); + if (Dot > BestDot) + { + BestDot = Dot; + BestIndex = Index; + } + } + + return BestIndex; +} + +UInventoryComponent* ACookingStation::GetInventoryOf(AActor* Interactor) +{ + return Interactor ? Interactor->FindComponentByClass() : nullptr; +} + +FCookingInteraction ACookingStation::ResolveAction(AActor* Interactor) const +{ + FCookingInteraction Result; + Result.SlotIndex = ResolveAimedSlot(Interactor); + + // Slots n'est dimensionne qu'au BeginPlay : un trace d'interaction qui + // arriverait avant lirait hors du tableau. + if (!Slots.IsValidIndex(Result.SlotIndex)) + { + Result.SlotIndex = INDEX_NONE; + } + + const UInventoryComponent* Inventory = GetInventoryOf(Interactor); + if (Inventory) + { + Result.HeldItem = Inventory->GetSelectedSlot().Item; + Result.FuelSourceSlot = FindFuelSource(Inventory, Result.FuelItem); + } + + // 1. Un emplacement vise et occupe par quelque chose qu'on a le droit de + // reprendre. Recuperer passe avant tout le reste, sinon on ne pourrait + // jamais sortir sa viande en ayant du bois en main. + if (CanTakeFromSlot(Result.SlotIndex)) + { + Result.Action = ECookingAction::TakeItem; + return Result; + } + + // 2. Un emplacement vise et libre, quelque chose de cuisinable en main. + if (Result.SlotIndex != INDEX_NONE && Slots[Result.SlotIndex].IsEmpty() + && Result.HeldItem && Result.HeldItem->CanBePlacedOnFire()) + { + Result.Action = ECookingAction::PlaceItem; + return Result; + } + + // 3. Du bois quelque part dans l'inventaire et de la place dans le foyer. + // Pas besoin de l'avoir en main : la main sert a CHOISIR le combustible, + // pas a autoriser le geste. + if (Result.FuelSourceSlot != INDEX_NONE && Fuel.Num() < GetFuelCapacity()) + { + Result.Action = ECookingAction::AddFuel; + return Result; + } + + // Pas de quatrieme cas : allumer n'est plus un geste du joueur. + return Result; +} + +// ---------------------------------------------------------------------- +// Actions +// ---------------------------------------------------------------------- + +bool ACookingStation::PlaceItemFromHand(int32 SlotIndex, AActor* Interactor) +{ + UInventoryComponent* Inventory = GetInventoryOf(Interactor); + if (!Inventory) + { + return false; + } + + // Un seul exemplaire, meme si la pile en contient vingt : un emplacement de + // grill porte une piece, comme dans Raft. + FInventorySlot Taken; + if (!Inventory->TakeFromSlot(Inventory->GetSelectedHotbarIndex(), 1, Taken) || Taken.IsEmpty()) + { + return false; + } + + FCookingSlotState& Slot = Slots[SlotIndex]; + Slot.Item = Taken.Item; + Slot.RemainingUses = Taken.RemainingUses; + + RefreshSlotMesh(SlotIndex); + StartSlotTimer(SlotIndex); + + // Poser quelque chose EST ce qui declenche le feu, s'il a du bois. + UpdateFireState(); + + UGameplayStatics::PlaySoundAtLocation(this, PlaceItemSound, SlotMeshes[SlotIndex]->GetComponentLocation()); + + OnStationChanged.Broadcast(); + return true; +} + +bool ACookingStation::TakeItemToInventory(int32 SlotIndex, AActor* Interactor) +{ + UInventoryComponent* Inventory = GetInventoryOf(Interactor); + if (!Inventory) + { + return false; + } + + FCookingSlotState& Slot = Slots[SlotIndex]; + + // On tente l'ajout AVANT de vider l'emplacement. Inventaire plein : la + // nourriture reste sur le feu -- et continue donc de cuire, ce qui est la + // bonne sanction plutot que de la faire disparaitre. + const int32 Leftover = Inventory->AddItem(Slot.Item, 1, Slot.RemainingUses); + if (Leftover > 0) + { + return false; + } + + GetWorldTimerManager().ClearTimer(Slot.Timer); + Slot.Item = nullptr; + Slot.RemainingUses = -1; + + RefreshSlotMesh(SlotIndex); + + // Le grill vide, plus rien ne justifie de bruler du bois. + UpdateFireState(); + + OnStationChanged.Broadcast(); + return true; +} + +// ---------------------------------------------------------------------- +// IInteractable +// ---------------------------------------------------------------------- + +bool ACookingStation::CanInteract_Implementation(AActor* Interactor) const +{ + // Toujours vrai : meme sans rien a faire, le poste doit rester visable pour + // que le prompt puisse annoncer son etat. C'est la seule interface qu'a le + // joueur pour savoir ou en est sa cuisson et combien de bois il lui reste. + // Le fait qu'une action soit possible ou non se dit par + // IsInteractionAvailable, qui n'efface pas le texte. + return true; +} + +FText ACookingStation::GetInteractionPrompt_Implementation(AActor* Interactor) const +{ + const FCookingInteraction Interaction = ResolveAction(Interactor); + + switch (Interaction.Action) + { + case ECookingAction::TakeItem: + { + const FText ItemName = Slots[Interaction.SlotIndex].Item->DisplayName; + const float Remaining = GetSlotTimeRemaining(Interaction.SlotIndex); + + // Exactement la formulation d'un objet au sol : une fois cuit, ce n'est + // plus une manipulation de four, c'est un ramassage. + if (!bShowRemainingTimeInPrompt || Remaining <= 0.f || !bIsLit) + { + return FText::Format(LOCTEXT("TakeDone", "Pick up {0}"), ItemName); + } + + return FText::Format(LOCTEXT("TakeDoneTimed", "Pick up {0} ({1}s)"), + ItemName, FText::AsNumber(FMath::CeilToInt(Remaining))); + } + + case ECookingAction::PlaceItem: + return FText::Format(LOCTEXT("PlaceOnFire", "Cook {0}"), Interaction.HeldItem->DisplayName); + + case ECookingAction::AddFuel: + return FText::Format(LOCTEXT("AddFuel", "Add {0} to the fire"), Interaction.FuelItem->DisplayName); + + default: + break; + } + + // Aucune action possible : aucun texte. Tout ce qu'un commentaire d'etat + // dirait -- ca cuit, il manque du bois, le foyer est plein -- se lit deja + // sur les flammes, sur le nombre de buches et sur le mesh de la nourriture. + // L'ecrire en plus, c'est reconstruire une interface par-dessus un monde + // qui se suffit a lui-meme. + return FText::GetEmpty(); +} + +void ACookingStation::Interact_Implementation(AActor* Interactor) +{ + // Meme fonction que le prompt : ce que le joueur vient de lire est + // exactement ce qui va se produire. + const FCookingInteraction Interaction = ResolveAction(Interactor); + + switch (Interaction.Action) + { + case ECookingAction::TakeItem: + TakeItemToInventory(Interaction.SlotIndex, Interactor); + break; + + case ECookingAction::PlaceItem: + PlaceItemFromHand(Interaction.SlotIndex, Interactor); + break; + + case ECookingAction::AddFuel: + AddFuelFrom(Interaction.FuelSourceSlot, Interactor); + break; + + default: + break; + } +} + +void ACookingStation::OnBeginFocus_Implementation() +{ + if (bHighlightOnFocus) + { + BaseMesh->SetRenderCustomDepth(true); + } +} + +void ACookingStation::OnEndFocus_Implementation() +{ + if (bHighlightOnFocus) + { + BaseMesh->SetRenderCustomDepth(false); + } +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/Survival_projet/Private/CraftingComponent.cpp b/Source/Survival_projet/Private/CraftingComponent.cpp index 7aa7ee9..26abe94 100644 --- a/Source/Survival_projet/Private/CraftingComponent.cpp +++ b/Source/Survival_projet/Private/CraftingComponent.cpp @@ -13,6 +13,12 @@ UCraftingComponent::UCraftingComponent() // Necessaire pour que InitializeComponent() soit appele. bWantsInitializeComponent = true; + + // Aucune propriete repliquee : le composant ne porte que StationRefCounts, + // tenu a jour des deux cotes par le controller. C'est uniquement le routage + // de Server_Craft qui l'exige -- un composant non replique voit ses RPC + // serveur silencieusement jetees. + SetIsReplicatedByDefault(true); } void UCraftingComponent::InitializeComponent() @@ -198,8 +204,29 @@ void UCraftingComponent::GatherIngredientStatus(const UCraftingRecipeDataAsset* } } +void UCraftingComponent::Server_Craft_Implementation(UCraftingRecipeDataAsset* Recipe) +{ + Craft(Recipe); +} + bool UCraftingComponent::Craft(UCraftingRecipeDataAsset* Recipe) { + // Fabriquer consomme et ajoute dans l'inventaire, devenu autoritaire. + // Le client demande, le serveur decide -- et il refait CanCraft de son cote + // avec SA liste de postes accessibles : sans ca, un client desynchronise + // fabriquerait une recette d'etabli au milieu de la foret. + const AActor* Owner = GetOwner(); + if (!Owner || !Owner->HasAuthority()) + { + Server_Craft(Recipe); + + // "Demande partie", pas "objet fabrique". Le resultat arrivera par la + // replication de l'inventaire, et OnCraftSucceeded n'est diffuse que + // sur le serveur -- une animation de reussite devra passer par une RPC + // Client_ le jour ou il y en aura une. + return true; + } + ECraftFailureReason Reason = ECraftFailureReason::None; if (!CanCraft(Recipe, Reason)) { diff --git a/Source/Survival_projet/Private/FpsPlayer.cpp b/Source/Survival_projet/Private/FpsPlayer.cpp index 14bb26a..b964cf1 100644 --- a/Source/Survival_projet/Private/FpsPlayer.cpp +++ b/Source/Survival_projet/Private/FpsPlayer.cpp @@ -17,6 +17,7 @@ #include "InteractionComponent.h" #include "InventoryComponent.h" #include "ItemDataAsset.h" +#include "Net/UnrealNetwork.h" #include "PickupItem.h" #include "SurvivalStatsComponent.h" #include "SurvivalUserSettings.h" @@ -448,14 +449,58 @@ void AFpsPlayer::StartSprint() return; } - bIsSprinting = true; - GetCharacterMovement()->MaxWalkSpeed = SprintSpeed; + SetSprinting(true); +} + +void AFpsPlayer::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + // COND_SkipOwner : celui qui sprinte le sait deja, il vient de l'appliquer + // en local. Le renvoyer ecraserait son etat courant par une valeur qui a un + // aller-retour de retard -- exactement ce qui fait "sauter" un sprint qu'on + // relance vite. + DOREPLIFETIME_CONDITION(AFpsPlayer, bIsSprinting, COND_SkipOwner); } void AFpsPlayer::StopSprint() { - bIsSprinting = false; - GetCharacterMovement()->MaxWalkSpeed = WalkSpeed; + SetSprinting(false); +} + +void AFpsPlayer::SetSprinting(bool bNewSprinting) +{ + if (bIsSprinting == bNewSprinting) + { + return; + } + + bIsSprinting = bNewSprinting; + ApplySprintSpeed(); + + // Applique en local d'abord pour que la vitesse soit instantanee sous les + // doigts, puis previent le serveur. Le sens inverse -- attendre la reponse -- + // donnerait un sprint qui demarre avec un temps de retard. + if (!HasAuthority()) + { + Server_SetSprinting(bNewSprinting); + } +} + +void AFpsPlayer::Server_SetSprinting_Implementation(bool bNewSprinting) +{ + bIsSprinting = bNewSprinting; + ApplySprintSpeed(); +} + +void AFpsPlayer::OnRep_IsSprinting() +{ + ApplySprintSpeed(); +} + +void AFpsPlayer::ApplySprintSpeed() +{ + GetCharacterMovement()->MaxWalkSpeed = bIsSprinting ? SprintSpeed : WalkSpeed; } void AFpsPlayer::HandleSprintReleased() @@ -471,7 +516,33 @@ void AFpsPlayer::HandleSprintReleased() void AFpsPlayer::UseSelectedItem() { - const FInventorySlot Selected = InventoryComponent->GetSelectedSlot(); + const int32 SelectedIndex = InventoryComponent->GetSelectedHotbarIndex(); + + // L'hote agit tout de suite, le client demande. On envoie meme si le slot + // parait vide chez nous : c'est le serveur qui fait foi, et son inventaire + // peut avoir une frame d'avance sur le notre. + if (!HasAuthority()) + { + Server_UseItem(SelectedIndex); + return; + } + + PerformUseItem(SelectedIndex); +} + +void AFpsPlayer::Server_UseItem_Implementation(int32 SlotIndex) +{ + PerformUseItem(SlotIndex); +} + +void AFpsPlayer::PerformUseItem(int32 SlotIndex) +{ + if (!HasAuthority() || !InventoryComponent->GetSlots().IsValidIndex(SlotIndex)) + { + return; + } + + const FInventorySlot Selected = InventoryComponent->GetSlots()[SlotIndex]; if (Selected.IsEmpty()) { return; @@ -493,7 +564,7 @@ void AFpsPlayer::UseSelectedItem() // usages multiples (une charge en moins, l'objet reste tant qu'il en a). // On vise le slot actif precisement, et pas RemoveItem qui piocherait // n'importe quelle pile du meme objet, y compris dans le sac. - InventoryComponent->ConsumeUse(InventoryComponent->GetSelectedHotbarIndex()); + InventoryComponent->ConsumeUse(SlotIndex); } FVector AFpsPlayer::PlaceOnGround(const FVector& Start) const @@ -543,6 +614,11 @@ bool AFpsPlayer::DropSlot(int32 SlotIndex, int32 Quantity) return DropFromInventory(InventoryComponent, SlotIndex, Quantity); } +void AFpsPlayer::Server_DropFromInventory_Implementation(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity) +{ + DropFromInventory(Inventory, SlotIndex, Quantity); +} + bool AFpsPlayer::DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity) { if (!Inventory) @@ -550,6 +626,16 @@ bool AFpsPlayer::DropFromInventory(UInventoryComponent* Inventory, int32 SlotInd return false; } + // Jeter engendre un acteur dans le monde : c'est une action d'autorite. + // La position de depot est calculee cote SERVEUR a partir de la camera + // repliquee du pawn -- a quelques centimetres pres du point vise par le + // client, ce qui n'a aucune importance pour un objet pose au sol. + if (!HasAuthority()) + { + Server_DropFromInventory(Inventory, SlotIndex, Quantity); + return true; + } + if (!DroppedPickupClass) { UE_LOG(LogTemp, Warning, TEXT("AFpsPlayer : DroppedPickupClass n'est pas assignee, impossible de jeter un objet.")); @@ -618,6 +704,14 @@ void AFpsPlayer::HandleCraftOverflow(UItemDataAsset* Item, int32 Quantity, int32 void AFpsPlayer::DropAllItems() { + // Declenche par la mort, qui sera arbitree par le serveur. La garde est la + // pour le jour ou un client l'appellerait : il engendrerait des pickups + // visibles de lui seul, qui disparaitraient au premier nettoyage reseau. + if (!HasAuthority()) + { + return; + } + if (!DroppedPickupClass) { UE_LOG(LogTemp, Warning, TEXT("AFpsPlayer : DroppedPickupClass n'est pas assignee, le butin est perdu.")); diff --git a/Source/Survival_projet/Private/FpsPlayerController.cpp b/Source/Survival_projet/Private/FpsPlayerController.cpp index 42bb774..4cc6381 100644 --- a/Source/Survival_projet/Private/FpsPlayerController.cpp +++ b/Source/Survival_projet/Private/FpsPlayerController.cpp @@ -16,9 +16,14 @@ #include "InteractionPromptWidget.h" #include "CraftingComponent.h" #include "InventoryScreenWidget.h" +#include "Engine/LocalPlayer.h" +#include "GameFramework/PlayerState.h" +#include "Interfaces/OnlineIdentityInterface.h" #include "Kismet/GameplayStatics.h" +#include "OnlineSubsystem.h" #include "PauseMenuWidget.h" #include "ScreenFadeComponent.h" +#include "SessionSubsystem.h" #include "SettingsMenuWidget.h" #include "StorageContainer.h" #include "SurvivalStatsWidget.h" @@ -87,6 +92,23 @@ void AFpsPlayerController::BeginPlay() { SetInputMode(FInputModeGameOnly()); SetShowMouseCursor(false); + + // Annonce notre pseudo Steam. Seul le client connait le sien : l'API + // d'identite ne renseigne que l'utilisateur local de chaque machine. + const FString Nickname = ResolveLocalPlayerNickname(); + if (!Nickname.IsEmpty()) + { + // L'hote est deja l'autorite : passer par la RPC marcherait, mais + // l'appel direct evite un aller-retour pour rien. + if (HasAuthority()) + { + Server_SetPlayerNickname_Implementation(Nickname); + } + else + { + Server_SetPlayerNickname(Nickname); + } + } } // Le delegue se declenche a la fin de CHAQUE fondu : c'est @@ -143,6 +165,21 @@ void AFpsPlayerController::HandlePawnDied() } void AFpsPlayerController::RespawnPlayer() +{ + // Le fondu de retour est purement local : il appartient a l'ecran de ce + // joueur, et il doit remonter meme si la reponse du serveur tarde. + ScreenFade->FadeIn(StartupFadeDuration); + + if (!HasAuthority()) + { + Server_RequestRespawn(); + return; + } + + Server_RequestRespawn_Implementation(); +} + +void AFpsPlayerController::Server_RequestRespawn_Implementation() { // On depossede AVANT de detruire : detruire un pawn encore possede laisse // le controller dans un etat incoherent le temps d'une frame. @@ -164,8 +201,6 @@ void AFpsPlayerController::RespawnPlayer() { UE_LOG(LogTemp, Error, TEXT("AFpsPlayerController : aucun GameMode, impossible de reapparaitre.")); } - - ScreenFade->FadeIn(StartupFadeDuration); } void AFpsPlayerController::SetupInputComponent() @@ -456,6 +491,32 @@ void AFpsPlayerController::OpenCraftingAtStation(ECraftingStation Station) return; } + // Appelee depuis Interact_Implementation, donc sur le SERVEUR. Pour un + // client distant on accorde le poste ici -- c'est ce controller-la que + // Server_Craft interrogera -- et on demande a l'ecran de s'ouvrir la-bas. + // Chez un hote, IsLocalController() est vrai et on passe tout droit. + if (!IsLocalController()) + { + GrantStation(Station); + Client_OpenCraftingAtStation(Station); + return; + } + + GrantStation(Station); + SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true); +} + +void AFpsPlayerController::Client_OpenCraftingAtStation_Implementation(ECraftingStation Station) +{ + // On rejoue le chemin local : accorder le poste sur NOTRE copie du + // composant, sans quoi la grille de recettes filtrerait sur {Hands} et + // l'onglet paraitrait vide devant un etabli. + GrantStation(Station); + SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true); +} + +void AFpsPlayerController::GrantStation(ECraftingStation Station) +{ // Deja ouvert sur un autre poste : on rend le precedent avant d'accorder le // nouveau, sinon le compteur du composant ne redescendrait jamais. ReleaseActiveStation(); @@ -465,8 +526,22 @@ void AFpsPlayerController::OpenCraftingAtStation(ECraftingStation Station) Crafting->AddAvailableStation(Station); ActiveStation = Station; } +} - SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true); +void AFpsPlayerController::Server_ReleaseStation_Implementation(ECraftingStation Station) +{ + if (UCraftingComponent* Crafting = GetPawnCrafting()) + { + Crafting->RemoveAvailableStation(Station); + } +} + +void AFpsPlayerController::Server_ReleaseStorage_Implementation(AStorageContainer* Container) +{ + if (Container) + { + Container->SetOpen(false); + } } void AFpsPlayerController::ReleaseActiveStation() @@ -481,6 +556,14 @@ void AFpsPlayerController::ReleaseActiveStation() Crafting->RemoveAvailableStation(ActiveStation); } + // Le serveur tient sa propre liste de postes accessibles, celle qui sert a + // valider Server_Craft. La laisser ouverte rendrait les recettes d'etabli + // fabricables n'importe ou pour le reste de la partie. + if (!HasAuthority()) + { + Server_ReleaseStation(ActiveStation); + } + ActiveStation = ECraftingStation::Count; } @@ -600,7 +683,22 @@ void AFpsPlayerController::SetUiInputMode(bool bUiOpen) void AFpsPlayerController::OpenStorage(AStorageContainer* Container) { - if (!Container || !IsLocalController() || !InventoryScreen) + if (!Container) + { + return; + } + + // Appelee depuis Interact_Implementation, donc sur le SERVEUR. Le couvercle + // est de l'etat de monde : il se leve ICI, et se replique aux quatre + // joueurs. Seul l'ecran repart chez le client. + if (!IsLocalController()) + { + Container->SetOpen(true); + Client_OpenStorage(Container); + return; + } + + if (!InventoryScreen) { return; } @@ -613,6 +711,10 @@ void AFpsPlayerController::OpenStorage(AStorageContainer* Container) bStorageOpen = true; InventoryScreen->ShowStorage(Container); + + // SetOpen ne fait rien sans autorite : chez un client c'est la replication + // de bOpen qui animera le couvercle. L'appel reste la pour l'hote, qui est + // bien l'autorite et doit lever le sien tout de suite. Container->SetOpen(true); // La barre rapide reste visible et cliquable par-dessus l'ecran : on lui @@ -634,6 +736,14 @@ void AFpsPlayerController::OpenStorage(AStorageContainer* Container) StorageDistanceCheckPeriod, /*bLoop=*/true); } +void AFpsPlayerController::Client_OpenStorage_Implementation(AStorageContainer* Container) +{ + // Chez le client IsLocalController() est vrai : OpenStorage prend donc sa + // branche locale et ouvre reellement l'ecran. Le couvercle, lui, a deja ete + // leve par le serveur et arrive par replication de bOpen. + OpenStorage(Container); +} + void AFpsPlayerController::CloseStorage() { if (!bStorageOpen) @@ -659,7 +769,17 @@ void AFpsPlayerController::ReleaseActiveStorage() if (AStorageContainer* Container = ActiveStorage.Get()) { - Container->SetOpen(false); + // Chez un client SetOpen ne fait rien : le couvercle appartient au + // serveur. On le lui demande, sinon le coffre resterait ouvert pour les + // trois autres joueurs alors que l'ecran est referme ici. + if (HasAuthority()) + { + Container->SetOpen(false); + } + else + { + Server_ReleaseStorage(Container); + } } ActiveStorage.Reset(); @@ -760,18 +880,18 @@ void AFpsPlayerController::OpenPauseMenu() bPauseMenuOpen = true; PauseMenu->SetVisibility(ESlateVisibility::Visible); - // GameAndUI et non UIOnly, meme raison que l'inventaire : en UIOnly plus - // aucune action Enhanced Input ne passe, et Echap ne refermerait rien. - FInputModeGameAndUI Mode; - Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); - Mode.SetHideCursorDuringCapture(false); - SetInputMode(Mode); - SetShowMouseCursor(true); - - // Pas de DisableInput sur le pawn ici, contrairement a l'inventaire : le - // monde est reellement fige, et les actions du pawn n'ont pas - // "Trigger When Paused" -- elles ne se declenchent donc plus d'elles-memes. - SetPause(true); + // AUCUN SetPause : en coop la pause est impossible par nature. Cote client + // l'appel est ignore -- la pause appartient a l'autorite -- et cote hote il + // figerait le monde des quatre joueurs parce qu'un seul a ouvert son menu. + // Le monde continue donc de tourner, comme dans Valheim ou Raft : ouvrir son + // menu ne met pas a l'abri, et c'est coherent avec un jeu de survie. + // + // Consequence directe : il faut desormais couper l'input du PAWN, ce que la + // pause faisait gratuitement. C'est exactement le travail de SetUiInputMode, + // deja utilise par l'inventaire et le coffre -- un second chemin finirait + // par diverger, et le symptome (camera qui tourne derriere le menu, sprint + // bloque en sortant) se cherche tres longtemps. + SetUiInputMode(true); } void AFpsPlayerController::ResumeGame() @@ -800,10 +920,65 @@ void AFpsPlayerController::ResumeGame() PauseMenu->SetVisibility(ESlateVisibility::Collapsed); } - SetPause(false); + SetUiInputMode(false); +} - SetInputMode(FInputModeGameOnly()); - SetShowMouseCursor(false); +FString AFpsPlayerController::ResolveLocalPlayerNickname() const +{ + // On passe par l'interface generique, jamais par du Steam explicite : le + // meme code rend le pseudo Steam en ligne et le nom local en LAN. + if (const IOnlineSubsystem* Online = IOnlineSubsystem::Get()) + { + const IOnlineIdentityPtr Identity = Online->GetIdentityInterface(); + if (Identity.IsValid()) + { + const FString Nickname = Identity->GetPlayerNickname(0); + if (!Nickname.IsEmpty()) + { + return Nickname; + } + } + } + + // Repli sans service en ligne : le nom du LocalPlayer, qui vaut le plus + // souvent "Player". Mieux qu'une etiquette vide. + if (const ULocalPlayer* LocalPlayerRef = GetLocalPlayer()) + { + return LocalPlayerRef->GetNickname(); + } + + return FString(); +} + +void AFpsPlayerController::Server_SetPlayerNickname_Implementation(const FString& Nickname) +{ + APlayerState* State = GetPlayerState(); + if (!State || Nickname.IsEmpty()) + { + return; + } + + // Borne de longueur : un pseudo Steam monte a 32 caracteres, mais rien + // n'empeche un client modifie d'en envoyer un de dix mille. On tronque + // plutot que de refuser -- refuser laisserait une etiquette vide, tronquer + // donne un nom lisible. + State->SetPlayerName(Nickname.Left(32)); + + UE_LOG(LogTemp, Log, TEXT("AFpsPlayerController : pseudo [%s]."), *State->GetPlayerName()); +} + +void AFpsPlayerController::ShowInviteFriends() +{ + if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr) + { + Sessions->ShowInviteUI(); + } +} + +bool AFpsPlayerController::CanInviteFriends() const +{ + const USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr; + return Sessions && Sessions->CanInvite(); } void AFpsPlayerController::OpenSettings() @@ -957,11 +1132,6 @@ void AFpsPlayerController::HandleFadeFinished() return; } - // La pause vit sur les WorldSettings, que OpenLevel detruit avec le monde. - // On la leve quand meme : un monde qu'on quitte en etat de pause est un - // piege si le chargement echoue. - SetPause(false); - UGameplayStatics::OpenLevelBySoftObjectPtr(this, MainMenuLevel); } diff --git a/Source/Survival_projet/Private/InteractionComponent.cpp b/Source/Survival_projet/Private/InteractionComponent.cpp index 17c1a53..4709cae 100644 --- a/Source/Survival_projet/Private/InteractionComponent.cpp +++ b/Source/Survival_projet/Private/InteractionComponent.cpp @@ -12,12 +12,26 @@ UInteractionComponent::UInteractionComponent() { PrimaryComponentTick.bCanEverTick = true; + + // Indispensable pour que Server_Interact soit route : un composant non + // replique voit ses RPC serveur silencieusement jetes par le moteur. Aucune + // propriete n'est repliquee pour autant, seul le routage nous interesse. + SetIsReplicatedByDefault(true); } void UInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + // Le trace ne sert qu'a afficher un prompt, donc uniquement a celui qui + // regarde. Sans ce filtre, l'hote lancerait un trace physique pour CHACUN + // des quatre pawns a 20 Hz, dont trois dont il n'affichera jamais l'UI. + const APawn* OwnerPawn = Cast(GetOwner()); + if (!OwnerPawn || !OwnerPawn->IsLocallyControlled()) + { + return; + } + // On espace les traces : c'est une requete physique, pas gratuite, et // l'affichage du prompt n'a pas besoin d'etre rafraichi chaque frame. TimeSinceLastTrace += DeltaTime; @@ -158,14 +172,55 @@ void UInteractionComponent::TryInteract() return; } - IInteractable::Execute_Interact(Target, GetOwner()); + // L'hote agit directement, le client demande. Le test porte sur l'autorite + // et non sur "suis-je un client" : sur un serveur d'ecoute, le joueur qui + // heberge passe par la premiere branche et ne paie aucune latence. + if (GetOwner()->HasAuthority()) + { + PerformInteract(Target); + } + else + { + Server_Interact(Target); + } // L'interaction vient peut-etre de detruire la cible, ou de la rendre // non-interactive (porte maintenant ouverte). On reevalue tout de suite // plutot que d'attendre le prochain trace periodique. + // + // Cote client la cible est encore la a cet instant -- le serveur n'a pas + // encore repondu. Ce n'est pas grave : la destruction repliquee videra + // FocusedActor toute seule, et bHasFocusedActor fera diffuser la transition. UpdateFocus(); } +void UInteractionComponent::Server_Interact_Implementation(AActor* Target) +{ + PerformInteract(Target); +} + +void UInteractionComponent::PerformInteract(AActor* Target) +{ + AActor* Owner = GetOwner(); + if (!Owner || !Owner->HasAuthority() || !IsValidInteractable(Target)) + { + return; + } + + // Le client a vise il y a un aller-retour : entre-temps un autre joueur a pu + // ramasser l'objet, ou s'en eloigner. On revalide donc la distance ici, avec + // la meme portee que le trace mais elargie par ServerRangeTolerance. + const float MaxDistance = InteractionRange * ServerRangeTolerance; + if (FVector::DistSquared(Owner->GetActorLocation(), Target->GetActorLocation()) > FMath::Square(MaxDistance)) + { + UE_LOG(LogTemp, Verbose, TEXT("UInteractionComponent : %s a demande %s, hors de portee cote serveur."), + *GetNameSafe(Owner), *GetNameSafe(Target)); + return; + } + + IInteractable::Execute_Interact(Target, Owner); +} + void UInteractionComponent::RefreshPrompt() { AActor* Target = FocusedActor.Get(); diff --git a/Source/Survival_projet/Private/InventoryComponent.cpp b/Source/Survival_projet/Private/InventoryComponent.cpp index 95228e0..ecb4c94 100644 --- a/Source/Survival_projet/Private/InventoryComponent.cpp +++ b/Source/Survival_projet/Private/InventoryComponent.cpp @@ -4,6 +4,7 @@ #include "InventoryComponent.h" #include "ItemDataAsset.h" +#include "Net/UnrealNetwork.h" UInventoryComponent::UInventoryComponent() { @@ -11,6 +12,9 @@ UInventoryComponent::UInventoryComponent() // Necessaire pour que InitializeComponent() soit appele. bWantsInitializeComponent = true; + + // Le contenu appartient au serveur et descend vers les clients. + SetIsReplicatedByDefault(true); } void UInventoryComponent::InitializeComponent() @@ -22,6 +26,30 @@ void UInventoryComponent::InitializeComponent() SelectedHotbarIndex = FMath::Clamp(SelectedHotbarIndex, 0, FMath::Max(0, GetHotbarSlotCount() - 1)); } +void UInventoryComponent::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + DOREPLIFETIME(UInventoryComponent, Slots); +} + +void UInventoryComponent::OnRep_Slots() +{ + // Pas de BroadcastChanged() ici : le regroupement differe ne sert qu'a + // eviter vingt reconstructions d'UI pendant un "Tout ranger" execute en + // local. La replication, elle, arrive deja groupee -- le serveur a fini son + // lot avant d'envoyer quoi que ce soit. + OnInventoryChanged.Broadcast(); +} + +/** + * SelectedHotbarIndex n'est volontairement PAS replique : le slot actif est une + * intention purement locale, chaque joueur choisit le sien et personne d'autre + * n'a besoin de le connaitre. Le jour ou l'objet en main devra etre visible par + * les autres, ce sera une propriete du PAWN -- ce qu'on tient dans sa main est + * une donnee de corps, pas de sac. + */ + void UInventoryComponent::SelectHotbarSlot(int32 Index) { const int32 Count = GetHotbarSlotCount(); @@ -178,8 +206,27 @@ int32 UInventoryComponent::RemoveItem(UItemDataAsset* Item, int32 Quantity) return Removed; } +void UInventoryComponent::Server_TransferAllTo_Implementation(UInventoryComponent* From, UInventoryComponent* To, int32 FirstIndex) +{ + if (From) + { + From->TransferAllTo(To, FirstIndex); + } +} + +void UInventoryComponent::Server_ConsumeUse_Implementation(int32 SlotIndex) +{ + ConsumeUse(SlotIndex); +} + bool UInventoryComponent::ConsumeUse(int32 SlotIndex) { + if (!HasInventoryAuthority()) + { + Server_ConsumeUse(SlotIndex); + return true; + } + if (!Slots.IsValidIndex(SlotIndex)) { return false; @@ -419,6 +466,28 @@ bool UInventoryComponent::ApplySlotMove(FInventorySlot& From, FInventorySlot& To return true; } +bool UInventoryComponent::HasInventoryAuthority() const +{ + const AActor* Owner = GetOwner(); + return Owner && Owner->HasAuthority(); +} + +UInventoryComponent* UInventoryComponent::FindNetProxy(UInventoryComponent* A, UInventoryComponent* B) +{ + auto IsLocalPawnInventory = [](const UInventoryComponent* Inventory) + { + const APawn* OwnerPawn = Inventory ? Cast(Inventory->GetOwner()) : nullptr; + return OwnerPawn && OwnerPawn->IsLocallyControlled(); + }; + + if (IsLocalPawnInventory(A)) + { + return A; + } + + return IsLocalPawnInventory(B) ? B : nullptr; +} + bool UInventoryComponent::TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity) { if (!From || !To || Quantity <= 0) @@ -426,6 +495,43 @@ bool UInventoryComponent::TransferSlot(UInventoryComponent* From, int32 FromInde return false; } + // Chez un client : on ne touche a rien, on demande. Le contenu redescendra + // par OnRep_Slots, qui rediffuse OnInventoryChanged et rafraichit l'UI. + if (!From->HasInventoryAuthority()) + { + UInventoryComponent* Proxy = FindNetProxy(From, To); + if (!Proxy) + { + UE_LOG(LogTemp, Warning, TEXT("UInventoryComponent::TransferSlot : aucun inventaire de joueur local dans l'echange, demande abandonnee.")); + return false; + } + + Proxy->Server_TransferSlot(From, FromIndex, To, ToIndex, Quantity); + + // True signifie ici "demande partie", pas "contenu modifie". Les + // appelants s'en servent pour savoir si le geste a ete pris en compte, + // ce qui reste exact. + return true; + } + + return ApplyTransferSlot(From, FromIndex, To, ToIndex, Quantity); +} + +void UInventoryComponent::Server_TransferSlot_Implementation(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity) +{ + // From et To arrivent par le reseau : ils peuvent etre nuls si l'acteur a + // ete detruit entre l'envoi et l'arrivee -- un coffre casse, un joueur + // deconnecte. ApplyTransferSlot les revalide de toute facon. + ApplyTransferSlot(From, FromIndex, To, ToIndex, Quantity); +} + +bool UInventoryComponent::ApplyTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity) +{ + if (!From || !To || Quantity <= 0) + { + return false; + } + // Meme inventaire et meme case : rien a faire. Le test doit porter sur le // couple et non sur l'index seul, deux inventaires differents ayant tous // les deux une case 3. @@ -491,6 +597,21 @@ int32 UInventoryComponent::QuickTransferSlot(UInventoryComponent* From, int32 Fr return 0; } + if (!From->HasInventoryAuthority()) + { + if (UInventoryComponent* Proxy = FindNetProxy(From, To)) + { + Proxy->Server_QuickTransferSlot(From, FromIndex, To); + + // Le nombre reellement deplace n'est connu que du serveur. On rend + // une valeur non nulle pour que l'appelant sache que le clic a ete + // pris en compte ; l'affichage, lui, viendra de OnRep_Slots. + return 1; + } + + return 0; + } + const int32 Moved = From->PushSlotInto(FromIndex, *To); if (Moved > 0) { @@ -500,6 +621,11 @@ int32 UInventoryComponent::QuickTransferSlot(UInventoryComponent* From, int32 Fr return Moved; } +void UInventoryComponent::Server_QuickTransferSlot_Implementation(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To) +{ + QuickTransferSlot(From, FromIndex, To); +} + int32 UInventoryComponent::TransferAllTo(UInventoryComponent* To, int32 FirstIndex) { if (!To || To == this) @@ -507,6 +633,17 @@ int32 UInventoryComponent::TransferAllTo(UInventoryComponent* To, int32 FirstInd return 0; } + if (!HasInventoryAuthority()) + { + if (UInventoryComponent* Proxy = FindNetProxy(this, To)) + { + Proxy->Server_TransferAllTo(this, To, FirstIndex); + return 1; + } + + return 0; + } + To->BeginBatch(); int32 Moved = 0; diff --git a/Source/Survival_projet/Private/MainMenuPlayerController.cpp b/Source/Survival_projet/Private/MainMenuPlayerController.cpp index 218cc07..a914e27 100644 --- a/Source/Survival_projet/Private/MainMenuPlayerController.cpp +++ b/Source/Survival_projet/Private/MainMenuPlayerController.cpp @@ -10,6 +10,7 @@ #include "MainMenuWidget.h" #include "MenuCameraSpot.h" #include "ScreenFadeComponent.h" +#include "SessionSubsystem.h" #include "SettingsMenuWidget.h" #include "SurvivalGameInstance.h" @@ -78,6 +79,18 @@ void AMainMenuPlayerController::BeginPlay() ScreenFade->OnFadeFinished.AddDynamic(this, &AMainMenuPlayerController::HandleFadeFinished); ScreenFade->FadeIn(OpeningFadeDuration); + + if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr) + { + Sessions->OnHostFailed.AddDynamic(this, &AMainMenuPlayerController::HandleHostFailed); + Sessions->OnJoinFailed.AddDynamic(this, &AMainMenuPlayerController::HandleJoinFailed); + + // Revenir au menu depuis une partie laisse la session ouverte : elle + // resterait annoncee a tes amis alors que plus personne n'est dedans. + Sessions->LeaveSession(); + + UE_LOG(LogTemp, Log, TEXT("AMainMenuPlayerController : service en ligne [%s]."), *Sessions->GetOnlineServiceName()); + } } AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot() @@ -135,7 +148,7 @@ AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot() return Chosen; } -void AMainMenuPlayerController::StartSoloGame() +void AMainMenuPlayerController::StartGame() { // Le fondu est deja lance : on ignore le second clic plutot que d'empiler // deux chargements. @@ -162,6 +175,33 @@ void AMainMenuPlayerController::StartSoloGame() ScreenFade->FadeOut(LeaveFadeDuration); } +void AMainMenuPlayerController::HandleHostFailed(FText Reason) +{ + UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : session indisponible (%s), la partie se lance hors ligne."), *Reason.ToString()); + + // Sans session on ouvre quand meme en serveur d'ecoute plutot qu'en solo + // sec : l'ecart de cout est nul a un joueur, et ca evite d'avoir deux + // facons differentes de demarrer une partie a maintenir. + UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel, /*bAbsolute=*/true, TEXT("listen")); +} + +void AMainMenuPlayerController::HandleJoinFailed(FText Reason) +{ + UE_LOG(LogTemp, Error, TEXT("AMainMenuPlayerController : connexion impossible -- %s"), *Reason.ToString()); + + // Ici pas de repli : le joueur voulait rejoindre un ami precis. On le rend + // simplement a son menu, ecran remonte -- sans quoi un echec le laisserait + // devant un noir definitif, ce qui se lit comme un plantage. + PendingAction = EMenuPendingAction::None; + + if (MainMenu) + { + MainMenu->SetVisibility(ESlateVisibility::Visible); + } + + ScreenFade->FadeIn(LeaveFadeDuration); +} + void AMainMenuPlayerController::QuitGame() { // Une partie est deja en cours de chargement : la question n'a plus de sens. @@ -287,9 +327,22 @@ void AMainMenuPlayerController::HandleFadeFinished() { // Le delegue se declenche aussi a la fin du fondu d'ouverture, ou il n'y a // rien a faire : c'est PendingAction qui distingue les deux cas. - if (PendingAction == EMenuPendingAction::StartGame) + if (PendingAction != EMenuPendingAction::StartGame) { - UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel); + return; + } + + // C'est le sous-systeme qui ouvrira la map, et seulement une fois la session + // reellement creee : ouvrir avant donnerait une partie que personne ne peut + // rejoindre. Le cas d'echec passe par HandleHostFailed, qui charge quand meme. + if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr) + { + Sessions->HostSession(GameLevel, USessionSubsystem::DefaultMaxPlayers); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : USessionSubsystem introuvable, partie lancee hors ligne.")); + UGameplayStatics::OpenLevelBySoftObjectPtr(this, GameLevel, /*bAbsolute=*/true, TEXT("listen")); } } diff --git a/Source/Survival_projet/Private/MainMenuWidget.cpp b/Source/Survival_projet/Private/MainMenuWidget.cpp index 5dc9570..aa155d7 100644 --- a/Source/Survival_projet/Private/MainMenuWidget.cpp +++ b/Source/Survival_projet/Private/MainMenuWidget.cpp @@ -25,24 +25,13 @@ void UMainMenuWidget::NativeConstruct() SettingsButton->OnClicked.AddDynamic(this, &UMainMenuWidget::HandleSettingsClicked); } - // Les boutons de coop sont visibles mais inertes : ils annoncent la couleur - // sans promettre une partie qui echouerait. - if (HostButton) - { - HostButton->SetIsEnabled(false); - } - - if (JoinButton) - { - JoinButton->SetIsEnabled(false); - } } void UMainMenuWidget::HandlePlayClicked() { if (AMainMenuPlayerController* Controller = GetOwningPlayer()) { - Controller->StartSoloGame(); + Controller->StartGame(); } } diff --git a/Source/Survival_projet/Private/PauseMenuWidget.cpp b/Source/Survival_projet/Private/PauseMenuWidget.cpp index 02c805e..d018168 100644 --- a/Source/Survival_projet/Private/PauseMenuWidget.cpp +++ b/Source/Survival_projet/Private/PauseMenuWidget.cpp @@ -24,6 +24,17 @@ void UPauseMenuWidget::NativeConstruct() { SettingsButton->OnClicked.AddDynamic(this, &UPauseMenuWidget::HandleSettingsClicked); } + + if (InviteButton) + { + InviteButton->OnClicked.AddDynamic(this, &UPauseMenuWidget::HandleInviteClicked); + + // Evalue une seule fois : le menu de pause n'est construit qu'a la + // premiere ouverture, donc bien apres que la session ait reussi ou + // echoue. Rien ne peut plus changer cet etat en cours de partie. + const AFpsPlayerController* Controller = GetOwningPlayer(); + InviteButton->SetIsEnabled(Controller && Controller->CanInviteFriends()); + } } void UPauseMenuWidget::NativeDestruct() @@ -43,6 +54,11 @@ void UPauseMenuWidget::NativeDestruct() QuitToMenuButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleQuitToMenuClicked); } + if (InviteButton) + { + InviteButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleInviteClicked); + } + Super::NativeDestruct(); } @@ -69,3 +85,11 @@ void UPauseMenuWidget::HandleQuitToMenuClicked() Controller->RequestQuitToMainMenu(); } } + +void UPauseMenuWidget::HandleInviteClicked() +{ + if (AFpsPlayerController* Controller = GetOwningPlayer()) + { + Controller->ShowInviteFriends(); + } +} diff --git a/Source/Survival_projet/Private/PickupItem.cpp b/Source/Survival_projet/Private/PickupItem.cpp index 6077729..3729bec 100644 --- a/Source/Survival_projet/Private/PickupItem.cpp +++ b/Source/Survival_projet/Private/PickupItem.cpp @@ -7,6 +7,7 @@ #include "Components/StaticMeshComponent.h" #include "InventoryComponent.h" #include "ItemDataAsset.h" +#include "Net/UnrealNetwork.h" #define LOCTEXT_NAMESPACE "PickupItem" @@ -14,6 +15,15 @@ APickupItem::APickupItem() { PrimaryActorTick.bCanEverTick = false; + // Acteur du monde : le serveur fait autorite. Un pickup ramasse doit + // disparaitre chez les quatre joueurs, pas seulement chez celui qui l'a pris. + bReplicates = true; + + // Un pickup ne bouge plus une fois pose. Couper la replication de mouvement + // evite d'envoyer une transform a chaque joueur qui entre dans sa zone de + // pertinence, alors que celle du spawn suffit. + SetReplicateMovement(false); + Mesh = CreateDefaultSubobject(TEXT("Mesh")); SetRootComponent(Mesh); // Purement decoratif : c'est la sphere qui porte la collision. @@ -29,6 +39,15 @@ APickupItem::APickupItem() InteractionSphere->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block); } +void APickupItem::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + DOREPLIFETIME(APickupItem, ItemData); + DOREPLIFETIME(APickupItem, Quantity); + DOREPLIFETIME(APickupItem, RemainingUses); +} + void APickupItem::InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32 InRemainingUses) { ItemData = InItemData; @@ -36,6 +55,19 @@ void APickupItem::InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32 RemainingUses = InRemainingUses; } +void APickupItem::ApplyItemVisuals() +{ + if (bUseMeshFromItemData && ItemData && ItemData->WorldMesh) + { + ItemData->ApplyWorldVisualsTo(Mesh); + } +} + +void APickupItem::OnRep_ItemData() +{ + ApplyItemVisuals(); +} + void APickupItem::OnConstruction(const FTransform& Transform) { Super::OnConstruction(Transform); @@ -43,10 +75,10 @@ void APickupItem::OnConstruction(const FTransform& Transform) // Confort d'edition : le mesh suit l'objet choisi, tu vois immediatement // dans le viewport ce que tu es en train de poser. Vaut aussi a l'execution // pour les objets jetes, puisque OnConstruction s'execute a FinishSpawning. - if (bUseMeshFromItemData && ItemData && ItemData->WorldMesh) - { - ItemData->ApplyWorldVisualsTo(Mesh); - } + // + // Chez un client, ItemData est encore nul a cet instant : c'est OnRep_ItemData + // qui reposera le visuel quand la propriete arrivera. + ApplyItemVisuals(); } FText APickupItem::GetInteractionPrompt_Implementation(AActor* Interactor) const @@ -75,6 +107,16 @@ bool APickupItem::CanInteract_Implementation(AActor* Interactor) const void APickupItem::Interact_Implementation(AActor* Interactor) { + // Filet de securite : UInteractionComponent ne nous appelle deja que cote + // autorite, mais un Blueprint ou un futur appelant pourrait l'oublier, et + // un ramassage joue en local donnerait un objet qui reapparait a la + // prochaine replication -- le pire des symptomes, parce qu'il est + // intermittent. + if (!HasAuthority()) + { + return; + } + UInventoryComponent* Inventory = Interactor ? Interactor->FindComponentByClass() : nullptr; if (!Inventory) { @@ -83,30 +125,18 @@ void APickupItem::Interact_Implementation(AActor* Interactor) } const int32 Leftover = Inventory->AddItem(ItemData, Quantity, RemainingUses); - const int32 Added = Quantity - Leftover; // Inventaire plein : on ne detruit surtout pas le tas, on retire seulement // ce qui est rentre. Le joueur peut revenir chercher le reste. - if (Added <= 0) + if (Leftover >= Quantity) { - if (GEngine) - { - GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Red, TEXT("Inventaire plein")); - } return; } - // Retour temporaire, le temps que l'UI d'inventaire existe. - if (GEngine) - { - GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Green, - FString::Printf(TEXT("+%d %s (total : %d, slots : %d/%d)"), - Added, - *ItemData->DisplayName.ToString(), - Inventory->GetItemCount(ItemData), - Inventory->GetUsedSlotCount(), - Inventory->GetTotalSlotCount())); - } + // Les anciens AddOnScreenDebugMessage ont disparu : en reseau ils + // s'affichaient sur l'ecran de l'HOTE, puisque c'est lui qui execute ce + // code, y compris quand c'est un client qui ramasse. L'UI d'inventaire + // existe desormais et dit la meme chose au bon joueur. if (Leftover > 0) { diff --git a/Source/Survival_projet/Private/PlayerNameplateComponent.cpp b/Source/Survival_projet/Private/PlayerNameplateComponent.cpp new file mode 100644 index 0000000..a462f7c --- /dev/null +++ b/Source/Survival_projet/Private/PlayerNameplateComponent.cpp @@ -0,0 +1,141 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "PlayerNameplateComponent.h" + +#include "Engine/Engine.h" +#include "Engine/World.h" +#include "GameFramework/Pawn.h" +#include "GameFramework/PlayerController.h" +#include "GameFramework/PlayerState.h" +#include "PlayerNameplateWidget.h" +#include "TimerManager.h" + +UPlayerNameplateComponent::UPlayerNameplateComponent() +{ + // Espace ecran : l'etiquette fait toujours face a la camera, par + // construction, et n'est jamais occultee par le decor. + SetWidgetSpace(EWidgetSpace::Screen); + + // Le widget se dimensionne sur son contenu : un pseudo court n'a pas a + // trainer une boite de la largeur du plus long. + SetDrawAtDesiredSize(true); + + // Une etiquette ne se vise pas et n'arrete pas un trace : sans ca, elle + // bloquerait le canal Visibility et masquerait l'interaction du joueur + // qu'elle surmonte. + SetCollisionEnabled(ECollisionEnabled::NoCollision); + SetGenerateOverlapEvents(false); + + // Au-dessus de la tete. Reglable dans le Blueprint, la capsule et la taille + // du personnage pouvant changer. + SetRelativeLocation(FVector(0.f, 0.f, 100.f)); + + // Cachee au depart : la premiere passe de Refresh decidera. Sans ca, une + // etiquette sans nom clignote pendant la frame qui precede l'arrivee du + // PlayerState. + SetVisibility(false); +} + +void UPlayerNameplateComponent::BeginPlay() +{ + Super::BeginPlay(); + + // Un serveur dedie ne rend rien : lui faire calculer des distances pour des + // etiquettes que personne ne verra serait du gaspillage pur. Sans effet sur + // notre serveur d'ecoute, qui n'est pas dans ce mode. + if (GetNetMode() == NM_DedicatedServer) + { + return; + } + + const APawn* OwnerPawn = Cast(GetOwner()); + + // Son propre pseudo : on eteint definitivement, sans timer. Ce n'est pas un + // cas a reevaluer -- un pawn ne change pas de proprietaire en cours de vie, + // et apres un respawn c'est un NOUVEL acteur qui repassera par ici. + if (bHideOnOwningPlayer && OwnerPawn && OwnerPawn->IsLocallyControlled()) + { + SetVisibility(false); + return; + } + + GetWorld()->GetTimerManager().SetTimer( + RefreshTimer, this, &UPlayerNameplateComponent::Refresh, + FMath::Max(RefreshInterval, 0.05f), /*bLoop=*/true); + + // Une premiere passe tout de suite : attendre RefreshInterval laisserait + // l'etiquette absente un quart de seconde a l'apparition d'un coequipier. + Refresh(); +} + +void UPlayerNameplateComponent::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // Un timer laisse en vie apres la destruction du composant rappellerait une + // methode sur un objet mort -- typiquement a la mort d'un joueur, ou son + // pawn est detruit. + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().ClearTimer(RefreshTimer); + } + + Super::EndPlay(EndPlayReason); +} + +APlayerState* UPlayerNameplateComponent::GetOwnerPlayerState() const +{ + const APawn* OwnerPawn = Cast(GetOwner()); + return OwnerPawn ? OwnerPawn->GetPlayerState() : nullptr; +} + +void UPlayerNameplateComponent::Refresh() +{ + // Le point de vue du joueur LOCAL, et non celui du pawn porteur : c'est de + // notre ecran qu'on parle. GetFirstLocalPlayerController et non + // GetFirstPlayerController -- en PIE a deux fenetres, le second rendrait le + // controller de l'autre instance et l'etiquette clignoterait. + const APlayerController* Viewer = GEngine ? GEngine->GetFirstLocalPlayerController(GetWorld()) : nullptr; + if (!Viewer || !Viewer->PlayerCameraManager) + { + SetVisibility(false); + return; + } + + const float DistanceSquared = FVector::DistSquared( + Viewer->PlayerCameraManager->GetCameraLocation(), + GetComponentLocation()); + + if (DistanceSquared > FMath::Square(MaxDrawDistance)) + { + SetVisibility(false); + return; + } + + // Le PlayerState arrive apres le pawn chez un client : tant qu'il manque, + // on reste invisible plutot que d'afficher une etiquette vide. La passe + // suivante le trouvera. + const APlayerState* State = GetOwnerPlayerState(); + if (!State) + { + SetVisibility(false); + return; + } + + const FString CurrentName = State->GetPlayerName(); + if (CurrentName.IsEmpty()) + { + SetVisibility(false); + return; + } + + if (CurrentName != LastShownName) + { + if (UPlayerNameplateWidget* Nameplate = Cast(GetUserWidgetObject())) + { + Nameplate->SetPlayerName(FText::FromString(CurrentName)); + LastShownName = CurrentName; + } + } + + SetVisibility(true); +} diff --git a/Source/Survival_projet/Private/PlayerNameplateWidget.cpp b/Source/Survival_projet/Private/PlayerNameplateWidget.cpp new file mode 100644 index 0000000..baacc5a --- /dev/null +++ b/Source/Survival_projet/Private/PlayerNameplateWidget.cpp @@ -0,0 +1,14 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "PlayerNameplateWidget.h" + +#include "Components/TextBlock.h" + +void UPlayerNameplateWidget::SetPlayerName(const FText& InName) +{ + if (PlayerNameText) + { + PlayerNameText->SetText(InName); + } +} diff --git a/Source/Survival_projet/Private/SessionSubsystem.cpp b/Source/Survival_projet/Private/SessionSubsystem.cpp new file mode 100644 index 0000000..be44dd3 --- /dev/null +++ b/Source/Survival_projet/Private/SessionSubsystem.cpp @@ -0,0 +1,447 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "SessionSubsystem.h" + +#include "GameFramework/PlayerController.h" +#include "Interfaces/OnlineExternalUIInterface.h" +#include "Kismet/GameplayStatics.h" +#include "Online/OnlineSessionNames.h" +#include "OnlineSessionSettings.h" +#include "OnlineSubsystem.h" + +#define LOCTEXT_NAMESPACE "Session" + +// Namespace NOMME et non anonyme : le build unity concatene les .cpp du module, +// et deux constantes homonymes dans deux fichiers deviennent une redefinition +// qui ne sort qu'au premier rebuild complet. +namespace SessionSubsystemConstants +{ + /** + * Cle plantee dans les reglages de session et redemandee a la recherche. + * Sans elle on tomberait sur les parties de n'importe quel autre jeu partageant + * l'AppId de test 480 (Spacewar), qui sont legion. + */ + const FName GameKeyName(TEXT("EMBERWILD")); + const TCHAR* GameKeyValue = TEXT("Coop"); + + /** Le service charge quand Steam n'est pas disponible. */ + const FName NullServiceName(TEXT("NULL")); +} + +void USessionSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + + IOnlineSubsystem* Online = IOnlineSubsystem::Get(); + if (!Online) + { + UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : aucun OnlineSubsystem charge, la coop sera indisponible.")); + return; + } + + SessionInterface = Online->GetSessionInterface(); + if (!SessionInterface.IsValid()) + { + UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : le service [%s] n'expose pas d'interface de session."), *Online->GetSubsystemName().ToString()); + return; + } + + InviteAcceptedHandle = SessionInterface->AddOnSessionUserInviteAcceptedDelegate_Handle( + FOnSessionUserInviteAcceptedDelegate::CreateUObject(this, &USessionSubsystem::HandleInviteAccepted)); + + UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : service en ligne [%s], mode LAN [%d]."), + *Online->GetSubsystemName().ToString(), IsLanMode() ? 1 : 0); +} + +void USessionSubsystem::Deinitialize() +{ + if (SessionInterface.IsValid()) + { + ClearPendingDelegates(); + SessionInterface->ClearOnSessionUserInviteAcceptedDelegate_Handle(InviteAcceptedHandle); + } + + SearchSettings.Reset(); + SessionInterface.Reset(); + + Super::Deinitialize(); +} + +bool USessionSubsystem::IsLanMode() const +{ + const IOnlineSubsystem* Online = IOnlineSubsystem::Get(); + return !Online || Online->GetSubsystemName() == SessionSubsystemConstants::NullServiceName; +} + +FString USessionSubsystem::GetOnlineServiceName() const +{ + const IOnlineSubsystem* Online = IOnlineSubsystem::Get(); + return Online ? Online->GetSubsystemName().ToString() : TEXT("None"); +} + +bool USessionSubsystem::IsSessionActive() const +{ + return SessionInterface.IsValid() && SessionInterface->GetNamedSession(NAME_GameSession) != nullptr; +} + +int32 USessionSubsystem::GetFoundSessionCount() const +{ + return SearchSettings.IsValid() ? SearchSettings->SearchResults.Num() : 0; +} + +void USessionSubsystem::ClearPendingDelegates() +{ + if (!SessionInterface.IsValid()) + { + return; + } + + SessionInterface->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteHandle); + SessionInterface->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteHandle); + SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteHandle); + SessionInterface->ClearOnDestroySessionCompleteDelegate_Handle(DestroySessionCompleteHandle); +} + +// ---------------------------------------------------------------------------- +// Hebergement +// ---------------------------------------------------------------------------- + +void USessionSubsystem::HostSession(const TSoftObjectPtr& Level, int32 MaxPlayers) +{ + if (!SessionInterface.IsValid()) + { + OnHostFailed.Broadcast(LOCTEXT("HostNoService", "No online service available.")); + return; + } + + if (Level.IsNull()) + { + UE_LOG(LogTemp, Error, TEXT("USessionSubsystem::HostSession : aucune map fournie.")); + OnHostFailed.Broadcast(LOCTEXT("HostNoLevel", "No level configured for the game.")); + return; + } + + PendingHostLevel = Level; + PendingMaxPlayers = FMath::Clamp(MaxPlayers, 1, 16); + + // Une session survit a un retour au menu si personne ne l'a detruite. Le + // moteur en refuse alors une seconde du meme nom, et l'echec est muet. + if (SessionInterface->GetNamedSession(NAME_GameSession) != nullptr) + { + bCreateAfterDestroy = true; + DestroySessionCompleteHandle = SessionInterface->AddOnDestroySessionCompleteDelegate_Handle( + FOnDestroySessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleDestroySessionComplete)); + + SessionInterface->DestroySession(NAME_GameSession); + return; + } + + CreateSessionNow(); +} + +void USessionSubsystem::CreateSessionNow() +{ + FOnlineSessionSettings Settings; + + Settings.NumPublicConnections = PendingMaxPlayers; + Settings.NumPrivateConnections = 0; + Settings.bIsLANMatch = IsLanMode(); + Settings.bShouldAdvertise = true; + Settings.bAllowJoinInProgress = true; + Settings.bAllowJoinViaPresence = true; + Settings.bAllowInvites = true; + Settings.bUsesPresence = true; + + // LE drapeau a ne pas oublier. Steam ne route le trafic P2P que par un LOBBY : + // sans lui la session est bien creee, elle apparait meme dans une recherche, + // mais aucune connexion n'aboutit en dehors du LAN. + Settings.bUseLobbiesIfAvailable = true; + Settings.bUseLobbiesVoiceChatIfAvailable = false; + + Settings.bUsesStats = false; + Settings.bAntiCheatProtected = false; + + Settings.Set(SETTING_MAPNAME, PendingHostLevel.GetAssetName(), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing); + Settings.Set(SessionSubsystemConstants::GameKeyName, FString(SessionSubsystemConstants::GameKeyValue), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing); + + CreateSessionCompleteHandle = SessionInterface->AddOnCreateSessionCompleteDelegate_Handle( + FOnCreateSessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleCreateSessionComplete)); + + // CreateSession rend false quand l'appel n'a meme pas pu partir : dans ce cas + // le callback ne viendra jamais, il faut donc debrancher et repondre ici. + if (!SessionInterface->CreateSession(0, NAME_GameSession, Settings)) + { + SessionInterface->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteHandle); + UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : CreateSession a ete refusee immediatement.")); + OnHostFailed.Broadcast(LOCTEXT("HostRefused", "Could not create the session.")); + } +} + +void USessionSubsystem::HandleCreateSessionComplete(FName SessionName, bool bWasSuccessful) +{ + SessionInterface->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteHandle); + + if (!bWasSuccessful) + { + UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : creation de la session [%s] echouee."), *SessionName.ToString()); + OnHostFailed.Broadcast(LOCTEXT("HostFailed", "Could not create the session.")); + return; + } + + UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : session [%s] creee pour %d joueurs."), *SessionName.ToString(), PendingMaxPlayers); + + // "listen" fait de cette instance un serveur d'ecoute : l'hote joue ET + // heberge dans le meme process, ce qui est exactement le modele voulu pour + // une coop a quatre. Sans cette option la map s'ouvre en solo et les clients + // se heurtent a un port ferme. + UGameplayStatics::OpenLevelBySoftObjectPtr(GetGameInstance(), PendingHostLevel, /*bAbsolute=*/true, TEXT("listen")); +} + +// ---------------------------------------------------------------------------- +// Recherche +// ---------------------------------------------------------------------------- + +void USessionSubsystem::FindSessions(int32 MaxResults) +{ + if (!SessionInterface.IsValid()) + { + OnSearchFinished.Broadcast(false, 0); + return; + } + + SearchSettings = MakeShared(); + SearchSettings->MaxSearchResults = FMath::Max(1, MaxResults); + SearchSettings->bIsLanQuery = IsLanMode(); + + // Cote Steam les parties sont des lobbies, pas des serveurs annonces : sans + // ce filtre la recherche part interroger le service de serveurs dedies et + // ne rend jamais rien. En LAN la notion n'existe pas, on l'omet. + if (!SearchSettings->bIsLanQuery) + { + SearchSettings->QuerySettings.Set(SEARCH_LOBBIES, true, EOnlineComparisonOp::Equals); + } + + SearchSettings->QuerySettings.Set(SessionSubsystemConstants::GameKeyName, FString(SessionSubsystemConstants::GameKeyValue), EOnlineComparisonOp::Equals); + + FindSessionsCompleteHandle = SessionInterface->AddOnFindSessionsCompleteDelegate_Handle( + FOnFindSessionsCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleFindSessionsComplete)); + + if (!SessionInterface->FindSessions(0, SearchSettings.ToSharedRef())) + { + SessionInterface->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteHandle); + OnSearchFinished.Broadcast(false, 0); + } +} + +void USessionSubsystem::HandleFindSessionsComplete(bool bWasSuccessful) +{ + SessionInterface->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteHandle); + + const int32 Count = GetFoundSessionCount(); + UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : recherche terminee (succes %d), %d partie(s)."), bWasSuccessful ? 1 : 0, Count); + + OnSearchFinished.Broadcast(bWasSuccessful, Count); +} + +// ---------------------------------------------------------------------------- +// Connexion +// ---------------------------------------------------------------------------- + +void USessionSubsystem::JoinFoundSession(int32 ResultIndex) +{ + if (!SearchSettings.IsValid() || !SearchSettings->SearchResults.IsValidIndex(ResultIndex)) + { + OnJoinFailed.Broadcast(LOCTEXT("JoinNoSession", "No game found to join.")); + return; + } + + JoinSearchResult(SearchSettings->SearchResults[ResultIndex]); +} + +void USessionSubsystem::EmberJoin() +{ + UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : EmberJoin, recherche d'une partie...")); + + // On se branche pour un seul tour : la commande enchaine recherche puis + // connexion, ce que le menu ne fait plus depuis que l'on ne rejoint que + // par invitation. + OnSearchFinished.AddDynamic(this, &USessionSubsystem::HandleExecJoinSearchFinished); + FindSessions(); +} + +void USessionSubsystem::HandleExecJoinSearchFinished(bool bWasSuccessful, int32 SessionCount) +{ + OnSearchFinished.RemoveDynamic(this, &USessionSubsystem::HandleExecJoinSearchFinished); + + if (!bWasSuccessful || SessionCount <= 0) + { + UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : EmberJoin n'a trouve aucune partie.")); + return; + } + + JoinFoundSession(0); +} + +bool USessionSubsystem::CanInvite() const +{ + if (!IsSessionActive() || IsLanMode()) + { + return false; + } + + const IOnlineSubsystem* Online = IOnlineSubsystem::Get(); + return Online && Online->GetExternalUIInterface().IsValid(); +} + +bool USessionSubsystem::ShowInviteUI() +{ + IOnlineSubsystem* Online = IOnlineSubsystem::Get(); + if (!Online) + { + UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : pas de service en ligne, invitation impossible.")); + return false; + } + + const IOnlineExternalUIPtr ExternalUI = Online->GetExternalUIInterface(); + if (!ExternalUI.IsValid()) + { + UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : le service [%s] n'a pas d'overlay."), *Online->GetSubsystemName().ToString()); + return false; + } + + // Sans session ouverte, l'overlay s'affiche mais l'ami invite arrive sur + // une partie qui n'existe pas. Mieux vaut refuser franchement. + if (!IsSessionActive()) + { + UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : aucune session ouverte, invitation impossible.")); + return false; + } + + return ExternalUI->ShowInviteUI(0, NAME_GameSession); +} + +void USessionSubsystem::JoinSearchResult(const FOnlineSessionSearchResult& Result) +{ + if (!SessionInterface.IsValid()) + { + OnJoinFailed.Broadcast(LOCTEXT("JoinNoService", "No online service available.")); + return; + } + + if (!Result.IsValid()) + { + OnJoinFailed.Broadcast(LOCTEXT("JoinInvalid", "This game is no longer available.")); + return; + } + + // Rejoindre alors qu'on heberge deja laisserait deux sessions ouvertes et + // le voyage echouerait sans message. + if (SessionInterface->GetNamedSession(NAME_GameSession) != nullptr) + { + SessionInterface->DestroySession(NAME_GameSession); + } + + JoinSessionCompleteHandle = SessionInterface->AddOnJoinSessionCompleteDelegate_Handle( + FOnJoinSessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleJoinSessionComplete)); + + if (!SessionInterface->JoinSession(0, NAME_GameSession, Result)) + { + SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteHandle); + OnJoinFailed.Broadcast(LOCTEXT("JoinRefused", "Could not join this game.")); + } +} + +void USessionSubsystem::HandleJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result) +{ + SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteHandle); + + if (Result != EOnJoinSessionCompleteResult::Success) + { + UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : JoinSession a echoue (code %d)."), static_cast(Result)); + + // On distingue le seul cas que le joueur peut corriger lui-meme du reste, + // qui ne lui apprendrait rien. + const FText Reason = (Result == EOnJoinSessionCompleteResult::SessionIsFull) + ? LOCTEXT("JoinFull", "This game is full.") + : LOCTEXT("JoinFailed", "Could not join this game."); + + OnJoinFailed.Broadcast(Reason); + return; + } + + // L'adresse n'est connue qu'ici : c'est le service en ligne qui la fabrique, + // et sous Steam ce n'est meme pas une IP mais un identifiant de relais. + FString ConnectString; + if (!SessionInterface->GetResolvedConnectString(SessionName, ConnectString)) + { + UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : adresse de l'hote introuvable.")); + OnJoinFailed.Broadcast(LOCTEXT("JoinNoAddress", "Could not reach the host.")); + return; + } + + APlayerController* Controller = GetGameInstance() ? GetGameInstance()->GetFirstLocalPlayerController() : nullptr; + if (!Controller) + { + OnJoinFailed.Broadcast(LOCTEXT("JoinNoController", "Could not reach the host.")); + return; + } + + UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : voyage vers [%s]."), *ConnectString); + Controller->ClientTravel(ConnectString, TRAVEL_Absolute); +} + +void USessionSubsystem::HandleInviteAccepted(const bool bWasSuccessful, const int32 ControllerId, FUniqueNetIdPtr UserId, const FOnlineSessionSearchResult& InviteResult) +{ + if (!bWasSuccessful) + { + UE_LOG(LogTemp, Warning, TEXT("USessionSubsystem : invitation acceptee mais illisible.")); + return; + } + + UE_LOG(LogTemp, Log, TEXT("USessionSubsystem : invitation acceptee, connexion en cours.")); + JoinSearchResult(InviteResult); +} + +// ---------------------------------------------------------------------------- +// Sortie +// ---------------------------------------------------------------------------- + +void USessionSubsystem::LeaveSession() +{ + if (!SessionInterface.IsValid() || SessionInterface->GetNamedSession(NAME_GameSession) == nullptr) + { + return; + } + + // Pas de reprise apres coup ici : on quitte, point. + bCreateAfterDestroy = false; + + DestroySessionCompleteHandle = SessionInterface->AddOnDestroySessionCompleteDelegate_Handle( + FOnDestroySessionCompleteDelegate::CreateUObject(this, &USessionSubsystem::HandleDestroySessionComplete)); + + SessionInterface->DestroySession(NAME_GameSession); +} + +void USessionSubsystem::HandleDestroySessionComplete(FName SessionName, bool bWasSuccessful) +{ + SessionInterface->ClearOnDestroySessionCompleteDelegate_Handle(DestroySessionCompleteHandle); + + if (!bCreateAfterDestroy) + { + return; + } + + bCreateAfterDestroy = false; + + if (!bWasSuccessful) + { + UE_LOG(LogTemp, Error, TEXT("USessionSubsystem : impossible de detruire la session [%s], hebergement annule."), *SessionName.ToString()); + OnHostFailed.Broadcast(LOCTEXT("HostStaleSession", "A previous game is still closing. Try again.")); + return; + } + + CreateSessionNow(); +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/Survival_projet/Private/StorageContainer.cpp b/Source/Survival_projet/Private/StorageContainer.cpp index ec70699..ba1238b 100644 --- a/Source/Survival_projet/Private/StorageContainer.cpp +++ b/Source/Survival_projet/Private/StorageContainer.cpp @@ -12,6 +12,7 @@ #include "InventoryComponent.h" #include "ItemDataAsset.h" #include "Kismet/GameplayStatics.h" +#include "Net/UnrealNetwork.h" #define LOCTEXT_NAMESPACE "StorageContainer" @@ -22,6 +23,12 @@ AStorageContainer::AStorageContainer() PrimaryActorTick.bCanEverTick = true; PrimaryActorTick.bStartWithTickEnabled = false; + // Acteur du monde : contenu et couvercle appartiennent au serveur. + // Le UInventoryComponent, lui, se replique deja tout seul -- c'est la + // classe entiere qui le fait, coffre comme sac. + bReplicates = true; + SetReplicateMovement(false); + BaseMesh = CreateDefaultSubobject(TEXT("BaseMesh")); SetRootComponent(BaseMesh); @@ -166,15 +173,34 @@ void AStorageContainer::Interact_Implementation(AActor* Interactor) PlayerController->OpenStorage(this); } +void AStorageContainer::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + DOREPLIFETIME(AStorageContainer, bOpen); +} + void AStorageContainer::SetOpen(bool bInOpen) { - if (bOpen == bInOpen) + // Le couvercle appartient au monde : seul le serveur en decide, et la + // replication l'annonce aux autres. Un client qui forcerait bOpen verrait + // son coffre s'ouvrir seul, et le prochain OnRep le refermerait. + if (!HasAuthority() || bOpen == bInOpen) { return; } bOpen = bInOpen; + ApplyOpenState(); +} +void AStorageContainer::OnRep_Open() +{ + ApplyOpenState(); +} + +void AStorageContainer::ApplyOpenState() +{ if (USoundBase* Sound = bOpen ? OpenSound : CloseSound) { UGameplayStatics::PlaySoundAtLocation(this, Sound, GetActorLocation()); diff --git a/Source/Survival_projet/Private/SurvivalGameMode.cpp b/Source/Survival_projet/Private/SurvivalGameMode.cpp new file mode 100644 index 0000000..00e6c47 --- /dev/null +++ b/Source/Survival_projet/Private/SurvivalGameMode.cpp @@ -0,0 +1,61 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "SurvivalGameMode.h" + +#include "FpsPlayerController.h" +#include "GameFramework/GameSession.h" +#include "GameFramework/PlayerState.h" +#include "SessionSubsystem.h" +#include "SurvivalGameState.h" +#include "SurvivalPlayerState.h" + +ASurvivalGameMode::ASurvivalGameMode() +{ + MaxCoopPlayers = USessionSubsystem::DefaultMaxPlayers; + + // Les classes du framework sont cablees ici et non dans le Blueprint : ce + // sont des choix d'architecture, pas des reglages a tatonner en PIE. + GameStateClass = ASurvivalGameState::StaticClass(); + PlayerStateClass = ASurvivalPlayerState::StaticClass(); + PlayerControllerClass = AFpsPlayerController::StaticClass(); + + // Une seule map pour l'instant, donc pas de voyage sans coupure a gerer. + // A repasser a true le jour ou la partie changera de niveau : sans lui, + // chaque changement de map deconnecte puis reconnecte tous les clients. + bUseSeamlessTravel = false; +} + +void ASurvivalGameMode::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage) +{ + Super::InitGame(MapName, Options, ErrorMessage); + + // AGameSession n'existe qu'apres le Super : c'est lui qui l'engendre. + if (GameSession) + { + GameSession->MaxPlayers = MaxCoopPlayers; + } +} + +void ASurvivalGameMode::PostLogin(APlayerController* NewPlayer) +{ + Super::PostLogin(NewPlayer); + + // Trace volontairement conservee : c'est la preuve la plus rapide qu'une + // connexion a bien abouti, et le premier endroit ou regarder quand un ami + // dit "ca tourne mais je ne te vois pas". + const APlayerState* State = NewPlayer ? NewPlayer->GetPlayerState() : nullptr; + UE_LOG(LogTemp, Log, TEXT("ASurvivalGameMode : arrivee de [%s], %d joueur(s) sur %d."), + State ? *State->GetPlayerName() : TEXT("inconnu"), + GetNumPlayers(), + MaxCoopPlayers); +} + +void ASurvivalGameMode::Logout(AController* Exiting) +{ + const APlayerState* State = Exiting ? Exiting->GetPlayerState() : nullptr; + UE_LOG(LogTemp, Log, TEXT("ASurvivalGameMode : depart de [%s]."), + State ? *State->GetPlayerName() : TEXT("inconnu")); + + Super::Logout(Exiting); +} diff --git a/Source/Survival_projet/Private/SurvivalStatsComponent.cpp b/Source/Survival_projet/Private/SurvivalStatsComponent.cpp index cfcb702..6cbf2d1 100644 --- a/Source/Survival_projet/Private/SurvivalStatsComponent.cpp +++ b/Source/Survival_projet/Private/SurvivalStatsComponent.cpp @@ -4,18 +4,64 @@ #include "SurvivalStatsComponent.h" #include "Engine/World.h" +#include "Net/UnrealNetwork.h" #include "TimerManager.h" USurvivalStatsComponent::USurvivalStatsComponent() { // Pas de Tick : tout passe par un timer a basse frequence. PrimaryComponentTick.bCanEverTick = false; + + SetIsReplicatedByDefault(true); +} + +void USurvivalStatsComponent::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + // COND_OwnerOnly ici, contrairement a l'inventaire : ces jauges n'existent + // que sur un pawn, qui appartient bien a une connexion -- la condition + // fonctionne donc. Et la faim d'un coequipier ne regarde personne d'autre + // que lui. Le jour ou une barre de vie s'affichera au-dessus des allies, + // c'est Health seul qu'il faudra ouvrir a tous. + DOREPLIFETIME_CONDITION(USurvivalStatsComponent, Health, COND_OwnerOnly); + DOREPLIFETIME_CONDITION(USurvivalStatsComponent, Hunger, COND_OwnerOnly); + DOREPLIFETIME_CONDITION(USurvivalStatsComponent, Thirst, COND_OwnerOnly); + DOREPLIFETIME_CONDITION(USurvivalStatsComponent, bDead, COND_OwnerOnly); +} + +void USurvivalStatsComponent::OnRep_Stats() +{ + OnStatsChanged.Broadcast(); +} + +void USurvivalStatsComponent::OnRep_Dead() +{ + OnStatsChanged.Broadcast(); + + // bDead ne repasse jamais a vrai sans un nouveau pawn : apres un respawn + // c'est un AUTRE composant qui arrive, plein et vivant. Pas de risque de + // rediffuser une mort deja traitee. + if (bDead) + { + OnDied.Broadcast(); + } } void USurvivalStatsComponent::BeginPlay() { Super::BeginPlay(); + // Un client ne simule rien : il recevra les valeurs par replication. + // Sans cette garde, chaque client ferait descendre SA copie des jauges en + // plus de celle du serveur, et les barres sauteraient a chaque OnRep -- + // un scintillement qu'on chercherait longtemps. + const AActor* Owner = GetOwner(); + if (!Owner || !Owner->HasAuthority()) + { + return; + } + Health = MaxHealth; Hunger = MaxHunger; Thirst = MaxThirst; @@ -91,6 +137,15 @@ void USurvivalStatsComponent::UpdateStats() void USurvivalStatsComponent::ApplyRestore(float HealthAmount, float HungerAmount, float ThirstAmount) { + // Manger se decide sur le serveur (AFpsPlayer::Server_UseItem). Une + // restauration jouee en local remonterait la barre du client, que le + // prochain OnRep ferait redescendre : un clignotement pour rien. + const AActor* Owner = GetOwner(); + if (!Owner || !Owner->HasAuthority()) + { + return; + } + if (bDead) { return; @@ -105,6 +160,13 @@ void USurvivalStatsComponent::ApplyRestore(float HealthAmount, float HungerAmoun void USurvivalStatsComponent::ApplyDamage(float Amount) { + // Meme regle que ApplyRestore : les degats appartiennent a l'autorite. + const AActor* Owner = GetOwner(); + if (!Owner || !Owner->HasAuthority()) + { + return; + } + if (bDead || Amount <= 0.f) { return; diff --git a/Source/Survival_projet/Public/CookingStation.h b/Source/Survival_projet/Public/CookingStation.h index be526ed..e6b39d1 100644 --- a/Source/Survival_projet/Public/CookingStation.h +++ b/Source/Survival_projet/Public/CookingStation.h @@ -367,6 +367,8 @@ private: * foyer. Se base sur le point de vue reel du pawn, donc sur ce qu'il voit a * l'ecran, et non sur la position de son corps. */ + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + int32 ResolveAimedSlot(const AActor* Interactor) const; /** Decide ce que ferait un appui sur Interagir, sans rien modifier. */ @@ -455,8 +457,17 @@ private: /** Inventaire du pawn qui interagit, ou nullptr. */ static UInventoryComponent* GetInventoryOf(AActor* Interactor); - /** Etat courant de chaque emplacement de cuisson. Toujours dimensionne a MaxCookingSlots. */ - UPROPERTY(Transient) + /** + * Etat courant de chaque emplacement de cuisson. Toujours dimensionne a + * MaxCookingSlots. + * + * Replique : c'est ce qui fait changer le mesh de la nourriture sous les + * yeux de tous. Le FTimerHandle que porte chaque slot n'est pas une + * UPROPERTY et ne part donc pas sur le reseau -- c'est voulu, la cuisson se + * compte uniquement sur le serveur et les clients n'en voient que le + * resultat. + */ + UPROPERTY(Transient, ReplicatedUsing = OnRep_Slots) TArray Slots; /** @@ -464,7 +475,12 @@ private: * qui brule ; quand il est consomme, tout le monde avance d'un cran et la * derniere buche disparait du foyer. */ - UPROPERTY(Transient) + /** + * Replique : le nombre de buches visibles EST la jauge de carburant, c'est + * la seule facon pour un joueur de savoir combien il reste de feu. Les + * secondes restantes partent avec, mais seul le serveur les decompte. + */ + UPROPERTY(Transient, ReplicatedUsing = OnRep_Fuel) TArray Fuel; FTimerHandle FuelTimer; @@ -477,5 +493,16 @@ private: UPROPERTY(Transient) TArray> FuelIndicators; + UPROPERTY(ReplicatedUsing = OnRep_IsLit) bool bIsLit = false; + + /** Le contenu du grill a change cote serveur : on repose tous les meshes. */ + UFUNCTION() + void OnRep_Slots(); + + UFUNCTION() + void OnRep_Fuel(); + + UFUNCTION() + void OnRep_IsLit(); }; diff --git a/Source/Survival_projet/Public/CraftingComponent.h b/Source/Survival_projet/Public/CraftingComponent.h index 9064cd2..f301eaa 100644 --- a/Source/Survival_projet/Public/CraftingComponent.h +++ b/Source/Survival_projet/Public/CraftingComponent.h @@ -117,6 +117,14 @@ public: * * @return true si la fabrication a eu lieu. */ + /** + * Fabrique la recette. Chez un client, envoie la demande au serveur et rend + * true : le resultat redescendra par la replication de l'inventaire. + * + * Le serveur refait TOUJOURS CanCraft de son cote, avec sa propre liste de + * postes accessibles -- celle que le controller tient a jour par + * Client_OpenCraftingAtStation et Server_ReleaseStation. + */ UFUNCTION(BlueprintCallable, Category = "Crafting") bool Craft(UCraftingRecipeDataAsset* Recipe); @@ -167,6 +175,10 @@ protected: TObjectPtr Inventory; private: + /** La fabrication demandee par un client, arbitree par le serveur. */ + UFUNCTION(Server, Reliable) + void Server_Craft(UCraftingRecipeDataAsset* Recipe); + /** * Combien de postes de chaque type sont a portee, et non un simple * "present / absent" -- voir AddAvailableStation. diff --git a/Source/Survival_projet/Public/FpsPlayer.h b/Source/Survival_projet/Public/FpsPlayer.h index b9ee635..c509259 100644 --- a/Source/Survival_projet/Public/FpsPlayer.h +++ b/Source/Survival_projet/Public/FpsPlayer.h @@ -76,6 +76,8 @@ public: // Called every frame virtual void Tick(float DeltaTime) override; + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + // Called to bind functionality to input virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override; @@ -487,6 +489,47 @@ protected: void UseSelectedItem(); private: + // ------------------------------------------------------------------ + // Réseau + // + // Jeter et utiliser engendrent ou détruisent des choses dans le monde : + // ce sont des actions d'autorité. Le routage vit dans les fonctions + // publiques elles-mêmes, pour que ni l'UI ni l'input n'aient à savoir + // s'ils tournent chez l'hôte ou chez un client. + // ------------------------------------------------------------------ + + UFUNCTION(Server, Reliable) + void Server_DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity); + + /** + * Le slot est passé explicitement : SelectedHotbarIndex n'est PAS répliqué, + * c'est une intention purement locale. Le serveur ne peut donc pas deviner + * ce que le joueur avait en main. + */ + UFUNCTION(Server, Reliable) + void Server_UseItem(int32 SlotIndex); + + /** L'utilisation réelle, côté autorité. */ + void PerformUseItem(int32 SlotIndex); + + /** + * Point d'entrée unique du sprint : applique en local ET prévient le serveur. + * + * Sans le second, le serveur croirait le client à `WalkSpeed` et corrigerait + * sa position à chaque pas — c'est le rubber-banding classique, et il ne se + * voit que sur une machine distante, jamais chez l'hôte. + */ + void SetSprinting(bool bNewSprinting); + + UFUNCTION(Server, Reliable) + void Server_SetSprinting(bool bNewSprinting); + + /** La vitesse suit l'état, y compris sur les pawns des autres joueurs. */ + UFUNCTION() + void OnRep_IsSprinting(); + + void ApplySprintSpeed(); + /** Fait avancer toutes les animations de caméra (intégration uniquement). */ void UpdateCameraEffects(float DeltaTime); @@ -542,6 +585,8 @@ private: /** Vitesse verticale mémorisée pendant la chute, pour calibrer l'atterrissage. */ float LastFallSpeed = 0.f; + /** Répliqué pour que les autres joueurs voient la bonne vitesse et la bonne anim. */ + UPROPERTY(ReplicatedUsing = OnRep_IsSprinting) bool bIsSprinting = false; /** Évite de spammer le log si IA_Look est mal configurée. */ diff --git a/Source/Survival_projet/Public/FpsPlayerController.h b/Source/Survival_projet/Public/FpsPlayerController.h index ce8eb00..e54f03e 100644 --- a/Source/Survival_projet/Public/FpsPlayerController.h +++ b/Source/Survival_projet/Public/FpsPlayerController.h @@ -103,6 +103,22 @@ public: UFUNCTION(BlueprintCallable, Category = "UI|Reglages") void OpenSettings(); + /** + * Ouvre le panneau d'invitation de l'overlay Steam. + * + * Passe par le controller et non directement du widget au sous-systeme : + * inviter est une action du JOUEUR LOCAL, et le controller est justement + * l'objet qui le represente. Un widget qui irait chercher un + * GameInstanceSubsystem tout seul se brancherait sur le mauvais joueur le + * jour ou l'ecran partage existera. + */ + UFUNCTION(BlueprintCallable, Category = "Coop") + void ShowInviteFriends(); + + /** Faux quand Steam est absent ou qu'aucune session n'a pu etre creee. */ + UFUNCTION(BlueprintPure, Category = "Coop") + bool CanInviteFriends() const; + protected: virtual void BeginPlay() override; virtual void OnPossess(APawn* InPawn) override; @@ -389,9 +405,68 @@ private: UFUNCTION() void HandlePawnDied(); - /** Detruit le cadavre et demande un nouveau pawn au GameMode. */ + /** + * Detruit le cadavre et demande un nouveau pawn au GameMode. + * + * Le GameMode n'existe QUE sur le serveur : chez un client + * GetAuthGameMode() rend nullptr, toujours. Un client route donc par + * Server_RequestRespawn, et ne garde en local que son fondu de retour -- + * c'est de l'affichage, ca lui appartient. + */ void RespawnPlayer(); + UFUNCTION(Server, Reliable) + void Server_RequestRespawn(); + + // ------------------------------------------------------------------ + // Ouverture d'ecran demandee par un acteur du monde + // + // Interact_Implementation s'execute sur le SERVEUR depuis le passage en + // coop. Or ces acteurs ouvrent une interface, qui n'existe que sur la + // machine du joueur : l'appel doit donc repartir vers le client par une + // RPC Client_. Sans elles, le serveur cree des widgets dans le vide et + // l'ecran du joueur ne s'ouvre jamais. + // + // L'etat "devant quel poste je suis" est tenu des DEUX cotes : le client en + // a besoin pour filtrer sa liste de recettes, le serveur pour valider le + // craft. Ne le tenir que d'un cote laisserait soit une liste vide, soit un + // etabli utilisable depuis la foret. + // ------------------------------------------------------------------ + + UFUNCTION(Client, Reliable) + void Client_OpenCraftingAtStation(ECraftingStation Station); + + UFUNCTION(Client, Reliable) + void Client_OpenStorage(AStorageContainer* Container); + + /** Le joueur a referme son ecran : le serveur reprend le poste accorde. */ + UFUNCTION(Server, Reliable) + void Server_ReleaseStation(ECraftingStation Station); + + /** Le joueur a referme le coffre : le serveur rabat le couvercle. */ + UFUNCTION(Server, Reliable) + void Server_ReleaseStorage(AStorageContainer* Container); + + /** Accorde le poste sur le composant du pawn et note lequel. */ + void GrantStation(ECraftingStation Station); + + // ------------------------------------------------------------------ + // Pseudo + // + // Le serveur ne peut PAS deviner le pseudo Steam d'un client : l'API + // d'identite ne connait que l'utilisateur local de chaque machine. C'est + // donc au client de l'annoncer. Sans ca, le moteur laisse le nom par + // defaut du fichier de configuration -- on lit "Player" au-dessus des + // quatre joueurs, ce qui se remarque tout de suite. + // ------------------------------------------------------------------ + + /** Le pseudo de l'utilisateur Steam local, ou une chaine vide si indisponible. */ + FString ResolveLocalPlayerNickname() const; + + /** Ecrit le pseudo sur le PlayerState, qui le replique a tout le monde. */ + UFUNCTION(Server, Reliable) + void Server_SetPlayerNickname(const FString& Nickname); + FTimerHandle RespawnTimerHandle; /** Surveillance de l'eloignement, actif seulement pendant qu'un coffre est ouvert. */ diff --git a/Source/Survival_projet/Public/InteractionComponent.h b/Source/Survival_projet/Public/InteractionComponent.h index f7e09cd..097939f 100644 --- a/Source/Survival_projet/Public/InteractionComponent.h +++ b/Source/Survival_projet/Public/InteractionComponent.h @@ -40,7 +40,14 @@ public: virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; - /** Declenche l'interaction sur l'objet vise, s'il y en a un. */ + /** + * Declenche l'interaction sur l'objet vise, s'il y en a un. + * + * Le trace, lui, reste PUREMENT LOCAL : chacun vise chez soi, a sa propre + * frequence, sans le moindre aller-retour reseau -- le prompt doit s'afficher + * a l'instant ou l'on vise, pas 60 ms plus tard. Seule l'action part au + * serveur, qui est le seul a pouvoir modifier le monde. + */ UFUNCTION(BlueprintCallable, Category = "Interaction") void TryInteract(); @@ -93,7 +100,36 @@ protected: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction") bool bDrawDebug = false; + /** + * Marge accordee par le serveur quand il verifie la distance annoncee par un + * client. 1.0 refuserait des interactions parfaitement legitimes : entre le + * moment ou le client vise et celui ou son message arrive, son personnage a + * bouge, et la position que le serveur connait de lui a jusqu'a un aller-retour + * de retard. + * + * Ce n'est PAS de l'anti-triche -- coop entre amis, on ne paie pas ce prix -- + * mais un garde-fou contre un client desynchronise qui ouvrirait un coffre a + * l'autre bout de la carte. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Reseau", meta = (ClampMin = "1.0")) + float ServerRangeTolerance = 2.f; + private: + /** + * L'interaction demandee par un client, executee par le serveur. + * + * Reliable : rater un ramassage parce qu'un paquet s'est perdu serait + * incomprehensible pour le joueur. Le debit est nul de toute facon, une + * interaction est un evenement rare a l'echelle du reseau. + */ + UFUNCTION(Server, Reliable) + void Server_Interact(AActor* Target); + + /** + * L'execution reelle, cote autorite. Revalide la cible : le client peut + * l'avoir vue valide alors qu'un autre joueur venait de la ramasser. + */ + void PerformInteract(AActor* Target); /** Relance le trace et met a jour FocusedActor. */ void UpdateFocus(); diff --git a/Source/Survival_projet/Public/InventoryComponent.h b/Source/Survival_projet/Public/InventoryComponent.h index 937ab78..0bf38e3 100644 --- a/Source/Survival_projet/Public/InventoryComponent.h +++ b/Source/Survival_projet/Public/InventoryComponent.h @@ -67,6 +67,8 @@ public: virtual void InitializeComponent() override; + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + /** * Ajoute des objets, en remplissant d'abord les piles entamees. * @param RemainingUses charges de l'exemplaire ajoute ; -1 signifie "plein". @@ -274,11 +276,89 @@ protected: UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire|Barre rapide", meta = (ClampMin = "0")) int32 HotbarSlotCount = 10; - /** Toujours dimensionne a GetTotalSlotCount(). */ - UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Inventaire") + /** + * Toujours dimensionne a GetTotalSlotCount(). + * + * Replique a TOUT LE MONDE et non au seul proprietaire, pour deux raisons. + * + * Technique d'abord : les conditions de GetLifetimeReplicatedProps sont + * mises en cache par CLASSE, pas par instance. Un COND_OwnerOnly viserait + * donc aussi le composant d'un coffre -- or un coffre n'appartient a aucune + * connexion, et sa grille n'arriverait chez personne. Le meme composant sert + * le sac et le contenant, c'est une decision d'architecture qu'on ne veut pas + * defaire pour un drapeau de replication. + * + * De fond ensuite : les coffres sont a acces simultane, donc leur contenu + * DOIT arriver a tous ceux qui l'ouvrent. Et voir le sac d'un coequipier n'a + * rien d'un probleme dans une coop entre amis sans anti-triche -- c'est meme + * ce qui rendra un futur echange d'objets gratuit. + * + * Cout reel : 4 joueurs x 30 slots de trois champs, envoyes uniquement quand + * ils changent, et delta-compresses. Negligeable. + */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, ReplicatedUsing = OnRep_Slots, Category = "Inventaire") TArray Slots; + /** + * Le contenu vient d'arriver du serveur : on rediffuse le delegue habituel. + * + * C'est LA ligne qui recable toute l'interface d'un coup. Grille, barre + * rapide, panneau de detail et coffres sont deja abonnes a + * OnInventoryChanged et ne font aucun Tick -- ils n'ont donc pas une seule + * ligne a changer pour devenir reseau. + */ + UFUNCTION() + void OnRep_Slots(); + private: + // ------------------------------------------------------------------ + // Reseau + // + // Le routage vit DANS les fonctions publiques et non chez leurs appelants : + // une case d'inventaire appelle MoveItem() sans savoir si elle tourne chez + // l'hote ou chez un client, et c'est tres bien ainsi. Ajouter le test + // d'autorite dans chaque widget serait dix occasions de l'oublier. + // + // Pas de prediction locale : le client envoie et attend OnRep_Slots. Sur un + // relais Steam l'aller-retour est de quelques dizaines de millisecondes, + // invisible pour un glisser-deposer -- alors qu'une prediction fausse + // donnerait une pile qui saute, symptome bien plus desagreable. + // ------------------------------------------------------------------ + + /** Ce composant peut-il modifier son contenu ici et maintenant. */ + bool HasInventoryAuthority() const; + + /** + * Par quel composant faire transiter une RPC serveur. + * + * Renvoie celui des deux qui appartient au pawn du joueur LOCAL : un coffre + * n'appartient a aucune connexion, et une RPC emise depuis lui serait + * silencieusement jetee par le moteur. C'est le piege numero un des + * transferts vers un conteneur. + */ + static UInventoryComponent* FindNetProxy(UInventoryComponent* A, UInventoryComponent* B); + + UFUNCTION(Server, Reliable) + void Server_TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity); + + UFUNCTION(Server, Reliable) + void Server_QuickTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To); + + /** + * From est passe explicitement et n'est PAS "this" cote serveur : la RPC + * transite par l'inventaire du joueur local, qui peut tres bien etre la + * DESTINATION -- c'est le cas du bouton "Tout prendre" d'un coffre. Deduire + * la source du recepteur viderait le sac du joueur dans lui-meme. + */ + UFUNCTION(Server, Reliable) + void Server_TransferAllTo(UInventoryComponent* From, UInventoryComponent* To, int32 FirstIndex); + + UFUNCTION(Server, Reliable) + void Server_ConsumeUse(int32 SlotIndex); + + /** Les regles de deplacement, une fois l'autorite acquise. */ + static bool ApplyTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity); + /** Redimensionne le tableau si la taille cible a change. */ void EnsureSlotCount(); diff --git a/Source/Survival_projet/Public/MainMenuPlayerController.h b/Source/Survival_projet/Public/MainMenuPlayerController.h index 828b584..2e70d0e 100644 --- a/Source/Survival_projet/Public/MainMenuPlayerController.h +++ b/Source/Survival_projet/Public/MainMenuPlayerController.h @@ -21,6 +21,12 @@ class USettingsMenuWidget; enum class EMenuPendingAction : uint8 { None, + + /** + * Le fondu part AVANT la creation de la session, qui est asynchrone. La map + * n'est donc chargee qu'a la reponse du service en ligne -- ou tout de suite + * si celui-ci refuse, puisqu'un echec reseau ne doit pas empecher de jouer. + */ StartGame }; @@ -38,9 +44,21 @@ class SURVIVAL_PROJET_API AMainMenuPlayerController : public APlayerController public: AMainMenuPlayerController(); - /** Fondu au noir, puis chargement de la map de jeu. */ + /** + * Fondu au noir, creation de la session, puis ouverture de la map en + * serveur d'ecoute. + * + * Il n'existe qu'UN seul bouton pour lancer une partie : toute partie est + * une partie en ligne, meme jouee seul. Distinguer "solo" et "heberger" + * obligerait le joueur a decider avant de savoir si un ami se connectera, + * et surtout a relancer sa partie pour changer d'avis. + * + * On ne rejoint jamais depuis ce menu : l'arrivee se fait uniquement par + * invitation Steam, geree par USessionSubsystem quelle que soit la map + * affichee a cet instant. + */ UFUNCTION(BlueprintCallable, Category = "Menu") - void StartSoloGame(); + void StartGame(); /** Ouvre la demande de confirmation. Ne quitte rien par elle-meme. */ UFUNCTION(BlueprintCallable, Category = "Menu") @@ -125,6 +143,25 @@ private: UFUNCTION() void HandleSettingsCloseRequested(); + /** + * La session n'a pas pu etre creee -- Steam eteint, hors ligne, service en + * panne. On charge la map QUAND MEME. + * + * C'est un choix delibere : un joueur qui clique Jouer veut jouer. Le rendre + * a son menu avec un message d'erreur reseau alors que rien ne l'empeche de + * jouer seul serait une punition pour une panne qui n'est pas la sienne. Il + * perd seulement la possibilite d'inviter, et le bouton Inviter le dira. + */ + UFUNCTION() + void HandleHostFailed(FText Reason); + + /** + * Echec de connexion sur invitation : on remonte le fondu et on rend le menu. + * Ici pas de repli possible, la partie visee est celle d'un ami. + */ + UFUNCTION() + void HandleJoinFailed(FText Reason); + /** Ferme le jeu sans rien demander. */ void CloseGame(); diff --git a/Source/Survival_projet/Public/MainMenuWidget.h b/Source/Survival_projet/Public/MainMenuWidget.h index 10174de..fdb8fa5 100644 --- a/Source/Survival_projet/Public/MainMenuWidget.h +++ b/Source/Survival_projet/Public/MainMenuWidget.h @@ -28,7 +28,14 @@ public: virtual void NativeConstruct() override; protected: - /** Lance la partie solo. Nomme exactement "PlayButton" dans le WBP. */ + /** + * Lance la partie. Nomme exactement "PlayButton" dans le WBP. + * + * Un seul bouton, et toute partie est en ligne : il n'y a ni "Heberger" ni + * "Rejoindre". On arrive chez un ami par invitation Steam et par rien + * d'autre, ce qui evite au joueur de choisir un mode avant de savoir si + * quelqu'un le rejoindra. + */ UPROPERTY(meta = (BindWidget)) TObjectPtr PlayButton; @@ -39,17 +46,6 @@ protected: UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr SettingsButton; - /** - * Coop a venir. BindWidgetOptional et non BindWidget : tu peux dessiner ton - * WBP_MainMenu sans eux aujourd'hui, le Blueprint compilera quand meme. - * S'ils existent, ils sont grises automatiquement. - */ - UPROPERTY(meta = (BindWidgetOptional)) - TObjectPtr HostButton; - - UPROPERTY(meta = (BindWidgetOptional)) - TObjectPtr JoinButton; - private: UFUNCTION() void HandlePlayClicked(); diff --git a/Source/Survival_projet/Public/PauseMenuWidget.h b/Source/Survival_projet/Public/PauseMenuWidget.h index bd3b010..000d83a 100644 --- a/Source/Survival_projet/Public/PauseMenuWidget.h +++ b/Source/Survival_projet/Public/PauseMenuWidget.h @@ -45,6 +45,18 @@ protected: UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr SettingsButton; + /** + * Ouvre le panneau d'invitation de l'overlay Steam. C'est la SEULE facon de + * faire venir un ami : il n'existe ni navigateur de parties, ni bouton + * Rejoindre dans le menu principal. + * + * Grise tout seul quand l'invitation est impossible -- Steam absent, ou + * partie lancee alors qu'il l'etait. Un bouton qui ne repond pas au clic + * passe pour un bug ; un bouton grise se comprend tout seul. + */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr InviteButton; + private: UFUNCTION() void HandleResumeClicked(); @@ -54,4 +66,7 @@ private: UFUNCTION() void HandleQuitToMenuClicked(); + + UFUNCTION() + void HandleInviteClicked(); }; diff --git a/Source/Survival_projet/Public/PickupItem.h b/Source/Survival_projet/Public/PickupItem.h index eeba36a..0225762 100644 --- a/Source/Survival_projet/Public/PickupItem.h +++ b/Source/Survival_projet/Public/PickupItem.h @@ -27,10 +27,15 @@ class SURVIVAL_PROJET_API APickupItem : public AActor, public IInteractable public: APickupItem(); + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + /** * Configure un pickup engendre a l'execution (objet jete au sol). * A appeler entre SpawnActorDeferred() et FinishSpawning() pour que * OnConstruction ait deja les bonnes donnees et pose le bon mesh. + * + * A n'appeler que sur le SERVEUR : c'est lui qui engendre les acteurs du + * monde, les clients recoivent l'acteur deja configure par replication. */ UFUNCTION(BlueprintCallable, Category = "Item") void InitPickup(UItemDataAsset* InItemData, int32 InQuantity, int32 InRemainingUses = -1); @@ -62,21 +67,44 @@ protected: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item") TObjectPtr InteractionSphere; - /** Quel objet ce pickup represente. A assigner sur chaque instance. */ - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item") + /** + * Quel objet ce pickup represente. A assigner sur chaque instance. + * + * ReplicatedUsing et non simplement Replicated : chez un client, l'acteur + * est construit AVANT que ses proprietes arrivent. OnConstruction s'execute + * donc avec ItemData a nullptr et ne pose aucun mesh -- l'objet resterait + * invisible pour tout le monde sauf l'hote. C'est OnRep qui repose le visuel + * au moment ou la donnee arrive vraiment. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, ReplicatedUsing = OnRep_ItemData, Category = "Item") TObjectPtr ItemData; - /** Combien d'exemplaires ce tas contient. */ - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item", meta = (ClampMin = "1")) + /** + * Combien d'exemplaires ce tas contient. + * Replique parce que le prompt l'affiche ("Ramasser Branche x3") et qu'un + * ramassage partiel, inventaire plein, le fait diminuer sous les yeux de tous. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Item") int32 Quantity = 1; /** * Charges restantes pour un objet a usages multiples. * -1 = plein, ce qui est le cas de tout objet pose a la main dans le niveau. */ - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item") + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Item") int32 RemainingUses = -1; + /** Repose le mesh quand la donnee de l'objet arrive chez un client. */ + UFUNCTION() + void OnRep_ItemData(); + + /** + * Applique le visuel decrit par l'ItemData. Partage par OnConstruction et + * OnRep_ItemData : deux copies finiraient par diverger, et le symptome + * serait un objet correct dans l'editeur mais nu en jeu. + */ + void ApplyItemVisuals(); + /** * Reprend automatiquement le WorldMesh de l'ItemData dans l'editeur. * Decoche pour imposer un mesh different sur une instance precise. diff --git a/Source/Survival_projet/Public/PlayerNameplateComponent.h b/Source/Survival_projet/Public/PlayerNameplateComponent.h new file mode 100644 index 0000000..21a6bf8 --- /dev/null +++ b/Source/Survival_projet/Public/PlayerNameplateComponent.h @@ -0,0 +1,88 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Components/WidgetComponent.h" +#include "PlayerNameplateComponent.generated.h" + +class APlayerState; + +/** + * L'etiquette de pseudo posee au-dessus d'un joueur. + * + * Herite de UWidgetComponent et le configure en espace ECRAN : c'est ce qui la + * fait toujours faire face a la camera, gratuitement et exactement, sans le + * moindre calcul d'orientation par frame. En espace Monde il faudrait la + * reorienter soi-meme a chaque image, et elle serait occultee par le decor -- + * un pseudo qui disparait derriere un caillou n'aide personne. + * + * Contrepartie assumee de l'espace ecran : la taille ne diminue pas avec la + * distance. C'est le comportement des jeux coop du genre, et c'est justement + * pourquoi une distance de disparition est indispensable -- sans elle, quatre + * pseudos resteraient lisibles a l'autre bout de la carte. + * + * Le composant ne replique RIEN : le pseudo vit deja sur le PlayerState, qui + * est replique par le moteur. Ajouter une propriete ici enverrait deux fois la + * meme chaine sur le reseau. + */ +UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent)) +class SURVIVAL_PROJET_API UPlayerNameplateComponent : public UWidgetComponent +{ + GENERATED_BODY() + +public: + UPlayerNameplateComponent(); + + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + +protected: + /** + * Au-dela de cette distance, l'etiquette disparait. 2500 cm = 25 m, soit un + * peu plus que la portee a laquelle on distingue un coequipier dans un + * decor dense. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Nameplate", meta = (ClampMin = "100", Units = "cm")) + float MaxDrawDistance = 2500.f; + + /** + * Periode de reevaluation, en secondes. + * + * Un timer et pas un Tick : la distance a la camera n'a aucune raison + * d'etre recalculee 120 fois par seconde pour une etiquette qui apparait ou + * disparait une fois toutes les dix secondes. Quatre joueurs a 120 ips + * feraient 480 calculs de distance par seconde pour rien. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Nameplate", meta = (ClampMin = "0.05", Units = "s")) + float RefreshInterval = 0.25f; + + /** + * Cacher l'etiquette de son PROPRE personnage. + * + * Vrai par defaut : en vue premiere personne on ne voit jamais son propre + * corps, mais une etiquette en espace ecran, elle, se projetterait en plein + * milieu du viseur. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Nameplate") + bool bHideOnOwningPlayer = true; + +private: + /** Distance a la camera locale, puis mise a jour du texte si besoin. */ + void Refresh(); + + /** Le PlayerState du pawn porteur, ou nullptr tant qu'il n'est pas arrive. */ + APlayerState* GetOwnerPlayerState() const; + + FTimerHandle RefreshTimer; + + /** + * Dernier pseudo reellement ecrit dans le widget. + * + * Le PlayerState arrive APRES le pawn chez un client : la premiere passe + * trouve donc un nom vide, et il faut continuer a regarder. Comparer plutot + * que reecrire evite de reconstruire la mise en page du Text Block quatre + * fois par seconde pour une chaine identique. + */ + FString LastShownName; +}; diff --git a/Source/Survival_projet/Public/PlayerNameplateWidget.h b/Source/Survival_projet/Public/PlayerNameplateWidget.h new file mode 100644 index 0000000..3476de1 --- /dev/null +++ b/Source/Survival_projet/Public/PlayerNameplateWidget.h @@ -0,0 +1,37 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "PlayerNameplateWidget.generated.h" + +class UTextBlock; + +/** + * L'etiquette flottante au-dessus d'un joueur : son pseudo Steam. + * + * Volontairement passive -- elle ne va chercher aucune donnee et ne s'abonne a + * rien. C'est UPlayerNameplateComponent qui la nourrit, parce que lui seul sait + * DE QUEL joueur il s'agit : un widget pose sur le pawn d'autrui n'a aucun moyen + * fiable de remonter jusqu'a son PlayerState sans supposer une hierarchie. + * + * Classe Abstract comme les autres widgets du projet : on cree un + * WBP_PlayerNameplate qui en herite, le C++ porte la logique et le Blueprint la + * mise en page. + */ +UCLASS(Abstract) +class SURVIVAL_PROJET_API UPlayerNameplateWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + /** Ecrit le pseudo affiche. Sans effet si le Text Block n'existe pas. */ + UFUNCTION(BlueprintCallable, Category = "Nameplate") + void SetPlayerName(const FText& InName); + +protected: + /** A nommer exactement "PlayerNameText" dans le WBP. */ + UPROPERTY(meta = (BindWidget)) + TObjectPtr PlayerNameText; +}; diff --git a/Source/Survival_projet/Public/SessionSubsystem.h b/Source/Survival_projet/Public/SessionSubsystem.h new file mode 100644 index 0000000..ffc22c6 --- /dev/null +++ b/Source/Survival_projet/Public/SessionSubsystem.h @@ -0,0 +1,198 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Interfaces/OnlineSessionInterface.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "SessionSubsystem.generated.h" + +/** + * Raison d'un echec, deja redigee pour etre affichee telle quelle. + * + * Il n'existe volontairement PAS de delegue de succes symetrique : quand + * l'hebergement ou la connexion reussit, la map change et tout ce qui ecoutait + * est detruit dans la seconde. Un delegue de succes serait donc soit jamais lu, + * soit lu par un widget qui n'existe deja plus. + */ +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); + +/** + * Hebergement et connexion : creer une partie, la trouver, la rejoindre. + * + * GameInstanceSubsystem et pas un composant : une session doit survivre au + * changement de map, puisque c'est justement elle qui le provoque. Le critere + * habituel du projet -- "si le pawn meurt, est-ce que ca doit survivre ?" -- + * la place au meme endroit que les reglages. + * + * Le C++ ici ne parle JAMAIS de Steam. Tout passe par IOnlineSubsystem, dont + * l'implementation est choisie par DefaultEngine.ini. Consequence directe : le + * meme code marche en LAN via OnlineSubsystemNULL, ce qui permet de tester a + * deux instances sur la meme machine sans client Steam. + */ +UCLASS() +class SURVIVAL_PROJET_API USessionSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +public: + /** Coop a quatre : la valeur de reference du projet. */ + static constexpr int32 DefaultMaxPlayers = 4; + + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** + * Cree la session puis ouvre la map en serveur d'ecoute. + * + * C'est le sous-systeme qui charge la map et non l'appelant, parce que la + * creation est ASYNCHRONE : entre le clic et la reponse de Steam il peut + * s'ecouler une seconde, et ouvrir la map avant d'avoir la session donnerait + * une partie que personne ne peut rejoindre. + * + * En cas d'echec, rien ne bouge et OnHostFailed est diffuse. + * + * MaxPlayers n'a volontairement PAS de valeur par defaut : UHT ne sait pas + * resoudre une constante qualifiee dans une signature UFUNCTION, et ecrire + * "= 4" ici recreerait le second nombre a tenir en phase avec le GameMode. + * Les appelants passent USessionSubsystem::DefaultMaxPlayers. + */ + UFUNCTION(BlueprintCallable, Category = "Session") + void HostSession(const TSoftObjectPtr& Level, int32 MaxPlayers); + + /** Cherche les parties joignables. Le resultat arrive par OnSearchFinished. */ + UFUNCTION(BlueprintCallable, Category = "Session") + void FindSessions(int32 MaxResults = 20); + + /** + * Rejoint un resultat de la derniere recherche, puis voyage vers l'hote. + * Un index hors bornes diffuse OnJoinFailed plutot que de ne rien faire. + */ + UFUNCTION(BlueprintCallable, Category = "Session") + void JoinFoundSession(int32 ResultIndex = 0); + + /** + * Cherche puis rejoint la premiere partie trouvee, en une commande console. + * + * Aucun bouton ne mene ici : on ne rejoint une partie QUE par invitation + * Steam. Cette commande n'existe que pour pouvoir tester a deux instances + * sur une seule machine, ou aucune invitation n'est possible. La taper dans + * la console (touche ~) sur la seconde fenetre. + */ + UFUNCTION(Exec, Category = "Session") + void EmberJoin(); + + /** + * Ouvre le panneau d'invitation de l'overlay Steam. + * + * @return false si l'overlay est indisponible -- pas de service en ligne, + * pas de session, ou overlay desactive cote client Steam. + */ + UFUNCTION(BlueprintCallable, Category = "Session") + bool ShowInviteUI(); + + /** + * Peut-on inviter maintenant. Sert a griser le bouton plutot qu'a le laisser + * cliquer dans le vide : une partie lancee alors que Steam etait absent + * tourne tres bien, mais n'a personne a qui s'annoncer. + */ + UFUNCTION(BlueprintPure, Category = "Session") + bool CanInvite() const; + + /** + * Quitte la session courante. A appeler en revenant au menu principal : + * une session laissee derriere soi empeche d'en creer une nouvelle, et + * laisse surtout ta partie annoncee a tes amis alors qu'elle n'existe plus. + */ + UFUNCTION(BlueprintCallable, Category = "Session") + void LeaveSession(); + + /** Vrai si une session est ouverte, qu'on l'heberge ou qu'on l'ait rejointe. */ + UFUNCTION(BlueprintPure, Category = "Session") + bool IsSessionActive() const; + + /** + * Nom du service en ligne reellement charge : "Steam", ou "NULL" en LAN. + * Sert a l'UI et surtout au diagnostic -- voir "NULL" alors qu'on attendait + * Steam est le symptome le plus frequent, et il se lit en une seconde. + */ + UFUNCTION(BlueprintPure, Category = "Session") + FString GetOnlineServiceName() const; + + /** Combien de resultats la derniere recherche a rapportes. */ + UFUNCTION(BlueprintPure, Category = "Session") + int32 GetFoundSessionCount() const; + + UPROPERTY(BlueprintAssignable, Category = "Session") + FOnSessionError OnHostFailed; + + UPROPERTY(BlueprintAssignable, Category = "Session") + FOnSessionError OnJoinFailed; + + UPROPERTY(BlueprintAssignable, Category = "Session") + FOnSessionSearchFinished OnSearchFinished; + +private: + void HandleCreateSessionComplete(FName SessionName, bool bWasSuccessful); + void HandleFindSessionsComplete(bool bWasSuccessful); + void HandleJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result); + void HandleDestroySessionComplete(FName SessionName, bool bWasSuccessful); + + /** + * L'ami a clique "Rejoindre la partie" dans l'overlay Steam. + * + * Ecoute des l'Initialize et jamais coupee : une invitation peut tomber a + * n'importe quel instant, y compris en pleine partie ou alors qu'aucun menu + * n'est affiche. Le sous-systeme est le seul objet dont on soit certain + * qu'il soit vivant a ce moment-la. + */ + void HandleInviteAccepted(const bool bWasSuccessful, const int32 ControllerId, FUniqueNetIdPtr UserId, const FOnlineSessionSearchResult& InviteResult); + + /** + * Second temps de la commande EmberJoin : la recherche a rendu, on rejoint. + * UFUNCTION obligatoire, c'est ce qui permet le AddDynamic sur OnSearchFinished. + */ + UFUNCTION() + void HandleExecJoinSearchFinished(bool bWasSuccessful, int32 SessionCount); + + /** La creation proprement dite, une fois le terrain degage. */ + void CreateSessionNow(); + + void JoinSearchResult(const FOnlineSessionSearchResult& Result); + + /** + * Vrai quand le service charge est OnlineSubsystemNULL, c'est-a-dire qu'on + * n'a pas Steam. Toutes les sessions basculent alors en LAN : c'est ce qui + * rend le test a deux instances possible sur une seule machine. + */ + bool IsLanMode() const; + + /** Coupe proprement un delegue encore branche. Idempotent. */ + void ClearPendingDelegates(); + + IOnlineSessionPtr SessionInterface; + + TSharedPtr SearchSettings; + + FDelegateHandle CreateSessionCompleteHandle; + FDelegateHandle FindSessionsCompleteHandle; + FDelegateHandle JoinSessionCompleteHandle; + FDelegateHandle DestroySessionCompleteHandle; + FDelegateHandle InviteAcceptedHandle; + + /** Map a ouvrir une fois la session creee. */ + TSoftObjectPtr PendingHostLevel; + + int32 PendingMaxPlayers = DefaultMaxPlayers; + + /** + * Une session trainait encore : on la detruit d'abord et la creation reprend + * dans le callback de destruction. Sans ce drapeau, quitter une partie puis + * en relancer une echoue silencieusement -- le moteur refuse deux sessions + * du meme nom, et l'erreur ne remonte nulle part. + */ + bool bCreateAfterDestroy = false; +}; diff --git a/Source/Survival_projet/Public/StorageContainer.h b/Source/Survival_projet/Public/StorageContainer.h index 24fe72c..4960079 100644 --- a/Source/Survival_projet/Public/StorageContainer.h +++ b/Source/Survival_projet/Public/StorageContainer.h @@ -64,6 +64,19 @@ public: virtual void Tick(float DeltaTime) override; + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + + /** Le couvercle vient de changer d'etat cote serveur : on joue l'animation. */ + UFUNCTION() + void OnRep_Open(); + + /** + * Son et relance du tick d'animation. Partage par SetOpen (serveur) et + * OnRep_Open (clients) : deux copies finiraient par diverger, et le + * symptome serait un couvercle muet chez les invites. + */ + void ApplyOpenState(); + // --- IInteractable --- virtual FText GetInteractionPrompt_Implementation(AActor* Interactor) const override; virtual void Interact_Implementation(AActor* Interactor) override; @@ -87,6 +100,11 @@ public: * parce qu'il s'est trop eloigne. Un coffre qui deciderait seul finirait * ouvert dans la moitie de ces cas. */ + /** + * Leve ou referme le couvercle. **Serveur uniquement** : le couvercle est + * un etat du MONDE, les quatre joueurs doivent le voir bouger. Un client + * qui l'appellerait verrait son coffre s'ouvrir tout seul chez lui. + */ UFUNCTION(BlueprintCallable, Category = "Storage") void SetOpen(bool bInOpen); @@ -232,6 +250,11 @@ private: /** Cale la boite sur le volume combine des deux meshes, couvercle ferme. */ void FitInteractionBox(); + /** + * Repliqué à tout le monde : voir le couvercle d'un coffre se lever est le + * seul signe qu'un coéquipier est en train de fouiller dedans. + */ + UPROPERTY(ReplicatedUsing = OnRep_Open) bool bOpen = false; /** 0 = ferme, 1 = ouvert. Interpole, jamais pose directement. */ diff --git a/Source/Survival_projet/Public/SurvivalGameMode.h b/Source/Survival_projet/Public/SurvivalGameMode.h new file mode 100644 index 0000000..5a84fd7 --- /dev/null +++ b/Source/Survival_projet/Public/SurvivalGameMode.h @@ -0,0 +1,51 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/GameModeBase.h" +#include "SurvivalGameMode.generated.h" + +/** + * L'arbitre de la partie. N'existe QUE sur le serveur -- sur un client, + * GetGameMode() rend nullptr, toujours. Ce n'est pas un bug mais le design du + * framework : le GameMode decide, le GameState montre. + * + * Corollaire a garder en tete pour tout le portage coop : le moindre appel a + * GetGameMode() ecrit du cote client doit etre remplace par un RPC serveur ou + * par une lecture du GameState. C'est le cas du respawn, aujourd'hui declenche + * depuis AFpsPlayerController. + * + * Y mettra plus tard : les regles de mort, le cycle jour/nuit, la sauvegarde + * du monde. Pour l'instant il ne fait que borner le nombre de joueurs et + * cabler GameState et PlayerState. + */ +UCLASS() +class SURVIVAL_PROJET_API ASurvivalGameMode : public AGameModeBase +{ + GENERATED_BODY() + +public: + ASurvivalGameMode(); + + virtual void InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage) override; + virtual void PostLogin(APlayerController* NewPlayer) override; + virtual void Logout(AController* Exiting) override; + +protected: + /** + * Plafond de joueurs, applique a la AGameSession. + * + * Regle ici et pas seulement dans les reglages de session : la session + * annonce des places libres, mais c'est le GameMode qui refuse reellement la + * connexion. Sans ce plafond, un cinquieme joueur qui arrive par une + * invitation directe entre quand meme. + * + * Sa valeur par defaut est posee dans le constructeur depuis + * USessionSubsystem::DefaultMaxPlayers, pour qu'il n'existe qu'UN seul 4 + * dans le projet : deux nombres qui doivent rester egaux finissent toujours + * par diverger, et le symptome serait une place annoncee mais refusee. + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Coop", meta = (ClampMin = "1", ClampMax = "16")) + int32 MaxCoopPlayers; +}; diff --git a/Source/Survival_projet/Public/SurvivalStatsComponent.h b/Source/Survival_projet/Public/SurvivalStatsComponent.h index 0cda3d9..c656ef0 100644 --- a/Source/Survival_projet/Public/SurvivalStatsComponent.h +++ b/Source/Survival_projet/Public/SurvivalStatsComponent.h @@ -34,6 +34,8 @@ public: virtual void BeginPlay() override; virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + /** Rend de la vie, de la faim et de la soif. Les valeurs sont bornees au max. */ UFUNCTION(BlueprintCallable, Category = "Survie") void ApplyRestore(float HealthAmount, float HungerAmount, float ThirstAmount); @@ -106,11 +108,38 @@ private: /** Appelee par le timer : fait descendre les jauges et applique les degats. */ void UpdateStats(); + /** + * Les jauges viennent d'arriver du serveur : on rediffuse le delegue + * habituel, et WBP_SurvivalStats se met a jour sans une ligne de changement. + */ + UFUNCTION() + void OnRep_Stats(); + FTimerHandle UpdateTimerHandle; + // La simulation tourne UNIQUEMENT sur le serveur : quatre clients qui + // feraient descendre leur faim chacun de leur cote divergeraient en + // quelques minutes, et le premier a mourir ne serait pas le meme partout. + // Les clients ne font que recevoir. + + UPROPERTY(ReplicatedUsing = OnRep_Stats) float Health = 0.f; + + UPROPERTY(ReplicatedUsing = OnRep_Stats) float Hunger = 0.f; + + UPROPERTY(ReplicatedUsing = OnRep_Stats) float Thirst = 0.f; + /** + * ReplicatedUsing et non simplement Replicated : OnDied n'est diffuse que + * sur le serveur, or c'est ce delegue qui declenche l'ecran de mort et le + * respawn cote joueur. Sans OnRep, un client mourrait sans jamais le savoir + * et continuerait a jouer un personnage que le serveur considere mort. + */ + UPROPERTY(ReplicatedUsing = OnRep_Dead) bool bDead = false; + + UFUNCTION() + void OnRep_Dead(); }; diff --git a/Source/Survival_projet/Survival_projet.Build.cs b/Source/Survival_projet/Survival_projet.Build.cs index ee46b2f..5cf211a 100644 --- a/Source/Survival_projet/Survival_projet.Build.cs +++ b/Source/Survival_projet/Survival_projet.Build.cs @@ -18,10 +18,16 @@ public class Survival_projet : ModuleRules PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GameplayTags", "UMG", "Niagara" }); PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" }); - - // Uncomment if you are using online features - // PrivateDependencyModuleNames.Add("OnlineSubsystem"); - // To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true + // Coop Steam : on ne depend QUE de l'API generique (OnlineSubsystem) et de + // ses helpers (OnlineSubsystemUtils, qui porte le resolveur d'adresses). + // Surtout PAS de OnlineSubsystemSteam en dur -- le code appellerait alors + // du Steam explicitement et ne compilerait plus sur une plateforme sans + // Steam. Le choix du service se fait par ini. + // + // Public et non Private : SessionSubsystem.h expose IOnlineSessionPtr et + // FOnlineSessionSearchResult dans sa declaration. Un header public qui + // montre les types d'un module prive ne compile pas chez celui qui l'inclut. + PublicDependencyModuleNames.AddRange(new string[] { "OnlineSubsystem", "OnlineSubsystemUtils" }); } } diff --git a/Survival_projet.uproject b/Survival_projet.uproject index 7045fb7..5471422 100644 --- a/Survival_projet.uproject +++ b/Survival_projet.uproject @@ -31,6 +31,14 @@ "TargetAllowList": [ "Editor" ] + }, + { + "Name": "OnlineSubsystemSteam", + "Enabled": true + }, + { + "Name": "SteamSockets", + "Enabled": true } ] } \ No newline at end of file