(Feat) Add mulitplayer
This commit is contained in:
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user