(Feat) Customisation -- replication et persistance

Lot 3 : l'apparence survit desormais au respawn, au changement de map et au
relancement du jeu, et les trois autres joueurs la voient.

Elle vit sur ASurvivalPlayerState et pas sur le pawn : c'est le JOUEUR
qu'elle decrit, pas le corps. Le pawn meurt et reapparait, le PlayerState
non -- reapparaitre avec le visage de quelqu'un d'autre n'aurait aucun sens,
alors que perdre son inventaire au sol est la regle du jeu.

SubmitCharacterAppearance() sur AFpsPlayerController est le point d'entree
UNIQUE : il persiste ET annonce au serveur, 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 lancement suivant.
C'est par la que passent les commandes console, et par la que passera
l'ecran du lot 4.

La course pawn / PlayerState est resolue des DEUX cotes. Chez un client ils
arrivent par deux chemins de replication independants et rien ne dit lequel
gagne : OnRep_CharacterAppearance monte le personnage si le pawn est deja la,
AFpsPlayer::OnRep_PlayerState va chercher l'apparence si c'est l'inverse. Les
deux appellent la meme fonction idempotente. Chez l'hote aucun OnRep ne se
declenche, d'ou l'appel local dans SetCharacterAppearance -- sans lui, l'hote
serait le seul a ne pas voir son propre personnage.

Rien n'est valide cote PlayerState, deliberement : borner un index demande le
catalogue, que seul UCharacterAppearanceComponent connait, et celui-ci passe
deja tout ce qu'il monte par Catalog->Sanitize() sur chaque machine.

Piege de test note dans CLAUDE.md : en PIE a deux joueurs, les deux fenetres
lisent le meme .ini, donc les deux personnages demarrent identiques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 19:45:01 +02:00
parent 368f27a417
commit 5970db0512
9 changed files with 337 additions and 10 deletions
@@ -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<ASurvivalPlayerState>())
{
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<AFpsPlayerController>(GetController()))
{
FpsController->SubmitCharacterAppearance(AppearanceComponent->GetAppearance());
}
}
@@ -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<ASurvivalPlayerState>())
{
State->SetCharacterAppearance(InAppearance);
}
}
void AFpsPlayerController::ShowInviteFriends()
{
if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr)
@@ -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<FLifetimeProperty>& 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<AFpsPlayer>(GetPawn()))
{
if (UCharacterAppearanceComponent* Appearance = Player->GetAppearanceComponent())
{
Appearance->ApplyAppearance(CharacterAppearance);
}
}
}
void ASurvivalPlayerState::CopyProperties(APlayerState* PlayerState)
{
Super::CopyProperties(PlayerState);
if (ASurvivalPlayerState* SurvivalState = Cast<ASurvivalPlayerState>(PlayerState))
{
SurvivalState->CharacterAppearance = CharacterAppearance;
}
}
@@ -344,3 +344,8 @@ void USurvivalUserSettings::SetPlayInBackground(bool bValue)
{
bPlayInBackground = bValue;
}
void USurvivalUserSettings::SetCharacterAppearance(const FCharacterAppearance& InAppearance)
{
CharacterAppearance = InAppearance;
}