(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
@@ -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<UStaticMeshComponent>(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<FLifetimeProperty>& 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.
@@ -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<UStaticMeshComponent>(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<USphereComponent>(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<UNiagaraComponent>(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<UPointLightComponent>(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<UStaticMeshComponent>(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<FLifetimeProperty>& 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<FInventorySlot>& 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<UStaticMeshComponent*> Found;
GetComponents<UStaticMeshComponent>(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<UInventoryComponent>() : 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
@@ -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))
{
+100 -6
View File
@@ -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<FLifetimeProperty>& 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."));
@@ -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<APlayerState>();
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<USessionSubsystem>() : nullptr)
{
Sessions->ShowInviteUI();
}
}
bool AFpsPlayerController::CanInviteFriends() const
{
const USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : 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);
}
@@ -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<APawn>(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();
@@ -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<FLifetimeProperty>& 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<APawn>(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;
@@ -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<USessionSubsystem>() : 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<USessionSubsystem>() : 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"));
}
}
@@ -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<AMainMenuPlayerController>())
{
Controller->StartSoloGame();
Controller->StartGame();
}
}
@@ -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<AFpsPlayerController>();
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<AFpsPlayerController>())
{
Controller->ShowInviteFriends();
}
}
+51 -21
View File
@@ -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<UStaticMeshComponent>(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<FLifetimeProperty>& 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<UInventoryComponent>() : 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)
{
@@ -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<APawn>(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<APawn>(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<UPlayerNameplateWidget>(GetUserWidgetObject()))
{
Nameplate->SetPlayerName(FText::FromString(CurrentName));
LastShownName = CurrentName;
}
}
SetVisibility(true);
}
@@ -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);
}
}
@@ -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<UWorld>& 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<FOnlineSessionSearch>();
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<int32>(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
@@ -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<UStaticMeshComponent>(TEXT("BaseMesh"));
SetRootComponent(BaseMesh);
@@ -166,15 +173,34 @@ void AStorageContainer::Interact_Implementation(AActor* Interactor)
PlayerController->OpenStorage(this);
}
void AStorageContainer::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& 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());
@@ -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<APlayerState>() : 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<APlayerState>() : nullptr;
UE_LOG(LogTemp, Log, TEXT("ASurvivalGameMode : depart de [%s]."),
State ? *State->GetPlayerName() : TEXT("inconnu"));
Super::Logout(Exiting);
}
@@ -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<FLifetimeProperty>& 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;