(Feat) Add mulitplayer

This commit is contained in:
2026-08-03 10:30:47 +02:00
parent 41d6e6a7f3
commit a4cc59a411
41 changed files with 3418 additions and 125 deletions
+30 -3
View File
@@ -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<FLifetimeProperty>& 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<FCookingSlotState> 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<FFuelSlotState> Fuel;
FTimerHandle FuelTimer;
@@ -477,5 +493,16 @@ private:
UPROPERTY(Transient)
TArray<TObjectPtr<UStaticMeshComponent>> 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();
};
@@ -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<UInventoryComponent> 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.
+45
View File
@@ -76,6 +76,8 @@ public:
// Called every frame
virtual void Tick(float DeltaTime) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& 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. */
@@ -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. */
@@ -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();
@@ -67,6 +67,8 @@ public:
virtual void InitializeComponent() override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& 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<FInventorySlot> 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();
@@ -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();
+8 -12
View File
@@ -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<UButton> PlayButton;
@@ -39,17 +46,6 @@ protected:
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> 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<UButton> HostButton;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> JoinButton;
private:
UFUNCTION()
void HandlePlayClicked();
@@ -45,6 +45,18 @@ protected:
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> 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<UButton> InviteButton;
private:
UFUNCTION()
void HandleResumeClicked();
@@ -54,4 +66,7 @@ private:
UFUNCTION()
void HandleQuitToMenuClicked();
UFUNCTION()
void HandleInviteClicked();
};
+33 -5
View File
@@ -27,10 +27,15 @@ class SURVIVAL_PROJET_API APickupItem : public AActor, public IInteractable
public:
APickupItem();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& 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<USphereComponent> 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<UItemDataAsset> 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.
@@ -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;
};
@@ -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<UTextBlock> PlayerNameText;
};
@@ -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<UWorld>& 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<FOnlineSessionSearch> SearchSettings;
FDelegateHandle CreateSessionCompleteHandle;
FDelegateHandle FindSessionsCompleteHandle;
FDelegateHandle JoinSessionCompleteHandle;
FDelegateHandle DestroySessionCompleteHandle;
FDelegateHandle InviteAcceptedHandle;
/** Map a ouvrir une fois la session creee. */
TSoftObjectPtr<UWorld> 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;
};
@@ -64,6 +64,19 @@ public:
virtual void Tick(float DeltaTime) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& 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. */
@@ -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;
};
@@ -34,6 +34,8 @@ public:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& 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();
};