diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4eeebbd..a64fe2b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -685,12 +685,31 @@ couleurs sur des MID). `DA_CharacterParts` couvre Body A ; **Body B reste à rem `ABP_PlayerBody_B` et sans barbes ni moustaches — le pack n'en livre pas, et une liste vide se comporte déjà comme `NoPart`. -Restent les lots 3 et 4 : +**Le lot 3 est fait** : l'apparence vit sur `ASurvivalPlayerState` en `ReplicatedUsing` et se +persiste dans `USurvivalUserSettings`. Elle survit donc au respawn — le pawn est un nouvel acteur, +le PlayerState non — et au relancement du jeu. -- **Lot 3** — réplication par `ASurvivalPlayerState` en `ReplicatedUsing`, persistance dans - `USurvivalUserSettings` -- **Lot 4** — l'écran : `ACharacterPreviewActor` dans `MenuScene`, caméra à cadrages interpolés, - UI à gauche +- **`SubmitCharacterAppearance()` sur `AFpsPlayerController` est le point d'entrée UNIQUE.** Il + persiste *et* annonce au serveur, jamais l'un sans l'autre : persister sans annoncer laisserait + le joueur seul à se voir changé, annoncer sans persister le ferait repartir au visage par défaut + au lancement suivant. C'est par là que passent les commandes console, et par là que passera + l'écran du lot 4. +- **La course pawn / PlayerState se résout des deux côtés.** Chez un client ils arrivent par deux + chemins de réplication indépendants et rien ne dit lequel gagne : `OnRep_CharacterAppearance` + monte le personnage si le pawn est déjà là, `AFpsPlayer::OnRep_PlayerState` va chercher + l'apparence si c'est le pawn qui est arrivé le premier. Les deux appellent la même fonction + idempotente. Chez l'hôte, aucun `OnRep` ne se déclenche — c'est le `BeginPlay` du pawn qui monte, + et `SetCharacterAppearance` qui applique en local juste après avoir écrit la propriété. +- **Rien n'est validé côté PlayerState, et c'est délibéré** : borner un index demande le catalogue, + que seul `UCharacterAppearanceComponent` connaît — or il passe déjà tout ce qu'il monte par + `Catalog->Sanitize()`, sur chaque machine. Dupliquer la validation demanderait de donner le + catalogue au PlayerState pour un résultat identique. +- Piège de test : **en PIE à deux joueurs, les deux fenêtres lisent le même `.ini`**, donc les deux + personnages démarrent identiques. Il faut changer l'un des deux à la console pour vérifier que la + réplication marche. + +Reste le **lot 4** — l'écran : `ACharacterPreviewActor` dans `MenuScene`, caméra à cadrages +interpolés, UI à gauche. Et le remplissage de **Body B** dans `DA_CharacterParts`. #### Trois pièges du lot 2, aucun visible depuis le code diff --git a/Source/Survival_projet/Private/FpsPlayer.cpp b/Source/Survival_projet/Private/FpsPlayer.cpp index d52c86a..3377a6e 100644 --- a/Source/Survival_projet/Private/FpsPlayer.cpp +++ b/Source/Survival_projet/Private/FpsPlayer.cpp @@ -17,6 +17,7 @@ #include "InputActionValue.h" #include "CharacterAppearanceComponent.h" #include "CraftingComponent.h" +#include "FpsPlayerController.h" #include "HeadBobComponent.h" #include "InteractionComponent.h" #include "InventoryComponent.h" @@ -24,6 +25,7 @@ #include "Net/UnrealNetwork.h" #include "PickupItem.h" #include "SurvivalGameInstance.h" +#include "SurvivalPlayerState.h" #include "SurvivalStatsComponent.h" #include "SurvivalUserSettings.h" @@ -134,6 +136,12 @@ void AFpsPlayer::BeginPlay() SurvivalStats->OnDied.AddDynamic(this, &AFpsPlayer::HandleDeath); + // Chez l'hote et en solo, le PlayerState est deja la a cet instant : + // OnRep_PlayerState ne s'y declenchant jamais, c'est ce seul appel qui monte + // le personnage. Chez un client il rend souvent la main sans rien faire, + // l'OnRep prenant le relais -- les deux chemins sont idempotents. + RefreshAppearanceFromPlayerState(); + // Sans cet abonnement, un objet fabrique alors que l'inventaire est plein // serait purement et simplement perdu. CraftingComponent->OnCraftOverflow.AddDynamic(this, &AFpsPlayer::HandleCraftOverflow); @@ -362,6 +370,28 @@ void AFpsPlayer::AddCrouchTilt(float Direction) CrouchTiltSpring.AddImpulse(Direction * UE_EULERS_NUMBER * Omega); } +void AFpsPlayer::OnRep_PlayerState() +{ + Super::OnRep_PlayerState(); + + RefreshAppearanceFromPlayerState(); +} + +void AFpsPlayer::RefreshAppearanceFromPlayerState() +{ + if (!AppearanceComponent) + { + return; + } + + // Absent n'est pas une erreur : c'est le cas normal quand le pawn gagne la + // course. L'apparence viendra par OnRep_CharacterAppearance. + if (const ASurvivalPlayerState* State = GetPlayerState()) + { + AppearanceComponent->ApplyAppearance(State->GetCharacterAppearance()); + } +} + // ---------------------------------------------------------------------- // Commandes console d'apparence -- de simples delegations. Voir le commentaire // de FpsPlayer.h : les UFUNCTION(Exec) d'un composant ne sont jamais routees, @@ -373,6 +403,7 @@ void AFpsPlayer::EmberPart(const FString& Part, int32 Index) if (AppearanceComponent) { AppearanceComponent->ApplyPartByName(Part, Index); + SubmitCurrentAppearance(); } } @@ -381,6 +412,19 @@ void AFpsPlayer::EmberColor(const FString& Part, int32 ColorIndex) if (AppearanceComponent) { AppearanceComponent->ApplyColorByName(Part, ColorIndex); + SubmitCurrentAppearance(); + } +} + +void AFpsPlayer::SubmitCurrentAppearance() +{ + // Applique en local D'ABORD (c'est deja fait par l'appelant), soumis + // ensuite : le contraire ferait attendre l'aller-retour reseau pour voir + // son propre changement, ce qui donnerait un reglage qui repond en retard. + // C'est le meme ordre que Server_SetSprinting. + if (AFpsPlayerController* FpsController = Cast(GetController())) + { + FpsController->SubmitCharacterAppearance(AppearanceComponent->GetAppearance()); } } diff --git a/Source/Survival_projet/Private/FpsPlayerController.cpp b/Source/Survival_projet/Private/FpsPlayerController.cpp index de8c0fb..f5a327c 100644 --- a/Source/Survival_projet/Private/FpsPlayerController.cpp +++ b/Source/Survival_projet/Private/FpsPlayerController.cpp @@ -9,7 +9,9 @@ #include "FpsPlayer.h" #include "GameFramework/GameModeBase.h" #include "SurvivalGameInstance.h" +#include "SurvivalPlayerState.h" #include "SurvivalStatsComponent.h" +#include "SurvivalUserSettings.h" #include "HotbarWidget.h" #include "InputActionValue.h" #include "InputCoreTypes.h" @@ -111,6 +113,12 @@ void AFpsPlayerController::BeginPlay() Server_SetPlayerNickname(Nickname); } } + + // Dans la foulee du pseudo : les deux repondent au meme constat, le + // serveur ne peut connaitre ni l'un ni l'autre. Ils partent donc + // ensemble, au seul moment ou l'on est certain que le PlayerState local + // existe. + AnnounceCharacterAppearance(); } // Le delegue se declenche a la fin de CHAQUE fondu : c'est @@ -1094,6 +1102,47 @@ void AFpsPlayerController::Server_SetPlayerNickname_Implementation(const FString UE_LOG(LogTemp, Log, TEXT("AFpsPlayerController : pseudo [%s]."), *State->GetPlayerName()); } +void AFpsPlayerController::SubmitCharacterAppearance(const FCharacterAppearance& InAppearance) +{ + if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get()) + { + Settings->SetCharacterAppearance(InAppearance); + + // Ecrit tout de suite, contrairement aux reglages de l'ecran d'options + // qui attendent sa fermeture : un changement d'apparence n'a pas + // d'ecran a fermer quand il vient de la console, et le perdre a la + // sortie du jeu serait le plus sur moyen de croire que rien ne marche. + Settings->SaveSettings(); + } + + // Meme raccourci que pour le pseudo : l'hote est deja l'autorite, passer + // par la RPC marcherait mais ferait un aller-retour pour rien. + if (HasAuthority()) + { + Server_SetCharacterAppearance_Implementation(InAppearance); + } + else + { + Server_SetCharacterAppearance(InAppearance); + } +} + +void AFpsPlayerController::AnnounceCharacterAppearance() +{ + if (const USurvivalUserSettings* Settings = USurvivalUserSettings::Get()) + { + SubmitCharacterAppearance(Settings->GetCharacterAppearance()); + } +} + +void AFpsPlayerController::Server_SetCharacterAppearance_Implementation(const FCharacterAppearance& InAppearance) +{ + if (ASurvivalPlayerState* State = GetPlayerState()) + { + State->SetCharacterAppearance(InAppearance); + } +} + void AFpsPlayerController::ShowInviteFriends() { if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem() : nullptr) diff --git a/Source/Survival_projet/Private/SurvivalPlayerState.cpp b/Source/Survival_projet/Private/SurvivalPlayerState.cpp new file mode 100644 index 0000000..d763917 --- /dev/null +++ b/Source/Survival_projet/Private/SurvivalPlayerState.cpp @@ -0,0 +1,74 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "SurvivalPlayerState.h" + +#include "CharacterAppearanceComponent.h" +#include "FpsPlayer.h" +#include "Net/UnrealNetwork.h" + +void ASurvivalPlayerState::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + // Aucune condition : COND_OwnerOnly serait exactement le contraire de ce + // qu'on veut. L'interet d'une apparence, c'est que les trois autres la + // voient ; celle du proprietaire est la seule dont il pourrait se passer, + // puisqu'il ne voit pas son propre corps en vue premiere personne. + DOREPLIFETIME(ASurvivalPlayerState, CharacterAppearance); +} + +void ASurvivalPlayerState::SetCharacterAppearance(const FCharacterAppearance& InAppearance) +{ + if (!HasAuthority()) + { + UE_LOG(LogTemp, Warning, TEXT("ASurvivalPlayerState : SetCharacterAppearance appelee sans autorite, ignoree.")); + return; + } + + // L'apparence n'est PAS validee ici, et c'est deliberé : borner un index + // demande le catalogue, que seul UCharacterAppearanceComponent connait. Or + // celui-ci passe deja tout ce qu'il monte par Catalog->Sanitize(), donc un + // index aberrant venu du reseau est corrige au montage, sur chaque machine. + // Dupliquer la validation ici demanderait de donner le catalogue au + // PlayerState pour un resultat identique -- et cette coop entre amis n'a de + // toute facon aucune anti-triche a faire respecter. + CharacterAppearance = InAppearance; + + // Un OnRep ne se declenche jamais sur la machine qui ecrit la propriete. + // Chez l'hote, qui est serveur ET client local, sans cette ligne son propre + // personnage resterait au visage par defaut pendant que les autres le + // verraient correctement. + ApplyAppearanceToPawn(); +} + +void ASurvivalPlayerState::OnRep_CharacterAppearance() +{ + ApplyAppearanceToPawn(); +} + +void ASurvivalPlayerState::ApplyAppearanceToPawn() +{ + // Le pawn peut manquer, et ce n'est pas une erreur : chez un client, le + // PlayerState et le pawn arrivent par deux chemins de replication + // independants, dans un ordre qu'on ne controle pas. Quand c'est le + // PlayerState qui gagne, on ne fait rien -- c'est le BeginPlay du pawn qui + // viendra chercher l'apparence a son tour. + if (AFpsPlayer* Player = Cast(GetPawn())) + { + if (UCharacterAppearanceComponent* Appearance = Player->GetAppearanceComponent()) + { + Appearance->ApplyAppearance(CharacterAppearance); + } + } +} + +void ASurvivalPlayerState::CopyProperties(APlayerState* PlayerState) +{ + Super::CopyProperties(PlayerState); + + if (ASurvivalPlayerState* SurvivalState = Cast(PlayerState)) + { + SurvivalState->CharacterAppearance = CharacterAppearance; + } +} diff --git a/Source/Survival_projet/Private/SurvivalUserSettings.cpp b/Source/Survival_projet/Private/SurvivalUserSettings.cpp index 8f35a6c..ecc580f 100644 --- a/Source/Survival_projet/Private/SurvivalUserSettings.cpp +++ b/Source/Survival_projet/Private/SurvivalUserSettings.cpp @@ -344,3 +344,8 @@ void USurvivalUserSettings::SetPlayInBackground(bool bValue) { bPlayInBackground = bValue; } + +void USurvivalUserSettings::SetCharacterAppearance(const FCharacterAppearance& InAppearance) +{ + CharacterAppearance = InAppearance; +} diff --git a/Source/Survival_projet/Public/FpsPlayer.h b/Source/Survival_projet/Public/FpsPlayer.h index 8a81922..519e63d 100644 --- a/Source/Survival_projet/Public/FpsPlayer.h +++ b/Source/Survival_projet/Public/FpsPlayer.h @@ -154,6 +154,16 @@ public: UFUNCTION(Exec, Category = "Apparence") void EmberAppearanceDump(); +private: + /** + * Persiste l'apparence du composant et l'annonce au serveur, via le + * controller. Appelee apres chaque commande console, pour que le + * changement se voie chez les autres et survive au relancement. + */ + void SubmitCurrentAppearance(); + +public: + /** Sert a l'UI pour afficher la vraie touche mappee, plutot qu'un "E" ecrit en dur. */ UFUNCTION(BlueprintPure, Category = "Input") const UInputAction* GetInteractAction() const { return InteractAction; } @@ -230,10 +240,25 @@ public: */ virtual void CalcCamera(float DeltaTime, FMinimalViewInfo& OutResult) override; + /** + * L'autre cote de la course avec le PlayerState. + * + * Chez un client, le pawn et le PlayerState arrivent par deux chemins de + * replication independants : tantot l'apparence est la avant le corps -- + * ASurvivalPlayerState::OnRep_CharacterAppearance la pose alors lui-meme -- + * tantot c'est l'inverse, et c'est ici qu'on va la chercher. Ni l'un ni + * l'autre ne peut supposer avoir gagne, donc les deux appellent la meme + * fonction idempotente. + */ + virtual void OnRep_PlayerState() override; + protected: // Called when the game starts or when spawned virtual void BeginPlay() override; + /** Lit l'apparence sur le PlayerState et la monte, s'il est deja arrive. */ + void RefreshAppearanceFromPlayerState(); + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; // Appelés par le CharacterMovement quand la capsule change de taille (accroupi) diff --git a/Source/Survival_projet/Public/FpsPlayerController.h b/Source/Survival_projet/Public/FpsPlayerController.h index 0396bc9..aa9a35d 100644 --- a/Source/Survival_projet/Public/FpsPlayerController.h +++ b/Source/Survival_projet/Public/FpsPlayerController.h @@ -6,6 +6,7 @@ // ESlateVisibility n'est que declare en avant par le moteur : il faut le type // complet pour en stocker une valeur par defaut. Meme raison pour // ECraftingStation, stocke en membre. +#include "CharacterAppearanceTypes.h" #include "Components/SlateWrapperTypes.h" #include "CraftingRecipeDataAsset.h" #include "GameFramework/PlayerController.h" @@ -115,6 +116,19 @@ public: UFUNCTION(BlueprintCallable, Category = "Coop") void ShowInviteFriends(); + /** + * Le point d'entree unique d'un changement d'apparence : persiste dans les + * reglages du joueur ET l'annonce au serveur. + * + * Les deux ensemble et jamais l'un sans l'autre -- persister sans annoncer + * laisserait le joueur seul a se voir change, annoncer sans persister le + * ferait repartir au visage par defaut au prochain lancement. C'est par ici + * que passent les commandes console, et par ici que passera l'ecran de + * customisation. + */ + UFUNCTION(BlueprintCallable, Category = "Apparence") + void SubmitCharacterAppearance(const FCharacterAppearance& InAppearance); + /** Faux quand Steam est absent ou qu'aucune session n'a pu etre creee. */ UFUNCTION(BlueprintPure, Category = "Coop") bool CanInviteFriends() const; @@ -572,6 +586,20 @@ private: UFUNCTION(Server, Reliable) void Server_SetPlayerNickname(const FString& Nickname); + /** + * Meme chemin que le pseudo, et pour la meme raison : le serveur ne peut + * pas deviner l'apparence choisie par un client, elle vit dans le fichier + * de reglages de SA machine. C'est donc lui qui l'annonce. + * + * Treize octets envoyes une fois a la connexion -- l'apparence ne change + * pas en cours de partie, l'ecran de customisation vit dans le menu. + */ + UFUNCTION(Server, Reliable) + void Server_SetCharacterAppearance(const FCharacterAppearance& InAppearance); + + /** Lit l'apparence persistee et l'envoie au serveur. Appelee au BeginPlay. */ + void AnnounceCharacterAppearance(); + FTimerHandle RespawnTimerHandle; /** Le fondu d'arrivée attend le pawn. */ diff --git a/Source/Survival_projet/Public/SurvivalPlayerState.h b/Source/Survival_projet/Public/SurvivalPlayerState.h index 43352cf..27d4df3 100644 --- a/Source/Survival_projet/Public/SurvivalPlayerState.h +++ b/Source/Survival_projet/Public/SurvivalPlayerState.h @@ -4,20 +4,70 @@ #include "CoreMinimal.h" #include "GameFramework/PlayerState.h" +#include "CharacterAppearanceTypes.h" #include "SurvivalPlayerState.generated.h" /** - * Ce que les AUTRES joueurs savent de toi. - * - * Vide pour l'instant. Y mettra : pseudo, couleur, temps de survie, nombre de - * morts -- tout ce qu'un coequipier doit pouvoir lire sur ton compte. + * Ce que les AUTRES joueurs savent de toi : le pseudo, porte par APlayerState + * lui-meme, et l'apparence du personnage. * * La regle de partage avec le pawn est simple : le PlayerState survit a la mort * du pawn, le pawn non. L'inventaire et les jauges restent donc sur le pawn, - * puisque la regle du jeu veut qu'on perde tout au sol en mourant. + * puisque la regle du jeu veut qu'on perde tout au sol en mourant -- alors que + * reapparaitre avec le visage de quelqu'un d'autre n'aurait aucun sens. + * + * C'est exactement pour cette raison que l'apparence est ici et non sur + * AFpsPlayer : elle appartient au JOUEUR, pas au corps. */ UCLASS() class SURVIVAL_PROJET_API ASurvivalPlayerState : public APlayerState { GENERATED_BODY() + +public: + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + + UFUNCTION(BlueprintPure, Category = "Apparence") + const FCharacterAppearance& GetCharacterAppearance() const { return CharacterAppearance; } + + /** + * A n'appeler que sur le SERVEUR : c'est lui qui fait autorite sur ce que + * les autres voient. + * + * Applique aussi en local dans la foulee, parce qu'un OnRep ne se declenche + * jamais sur la machine qui ecrit. Sans ce second appel, l'hote serait le + * seul a ne pas voir sa propre apparence -- le genre de bug qui ne se + * remarque qu'en testant a deux, et du mauvais cote. + */ + void SetCharacterAppearance(const FCharacterAppearance& InAppearance); + + /** + * Pousse l'apparence sur le pawn possede, s'il existe deja. + * + * Idempotente et appelee des DEUX cotes de la course : par l'OnRep quand + * l'apparence arrive avant le pawn, et par le pawn lui-meme a son BeginPlay + * quand c'est l'inverse. On ne sait pas lequel des deux gagne -- chez un + * client le pawn et le PlayerState arrivent par deux chemins de replication + * independants -- donc les deux doivent savoir se debrouiller seuls. + */ + void ApplyAppearanceToPawn(); + +protected: + /** + * Repliquee a TOUT LE MONDE et pas au seul proprietaire : c'est justement + * ce que les autres doivent voir. Treize octets par joueur, envoyes une + * fois -- l'apparence ne change pas en cours de partie. + */ + UPROPERTY(ReplicatedUsing = OnRep_CharacterAppearance) + FCharacterAppearance CharacterAppearance; + + UFUNCTION() + void OnRep_CharacterAppearance(); + + /** + * Le moteur engendre un NOUVEAU PlayerState a chaque voyage de map, et + * recopie l'ancien par ici. Sans cette surcharge, changer de carte remettrait + * tout le monde au visage par defaut. + */ + virtual void CopyProperties(APlayerState* PlayerState) override; }; diff --git a/Source/Survival_projet/Public/SurvivalUserSettings.h b/Source/Survival_projet/Public/SurvivalUserSettings.h index 35290e2..5c2007e 100644 --- a/Source/Survival_projet/Public/SurvivalUserSettings.h +++ b/Source/Survival_projet/Public/SurvivalUserSettings.h @@ -4,6 +4,7 @@ #include "CoreMinimal.h" #include "GameFramework/GameUserSettings.h" +#include "CharacterAppearanceTypes.h" #include "SurvivalUserSettings.generated.h" DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSurvivalSettingsApplied); @@ -286,7 +287,39 @@ public: UFUNCTION(BlueprintCallable, Category = "Reglages|Audio") void SetPlayInBackground(bool bValue); + // ------------------------------------------------------------------ + // Apparence du personnage + // + // Ici et pas dans une sauvegarde de partie : l'apparence appartient au + // JOUEUR et pas a une partie donnee, elle doit le suivre d'une session a + // l'autre. C'est aussi ce qui donne un visage a qui arrive par invitation + // Steam sans passer par l'ecran de customisation. + // ------------------------------------------------------------------ + + UFUNCTION(BlueprintPure, Category = "Reglages|Apparence") + const FCharacterAppearance& GetCharacterAppearance() const { return CharacterAppearance; } + + /** + * N'ecrit rien sur le disque : la sauvegarde a lieu a la fermeture de + * l'ecran, comme pour tous les autres reglages. Ne diffuse pas non plus + * OnSurvivalSettingsApplied -- le pawn ne relit pas son apparence dans les + * reglages, il la tient du PlayerState. + */ + UFUNCTION(BlueprintCallable, Category = "Reglages|Apparence") + void SetCharacterAppearance(const FCharacterAppearance& InAppearance); + private: + /** + * Serialisee telle quelle dans le .ini, sous la forme + * `CharacterAppearance=(BodyType=BodyA,HeadIndex=0,...)`. + * + * C'est le dividende de la struct d'index : une apparence faite de + * TSoftObjectPtr aurait donne une douzaine de chemins d'assets a ecrire, et + * une seule renommee d'asset aurait suffi a la casser silencieusement. + */ + UPROPERTY(config) + FCharacterAppearance CharacterAppearance; + /** Echelle lisible (5 a 100), convertie a la lecture. Jamais brute. */ UPROPERTY(config) float LookSensitivity;