(Feat) Add Animation Fps Charater

This commit is contained in:
2026-08-13 22:07:08 +02:00
parent 176beab9fc
commit cca54a5e0d
157 changed files with 850 additions and 148 deletions
@@ -0,0 +1,43 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "AnimNotify_ItemAction.h"
#include "Components/SkeletalMeshComponent.h"
#include "Engine/World.h"
#include "FpsPlayer.h"
void UAnimNotify_ItemAction::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,
const FAnimNotifyEventReference& EventReference)
{
Super::Notify(MeshComp, Animation, EventReference);
if (!MeshComp)
{
return;
}
// L'editeur d'animation joue les notifies dans sa fenetre de previsualisation,
// sur un mesh de preview qui n'appartient a aucun pawn. Le Cast ci-dessous
// suffirait a s'en proteger, mais le monde de preview n'est pas un monde de
// jeu et rien de ce qui suit n'y aurait de sens : on s'arrete plus tot.
const UWorld* World = MeshComp->GetWorld();
if (!World || !World->IsGameWorld())
{
return;
}
// Le montage tourne sur les bras ou sur le corps, deux composants du pawn :
// dans les deux cas le proprietaire du mesh EST le personnage.
if (AFpsPlayer* Player = Cast<AFpsPlayer>(MeshComp->GetOwner()))
{
Player->TriggerHeldItemAction();
}
}
FString UAnimNotify_ItemAction::GetNotifyName_Implementation() const
{
// Ce qui s'affiche sur le marqueur dans la timeline du montage. Sans cette
// surcharge le moteur y ecrirait le nom de la classe, prefixe compris.
return TEXT("Item Action");
}
@@ -0,0 +1,578 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CharacterAppearanceComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "CharacterPartsDataAsset.h"
#include "Engine/SkeletalMesh.h"
#include "GameFramework/Character.h"
#include "Materials/MaterialInstanceDynamic.h"
namespace
{
/**
* Vrai si les deux apparences designent exactement les memes PIECES.
*
* Les couleurs en sont volontairement exclues : elles se posent sur des
* materiaux deja en place, sans toucher au montage. C'est cette distinction
* qui evite qu'un curseur de couleur glisse reengendre six meshes par image.
*
* Prefixe du fichier oblige : le build unity concatene les .cpp du module,
* un namespace anonyme n'isole rien.
*/
bool AppearanceHasSameParts(const FCharacterAppearance& A, const FCharacterAppearance& B)
{
return A.BodyType == B.BodyType
&& A.HeadIndex == B.HeadIndex
&& A.HairIndex == B.HairIndex
&& A.EyebrowsIndex == B.EyebrowsIndex
&& A.BeardIndex == B.BeardIndex
&& A.MustacheIndex == B.MustacheIndex
&& A.EyesIndex == B.EyesIndex;
}
/** Traduit l'argument d'une commande console en categorie. */
bool AppearanceParsePartCategory(const FString& Name, ECharacterPartCategory& OutCategory)
{
if (Name.Equals(TEXT("head"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Head; return true; }
if (Name.Equals(TEXT("hair"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Hair; return true; }
if (Name.Equals(TEXT("eyebrows"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Eyebrows; return true; }
if (Name.Equals(TEXT("beard"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Beard; return true; }
if (Name.Equals(TEXT("mustache"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Mustache; return true; }
return false;
}
/** L'index de piece stocke dans l'apparence, par categorie. */
uint8* AppearancePartIndexPtr(FCharacterAppearance& Target, ECharacterPartCategory Category)
{
switch (Category)
{
case ECharacterPartCategory::Head: return &Target.HeadIndex;
case ECharacterPartCategory::Hair: return &Target.HairIndex;
case ECharacterPartCategory::Eyebrows: return &Target.EyebrowsIndex;
case ECharacterPartCategory::Beard: return &Target.BeardIndex;
case ECharacterPartCategory::Mustache: return &Target.MustacheIndex;
default: return nullptr;
}
}
}
UCharacterAppearanceComponent::UCharacterAppearanceComponent()
{
// Aucun Tick : le montage est evenementiel, il ne se declenche qu'a
// l'application d'une apparence.
PrimaryComponentTick.bCanEverTick = false;
PartComponents.SetNum(static_cast<int32>(ECharacterPartCategory::Count));
}
void UCharacterAppearanceComponent::BeginPlay()
{
Super::BeginPlay();
// L'apparence par defaut, faute de mieux. Le lot reseau branchera ici la
// lecture du PlayerState -- ce BeginPlay restera le repli du premier
// lancement, quand aucun reglage n'a encore ete sauvegarde.
ApplyAppearance(DefaultAppearance);
}
void UCharacterAppearanceComponent::OnUnregister()
{
// Les composants engendres appartiennent a l'acteur, pas a nous : ils
// disparaissent avec lui. On lache seulement nos references, pour ne pas
// retenir des objets que le GC doit pouvoir reprendre si le composant est
// retire d'un acteur qui, lui, survit.
PartComponents.Reset();
PartComponents.SetNum(static_cast<int32>(ECharacterPartCategory::Count));
ExtraBodyComponents.Reset();
bBuilt = false;
Super::OnUnregister();
}
void UCharacterAppearanceComponent::SetTargetMesh(USkeletalMeshComponent* InTargetMesh)
{
if (TargetMesh == InTargetMesh)
{
return;
}
TargetMesh = InTargetMesh;
// Les pieces deja montees suivaient l'ancien porteur : les laisser en place
// donnerait une tete qui garde la pose d'un corps dont on ne se sert plus.
bBuilt = false;
}
USkeletalMeshComponent* UCharacterAppearanceComponent::ResolveTargetMesh()
{
if (TargetMesh)
{
return TargetMesh;
}
AActor* Owner = GetOwner();
if (!Owner)
{
return nullptr;
}
// L'ACharacter d'abord, et surtout pas « le premier SkeletalMesh trouve » :
// AFpsPlayer porte aussi FirstPersonArms, et l'ordre des composants n'est
// garanti par rien.
if (const ACharacter* OwningCharacter = Cast<ACharacter>(Owner))
{
TargetMesh = OwningCharacter->GetMesh();
}
if (!TargetMesh)
{
TargetMesh = Owner->FindComponentByClass<USkeletalMeshComponent>();
}
return TargetMesh;
}
USkeletalMeshComponent* UCharacterAppearanceComponent::CreateFollowerComponent(FName ComponentName)
{
AActor* Owner = GetOwner();
if (!Owner || !TargetMesh)
{
return nullptr;
}
USkeletalMeshComponent* Follower = NewObject<USkeletalMeshComponent>(Owner, ComponentName);
if (!Follower)
{
return nullptr;
}
Follower->SetupAttachment(TargetMesh);
// Recopiees du porteur plutot que posees en dur : sur le pawn elles valent
// « invisible pour moi, mais je garde mon ombre », sur le mannequin du menu
// elles valent l'inverse. Le composant n'a pas a savoir dans lequel il vit.
Follower->SetOwnerNoSee(TargetMesh->bOwnerNoSee);
Follower->SetOnlyOwnerSee(TargetMesh->bOnlyOwnerSee);
Follower->SetCastHiddenShadow(TargetMesh->bCastHiddenShadow);
// Une piece d'apparence n'est pas une surface de jeu : la capsule porte la
// collision, et une tete qui bloque un trace d'interaction se remarquerait
// tout de suite.
Follower->SetCollisionEnabled(ECollisionEnabled::NoCollision);
Follower->RegisterComponent();
// Apres l'enregistrement : c'est la que le composant a son monde et sa
// hierarchie, donc que la table de correspondance des os peut se batir.
// La correspondance se fait PAR NOM D'OS, ce qui la rend indifferente au
// fait que les tetes du pack de customisation soient sur SK_BodyA_empty
// quand le corps est sur SK_BodyA.
Follower->SetLeaderPoseComponent(TargetMesh);
return Follower;
}
USkeletalMeshComponent* UCharacterAppearanceComponent::EnsurePartComponent(ECharacterPartCategory Category)
{
const int32 Index = static_cast<int32>(Category);
if (!PartComponents.IsValidIndex(Index))
{
return nullptr;
}
if (PartComponents[Index])
{
return PartComponents[Index];
}
const FName ComponentName(*FString::Printf(TEXT("AppearancePart_%d"), Index));
PartComponents[Index] = CreateFollowerComponent(ComponentName);
return PartComponents[Index];
}
void UCharacterAppearanceComponent::ApplyAppearance(const FCharacterAppearance& InAppearance)
{
if (!Catalog)
{
UE_LOG(LogTemp, Warning, TEXT("UCharacterAppearanceComponent : aucun catalogue assigne sur %s, rien n'est monte."),
*GetNameSafe(GetOwner()));
return;
}
if (!ResolveTargetMesh())
{
UE_LOG(LogTemp, Warning, TEXT("UCharacterAppearanceComponent : aucun mesh porteur sur %s."),
*GetNameSafe(GetOwner()));
return;
}
const FCharacterAppearance Previous = Appearance;
Appearance = InAppearance;
// Systematique, et pas seulement pour ce qui vient du reseau : un catalogue
// ampute entre deux versions du jeu laisserait des index qui ne designent
// plus rien, y compris dans les reglages sauvegardes du joueur.
Catalog->Sanitize(Appearance);
const FCharacterBodySet* BodySet = Catalog->GetBodySet(Appearance.BodyType);
if (!BodySet)
{
UE_LOG(LogTemp, Warning, TEXT("UCharacterAppearanceComponent : le catalogue %s n'a aucune silhouette."),
*GetNameSafe(Catalog));
return;
}
if (!bBuilt || !AppearanceHasSameParts(Previous, Appearance))
{
RebuildMeshes(*BodySet);
bBuilt = true;
}
ApplyColors(*BodySet);
OnAppearanceApplied.Broadcast();
}
void UCharacterAppearanceComponent::RebuildMeshes(const FCharacterBodySet& BodySet)
{
// ------------------------------------------------------------------
// Le corps. La case 0 est le porteur, les suivantes des composants
// enfants -- aujourd'hui aucune, demain les segments d'armure.
// ------------------------------------------------------------------
if (BodySet.BodyMeshes.Num() > 0 && BodySet.BodyMeshes[0])
{
SetPartMesh(TargetMesh, BodySet.BodyMeshes[0]);
}
if (BodySet.AnimClass)
{
// Reposee a chaque changement de silhouette : l'AnimBP est lie a un
// squelette, et passer de A a B sans le changer figerait le corps.
TargetMesh->SetAnimInstanceClass(BodySet.AnimClass);
}
const int32 ExtraCount = FMath::Max(0, BodySet.BodyMeshes.Num() - 1);
for (int32 i = 0; i < ExtraCount; ++i)
{
if (!ExtraBodyComponents.IsValidIndex(i))
{
const FName ComponentName(*FString::Printf(TEXT("AppearanceBody_%d"), i));
ExtraBodyComponents.Add(CreateFollowerComponent(ComponentName));
}
if (USkeletalMeshComponent* Segment = ExtraBodyComponents[i])
{
SetPartMesh(Segment, BodySet.BodyMeshes[i + 1]);
}
}
// Les segments en trop apres un changement de silhouette : on les vide au
// lieu de les detruire, pour ne pas payer une reconstruction a chaque
// aller-retour entre deux corps dans l'ecran de customisation.
for (int32 i = ExtraCount; i < ExtraBodyComponents.Num(); ++i)
{
if (USkeletalMeshComponent* Segment = ExtraBodyComponents[i])
{
SetPartMesh(Segment, nullptr);
}
}
// ------------------------------------------------------------------
// Les pieces. Un index a NoPart, ou une liste vide, laisse simplement le
// composant sans mesh -- rien a detruire, rien a recreer au retour.
// ------------------------------------------------------------------
auto ApplyPart = [this](ECharacterPartCategory Category, const TArray<FCharacterPartEntry>& Entries, uint8 PartIndex)
{
USkeletalMesh* Mesh = nullptr;
if (PartIndex != FCharacterAppearance::NoPart && Entries.IsValidIndex(PartIndex))
{
Mesh = Entries[PartIndex].Mesh;
}
// On n'engendre pas le composant pour rien : une silhouette sans barbe
// n'a aucune raison de porter un SkeletalMeshComponent vide.
const int32 Index = static_cast<int32>(Category);
if (!Mesh && (!PartComponents.IsValidIndex(Index) || !PartComponents[Index]))
{
return;
}
if (USkeletalMeshComponent* PartComponent = EnsurePartComponent(Category))
{
SetPartMesh(PartComponent, Mesh);
}
};
ApplyPart(ECharacterPartCategory::Head, BodySet.Heads, Appearance.HeadIndex);
ApplyPart(ECharacterPartCategory::Hair, BodySet.Hairstyles, Appearance.HairIndex);
ApplyPart(ECharacterPartCategory::Eyebrows, BodySet.Eyebrows, Appearance.EyebrowsIndex);
ApplyPart(ECharacterPartCategory::Beard, BodySet.Beards, Appearance.BeardIndex);
ApplyPart(ECharacterPartCategory::Mustache, BodySet.Mustaches, Appearance.MustacheIndex);
// ------------------------------------------------------------------
// Les yeux : un MATERIAU sur un slot du mesh de tete, pas un mesh. Il se
// pose ici et pas dans ApplyColors parce qu'un SetMaterial jette le
// materiau dynamique en place -- le refaire a chaque image en engendrerait
// un par frame, exactement ce que la separation des deux etapes evite.
// ------------------------------------------------------------------
const int32 HeadSlot = static_cast<int32>(ECharacterPartCategory::Head);
if (PartComponents.IsValidIndex(HeadSlot) && PartComponents[HeadSlot]
&& Catalog->EyeMaterials.IsValidIndex(Appearance.EyesIndex))
{
if (UMaterialInterface* EyeMaterial = Catalog->EyeMaterials[Appearance.EyesIndex])
{
USkeletalMeshComponent* HeadComponent = PartComponents[HeadSlot];
const int32 EyeSlot = FindEyeMaterialSlot(HeadComponent, BodySet.EyeMaterialSlotHint);
if (EyeSlot != INDEX_NONE)
{
HeadComponent->SetMaterial(EyeSlot, EyeMaterial);
}
}
}
}
int32 UCharacterAppearanceComponent::FindEyeMaterialSlot(const USkeletalMeshComponent* HeadComponent, FName SlotHint)
{
if (!HeadComponent || SlotHint.IsNone())
{
return INDEX_NONE;
}
// Contains et pas une egalite : les six tetes du pack nomment leur slot
// differemment (`M_Eye_A_01`, `M_Eye`, `M_Eyes_02`), mais toutes contiennent
// le fragment -- et aucun slot de peau ne le contient.
const FString Hint = SlotHint.ToString();
const TArray<FName> SlotNames = HeadComponent->GetMaterialSlotNames();
for (int32 i = 0; i < SlotNames.Num(); ++i)
{
if (SlotNames[i].ToString().Contains(Hint, ESearchCase::IgnoreCase))
{
return i;
}
}
// Rien pose plutot qu'un index au hasard : ecraser le slot de la peau
// repeindrait le visage entier de la couleur des yeux, et on chercherait
// longtemps d'ou ca vient. Le log, lui, se lit tout de suite.
UE_LOG(LogTemp, Warning,
TEXT("UCharacterAppearanceComponent : aucun slot de materiau ne contient '%s' sur le mesh de tete. ")
TEXT("Les yeux ne seront pas poses. Slots disponibles : %s"),
*Hint,
*FString::JoinBy(SlotNames, TEXT(", "), [](const FName& Name) { return Name.ToString(); }));
return INDEX_NONE;
}
void UCharacterAppearanceComponent::ApplyColors(const FCharacterBodySet& BodySet)
{
// Chaque parametre est pose sur TOUS les elements de chaque mesh, sans
// chercher lequel le porte : un SetVectorParameterValue dont le parametre
// n'existe pas dans le materiau est simplement ignore. Le slot des yeux
// ignore donc « Skin Base color », et celui de la peau « Eye color ». Cela
// evite de tenir a jour une table d'index de slots qui casserait au premier
// mesh dont l'ordre des materiaux differe.
// Le blanc en repli et pas le noir : un materiau teinte par une couleur
// manquante doit rester lisible. Le blanc rend la texture telle qu'elle est,
// le noir donnerait un personnage en silhouette qu'on prendrait pour un bug
// de rendu plutot que pour une palette vide.
auto PickColor = [](const TArray<FLinearColor>& Palette, uint8 Index)
{
return Palette.IsValidIndex(Index) ? Palette[Index] : FLinearColor::White;
};
const FLinearColor SkinColor = PickColor(Catalog->SkinColors, Appearance.SkinColorIndex);
const FLinearColor HairColor = PickColor(Catalog->HairColors, Appearance.HairColorIndex);
const FLinearColor EyeColor = PickColor(Catalog->EyeColors, Appearance.EyeColorIndex);
const FLinearColor UnderwearColor = PickColor(Catalog->UnderwearColors, Appearance.UnderwearColorIndex);
// Le corps : peau et sous-vetement partagent le meme master.
TintAllElements(TargetMesh, Catalog->SkinColorParameter, SkinColor);
TintAllElements(TargetMesh, Catalog->UnderwearColorParameter, UnderwearColor);
for (USkeletalMeshComponent* Segment : ExtraBodyComponents)
{
TintAllElements(Segment, Catalog->SkinColorParameter, SkinColor);
TintAllElements(Segment, Catalog->UnderwearColorParameter, UnderwearColor);
}
// La tete porte la peau ET les yeux, sur deux masters differents.
const int32 HeadSlot = static_cast<int32>(ECharacterPartCategory::Head);
if (PartComponents.IsValidIndex(HeadSlot))
{
TintAllElements(PartComponents[HeadSlot], Catalog->HeadSkinColorParameter, SkinColor);
TintAllElements(PartComponents[HeadSlot], Catalog->EyeColorParameter, EyeColor);
}
// Cheveux, sourcils, barbe et moustache descendent tous de M_Hairstyle_Base :
// un seul parametre les teint ensemble, ce qui est aussi le comportement
// qu'on veut -- une barbe blonde sur des cheveux bruns se remarque.
static const ECharacterPartCategory HairLikeCategories[] = {
ECharacterPartCategory::Hair,
ECharacterPartCategory::Eyebrows,
ECharacterPartCategory::Beard,
ECharacterPartCategory::Mustache
};
for (const ECharacterPartCategory Category : HairLikeCategories)
{
const int32 Index = static_cast<int32>(Category);
if (PartComponents.IsValidIndex(Index))
{
TintAllElements(PartComponents[Index], Catalog->HairColorParameter, HairColor);
}
}
}
void UCharacterAppearanceComponent::SetPartMesh(USkeletalMeshComponent* MeshComponent, USkeletalMesh* NewMesh)
{
if (!MeshComponent || MeshComponent->GetSkeletalMeshAsset() == NewMesh)
{
// Rien a faire, et surtout : on ne jette pas les MID en place pour
// rien. ApplyColors les reutilise, c'est ce qui rend le changement de
// couleur seul aussi bon marche.
return;
}
// LE point du montage a ne pas oublier : les materiaux surcharges d'un
// composant vivent dans OverrideMaterials, un tableau qui lui appartient et
// que changer de mesh ne touche PAS. Sans ce vidage, passer du corps A au
// corps B garde le MID derive de MI_BodyA_a sur l'element 0 -- on obtient
// une silhouette feminine peinte avec la peau et les abdominaux du modele
// masculin. Le symptome trompe, parce que le mesh, lui, a bien change.
MeshComponent->EmptyOverrideMaterials();
MeshComponent->SetSkeletalMeshAsset(NewMesh);
}
void UCharacterAppearanceComponent::TintAllElements(USkeletalMeshComponent* MeshComponent, FName Parameter, const FLinearColor& Color)
{
if (!MeshComponent || Parameter.IsNone() || !MeshComponent->GetSkeletalMeshAsset())
{
return;
}
const int32 ElementCount = MeshComponent->GetNumMaterials();
for (int32 Element = 0; Element < ElementCount; ++Element)
{
// Rend le materiau dynamique DEJA en place s'il y en a un, et n'en cree
// un que la premiere fois (verifie dans PrimitiveComponent.cpp). C'est
// ce qui rend cette fonction sure a appeler par image.
if (UMaterialInstanceDynamic* Dynamic = MeshComponent->CreateAndSetMaterialInstanceDynamic(Element))
{
Dynamic->SetVectorParameterValue(Parameter, Color);
}
}
}
// ----------------------------------------------------------------------
// Reglage par nom, appele par les commandes console d'AFpsPlayer
// ----------------------------------------------------------------------
void UCharacterAppearanceComponent::ApplyPartByName(const FString& Part, int32 Index)
{
if (!Catalog)
{
UE_LOG(LogTemp, Warning, TEXT("EmberPart : aucun catalogue assigne."));
return;
}
FCharacterAppearance Next = Appearance;
if (Part.Equals(TEXT("body"), ESearchCase::IgnoreCase))
{
Next.BodyType = static_cast<ECharacterBodyType>(FMath::Clamp(Index, 0, static_cast<int32>(ECharacterBodyType::Count) - 1));
}
else if (Part.Equals(TEXT("eyes"), ESearchCase::IgnoreCase))
{
Next.EyesIndex = static_cast<uint8>(FMath::Max(0, Index));
}
else
{
ECharacterPartCategory Category;
if (!AppearanceParsePartCategory(Part, Category))
{
UE_LOG(LogTemp, Warning, TEXT("EmberPart : categorie inconnue '%s'. Attendu : body, head, hair, eyebrows, beard, mustache, eyes."), *Part);
return;
}
if (uint8* Target = AppearancePartIndexPtr(Next, Category))
{
// Un index negatif retire la piece : c'est la seule facon de se
// raser la barbe depuis la console, NoPart valant 255.
*Target = (Index < 0) ? FCharacterAppearance::NoPart : static_cast<uint8>(Index);
}
}
ApplyAppearance(Next);
// Apres l'application, donc apres Sanitize : on affiche ce qui a REELLEMENT
// ete pose, pas ce qui a ete demande. Un index hors bornes se lit alors tout
// de suite, au lieu de laisser croire que la commande n'a rien fait.
UE_LOG(LogTemp, Log, TEXT("EmberPart %s %d -> applique."), *Part, Index);
LogAppearance();
}
void UCharacterAppearanceComponent::ApplyColorByName(const FString& Part, int32 ColorIndex)
{
FCharacterAppearance Next = Appearance;
const uint8 Index = static_cast<uint8>(FMath::Max(0, ColorIndex));
if (Part.Equals(TEXT("skin"), ESearchCase::IgnoreCase))
{
Next.SkinColorIndex = Index;
}
else if (Part.Equals(TEXT("hair"), ESearchCase::IgnoreCase))
{
Next.HairColorIndex = Index;
}
else if (Part.Equals(TEXT("eye"), ESearchCase::IgnoreCase))
{
Next.EyeColorIndex = Index;
}
else if (Part.Equals(TEXT("underwear"), ESearchCase::IgnoreCase))
{
Next.UnderwearColorIndex = Index;
}
else
{
UE_LOG(LogTemp, Warning, TEXT("EmberColor : categorie inconnue '%s'. Attendu : skin, hair, eye, underwear."), *Part);
return;
}
ApplyAppearance(Next);
LogAppearance();
}
void UCharacterAppearanceComponent::LogAppearance() const
{
if (!Catalog)
{
UE_LOG(LogTemp, Warning, TEXT("EmberAppearanceDump : aucun catalogue assigne."));
return;
}
const ECharacterBodyType Body = Appearance.BodyType;
UE_LOG(LogTemp, Log, TEXT("--- Apparence de %s ---"), *GetNameSafe(GetOwner()));
UE_LOG(LogTemp, Log, TEXT(" body = %d (0..%d)"), static_cast<int32>(Body), static_cast<int32>(ECharacterBodyType::Count) - 1);
UE_LOG(LogTemp, Log, TEXT(" head = %d (%d options)"), Appearance.HeadIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Head));
UE_LOG(LogTemp, Log, TEXT(" hair = %d (%d options)"), Appearance.HairIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Hair));
UE_LOG(LogTemp, Log, TEXT(" eyebrows = %d (%d options)"), Appearance.EyebrowsIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Eyebrows));
UE_LOG(LogTemp, Log, TEXT(" beard = %d (%d options)"), Appearance.BeardIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Beard));
UE_LOG(LogTemp, Log, TEXT(" mustache = %d (%d options)"), Appearance.MustacheIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Mustache));
UE_LOG(LogTemp, Log, TEXT(" eyes = %d (%d options)"), Appearance.EyesIndex, Catalog->EyeMaterials.Num());
UE_LOG(LogTemp, Log, TEXT(" skin = couleur %d (%d couleurs)"), Appearance.SkinColorIndex, Catalog->SkinColors.Num());
UE_LOG(LogTemp, Log, TEXT(" hairColor = couleur %d (%d couleurs)"), Appearance.HairColorIndex, Catalog->HairColors.Num());
UE_LOG(LogTemp, Log, TEXT(" eyeColor = couleur %d (%d couleurs)"), Appearance.EyeColorIndex, Catalog->EyeColors.Num());
UE_LOG(LogTemp, Log, TEXT(" underwear = couleur %d (%d couleurs)"), Appearance.UnderwearColorIndex, Catalog->UnderwearColors.Num());
UE_LOG(LogTemp, Log, TEXT(" 255 = aucune piece (barbe rasee, crane nu)"));
}
@@ -0,0 +1,459 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CharacterCustomizationWidget.h"
#include "CharacterPartsDataAsset.h"
#include "Components/Button.h"
#include "Components/PanelWidget.h"
#include "CustomizationCategoryWidget.h"
#include "CustomizationOptionWidget.h"
#define LOCTEXT_NAMESPACE "CharacterCustomization"
namespace CharacterCustomizationLabels
{
/**
* Le libelle de chaque categorie, dans l'ordre de l'enum.
*
* Ici et pas en UPROPERTY : ce sont des textes d'interface, pas des
* reglages. Les exposer inviterait a les vider par erreur, et une colonne
* aux rangees anonymes est inutilisable.
*
* Nom de namespace unique dans TOUT le module : le build unity concatene
* les .cpp, et un namespace anonyme n'isolerait rien.
*/
static FText Get(ECustomizationCategory Category)
{
switch (Category)
{
case ECustomizationCategory::Body: return LOCTEXT("CatBody", "Silhouette");
case ECustomizationCategory::Head: return LOCTEXT("CatHead", "Visage");
case ECustomizationCategory::Hair: return LOCTEXT("CatHair", "Cheveux");
case ECustomizationCategory::Eyebrows: return LOCTEXT("CatEyebrows", "Sourcils");
case ECustomizationCategory::Beard: return LOCTEXT("CatBeard", "Barbe");
case ECustomizationCategory::Mustache: return LOCTEXT("CatMustache", "Moustache");
case ECustomizationCategory::Eyes: return LOCTEXT("CatEyes", "Yeux");
case ECustomizationCategory::SkinColor: return LOCTEXT("CatSkinColor", "Teint");
case ECustomizationCategory::HairColor: return LOCTEXT("CatHairColor", "Couleur des cheveux");
case ECustomizationCategory::EyeColor: return LOCTEXT("CatEyeColor", "Couleur des yeux");
case ECustomizationCategory::UnderwearColor: return LOCTEXT("CatUnderwear", "Sous-vetement");
default: return FText::GetEmpty();
}
}
/** Une categorie de couleur remplit la grille de pastilles, pas de vignettes. */
static bool IsColor(ECustomizationCategory Category)
{
return Category >= ECustomizationCategory::SkinColor;
}
/**
* Le cadrage a montrer. Seules la silhouette et le sous-vetement demandent
* de reculer : tout le reste se juge sur le visage.
*/
static ECharacterPreviewFraming GetFraming(ECustomizationCategory Category)
{
const bool bWholeBody = (Category == ECustomizationCategory::Body)
|| (Category == ECustomizationCategory::SkinColor)
|| (Category == ECustomizationCategory::UnderwearColor);
return bWholeBody ? ECharacterPreviewFraming::FullBody : ECharacterPreviewFraming::Face;
}
/** La categorie de pieces correspondante, pour interroger le catalogue. */
static ECharacterPartCategory ToPartCategory(ECustomizationCategory Category)
{
switch (Category)
{
case ECustomizationCategory::Head: return ECharacterPartCategory::Head;
case ECustomizationCategory::Hair: return ECharacterPartCategory::Hair;
case ECustomizationCategory::Eyebrows: return ECharacterPartCategory::Eyebrows;
case ECustomizationCategory::Beard: return ECharacterPartCategory::Beard;
default: return ECharacterPartCategory::Mustache;
}
}
}
UCharacterCustomizationWidget::UCharacterCustomizationWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Sans ce drapeau, NativeOnKeyDown ne serait jamais appele -- et le
// SetWidgetToFocus() pose par AMainMenuPlayerController::OpenCustomization
// serait lui aussi sans effet : Slate REFUSE de focaliser un widget qui ne
// declare pas supporter le focus clavier.
SetIsFocusable(true);
}
void UCharacterCustomizationWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
// Sans ca, le clic-glisser qui fait tourner le personnage ne repond jamais :
// un UUserWidget ne recoit NativeOnMouseButtonDown que si sa Visibility vaut
// Visible, et le defaut d'un widget cree est SelfHitTestInvisible -- il
// laisse passer les clics au lieu de les capter. Impose ici plutot que
// laisse a la Designer, ou l'oubli serait silencieux.
//
// Les boutons ne sont pas prives de leurs clics pour autant : Slate teste
// les enfants AVANT le parent, donc seul ce qui est lache dans le vide
// arrive jusqu'ici -- c'est-a-dire sur le personnage.
SetVisibility(ESlateVisibility::Visible);
if (PlayButton)
{
PlayButton->OnClicked.AddDynamic(this, &UCharacterCustomizationWidget::HandlePlayClicked);
}
if (BackButton)
{
BackButton->OnClicked.AddDynamic(this, &UCharacterCustomizationWidget::HandleBackClicked);
}
}
void UCharacterCustomizationWidget::NativeConstruct()
{
Super::NativeConstruct();
BuildCategories();
BuildOptions();
RefreshPreview();
// Le focus est repris ici et pas seulement par le SetWidgetToFocus du
// controller : les deux chemins existent, mais celui-ci est le seul qui vaut
// encore apres une reconstruction du widget.
SetKeyboardFocus();
}
void UCharacterCustomizationWidget::SetupCustomization(ACharacterPreviewActor* InPreview, const FCharacterAppearance& InAppearance)
{
Preview = InPreview;
Appearance = InAppearance;
// Systematique : un catalogue ampute entre deux versions du jeu laisserait
// dans les reglages sauvegardes des index qui ne designent plus rien.
if (Catalog)
{
Catalog->Sanitize(Appearance);
}
}
void UCharacterCustomizationWidget::BuildCategories()
{
if (!CategoryList || !CategoryWidgetClass)
{
return;
}
CategoryList->ClearChildren();
CategoryWidgets.Reset();
CategoryWidgets.Reserve(static_cast<int32>(ECustomizationCategory::Count));
for (int32 i = 0; i < static_cast<int32>(ECustomizationCategory::Count); ++i)
{
const ECustomizationCategory Category = static_cast<ECustomizationCategory>(i);
// Une categorie sans option n'a pas de rangee : c'est ce qui fait
// disparaitre « Barbe » et « Moustache » quand on passe sur le corps B,
// pour lequel le pack n'en livre aucune. Une rangee qui ouvre une grille
// vide se lit comme un bug.
if (GetOptionCountForCategory(Category) <= 0)
{
continue;
}
UCustomizationCategoryWidget* Row = CreateWidget<UCustomizationCategoryWidget>(this, CategoryWidgetClass);
if (!Row)
{
continue;
}
Row->Setup(i, CharacterCustomizationLabels::Get(Category));
Row->OnCategoryClicked.AddDynamic(this, &UCharacterCustomizationWidget::HandleCategoryClicked);
CategoryList->AddChild(Row);
CategoryWidgets.Add(Row);
}
// La categorie active a pu disparaitre en changeant de silhouette : on
// retombe sur la premiere rangee encore presente plutot que sur une grille
// vide.
if (GetOptionCountForCategory(ActiveCategory) <= 0 && CategoryWidgets.Num() > 0)
{
ActiveCategory = ECustomizationCategory::Body;
}
}
void UCharacterCustomizationWidget::BuildOptions()
{
if (!OptionGrid || !OptionWidgetClass || !Catalog)
{
return;
}
OptionGrid->ClearChildren();
OptionWidgets.Reset();
const int32 Count = GetOptionCountForCategory(ActiveCategory);
OptionWidgets.Reserve(Count);
for (int32 i = 0; i < Count; ++i)
{
UCustomizationOptionWidget* Option = CreateWidget<UCustomizationOptionWidget>(this, OptionWidgetClass);
if (!Option)
{
continue;
}
if (CharacterCustomizationLabels::IsColor(ActiveCategory))
{
const TArray<FLinearColor>* Palette = nullptr;
switch (ActiveCategory)
{
case ECustomizationCategory::SkinColor: Palette = &Catalog->SkinColors; break;
case ECustomizationCategory::HairColor: Palette = &Catalog->HairColors; break;
case ECustomizationCategory::EyeColor: Palette = &Catalog->EyeColors; break;
default: Palette = &Catalog->UnderwearColors; break;
}
Option->SetupAsColor(i, (*Palette)[i]);
}
else if (ActiveCategory == ECustomizationCategory::Body)
{
Option->SetupAsPart(i, Catalog->BodySets.IsValidIndex(i) ? Catalog->BodySets[i].Icon : nullptr);
}
else if (ActiveCategory == ECustomizationCategory::Eyes)
{
// Les yeux n'ont pas de vignette : ce sont des materiaux, pas des
// entrees de catalogue. La case retombe sur son numero.
Option->SetupAsPart(i, nullptr);
}
else
{
const ECharacterPartCategory PartCategory = CharacterCustomizationLabels::ToPartCategory(ActiveCategory);
const TArray<FCharacterPartEntry>& Entries = Catalog->GetParts(Appearance.BodyType, PartCategory);
Option->SetupAsPart(i, Entries.IsValidIndex(i) ? Entries[i].Icon : nullptr);
}
Option->OnOptionClicked.AddDynamic(this, &UCharacterCustomizationWidget::HandleOptionClicked);
OptionGrid->AddChild(Option);
OptionWidgets.Add(Option);
}
RefreshSelection();
}
void UCharacterCustomizationWidget::RefreshPreview()
{
if (Preview)
{
Preview->ApplyAppearance(Appearance);
Preview->SetFraming(CharacterCustomizationLabels::GetFraming(ActiveCategory));
}
}
void UCharacterCustomizationWidget::RefreshSelection()
{
const int32 Current = GetCurrentIndexForCategory(ActiveCategory);
for (int32 i = 0; i < OptionWidgets.Num(); ++i)
{
if (OptionWidgets[i])
{
OptionWidgets[i]->SetSelected(OptionWidgets[i]->GetOptionIndex() == Current);
}
}
for (UCustomizationCategoryWidget* Row : CategoryWidgets)
{
if (Row)
{
Row->SetSelected(false);
}
}
// La rangee active n'est pas au meme rang que la categorie dans l'enum :
// les categories vides ont ete sautees. On la retrouve par son libelle
// d'index, pose dans Setup.
const int32 ActiveIndex = static_cast<int32>(ActiveCategory);
for (UCustomizationCategoryWidget* Row : CategoryWidgets)
{
if (Row && Row->GetCategoryIndex() == ActiveIndex)
{
Row->SetSelected(true);
break;
}
}
}
int32 UCharacterCustomizationWidget::GetCurrentIndexForCategory(ECustomizationCategory Category) const
{
switch (Category)
{
case ECustomizationCategory::Body: return static_cast<int32>(Appearance.BodyType);
case ECustomizationCategory::Head: return Appearance.HeadIndex;
case ECustomizationCategory::Hair: return Appearance.HairIndex;
case ECustomizationCategory::Eyebrows: return Appearance.EyebrowsIndex;
case ECustomizationCategory::Beard: return Appearance.BeardIndex;
case ECustomizationCategory::Mustache: return Appearance.MustacheIndex;
case ECustomizationCategory::Eyes: return Appearance.EyesIndex;
case ECustomizationCategory::SkinColor: return Appearance.SkinColorIndex;
case ECustomizationCategory::HairColor: return Appearance.HairColorIndex;
case ECustomizationCategory::EyeColor: return Appearance.EyeColorIndex;
case ECustomizationCategory::UnderwearColor: return Appearance.UnderwearColorIndex;
default: return 0;
}
}
void UCharacterCustomizationWidget::SetIndexForCategory(ECustomizationCategory Category, int32 NewIndex)
{
const uint8 Value = static_cast<uint8>(FMath::Clamp(NewIndex, 0, 254));
switch (Category)
{
case ECustomizationCategory::Body: Appearance.BodyType = static_cast<ECharacterBodyType>(Value); break;
case ECustomizationCategory::Head: Appearance.HeadIndex = Value; break;
case ECustomizationCategory::Hair: Appearance.HairIndex = Value; break;
case ECustomizationCategory::Eyebrows: Appearance.EyebrowsIndex = Value; break;
case ECustomizationCategory::Beard: Appearance.BeardIndex = Value; break;
case ECustomizationCategory::Mustache: Appearance.MustacheIndex = Value; break;
case ECustomizationCategory::Eyes: Appearance.EyesIndex = Value; break;
case ECustomizationCategory::SkinColor: Appearance.SkinColorIndex = Value; break;
case ECustomizationCategory::HairColor: Appearance.HairColorIndex = Value; break;
case ECustomizationCategory::EyeColor: Appearance.EyeColorIndex = Value; break;
case ECustomizationCategory::UnderwearColor: Appearance.UnderwearColorIndex = Value; break;
default: break;
}
}
int32 UCharacterCustomizationWidget::GetOptionCountForCategory(ECustomizationCategory Category) const
{
if (!Catalog)
{
return 0;
}
switch (Category)
{
case ECustomizationCategory::Body: return Catalog->BodySets.Num();
case ECustomizationCategory::Eyes: return Catalog->EyeMaterials.Num();
case ECustomizationCategory::SkinColor: return Catalog->SkinColors.Num();
case ECustomizationCategory::HairColor: return Catalog->HairColors.Num();
case ECustomizationCategory::EyeColor: return Catalog->EyeColors.Num();
case ECustomizationCategory::UnderwearColor: return Catalog->UnderwearColors.Num();
default:
return Catalog->GetPartCount(Appearance.BodyType, CharacterCustomizationLabels::ToPartCategory(Category));
}
}
void UCharacterCustomizationWidget::HandleCategoryClicked(int32 CategoryIndex)
{
ActiveCategory = static_cast<ECustomizationCategory>(CategoryIndex);
BuildOptions();
RefreshPreview();
}
void UCharacterCustomizationWidget::HandleOptionClicked(int32 OptionIndex)
{
SetIndexForCategory(ActiveCategory, OptionIndex);
// Changer de silhouette change TOUT le reste : les listes de tetes, de
// coiffures et de sourcils appartiennent au corps, et le corps B n'a ni
// barbe ni moustache. On rebatit donc la colonne entiere, apres avoir
// laisse Sanitize ramener les index devenus hors bornes.
if (ActiveCategory == ECustomizationCategory::Body)
{
if (Catalog)
{
Catalog->Sanitize(Appearance);
}
BuildCategories();
BuildOptions();
}
else
{
RefreshSelection();
}
RefreshPreview();
}
void UCharacterCustomizationWidget::HandlePlayClicked()
{
OnConfirmed.Broadcast();
}
void UCharacterCustomizationWidget::HandleBackClicked()
{
OnCancelled.Broadcast();
}
FReply UCharacterCustomizationWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
const FKey Key = InKeyEvent.GetKey();
// GetVirtualKey() et non la constante, depreciee en 5.8 : la resolution se
// fait a l'appel, donc selon la manette reellement branchee.
if (Key == EKeys::Escape || Key == EKeys::Virtual_Gamepad_Back.GetVirtualKey())
{
// Exactement le bouton Retour, jamais un second chemin vers le menu : le
// controller a un fondu et une bascule de camera a jouer au retour, et
// c'est OnCancelled qui les declenche.
HandleBackClicked();
// Handled : sans ca la touche continue de remonter et serait consommee
// une seconde fois par ce qui se trouve derriere.
return FReply::Handled();
}
return Super::NativeOnKeyDown(InGeometry, InKeyEvent);
}
// ----------------------------------------------------------------------
// Rotation du personnage
//
// Sur le widget plein ecran et pas sur une zone dediee : les boutons de la
// colonne consomment deja leurs propres clics, donc tout ce qui arrive ici a
// forcement ete lache dans le vide -- c'est-a-dire sur le personnage.
// ----------------------------------------------------------------------
FReply UCharacterCustomizationWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton)
{
bDragging = true;
// CaptureMouse : sans elle, sortir du widget en glissant relacherait le
// suivi et laisserait bDragging a vrai pour toujours.
return FReply::Handled().CaptureMouse(TakeWidget());
}
return Super::NativeOnMouseButtonDown(InGeometry, InMouseEvent);
}
FReply UCharacterCustomizationWidget::NativeOnMouseButtonUp(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton && bDragging)
{
bDragging = false;
return FReply::Handled().ReleaseMouseCapture();
}
return Super::NativeOnMouseButtonUp(InGeometry, InMouseEvent);
}
FReply UCharacterCustomizationWidget::NativeOnMouseMove(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (bDragging && Preview)
{
// Signe inverse : glisser vers la droite doit tourner le personnage
// vers la droite, donc le faire pivoter dans l'autre sens.
Preview->AddPreviewYaw(-InMouseEvent.GetCursorDelta().X * Preview->GetYawPerPixel());
return FReply::Handled();
}
return Super::NativeOnMouseMove(InGeometry, InMouseEvent);
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,97 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CharacterPartsDataAsset.h"
namespace
{
/**
* Rendue par GetParts quand la categorie n'existe pas, pour que l'appelant
* n'ait jamais a tester un pointeur.
*
* Le nom porte le prefixe du fichier : le build unity concatene les .cpp du
* module et un namespace anonyme n'isole plus rien. Une constante nommee
* "EmptyParts" tout court finirait par entrer en collision -- invisible en
* build incremental, elle ne sortirait qu'au premier -Rebuild.
*/
const TArray<FCharacterPartEntry> CharacterPartsEmptyList;
/** Borne un index de piece OBLIGATOIRE : hors bornes, on retombe sur 0. */
void ClampRequiredIndex(uint8& Index, int32 Count)
{
if (Count <= 0 || Index >= Count)
{
Index = 0;
}
}
/** Borne un index de piece FACULTATIVE : hors bornes, on retire la piece. */
void ClampOptionalIndex(uint8& Index, int32 Count)
{
if (Index == FCharacterAppearance::NoPart)
{
return;
}
if (Count <= 0 || Index >= Count)
{
Index = FCharacterAppearance::NoPart;
}
}
}
const FCharacterBodySet* UCharacterPartsDataAsset::GetBodySet(ECharacterBodyType BodyType) const
{
const int32 Index = static_cast<int32>(BodyType);
return BodySets.IsValidIndex(Index) ? &BodySets[Index] : nullptr;
}
const TArray<FCharacterPartEntry>& UCharacterPartsDataAsset::GetParts(ECharacterBodyType BodyType, ECharacterPartCategory Category) const
{
const FCharacterBodySet* Set = GetBodySet(BodyType);
if (!Set)
{
return CharacterPartsEmptyList;
}
switch (Category)
{
case ECharacterPartCategory::Head: return Set->Heads;
case ECharacterPartCategory::Hair: return Set->Hairstyles;
case ECharacterPartCategory::Eyebrows: return Set->Eyebrows;
case ECharacterPartCategory::Beard: return Set->Beards;
case ECharacterPartCategory::Mustache: return Set->Mustaches;
default: return CharacterPartsEmptyList;
}
}
int32 UCharacterPartsDataAsset::GetPartCount(ECharacterBodyType BodyType, ECharacterPartCategory Category) const
{
return GetParts(BodyType, Category).Num();
}
void UCharacterPartsDataAsset::Sanitize(FCharacterAppearance& InOut) const
{
// La silhouette d'abord : tout le reste est indexe DANS son jeu de pieces,
// donc la borner apres ne servirait a rien.
if (static_cast<uint8>(InOut.BodyType) >= BodySets.Num())
{
InOut.BodyType = ECharacterBodyType::BodyA;
}
ClampRequiredIndex(InOut.HeadIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Head));
ClampRequiredIndex(InOut.EyebrowsIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Eyebrows));
// Cheveux, barbe et moustache sont facultatifs : un crane rase est un choix,
// pas une erreur.
ClampOptionalIndex(InOut.HairIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Hair));
ClampOptionalIndex(InOut.BeardIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Beard));
ClampOptionalIndex(InOut.MustacheIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Mustache));
ClampRequiredIndex(InOut.EyesIndex, EyeMaterials.Num());
ClampRequiredIndex(InOut.SkinColorIndex, SkinColors.Num());
ClampRequiredIndex(InOut.HairColorIndex, HairColors.Num());
ClampRequiredIndex(InOut.EyeColorIndex, EyeColors.Num());
ClampRequiredIndex(InOut.UnderwearColorIndex, UnderwearColors.Num());
}
@@ -0,0 +1,149 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CharacterPreviewActor.h"
#include "Camera/CameraComponent.h"
#include "CharacterAppearanceComponent.h"
#include "Components/SkeletalMeshComponent.h"
ACharacterPreviewActor::ACharacterPreviewActor()
{
// Allume par SetFraming et eteint des que la camera est arrivee. Un
// mannequin immobile dans un menu n'a aucune raison de couter une frame.
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = false;
USceneComponent* SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
SetRootComponent(SceneRoot);
TurntablePivot = CreateDefaultSubobject<USceneComponent>(TEXT("TurntablePivot"));
TurntablePivot->SetupAttachment(SceneRoot);
BodyMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("BodyMesh"));
BodyMesh->SetupAttachment(TurntablePivot);
// Aucune collision : c'est un decor de menu, rien ne doit le heurter ni le
// tracer.
BodyMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// Les reperes de cadrage sont filles de la RACINE et pas du pivot : ils ne
// doivent surtout pas tourner avec le personnage, sinon la camera suivrait
// le clic-glisser au lieu de le montrer.
FullBodyViewSpot = CreateDefaultSubobject<USceneComponent>(TEXT("FullBodyViewSpot"));
FullBodyViewSpot->SetupAttachment(SceneRoot);
FullBodyViewSpot->SetRelativeLocation(FVector(260.f, 60.f, 95.f));
FullBodyViewSpot->SetRelativeRotation(FRotator(0.f, 180.f, 0.f));
FaceViewSpot = CreateDefaultSubobject<USceneComponent>(TEXT("FaceViewSpot"));
FaceViewSpot->SetupAttachment(SceneRoot);
FaceViewSpot->SetRelativeLocation(FVector(90.f, 25.f, 165.f));
FaceViewSpot->SetRelativeRotation(FRotator(0.f, 180.f, 0.f));
PreviewCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("PreviewCamera"));
PreviewCamera->SetupAttachment(SceneRoot);
AppearanceComponent = CreateDefaultSubobject<UCharacterAppearanceComponent>(TEXT("AppearanceComponent"));
}
void ACharacterPreviewActor::BeginPlay()
{
Super::BeginPlay();
// Le composant cherche le mesh d'un ACharacter par defaut ; ici il n'y en a
// pas, on lui designe le notre.
if (AppearanceComponent)
{
AppearanceComponent->SetTargetMesh(BodyMesh);
}
// Pose la camera sur le cadrage de depart sans interpolation : glisser
// depuis l'origine de l'acteur au premier affichage montrerait un travelling
// que personne n'a demande.
if (const USceneComponent* Spot = GetSpotForFraming(CurrentFraming))
{
PreviewCamera->SetRelativeLocationAndRotation(Spot->GetRelativeLocation(), Spot->GetRelativeRotation());
}
}
void ACharacterPreviewActor::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
UpdateFramingInterpolation(DeltaSeconds);
}
void ACharacterPreviewActor::ApplyAppearance(const FCharacterAppearance& InAppearance)
{
if (AppearanceComponent)
{
AppearanceComponent->ApplyAppearance(InAppearance);
}
}
void ACharacterPreviewActor::SetFraming(ECharacterPreviewFraming NewFraming)
{
if (CurrentFraming == NewFraming)
{
return;
}
CurrentFraming = NewFraming;
SetActorTickEnabled(true);
}
void ACharacterPreviewActor::AddPreviewYaw(float DeltaDegrees)
{
TurntableYaw = FRotator::NormalizeAxis(TurntableYaw + DeltaDegrees);
if (TurntablePivot)
{
TurntablePivot->SetRelativeRotation(FRotator(0.f, TurntableYaw, 0.f));
}
}
void ACharacterPreviewActor::ResetPreviewYaw()
{
TurntableYaw = 0.f;
if (TurntablePivot)
{
TurntablePivot->SetRelativeRotation(FRotator::ZeroRotator);
}
}
USceneComponent* ACharacterPreviewActor::GetSpotForFraming(ECharacterPreviewFraming InFraming) const
{
return (InFraming == ECharacterPreviewFraming::Face) ? FaceViewSpot : FullBodyViewSpot;
}
void ACharacterPreviewActor::UpdateFramingInterpolation(float DeltaSeconds)
{
const USceneComponent* Spot = GetSpotForFraming(CurrentFraming);
if (!Spot || !PreviewCamera)
{
SetActorTickEnabled(false);
return;
}
const FVector TargetLocation = Spot->GetRelativeLocation();
const FRotator TargetRotation = Spot->GetRelativeRotation();
const FVector NewLocation = FMath::VInterpTo(
PreviewCamera->GetRelativeLocation(), TargetLocation, DeltaSeconds, FramingInterpSpeed);
const FRotator NewRotation = FMath::RInterpTo(
PreviewCamera->GetRelativeRotation(), TargetRotation, DeltaSeconds, FramingInterpSpeed);
PreviewCamera->SetRelativeLocationAndRotation(NewLocation, NewRotation);
// Un interpolateur exponentiel n'atteint jamais sa cible exactement : sans
// ce seuil, le Tick ne s'eteindrait plus jamais. On termine le dernier
// millimetre d'un coup, il ne se voit pas.
const bool bLocationReached = NewLocation.Equals(TargetLocation, 0.5f);
const bool bRotationReached = NewRotation.Equals(TargetRotation, 0.5f);
if (bLocationReached && bRotationReached)
{
PreviewCamera->SetRelativeLocationAndRotation(TargetLocation, TargetRotation);
SetActorTickEnabled(false);
}
}
@@ -0,0 +1,189 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "ConfirmDialogWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#include "InputCoreTypes.h"
UConfirmDialogWidget::UConfirmDialogWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Sans ce drapeau le widget ne peut pas prendre le focus clavier, et
// NativeOnKeyDown ne serait jamais appele. Il ne se change qu'a la
// construction, d'ou sa presence ici et pas dans Open().
SetIsFocusable(true);
}
void UConfirmDialogWidget::NativeConstruct()
{
Super::NativeConstruct();
if (ConfirmButton)
{
ConfirmButton->OnClicked.AddDynamic(this, &UConfirmDialogWidget::HandleConfirmClicked);
}
if (CancelButton)
{
CancelButton->OnClicked.AddDynamic(this, &UConfirmDialogWidget::HandleCancelClicked);
}
}
void UConfirmDialogWidget::Open(const FText& InMessage)
{
// Une ouverture simple annule un rebours laisse par la precedente.
StopCountdown();
if (MessageText)
{
MessageText->SetText(InMessage);
}
SetVisibility(ESlateVisibility::Visible);
// Le focus doit etre repris a CHAQUE ouverture : le refermer le rend au
// menu, et une boite visible mais sans focus ignorerait le clavier.
SetKeyboardFocus();
}
void UConfirmDialogWidget::OpenWithCountdown(const FText& InMessageFormat, float Seconds)
{
StopCountdown();
CountdownMessageFormat = InMessageFormat;
CountdownRemaining = FMath::Max(Seconds, 1.f);
LastDisplayedSeconds = -1;
RefreshCountdownText();
SetVisibility(ESlateVisibility::Visible);
SetKeyboardFocus();
CountdownTickerHandle = FTSTicker::GetCoreTicker().AddTicker(
FTickerDelegate::CreateUObject(this, &UConfirmDialogWidget::TickCountdown));
}
void UConfirmDialogWidget::Close()
{
StopCountdown();
SetVisibility(ESlateVisibility::Collapsed);
}
void UConfirmDialogWidget::StopCountdown()
{
if (CountdownTickerHandle.IsValid())
{
FTSTicker::GetCoreTicker().RemoveTicker(CountdownTickerHandle);
CountdownTickerHandle.Reset();
}
}
void UConfirmDialogWidget::RefreshCountdownText()
{
if (!MessageText)
{
return;
}
// CeilToInt : on affiche "10" pendant la premiere seconde et "1" pendant la
// derniere. Avec un arrondi classique on afficherait "0" une demi-seconde
// avant que ca se declenche, ce qui donne l'impression d'un bug.
const int32 Seconds = FMath::CeilToInt(CountdownRemaining);
if (Seconds == LastDisplayedSeconds)
{
return;
}
LastDisplayedSeconds = Seconds;
MessageText->SetText(FText::Format(CountdownMessageFormat, FText::AsNumber(Seconds)));
}
bool UConfirmDialogWidget::TickCountdown(float DeltaTime)
{
CountdownRemaining -= DeltaTime;
if (CountdownRemaining > 0.f)
{
RefreshCountdownText();
// true : on veut etre rappele a la frame suivante.
return true;
}
// On coupe le ticker AVANT de diffuser : un abonne qui rouvre une boite
// dans son handler doit pouvoir relancer un rebours.
StopCountdown();
OnCancelled.Broadcast();
return false;
}
FReply UConfirmDialogWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
const FKey Key = InKeyEvent.GetKey();
// La touche "retour" de la manette en plus d'Echap : Slate y fait pointer le
// bouton retour de la plateforme (B sur Xbox, Rond sur PlayStation). Une
// seule ligne et le clavier comme la manette sont couverts, sans avoir a
// enumerer les touches de chaque constructeur.
//
// GetVirtualKey() et non la constante EKeys::Virtual_Back, depreciee en 5.8 :
// la resolution se fait desormais a l'appel, donc a chaud selon la manette
// reellement branchee.
if (bCancelOnEscape && (Key == EKeys::Escape || Key == EKeys::Virtual_Gamepad_Back.GetVirtualKey()))
{
StopCountdown();
OnCancelled.Broadcast();
// Handled et non Unhandled : sans ca la touche continue de remonter et
// serait consommee une seconde fois par ce qui se trouve derriere.
return FReply::Handled();
}
if (bConfirmOnEnter && (Key == EKeys::Enter || Key == EKeys::Virtual_Gamepad_Accept.GetVirtualKey()))
{
StopCountdown();
OnConfirmed.Broadcast();
return FReply::Handled();
}
return Super::NativeOnKeyDown(InGeometry, InKeyEvent);
}
void UConfirmDialogWidget::HandleConfirmClicked()
{
// Le joueur a repondu : le rebours n'a plus lieu d'etre, et le laisser
// tourner ferait arriver un OnCancelled apres coup.
StopCountdown();
OnConfirmed.Broadcast();
}
void UConfirmDialogWidget::HandleCancelClicked()
{
StopCountdown();
OnCancelled.Broadcast();
}
void UConfirmDialogWidget::NativeDestruct()
{
// Le ticker du moteur survit au widget : sans ce retrait, il rappellerait
// une methode sur un objet detruit.
StopCountdown();
if (ConfirmButton)
{
ConfirmButton->OnClicked.RemoveDynamic(this, &UConfirmDialogWidget::HandleConfirmClicked);
}
if (CancelButton)
{
CancelButton->OnClicked.RemoveDynamic(this, &UConfirmDialogWidget::HandleCancelClicked);
}
Super::NativeDestruct();
}
+900
View File
@@ -0,0 +1,900 @@
// 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()
{
// 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.
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
@@ -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
@@ -0,0 +1,269 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CraftingComponent.h"
#include "CraftingRecipeBook.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
UCraftingComponent::UCraftingComponent()
{
PrimaryComponentTick.bCanEverTick = false;
// 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()
{
Super::InitializeComponent();
// Les mains sont toujours disponibles, et rien ne peut les retirer.
StationRefCounts.Add(ECraftingStation::Hands, 1);
if (const AActor* Owner = GetOwner())
{
Inventory = Owner->FindComponentByClass<UInventoryComponent>();
}
if (!Inventory)
{
UE_LOG(LogTemp, Warning, TEXT("UCraftingComponent : aucun UInventoryComponent sur %s, aucune fabrication ne sera possible."),
*GetNameSafe(GetOwner()));
}
if (!RecipeBook)
{
UE_LOG(LogTemp, Warning, TEXT("UCraftingComponent : RecipeBook n'est pas assigne sur %s, la grille de craft restera vide."),
*GetNameSafe(GetOwner()));
}
}
bool UCraftingComponent::HasStation(ECraftingStation Station) const
{
const int32* Count = StationRefCounts.Find(Station);
return Count && *Count > 0;
}
void UCraftingComponent::AddAvailableStation(ECraftingStation Station)
{
if (Station == ECraftingStation::Count)
{
return;
}
int32& Count = StationRefCounts.FindOrAdd(Station);
++Count;
// On ne diffuse que sur la transition 0 -> 1. Entrer dans le rayon d'un
// deuxieme etabli ne change rien de visible, inutile de reconstruire la
// grille de recettes pour ca.
if (Count == 1)
{
OnAvailableStationsChanged.Broadcast();
}
}
void UCraftingComponent::RemoveAvailableStation(ECraftingStation Station)
{
// Retirer les mains laisserait le joueur incapable de fabriquer quoi que ce
// soit, sans qu'aucun message ne l'explique.
if (Station == ECraftingStation::Hands)
{
return;
}
int32* Count = StationRefCounts.Find(Station);
if (!Count || *Count <= 0)
{
return;
}
--(*Count);
if (*Count <= 0)
{
StationRefCounts.Remove(Station);
OnAvailableStationsChanged.Broadcast();
}
}
void UCraftingComponent::GatherRecipes(TArray<UCraftingRecipeDataAsset*>& OutRecipes, ECraftingStation StationFilter) const
{
OutRecipes.Reset();
if (!RecipeBook)
{
return;
}
const TArray<TObjectPtr<UCraftingRecipeDataAsset>>& AllRecipes = RecipeBook->GetRecipes();
OutRecipes.Reserve(AllRecipes.Num());
for (UCraftingRecipeDataAsset* Recipe : AllRecipes)
{
// Une entree vide dans le livre est signalee par sa validation d'asset.
// Ici on se contente de la sauter.
if (!Recipe)
{
continue;
}
if (!HasStation(Recipe->RequiredStation))
{
continue;
}
if (StationFilter != ECraftingStation::Count && Recipe->RequiredStation != StationFilter)
{
continue;
}
OutRecipes.Add(Recipe);
}
}
bool UCraftingComponent::CanCraft(const UCraftingRecipeDataAsset* Recipe, ECraftFailureReason& OutReason) const
{
OutReason = ECraftFailureReason::None;
if (!RecipeBook)
{
OutReason = ECraftFailureReason::NoRecipeBook;
return false;
}
if (!Recipe || !Recipe->ResultItem)
{
OutReason = ECraftFailureReason::InvalidRecipe;
return false;
}
if (!Inventory)
{
OutReason = ECraftFailureReason::InvalidRecipe;
return false;
}
if (!HasStation(Recipe->RequiredStation))
{
OutReason = ECraftFailureReason::StationUnavailable;
return false;
}
for (const FCraftIngredient& Ingredient : Recipe->Ingredients)
{
// Un ingredient nul est refuse par la validation de l'asset. S'il passe
// quand meme, on ne fabrique pas : mieux vaut un bouton grise qu'une
// recette qui coute moins cher que ce qu'elle affiche.
if (!Ingredient.Item)
{
OutReason = ECraftFailureReason::InvalidRecipe;
return false;
}
if (Inventory->GetItemCount(Ingredient.Item) < Ingredient.Quantity)
{
OutReason = ECraftFailureReason::MissingIngredients;
return false;
}
}
return true;
}
void UCraftingComponent::GatherIngredientStatus(const UCraftingRecipeDataAsset* Recipe, TArray<FCraftIngredientStatus>& OutStatus) const
{
OutStatus.Reset();
if (!Recipe)
{
return;
}
OutStatus.Reserve(Recipe->Ingredients.Num());
for (const FCraftIngredient& Ingredient : Recipe->Ingredients)
{
if (!Ingredient.Item)
{
continue;
}
FCraftIngredientStatus& Status = OutStatus.AddDefaulted_GetRef();
Status.Item = Ingredient.Item;
Status.RequiredQuantity = Ingredient.Quantity;
Status.OwnedQuantity = Inventory ? Inventory->GetItemCount(Ingredient.Item) : 0;
}
}
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))
{
return false;
}
// CanCraft a deja verifie que tout est present : aucun RemoveItem ne peut
// echouer a partir d'ici, donc pas de consommation a moitie faite.
//
// Chaque retrait diffuse OnInventoryChanged, donc l'interface se rafraichit
// une fois par ingredient. C'est du gaspillage assume : ca reste quelques
// dizaines de SetSlot sur un clic, et grouper les diffusions demanderait
// une API de lot dans l'inventaire pour un gain invisible.
for (const FCraftIngredient& Ingredient : Recipe->Ingredients)
{
Inventory->RemoveItem(Ingredient.Item, Ingredient.Quantity);
}
// -1 : l'objet sort neuf de la fabrication, avec toutes ses charges.
const int32 Leftover = Inventory->AddItem(Recipe->ResultItem, Recipe->ResultQuantity, -1);
if (Leftover > 0)
{
if (OnCraftOverflow.IsBound())
{
OnCraftOverflow.Broadcast(Recipe->ResultItem, Leftover, -1);
}
else
{
// Personne pour poser l'objet au sol : il est perdu. C'est une erreur
// de branchement, pas une situation de jeu -- d'ou le niveau Error.
UE_LOG(LogTemp, Error,
TEXT("UCraftingComponent : %d x %s n'ont pas pu entrer dans l'inventaire et personne n'ecoute OnCraftOverflow. Objets perdus."),
Leftover, *GetNameSafe(Recipe->ResultItem));
}
}
OnCraftSucceeded.Broadcast(Recipe);
return true;
}
@@ -0,0 +1,41 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CraftingIngredientRowWidget.h"
#include "Components/Image.h"
#include "Components/TextBlock.h"
#include "ItemDataAsset.h"
void UCraftingIngredientRowWidget::SetIngredient(const FCraftIngredientStatus& Status)
{
if (IngredientIcon)
{
UTexture2D* Icon = Status.Item ? Status.Item->Icon : nullptr;
if (Icon)
{
IngredientIcon->SetBrushFromTexture(Icon);
IngredientIcon->SetVisibility(ESlateVisibility::HitTestInvisible);
}
else
{
// Sans ca, un objet sans icone afficherait le carre blanc du brush
// par defaut au milieu de la liste.
IngredientIcon->SetVisibility(ESlateVisibility::Hidden);
}
}
if (IngredientNameText)
{
IngredientNameText->SetText(Status.Item ? Status.Item->DisplayName : FText::GetEmpty());
}
if (IngredientCountText)
{
IngredientCountText->SetText(FText::Format(CountFormat,
FText::AsNumber(Status.OwnedQuantity),
FText::AsNumber(Status.RequiredQuantity)));
IngredientCountText->SetColorAndOpacity(FSlateColor(Status.IsSatisfied() ? EnoughColor : MissingColor));
}
}
@@ -0,0 +1,50 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CraftingRecipeBook.h"
#include "CraftingRecipeDataAsset.h"
#if WITH_EDITOR
#include "Misc/DataValidation.h"
#endif
#define LOCTEXT_NAMESPACE "CraftingBook"
#if WITH_EDITOR
EDataValidationResult UCraftingRecipeBook::IsDataValid(FDataValidationContext& Context) const
{
EDataValidationResult Result = Super::IsDataValid(Context);
// Une case vide dans le tableau ne casse rien au runtime, on la saute. Mais
// c'est le symptome d'une recette supprimee ou d'un ajout laisse en plan, et
// la grille de craft aurait un trou sans qu'on sache pourquoi.
TSet<const UCraftingRecipeDataAsset*> SeenRecipes;
SeenRecipes.Reserve(Recipes.Num());
for (int32 Index = 0; Index < Recipes.Num(); ++Index)
{
const UCraftingRecipeDataAsset* Recipe = Recipes[Index];
if (!Recipe)
{
Context.AddWarning(FText::Format(
LOCTEXT("BookNullRecipe", "Book entry {0} is empty."), Index));
continue;
}
bool bAlreadySeen = false;
SeenRecipes.Add(Recipe, &bAlreadySeen);
if (bAlreadySeen)
{
Context.AddWarning(FText::Format(
LOCTEXT("BookDuplicateRecipe", "Recipe {0} is listed twice: it will show up twice in the grid."),
FText::FromString(Recipe->GetName())));
}
}
return Result;
}
#endif
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,83 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CraftingRecipeDataAsset.h"
#include "ItemDataAsset.h"
#if WITH_EDITOR
#include "Misc/DataValidation.h"
#endif
#define LOCTEXT_NAMESPACE "Crafting"
FText UCraftingRecipeDataAsset::GetDisplayName() const
{
if (!DisplayNameOverride.IsEmpty())
{
return DisplayNameOverride;
}
return ResultItem ? ResultItem->DisplayName : FText::GetEmpty();
}
UTexture2D* UCraftingRecipeDataAsset::GetIcon() const
{
if (IconOverride)
{
return IconOverride;
}
return ResultItem ? ResultItem->Icon : nullptr;
}
#if WITH_EDITOR
EDataValidationResult UCraftingRecipeDataAsset::IsDataValid(FDataValidationContext& Context) const
{
EDataValidationResult Result = Super::IsDataValid(Context);
if (!ResultItem)
{
Context.AddError(LOCTEXT("RecipeNoResult", "This recipe has no result item: it would consume the ingredients and give nothing back."));
Result = EDataValidationResult::Invalid;
}
if (RecipeId.IsNone())
{
Context.AddWarning(LOCTEXT("RecipeNoId", "RecipeId is empty. Saved recipe unlocks will not be able to identify this recipe."));
}
// Deux lignes portant le meme objet donneraient un cout affiche en double
// dans le panneau de detail, alors que la verification, elle, les cumule.
// L'ecart entre les deux est indetectable a la lecture de l'asset.
TSet<const UItemDataAsset*> SeenItems;
SeenItems.Reserve(Ingredients.Num());
for (int32 Index = 0; Index < Ingredients.Num(); ++Index)
{
const FCraftIngredient& Ingredient = Ingredients[Index];
if (!Ingredient.Item)
{
Context.AddError(FText::Format(
LOCTEXT("RecipeNullIngredient", "Ingredient {0} has no item assigned."), Index));
Result = EDataValidationResult::Invalid;
continue;
}
bool bAlreadySeen = false;
SeenItems.Add(Ingredient.Item, &bAlreadySeen);
if (bAlreadySeen)
{
Context.AddError(FText::Format(
LOCTEXT("RecipeDuplicateIngredient", "Item {0} appears twice in the ingredients. Merge them into a single line."),
FText::FromString(Ingredient.Item->GetName())));
Result = EDataValidationResult::Invalid;
}
}
return Result;
}
#endif
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,66 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CraftingRecipeSlotWidget.h"
#include "Components/Image.h"
#include "Components/TextBlock.h"
#include "CraftingRecipeDataAsset.h"
#include "CraftingWidget.h"
void UCraftingRecipeSlotWidget::InitSlot(UCraftingRecipeDataAsset* InRecipe, UCraftingWidget* InOwningGrid)
{
Recipe = InRecipe;
OwningGrid = InOwningGrid;
if (RecipeIcon)
{
UTexture2D* Icon = Recipe ? Recipe->GetIcon() : nullptr;
if (Icon)
{
RecipeIcon->SetBrushFromTexture(Icon);
RecipeIcon->SetVisibility(ESlateVisibility::HitTestInvisible);
}
else
{
// Pas d'icone : on masque plutot que de laisser le brush par defaut,
// qui afficherait un carre blanc franchement laid dans la grille.
RecipeIcon->SetVisibility(ESlateVisibility::Hidden);
}
}
if (RecipeNameText)
{
RecipeNameText->SetText(Recipe ? Recipe->GetDisplayName() : FText::GetEmpty());
}
SetSelected(false);
SetCraftable(true);
}
void UCraftingRecipeSlotWidget::SetSelected(bool bSelected)
{
if (SelectionBorder)
{
SelectionBorder->SetVisibility(bSelected ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Hidden);
}
}
void UCraftingRecipeSlotWidget::SetCraftable(bool bCraftable)
{
if (RecipeIcon)
{
RecipeIcon->SetColorAndOpacity(bCraftable ? CraftableTint : UncraftableTint);
}
}
FReply UCraftingRecipeSlotWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton && OwningGrid.IsValid())
{
OwningGrid->NotifyRecipeClicked(Recipe);
return FReply::Handled();
}
return Super::NativeOnMouseButtonDown(InGeometry, InMouseEvent);
}
@@ -0,0 +1,49 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CraftingStation.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/CollisionProfile.h"
#include "FpsPlayerController.h"
#include "GameFramework/Pawn.h"
#define LOCTEXT_NAMESPACE "CraftingStation"
ACraftingStation::ACraftingStation()
{
PrimaryActorTick.bCanEverTick = false;
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
SetRootComponent(Mesh);
// Explicite plutot que de compter sur le profil par defaut : ce composant a
// deux roles indissociables, empecher le joueur de traverser le meuble et
// bloquer le canal Visibility pour que le trace d'interaction le trouve.
// Un profil qui laisserait passer Visibility rendrait le poste inutilisable
// sans qu'aucune erreur ne le signale.
Mesh->SetCollisionProfileName(UCollisionProfile::BlockAll_ProfileName);
InteractionPrompt = LOCTEXT("UseStation", "Utiliser l'établi");
}
FText ACraftingStation::GetInteractionPrompt_Implementation(AActor* Interactor) const
{
return InteractionPrompt;
}
void ACraftingStation::Interact_Implementation(AActor* Interactor)
{
// L'ecran vit sur le controller, pas sur le pawn : c'est lui qu'on cherche.
const APawn* InteractingPawn = Cast<APawn>(Interactor);
AFpsPlayerController* PlayerController = InteractingPawn ? Cast<AFpsPlayerController>(InteractingPawn->GetController()) : nullptr;
if (!PlayerController)
{
return;
}
PlayerController->OpenCraftingAtStation(StationType);
}
#undef LOCTEXT_NAMESPACE
+397
View File
@@ -0,0 +1,397 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CraftingWidget.h"
#include "Components/Button.h"
#include "Components/Image.h"
#include "Components/TextBlock.h"
#include "Components/UniformGridPanel.h"
#include "CraftingComponent.h"
#include "CraftingIngredientRowWidget.h"
#include "CraftingRecipeSlotWidget.h"
#include "FpsPlayer.h"
#include "GameFramework/PlayerController.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
void UCraftingWidget::NativeConstruct()
{
Super::NativeConstruct();
if (CraftButton)
{
CraftButton->OnClicked.AddDynamic(this, &UCraftingWidget::HandleCraftClicked);
}
BindToOwningPawn();
}
void UCraftingWidget::NativeDestruct()
{
UnbindDelegates();
Super::NativeDestruct();
}
UCraftingComponent* UCraftingWidget::ResolveCrafting() const
{
const APlayerController* OwningController = GetOwningPlayer();
AFpsPlayer* PlayerPawn = OwningController ? Cast<AFpsPlayer>(OwningController->GetPawn()) : nullptr;
return PlayerPawn ? PlayerPawn->GetCraftingComponent() : nullptr;
}
void UCraftingWidget::UnbindDelegates()
{
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.RemoveDynamic(this, &UCraftingWidget::HandleInventoryChanged);
}
BoundInventory.Reset();
if (BoundCrafting.IsValid())
{
BoundCrafting->OnAvailableStationsChanged.RemoveDynamic(this, &UCraftingWidget::HandleStationsChanged);
}
BoundCrafting.Reset();
}
void UCraftingWidget::BindToOwningPawn()
{
UCraftingComponent* NewCrafting = ResolveCrafting();
if (BoundCrafting.Get() == NewCrafting)
{
return;
}
UnbindDelegates();
BoundCrafting = NewCrafting;
if (BoundCrafting.IsValid())
{
BoundCrafting->OnAvailableStationsChanged.AddDynamic(this, &UCraftingWidget::HandleStationsChanged);
// L'inventaire est la vraie source des couleurs et de l'etat du bouton :
// c'est lui qui change quand le joueur ramasse ou consomme.
if (const AActor* Owner = BoundCrafting->GetOwner())
{
BoundInventory = Owner->FindComponentByClass<UInventoryComponent>();
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.AddDynamic(this, &UCraftingWidget::HandleInventoryChanged);
}
}
}
else
{
UE_LOG(LogTemp, Warning, TEXT("UCraftingWidget : aucun UCraftingComponent trouve sur le pawn possede."));
}
// Les cases gardent l'ancienne recette et l'ancien parent en memoire : on
// force leur recreation en vidant la grille.
for (UCraftingRecipeSlotWidget* SlotWidget : RecipeSlots)
{
if (SlotWidget)
{
SlotWidget->RemoveFromParent();
}
}
RecipeSlots.Reset();
// Les lignes de cout aussi : elles affichent l'inventaire du pawn precedent.
for (UCraftingIngredientRowWidget* Row : IngredientRows)
{
if (Row)
{
Row->RemoveFromParent();
}
}
IngredientRows.Reset();
SelectedRecipe = nullptr;
Refresh();
}
void UCraftingWidget::HandleInventoryChanged()
{
// Seules les couleurs et l'etat du bouton bougent : la liste des recettes,
// elle, ne depend que des postes accessibles.
RefreshStates();
}
void UCraftingWidget::HandleStationsChanged()
{
Refresh();
}
void UCraftingWidget::SetStationFilter(ECraftingStation InFilter)
{
if (StationFilter == InFilter)
{
return;
}
StationFilter = InFilter;
Refresh();
}
void UCraftingWidget::Refresh()
{
if (!RecipeGrid)
{
UE_LOG(LogTemp, Warning, TEXT("UCraftingWidget::Refresh : le widget nomme 'RecipeGrid' manque dans le Blueprint."));
return;
}
TArray<UCraftingRecipeDataAsset*> Recipes;
if (BoundCrafting.IsValid())
{
BoundCrafting->GatherRecipes(Recipes, StationFilter);
}
RebuildGrid(Recipes);
if (EmptyGridMessage)
{
EmptyGridMessage->SetVisibility(Recipes.Num() > 0 ? ESlateVisibility::Collapsed : ESlateVisibility::Visible);
}
// La recette choisie a pu disparaitre de la liste : on s'est eloigne de
// l'etabli, ou le filtre a change.
if (SelectedRecipe && !Recipes.Contains(SelectedRecipe))
{
SelectedRecipe = nullptr;
}
RefreshStates();
}
void UCraftingWidget::RebuildGrid(const TArray<UCraftingRecipeDataAsset*>& NewRecipes)
{
// La liste ne change qu'en s'approchant d'un poste ou en changeant de
// filtre. Recreer les cases a chaque ramassage serait du gaspillage pur,
// d'ou cette comparaison prealable.
bool bSameList = RecipeSlots.Num() == NewRecipes.Num();
if (bSameList)
{
for (int32 Index = 0; Index < RecipeSlots.Num(); ++Index)
{
if (!RecipeSlots[Index] || RecipeSlots[Index]->GetRecipe() != NewRecipes[Index])
{
bSameList = false;
break;
}
}
}
if (bSameList)
{
return;
}
for (UCraftingRecipeSlotWidget* SlotWidget : RecipeSlots)
{
if (SlotWidget)
{
SlotWidget->RemoveFromParent();
}
}
RecipeSlots.Reset();
if (NewRecipes.Num() == 0)
{
return;
}
if (!RecipeSlotClass)
{
UE_LOG(LogTemp, Warning, TEXT("UCraftingWidget : RecipeSlotClass n'est pas assignee, la grille restera vide."));
return;
}
const int32 Columns = FMath::Max(1, ColumnCount);
RecipeSlots.Reserve(NewRecipes.Num());
for (int32 Index = 0; Index < NewRecipes.Num(); ++Index)
{
UCraftingRecipeSlotWidget* NewSlot = CreateWidget<UCraftingRecipeSlotWidget>(this, RecipeSlotClass);
if (!NewSlot)
{
// Arrive typiquement quand le Widget Blueprint de case ne compile
// plus, apres un changement de BindWidget cote C++ par exemple.
UE_LOG(LogTemp, Error, TEXT("UCraftingWidget : impossible de creer une case a partir de %s. Recompile ce Widget Blueprint."),
*GetNameSafe(RecipeSlotClass));
break;
}
NewSlot->InitSlot(NewRecipes[Index], this);
RecipeGrid->AddChildToUniformGrid(NewSlot, Index / Columns, Index % Columns);
RecipeSlots.Add(NewSlot);
}
}
void UCraftingWidget::RefreshStates()
{
UCraftingComponent* Crafting = BoundCrafting.Get();
for (UCraftingRecipeSlotWidget* SlotWidget : RecipeSlots)
{
if (!SlotWidget)
{
continue;
}
ECraftFailureReason Reason = ECraftFailureReason::None;
const bool bCraftable = Crafting && Crafting->CanCraft(SlotWidget->GetRecipe(), Reason);
SlotWidget->SetCraftable(bCraftable);
SlotWidget->SetSelected(SlotWidget->GetRecipe() == SelectedRecipe);
}
RefreshDetails();
}
void UCraftingWidget::RefreshDetails()
{
UCraftingComponent* Crafting = BoundCrafting.Get();
if (DetailsPanel)
{
DetailsPanel->SetVisibility(SelectedRecipe ? ESlateVisibility::Visible : ESlateVisibility::Hidden);
}
if (!SelectedRecipe)
{
if (CraftButton)
{
CraftButton->SetIsEnabled(false);
}
// Le panneau est masque, mais on vide quand meme : le contenu de
// l'ancienne recette reapparaitrait un instant a la selection suivante.
RefreshIngredients();
return;
}
if (ResultIcon)
{
if (UTexture2D* Icon = SelectedRecipe->GetIcon())
{
ResultIcon->SetBrushFromTexture(Icon);
ResultIcon->SetVisibility(ESlateVisibility::HitTestInvisible);
}
else
{
ResultIcon->SetVisibility(ESlateVisibility::Hidden);
}
}
if (ResultNameText)
{
ResultNameText->SetText(SelectedRecipe->GetDisplayName());
}
if (ResultQuantityText)
{
const bool bMultiple = SelectedRecipe->ResultQuantity > 1;
ResultQuantityText->SetVisibility(bMultiple ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Collapsed);
if (bMultiple)
{
ResultQuantityText->SetText(FText::AsNumber(SelectedRecipe->ResultQuantity));
}
}
if (ResultDescriptionText)
{
ResultDescriptionText->SetText(SelectedRecipe->ResultItem ? SelectedRecipe->ResultItem->Description : FText::GetEmpty());
}
RefreshIngredients();
if (CraftButton)
{
ECraftFailureReason Reason = ECraftFailureReason::None;
CraftButton->SetIsEnabled(Crafting && Crafting->CanCraft(SelectedRecipe, Reason));
}
}
void UCraftingWidget::RefreshIngredients()
{
if (!IngredientList)
{
return;
}
TArray<FCraftIngredientStatus> Statuses;
if (SelectedRecipe && BoundCrafting.IsValid())
{
BoundCrafting->GatherIngredientStatus(SelectedRecipe, Statuses);
}
// Trop de lignes : on retire le surplus par la fin.
while (IngredientRows.Num() > Statuses.Num())
{
const int32 Last = IngredientRows.Num() - 1;
if (IngredientRows[Last])
{
IngredientRows[Last]->RemoveFromParent();
}
IngredientRows.RemoveAt(Last);
}
if (IngredientRows.Num() < Statuses.Num() && !IngredientRowClass)
{
UE_LOG(LogTemp, Warning, TEXT("UCraftingWidget : IngredientRowClass n'est pas assignee, la liste de cout restera vide."));
}
// Pas assez : on en cree.
while (IngredientRows.Num() < Statuses.Num() && IngredientRowClass)
{
UCraftingIngredientRowWidget* NewRow = CreateWidget<UCraftingIngredientRowWidget>(this, IngredientRowClass);
if (!NewRow)
{
UE_LOG(LogTemp, Error, TEXT("UCraftingWidget : impossible de creer une ligne a partir de %s. Recompile ce Widget Blueprint."),
*GetNameSafe(IngredientRowClass));
break;
}
IngredientList->AddChild(NewRow);
IngredientRows.Add(NewRow);
}
for (int32 Index = 0; Index < IngredientRows.Num(); ++Index)
{
if (IngredientRows[Index] && Statuses.IsValidIndex(Index))
{
IngredientRows[Index]->SetIngredient(Statuses[Index]);
}
}
}
void UCraftingWidget::NotifyRecipeClicked(UCraftingRecipeDataAsset* InRecipe)
{
if (SelectedRecipe == InRecipe)
{
return;
}
SelectedRecipe = InRecipe;
RefreshStates();
}
void UCraftingWidget::HandleCraftClicked()
{
if (!SelectedRecipe || !BoundCrafting.IsValid())
{
return;
}
// Pas de re-verification ici : Craft() refait CanCraft de toute facon, et
// dupliquer la condition finirait par la faire diverger.
BoundCrafting->Craft(SelectedRecipe);
// L'inventaire diffuse son changement, donc RefreshStates est deja passe.
// Rien a rappeler.
}
@@ -0,0 +1,48 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CustomizationCategoryWidget.h"
#include "Components/Border.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
void UCustomizationCategoryWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (CategoryButton)
{
CategoryButton->OnClicked.AddDynamic(this, &UCustomizationCategoryWidget::HandleButtonClicked);
}
SetSelected(false);
}
void UCustomizationCategoryWidget::Setup(int32 InIndex, const FText& InLabel)
{
CategoryIndex = InIndex;
if (CategoryLabel)
{
CategoryLabel->SetText(InLabel);
}
}
void UCustomizationCategoryWidget::SetSelected(bool bInSelected)
{
if (CategoryLabel)
{
CategoryLabel->SetColorAndOpacity(FSlateColor(bInSelected ? SelectedLabelColor : UnselectedLabelColor));
}
if (SelectionBorder)
{
SelectionBorder->SetBrushColor(bInSelected ? SelectedBorderColor : UnselectedBorderColor);
}
}
void UCustomizationCategoryWidget::HandleButtonClicked()
{
OnCategoryClicked.Broadcast(CategoryIndex);
}
@@ -0,0 +1,88 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CustomizationOptionWidget.h"
#include "Components/Border.h"
#include "Components/Button.h"
#include "Components/Image.h"
#include "Components/TextBlock.h"
#include "Engine/Texture2D.h"
void UCustomizationOptionWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (OptionButton)
{
OptionButton->OnClicked.AddDynamic(this, &UCustomizationOptionWidget::HandleButtonClicked);
}
SetSelected(false);
}
void UCustomizationOptionWidget::SetupAsPart(int32 InIndex, UTexture2D* Icon)
{
OptionIndex = InIndex;
if (OptionImage)
{
// Le blanc et pas la couleur d'origine : SetupAsColor a pu teinter
// cette image lors d'un passage precedent, et la case est recyclee.
// Sans cette remise a plat, une vignette heriterait de la teinte de la
// pastille qui occupait la meme case avant elle.
OptionImage->SetColorAndOpacity(FLinearColor::White);
OptionImage->SetBrushFromTexture(Icon);
OptionImage->SetVisibility(Icon ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);
}
if (IndexText)
{
// Le numero ne sert que de repli. Affiche a cote d'une vignette, il la
// salirait ; affiche seul, il rend la case utilisable.
IndexText->SetText(FText::AsNumber(InIndex + 1));
IndexText->SetVisibility(Icon ? ESlateVisibility::Collapsed : ESlateVisibility::HitTestInvisible);
}
}
void UCustomizationOptionWidget::SetupAsColor(int32 InIndex, const FLinearColor& Color)
{
OptionIndex = InIndex;
if (OptionImage)
{
// Aucune texture assignee : le pinceau par defaut d'une UImage est un
// aplat blanc, que la teinte suffit a colorer. C'est ce qui permet a
// une pastille de n'avoir besoin d'aucun asset.
OptionImage->SetBrushFromTexture(nullptr);
OptionImage->SetColorAndOpacity(Color);
OptionImage->SetVisibility(ESlateVisibility::Visible);
}
if (IndexText)
{
IndexText->SetVisibility(ESlateVisibility::Collapsed);
}
}
void UCustomizationOptionWidget::SetSelected(bool bInSelected)
{
if (SelectionBorder)
{
SelectionBorder->SetBrushColor(bInSelected ? SelectedBorderColor : UnselectedBorderColor);
}
// La case deja choisie n'est plus cliquable : re-cliquer une option active
// ne ferait rien, et un bouton qui ne fait rien laisse croire que le clic
// n'a pas ete pris. Le style Disabled du bouton la marque au passage --
// c'est un second signal de selection, gratuit, en plus du cadre.
if (OptionButton)
{
OptionButton->SetIsEnabled(!bInSelected);
}
}
void UCustomizationOptionWidget::HandleButtonClicked()
{
OnOptionClicked.Broadcast(OptionIndex);
}
@@ -0,0 +1,363 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "FootstepComponent.h"
#include "Components/CapsuleComponent.h"
#include "Engine/World.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Kismet/GameplayStatics.h"
UFootstepComponent::UFootstepComponent()
{
PrimaryComponentTick.bCanEverTick = true;
// 20 Hz, et pas la frequence d'affichage. Le composant n'accumule qu'une
// DISTANCE, mesuree entre deux positions exactes : allonger l'intervalle ne
// degrade donc rien, la somme des deplacements reste la meme. A 120 fps
// c'est six fois moins de reveils pour un resultat identique a l'oreille.
//
// Un TickComponent a nous et pas un appel depuis le Tick du pawn, contrairement
// a UHeadBobComponent : lui doit tourner avant que la camera soit composee, ici
// une frame de retard sur un son ne s'entend pas -- et surtout ce composant doit
// rester posable sur n'importe quel ACharacter, y compris ceux qu'on n'ecrit pas.
PrimaryComponentTick.TickInterval = 0.05f;
// Foulees calees sur les frequences de UHeadBobComponent, avec les vitesses
// du pawn : StrideLength = Vitesse / (2 x StrideFrequency).
// accroupi : 200 / (2 x 0.70) = 143
// marche : 400 / (2 x 1.10) = 182
// sprint : 650 / (2 x 1.55) = 210
// Elles sont ici et non en initialiseurs de membre pour que le constructeur
// reste le seul endroit a lire quand on cherche les valeurs par defaut.
CrouchGait = FFootstepGaitSettings(143.f, 0.35f);
WalkGait = FFootstepGaitSettings(182.f, 0.80f);
SprintGait = FFootstepGaitSettings(210.f, 1.00f);
}
void UFootstepComponent::BeginPlay()
{
Super::BeginPlay();
OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter)
{
UE_LOG(LogTemp, Warning, TEXT("UFootstepComponent : le proprietaire n'est pas un ACharacter, les bruits de pas sont desactives."));
SetComponentTickEnabled(false);
return;
}
OwnerMovement = OwnerCharacter->GetCharacterMovement();
// Le seul signal de decollage et de reception qui existe sur les quatre
// machines. Voir le commentaire de HandleMovementModeChanged.
OwnerCharacter->MovementModeChangedDelegate.AddDynamic(this, &UFootstepComponent::HandleMovementModeChanged);
if (!SoundBank)
{
UE_LOG(LogTemp, Warning, TEXT("UFootstepComponent : aucune banque de sons assignee sur %s, aucun bruit de pas ne sera joue."),
*GetNameSafe(GetOwner()));
}
}
void UFootstepComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (OwnerCharacter)
{
OwnerCharacter->MovementModeChangedDelegate.RemoveDynamic(this, &UFootstepComponent::HandleMovementModeChanged);
}
Super::EndPlay(EndPlayReason);
}
void UFootstepComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!bEnableFootsteps || !OwnerCharacter || !OwnerMovement || !SoundBank || !ShouldPlaySounds())
{
return;
}
const FVector Location = OwnerCharacter->GetActorLocation();
// Premier tick valide : on se contente de memoriser la position. Sans ce
// garde-fou, le premier deplacement mesure partirait de l'origine du monde
// et declencherait une rafale de pas au spawn.
if (!bHasLastLocation)
{
LastLocation = Location;
bHasLastLocation = true;
return;
}
const float Travelled = (Location - LastLocation).Size2D();
LastLocation = Location;
// En l'air on ne compte pas de foulee : le vol est traite par les deux cues
// de HandleMovementModeChanged. On y profite du passage pour echantillonner
// la vitesse de chute, introuvable au moment de l'impact.
if (OwnerMovement->IsFalling())
{
MaxFallSpeed = FMath::Max(MaxFallSpeed, -OwnerMovement->Velocity.Z);
return;
}
const float Speed = OwnerMovement->Velocity.Size2D();
const bool bIsMoving = Speed > IdleSpeedThreshold;
if (bIsMoving && !bWasMoving)
{
// Entree en mouvement : on amorce le compteur pour que le premier pas
// tombe tout de suite plutot qu'apres deux metres de silence.
DistanceSinceLastStep = GetGaitSettings(SelectGait(Speed)).StrideLength * FirstStepFraction;
StepsSinceMoveStart = 0;
}
else if (!bIsMoving && bWasMoving)
{
// Immobilisation : le pas final. Conditionne a un vrai pas deja joue,
// sinon un simple a-coup de collision ferait claquer un arret alors que
// le personnage n'a pas avance d'un centimetre.
if (bPlayStopSounds && StepsSinceMoveStart > 0)
{
FVector StopLocation;
if (const FFootstepSurfaceSounds* Sounds = QueryGroundSounds(StopLocation))
{
const FFootstepGaitSettings& StopSettings = GetGaitSettings(LastGait);
PlayCue(Sounds->GetStopSound(LastGait), StopSettings.Volume, StopSettings.PitchVariation, StopLocation);
}
}
DistanceSinceLastStep = 0.f;
StepsSinceMoveStart = 0;
}
bWasMoving = bIsMoving;
if (!bIsMoving)
{
return;
}
DistanceSinceLastStep += Travelled;
const EFootstepGait Gait = SelectGait(Speed);
const float StrideLength = GetGaitSettings(Gait).StrideLength;
if (DistanceSinceLastStep >= StrideLength)
{
// Soustraction et pas remise a zero : le reliquat de distance doit
// survivre au pas, sinon le rythme derive a chaque hoquet de framerate.
DistanceSinceLastStep -= StrideLength;
// Mais borne quand meme : apres une teleportation ou un long gel, le
// reliquat vaudrait plusieurs foulees et partirait en rafale.
DistanceSinceLastStep = FMath::Min(DistanceSinceLastStep, StrideLength);
++StepsSinceMoveStart;
PlayFootstepNow();
}
}
void UFootstepComponent::PlayFootstepNow()
{
if (!OwnerMovement || !SoundBank || !ShouldPlaySounds())
{
return;
}
const EFootstepGait Gait = SelectGait(OwnerMovement->Velocity.Size2D());
FVector StepLocation;
const FFootstepSurfaceSounds* Sounds = QueryGroundSounds(StepLocation);
if (!Sounds)
{
return;
}
const FFootstepGaitSettings& Settings = GetGaitSettings(Gait);
PlayCue(Sounds->GetStepSound(Gait), Settings.Volume, Settings.PitchVariation, StepLocation);
// Memorise APRES coup : c'est cette allure-la que le son d'arret devra
// reprendre, pas celle qu'aura le personnage une fois immobile.
LastGait = Gait;
}
void UFootstepComponent::HandleMovementModeChanged(ACharacter* Character, EMovementMode PrevMovementMode, uint8 PreviousCustomMode)
{
if (!bEnableFootsteps || !OwnerMovement || !SoundBank || !ShouldPlaySounds())
{
return;
}
const EMovementMode NewMode = OwnerMovement->MovementMode;
// Decollage
if (NewMode == MOVE_Falling && PrevMovementMode == MOVE_Walking)
{
MaxFallSpeed = 0.f;
// Un saut, et pas une sortie de rebord : c'est le SIGNE de la vitesse
// verticale qui tranche, exactement comme dans l'AnimBP du corps. Sans
// ce test, marcher au bord d'une caisse ferait grogner le personnage.
if (OwnerMovement->Velocity.Z > 0.f)
{
FVector JumpLocation;
if (const FFootstepSurfaceSounds* Sounds = QueryGroundSounds(JumpLocation))
{
const FFootstepGaitSettings& Settings = GetGaitSettings(SelectGait(OwnerMovement->Velocity.Size2D()));
PlayCue(Sounds->Jump, AirVolume, Settings.PitchVariation, JumpLocation);
}
}
// Le cycle de foulee repart de zero a la reception : bWasMoving remis a
// faux fait passer le premier tick au sol par la branche "entree en
// mouvement", donc par FirstStepFraction. Retomber en pleine course
// redonne ainsi un pas tout de suite.
DistanceSinceLastStep = 0.f;
StepsSinceMoveStart = 0;
bWasMoving = false;
return;
}
// Reception
if (PrevMovementMode == MOVE_Falling && NewMode == MOVE_Walking)
{
if (MaxFallSpeed > LandMinFallSpeed)
{
FVector LandLocation;
if (const FFootstepSurfaceSounds* Sounds = QueryGroundSounds(LandLocation))
{
const FFootstepGaitSettings& Settings = GetGaitSettings(SelectGait(OwnerMovement->Velocity.Size2D()));
PlayCue(Sounds->Land, AirVolume, Settings.PitchVariation, LandLocation);
}
}
MaxFallSpeed = 0.f;
DistanceSinceLastStep = 0.f;
StepsSinceMoveStart = 0;
bWasMoving = false;
}
}
void UFootstepComponent::SetFootstepsEnabled(bool bEnabled)
{
bEnableFootsteps = bEnabled;
if (!bEnabled)
{
ResetStride();
}
}
void UFootstepComponent::ResetStride()
{
DistanceSinceLastStep = 0.f;
StepsSinceMoveStart = 0;
MaxFallSpeed = 0.f;
bWasMoving = false;
// La position memorisee aussi : un respawn deplace le pawn de plusieurs
// centaines de metres, et ce deplacement ne doit pas compter comme un pas.
bHasLastLocation = false;
}
EFootstepGait UFootstepComponent::SelectGait(float HorizontalSpeed) const
{
if (OwnerMovement && OwnerMovement->IsCrouching())
{
return EFootstepGait::Walk;
}
// Le seuil se lit sur la vitesse REELLE et pas sur AFpsPlayer::IsSprinting() :
// le composant reste ainsi posable sur un PNJ, qui n'a pas de touche Maj.
return (HorizontalSpeed > SprintSpeedThreshold) ? EFootstepGait::Run : EFootstepGait::Jog;
}
const FFootstepGaitSettings& UFootstepComponent::GetGaitSettings(EFootstepGait Gait) const
{
switch (Gait)
{
case EFootstepGait::Walk: return CrouchGait;
case EFootstepGait::Run: return SprintGait;
default: return WalkGait;
}
}
const FFootstepSurfaceSounds* UFootstepComponent::QueryGroundSounds(FVector& OutLocation) const
{
if (!SoundBank || !OwnerCharacter)
{
return nullptr;
}
const UCapsuleComponent* Capsule = OwnerCharacter->GetCapsuleComponent();
const float HalfHeight = Capsule ? Capsule->GetScaledCapsuleHalfHeight() : 0.f;
const FVector Center = OwnerCharacter->GetActorLocation();
const FVector Feet = Center - FVector(0.f, 0.f, HalfHeight);
// Repli de position : si le trace ne touche rien, le son part quand meme,
// depuis les pieds. Un silence serait plus difficile a diagnostiquer.
OutLocation = Feet;
const UWorld* World = GetWorld();
if (!World)
{
return &SoundBank->DefaultSurface;
}
// bTraceComplex : voir le commentaire de la propriete. Sans lui on recupere
// le PhysicalMaterial du BodySetup, presque toujours vide, et tout le decor
// sonne sur DefaultSurface.
FCollisionQueryParams Params(SCENE_QUERY_STAT(Footstep), bTraceComplex, OwnerCharacter);
Params.bReturnPhysicalMaterial = true;
FHitResult Hit;
if (!World->LineTraceSingleByChannel(Hit, Center, Feet - FVector(0.f, 0.f, TraceDepth), TraceChannel, Params))
{
return &SoundBank->DefaultSurface;
}
OutLocation = Hit.ImpactPoint;
return &SoundBank->GetSurfaceSounds(UGameplayStatics::GetSurfaceType(Hit));
}
void UFootstepComponent::PlayCue(USoundBase* Sound, float Volume, float PitchVariation, const FVector& Location) const
{
if (!Sound || Volume <= 0.f)
{
return;
}
const float Pitch = (PitchVariation > 0.f)
? FMath::FRandRange(1.f - PitchVariation, 1.f + PitchVariation)
: 1.f;
// Le pas du joueur local en 2D : joue en 3D depuis ses propres chevilles, il
// passe par l'attenuation et sonne creux, alors qu'il devrait etre le son le
// plus present du mixage. Les Cue n'ont pas de SoundClass, donc les deux
// chemins tombent sur SC_SFX, la classe par defaut du projet -- le curseur
// "Effets" les couvre sans qu'on ait rien a taguer.
if (bPlayLocalStepsIn2D && OwnerCharacter && OwnerCharacter->IsLocallyControlled())
{
UGameplayStatics::PlaySound2D(this, Sound, Volume * LocalVolumeScale, Pitch, 0.f, Concurrency, GetOwner());
return;
}
// PlaySoundAtLocation et pas SpawnSoundAttached : un pas est un one-shot de
// quelques dixiemes de seconde, l'attacher creerait puis detruirait un
// UAudioComponent trois fois par seconde et par joueur pour rien.
UGameplayStatics::PlaySoundAtLocation(this, Sound, Location, FRotator::ZeroRotator,
Volume, Pitch, 0.f, Attenuation, Concurrency, GetOwner());
}
bool UFootstepComponent::ShouldPlaySounds() const
{
// Un serveur dedie n'a pas de sortie audio : inutile d'y tracer le sol pour
// quatre personnages. Le projet tourne en serveur d'ecoute, mais la garde ne
// coute qu'une comparaison et evite d'y repenser le jour ou ca change.
const UWorld* World = GetWorld();
return World && World->GetNetMode() != NM_DedicatedServer;
}
@@ -0,0 +1,38 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "FootstepSoundsDataAsset.h"
USoundBase* FFootstepSurfaceSounds::GetStepSound(EFootstepGait Gait) const
{
switch (Gait)
{
case EFootstepGait::Walk: return WalkStep;
case EFootstepGait::Jog: return JogStep;
case EFootstepGait::Run: return RunStep;
default: return nullptr;
}
}
USoundBase* FFootstepSurfaceSounds::GetStopSound(EFootstepGait Gait) const
{
switch (Gait)
{
case EFootstepGait::Walk: return WalkStop;
case EFootstepGait::Jog: return JogStop;
case EFootstepGait::Run: return RunStop;
default: return nullptr;
}
}
const FFootstepSurfaceSounds& UFootstepSoundsDataAsset::GetSurfaceSounds(EPhysicalSurface Surface) const
{
// Find et pas FindRef : on rend une reference sur l'entree de la table, sans
// copier les huit pointeurs a chaque pas.
if (const FFootstepSurfaceSounds* Found = Surfaces.Find(Surface))
{
return *Found;
}
return DefaultSurface;
}
@@ -0,0 +1,85 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "FpsArmsAnimInstance.h"
#include "FpsPlayer.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "HeldItemComponent.h"
void UFpsArmsAnimInstance::NativeInitializeAnimation()
{
Super::NativeInitializeAnimation();
OwningPlayer = Cast<AFpsPlayer>(TryGetPawnOwner());
}
void UFpsArmsAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
// L'AnimInstance est initialisee avec le composant, c'est-a-dire potentiellement
// avant que le pawn soit completement en place. On retente donc tant qu'on n'a
// personne, plutot que d'abandonner definitivement sur un echec au demarrage.
if (!OwningPlayer.IsValid())
{
OwningPlayer = Cast<AFpsPlayer>(TryGetPawnOwner());
if (!OwningPlayer.IsValid())
{
return;
}
}
const AFpsPlayer* Player = OwningPlayer.Get();
const UCharacterMovementComponent* Movement = Player->GetCharacterMovement();
if (!Movement)
{
return;
}
// Size2D et pas Size : en pleine chute la composante verticale ferait courir
// les bras alors que le joueur ne pose pas un pied.
GroundSpeed = Movement->Velocity.Size2D();
// Le test d'acceleration est ce qui distingue « le joueur avance » de « le
// joueur a lache la touche et glisse encore ». Sans lui, l'animation de course
// continue pendant toute la decceleration, les pieds patinant dans le vide --
// invisible sur des bras, mais le meme AnimInstance servira au corps un jour.
bIsMoving = GroundSpeed > MovingSpeedThreshold
&& !Movement->GetCurrentAcceleration().IsNearlyZero();
// Lu depuis le composant plutot que mis en cache dans un membre : apres un
// respawn le pawn est un NOUVEL acteur, avec un nouveau composant, et un
// pointeur garde ici pointerait sur celui du cadavre.
if (const UHeldItemComponent* HeldItems = Player->GetHeldItemComponent())
{
bHasItemInHand = HeldItems->HasItemInHand();
}
bIsSprinting = Player->IsSprinting();
bIsFalling = Movement->IsFalling();
LandingImpact = Player->GetLandingImpact();
bIsCrouching = Movement->IsCrouching();
VerticalSpeed = Movement->Velocity.Z;
// Les deux etats de deplacement au sol sont composes ICI et nulle part
// ailleurs. bIsSprinting ne dit que « la touche est enfoncee » : sans le ET
// avec bIsMoving, appuyer sur Maj a l'arret suffit a lancer l'animation de
// course, bras qui pompent devant un personnage qui ne bouge pas.
//
// Le sol et l'accroupi sont exclus des deux pour que ces booleens restent
// mutuellement exclusifs avec les etats Air et Crouch de la machine : deux
// conditions vraies en meme temps, c'est une transition prise au hasard.
const bool bGrounded = !bIsFalling && !bIsCrouching;
bIsRunning = bGrounded && bIsMoving && bIsSprinting;
bIsWalking = bGrounded && bIsMoving && !bIsSprinting;
// La cadence suit la vitesse REELLE et pas l'etat de sprint : c'est ce qui
// fait que les bras accelerent pendant la montee en vitesse, au lieu de
// sauter d'une cadence a l'autre a l'instant ou la touche est pressee.
// Elle couvre donc aussi l'accroupi, gratuitement.
LocomotionPlayRate = FMath::Clamp(
GroundSpeed / ReferenceLocomotionSpeed,
MinLocomotionPlayRate,
MaxLocomotionPlayRate);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "HeadBobComponent.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
UHeadBobComponent::UHeadBobComponent()
{
// C'est le pawn qui appelle UpdateBob depuis son propre Tick, juste avant de
// composer la transform de la camera. Un tick a nous ne servirait qu'a
// introduire une incertitude d'ordre.
PrimaryComponentTick.bCanEverTick = false;
// Valeurs de depart, a affiner a l'oeil dans BP_FpsPlayer. Elles sont ici et
// non en initialiseurs de membre pour que le constructeur reste le seul
// endroit a lire quand on cherche "combien ca bouge par defaut".
IdleProfile = FHeadBobProfile(0.15f, 0.55f, 0.05f, 0.03f, 0.f);
WalkProfile = FHeadBobProfile(1.1f, 1.6f, 1.2f, 0.4f, 0.f);
SprintProfile = FHeadBobProfile(1.55f, 2.1f, 1.7f, 0.8f, 0.f);
CrouchProfile = FHeadBobProfile(0.7f, 1.f, 0.9f, 0.3f, 0.f);
AirProfile = FHeadBobProfile(0.f, 0.f, 0.f, 0.f, 0.f);
// On demarre deja sur le profil d'immobilite : partir de zeros ferait monter
// la respiration pendant la premiere seconde de jeu, ce qui se voit.
BlendedProfile = IdleProfile;
}
void UHeadBobComponent::BeginPlay()
{
Super::BeginPlay();
OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter)
{
UE_LOG(LogTemp, Warning, TEXT("UHeadBobComponent : le proprietaire n'est pas un ACharacter, le balancement de tete est desactive."));
return;
}
// GetCharacterMovement() est un simple accesseur sur un membre du Character,
// mais le stocker evite un appel indirect par frame pour rien.
OwnerMovement = OwnerCharacter->GetCharacterMovement();
}
void UHeadBobComponent::UpdateBob(float DeltaTime)
{
// IsLocallyControlled : chez l'hote, le pawn de chaque ami porte AUSSI ce
// composant. Sans cette garde, le serveur calculerait trois balancements pour
// des cameras que personne ne regarde.
if (!bEnableHeadBob || !OwnerCharacter || !OwnerMovement || !OwnerCharacter->IsLocallyControlled())
{
// Remise a zero et pas simple sortie : couper l'effet en pleine partie
// laisserait sinon la camera figee sur le dernier decalage calcule.
LocationOffset = FVector::ZeroVector;
RotationOffset = FRotator::ZeroRotator;
return;
}
// Meme garde-fou que les ressorts de camera : un hoquet de framerate ferait
// sauter la phase de plusieurs pas d'un coup.
const float Step = FMath::Min(DeltaTime, 1.f / 30.f);
BlendTowards(SelectTargetProfile(), Step);
// La phase avance a la frequence FONDUE, pas a celle du profil vise : c'est
// ce qui fait que le rythme des pas accelere progressivement au demarrage du
// sprint au lieu de doubler d'une frame a l'autre.
StridePhase = FMath::Fmod(StridePhase + BlendedProfile.StrideFrequency * 2.f * PI * Step, 2.f * PI);
const float Intensity = GlobalIntensity * IntensityScale;
// Une foulee = deux pas. Le balancement lateral suit la foulee entiere (on
// bascule sur un pied puis sur l'autre), la montee-descente bat deux fois plus
// vite, une bosse par pied qui touche le sol. C'est ce rapport 1:2 qui dessine
// le fameux 8 couche et qui fait qu'on lit une marche plutot qu'un flottement.
const float LateralWave = FMath::Sin(StridePhase);
const float VerticalWave = FMath::Sin(StridePhase * 2.f);
// X reste nul : une composante avant-arriere donnerait un effet de zoom qui,
// en vue premiere personne, se lit comme du mal de mer plus que comme un pas.
LocationOffset = FVector(
0.f,
LateralWave * BlendedProfile.LateralAmount * Intensity,
VerticalWave * BlendedProfile.VerticalAmount * Intensity);
// Le roulis suit le balancement lateral : la tete penche du cote ou le corps
// se porte. Le tangage suit la montee-descente, et vaut 0 par defaut.
RotationOffset = FRotator(
VerticalWave * BlendedProfile.PitchAmount * Intensity,
0.f,
LateralWave * BlendedProfile.RollAmount * Intensity);
}
const FHeadBobProfile& UHeadBobComponent::SelectTargetProfile() const
{
if (OwnerMovement->IsFalling())
{
return AirProfile;
}
// Vitesse a plat : la composante verticale d'une pente ou d'un ascenseur n'a
// rien a voir avec la cadence des pas.
const float GroundSpeed = OwnerMovement->Velocity.Size2D();
// L'immobilite passe AVANT l'accroupissement : accroupi sans bouger, on
// respire, on ne marche pas sur place.
if (GroundSpeed < IdleSpeedThreshold)
{
return IdleProfile;
}
if (OwnerCharacter->bIsCrouched)
{
return CrouchProfile;
}
return (GroundSpeed > SprintSpeedThreshold) ? SprintProfile : WalkProfile;
}
void UHeadBobComponent::BlendTowards(const FHeadBobProfile& Target, float Step)
{
BlendedProfile.StrideFrequency = FMath::FInterpTo(BlendedProfile.StrideFrequency, Target.StrideFrequency, Step, ProfileBlendSpeed);
BlendedProfile.VerticalAmount = FMath::FInterpTo(BlendedProfile.VerticalAmount, Target.VerticalAmount, Step, ProfileBlendSpeed);
BlendedProfile.LateralAmount = FMath::FInterpTo(BlendedProfile.LateralAmount, Target.LateralAmount, Step, ProfileBlendSpeed);
BlendedProfile.RollAmount = FMath::FInterpTo(BlendedProfile.RollAmount, Target.RollAmount, Step, ProfileBlendSpeed);
BlendedProfile.PitchAmount = FMath::FInterpTo(BlendedProfile.PitchAmount, Target.PitchAmount, Step, ProfileBlendSpeed);
}
void UHeadBobComponent::ResetBob()
{
StridePhase = 0.f;
BlendedProfile = IdleProfile;
LocationOffset = FVector::ZeroVector;
RotationOffset = FRotator::ZeroRotator;
}
void UHeadBobComponent::SetIntensityScale(float NewScale)
{
IntensityScale = FMath::Max(NewScale, 0.f);
}
@@ -0,0 +1,510 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "HeldItemComponent.h"
#include "Animation/AnimMontage.h"
#include "Components/SkeletalMeshComponent.h"
#include "Components/StaticMeshComponent.h"
#include "FpsPlayer.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Net/UnrealNetwork.h"
UHeldItemComponent::UHeldItemComponent()
{
// Aucun Tick : le montage est evenementiel. Il ne se declenche qu'au
// changement de case active ou de contenu d'inventaire, et les deux sont
// deja des delegues.
PrimaryComponentTick.bCanEverTick = false;
// Obligatoire, et pas seulement pour repliquer HeldItem : sans lui le moteur
// JETTE SILENCIEUSEMENT Server_SetHeldItem. Aucun message, aucune erreur --
// l'objet reste simplement invisible chez les autres joueurs.
SetIsReplicatedByDefault(true);
}
void UHeldItemComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// COND_SkipOwner, exactement comme AFpsPlayer::bIsSprinting : le proprietaire
// est celui qui a DECIDE de ce changement et l'a deja applique en local. Le
// lui renvoyer poserait une valeur ayant un aller-retour de retard, ce qui
// fait clignoter l'objet quand on change de case rapidement.
DOREPLIFETIME_CONDITION(UHeldItemComponent, HeldItem, COND_SkipOwner);
}
void UHeldItemComponent::BeginPlay()
{
Super::BeginPlay();
AFpsPlayer* Player = GetOwningPlayer();
if (!Player)
{
UE_LOG(LogTemp, Warning, TEXT("UHeldItemComponent : porteur %s n'est pas un AFpsPlayer, aucun objet ne sera montre en main."),
*GetNameSafe(GetOwner()));
return;
}
if (UInventoryComponent* Inventory = Player->GetInventoryComponent())
{
Inventory->OnSelectedHotbarSlotChanged.AddDynamic(this, &UHeldItemComponent::HandleSelectedHotbarSlotChanged);
// Le second abonnement n'est pas une redondance : vider la case active
// au glisser-deposer, ou consommer la derniere charge de ce qu'on tient,
// ne change pas l'INDEX selectionne. Sans lui, la pomme resterait en main
// apres avoir ete mangee.
Inventory->OnInventoryChanged.AddDynamic(this, &UHeldItemComponent::HandleInventoryChanged);
}
// Chez l'hote et en solo, tout est deja en place a cet instant. Chez un
// client le controller peut manquer encore : AFpsPlayer rappellera alors
// depuis OnRep_Controller, et cette fonction est idempotente.
RefreshFromInventory();
}
void UHeldItemComponent::OnUnregister()
{
// Les composants engendres appartiennent a l'acteur et disparaissent avec
// lui. On ne lache que nos references, pour ne pas retenir des objets que le
// GC doit pouvoir reprendre.
FirstPersonHeldMesh = nullptr;
ThirdPersonHeldMesh = nullptr;
Super::OnUnregister();
}
AFpsPlayer* UHeldItemComponent::GetOwningPlayer() const
{
return Cast<AFpsPlayer>(GetOwner());
}
void UHeldItemComponent::HandleInventoryChanged()
{
RefreshFromInventory();
}
void UHeldItemComponent::HandleSelectedHotbarSlotChanged(int32 /*NewIndex*/)
{
RefreshFromInventory();
}
void UHeldItemComponent::RefreshFromInventory()
{
const AFpsPlayer* Player = GetOwningPlayer();
if (!Player)
{
return;
}
// Reposee AVANT la garde ci-dessous, et c'est tout l'interet : le passage de
// « pas encore controle » a « controle localement » est justement ce qui doit
// cacher l'objet en main, et AFpsPlayer rappelle cette fonction depuis
// OnRep_Controller. La poser apres la garde ne la ferait jamais tourner chez
// celui qui vient de prendre le controle.
UpdateLocalVisibility();
// LA garde de ce composant. Chez l'hote, le pawn de chaque ami porte aussi
// un inventaire et un UHeldItemComponent : lire la case active d'un pawn
// distant y trouverait toujours l'index 0, puisque SelectedHotbarIndex n'est
// pas replique. Sans ce test, le serveur remplacerait la hache d'un client
// par le contenu de sa premiere case, en boucle.
if (!Player->IsLocallyControlled())
{
return;
}
const UInventoryComponent* Inventory = Player->GetInventoryComponent();
if (!Inventory)
{
return;
}
const FInventorySlot Selected = Inventory->GetSelectedSlot();
SetHeldItem(Selected.IsEmpty() ? nullptr : Selected.Item.Get());
// Reposee meme quand SetHeldItem est sortie tot faute de changement : chez un
// client, les premiers rafraichissements tombent avant que le controller soit
// la, donc avant qu'on ait le droit de toucher aux bras. Sans ce rattrapage,
// un joueur qui reapparait avec sa pioche deja selectionnee garderait les
// cycles mains nues jusqu'a ce qu'il change de case.
UpdateArmsAnimLayer();
}
void UHeldItemComponent::SetHeldItem(UItemDataAsset* NewItem)
{
if (HeldItem == NewItem)
{
return;
}
HeldItem = NewItem;
ApplyHeldVisuals();
// La couche AVANT le montage : le geste de sortie doit se superposer aux
// cycles du nouvel outil, pas a ceux qu'on vient de quitter. L'inverse se
// voit sur la premiere image, la main partant de la mauvaise pose.
UpdateArmsAnimLayer();
PlayEquipMontage();
// En local d'abord, pour que l'objet apparaisse sous les doigts sans attendre
// un aller-retour ; le serveur suit. Le sens inverse donnerait une main qui
// se remplit avec un temps de retard a chaque coup de molette.
if (const AActor* Owner = GetOwner(); Owner && !Owner->HasAuthority())
{
Server_SetHeldItem(NewItem);
}
}
void UHeldItemComponent::UpdateArmsAnimLayer()
{
AFpsPlayer* Player = GetOwningPlayer();
if (!Player || !Player->IsLocallyControlled())
{
return;
}
USkeletalMeshComponent* Arms = Player->GetFirstPersonArms();
if (!Arms)
{
return;
}
// Le mesh REELLEMENT monte fait foi, pas le seul HeldItem : un objet dont on
// a oublie de cocher bShowInHand ferait sinon jouer les cycles de la pioche
// sur une main vide.
const TSubclassOf<UAnimInstance> DesiredLayer =
(bItemVisibleInHand && HeldItem) ? HeldItem->ArmsAnimLayer : nullptr;
if (DesiredLayer == LinkedArmsLayer)
{
return;
}
// Delier AVANT de lier, toujours. LinkAnimClassLayers ajoute une couche sans
// retirer la precedente : enchainer hache puis pioche laisserait les deux en
// place, et la hache l'emporterait sur toute fonction que la pioche
// n'implemente pas. Aucun message ne le signale.
if (LinkedArmsLayer)
{
Arms->UnlinkAnimClassLayers(LinkedArmsLayer);
}
if (DesiredLayer)
{
Arms->LinkAnimClassLayers(DesiredLayer);
}
LinkedArmsLayer = DesiredLayer;
}
void UHeldItemComponent::PlayEquipMontage()
{
// Ranger un objet ne joue rien : le pack ne livre que des gestes de sortie
// d'outil, et un desequipement sans animation ne se remarque pas -- l'objet
// suivant arrive dans la foulee avec le sien.
if (!HeldItem)
{
return;
}
PlayLocalArmsMontage(HeldItem->EquipMontage, /*bSkipIfAlreadyPlaying=*/false);
}
EUseMontageResult UHeldItemComponent::PlayUseMontage()
{
// Le test porte sur l'OBJET et pas sur le mesh monte : une pomme n'apparait
// pas dans la main (bShowInHand faux) mais elle occupe bien la case active,
// et la manger ne doit pas declencher un coup de poing.
UAnimMontage* const Montage = HeldItem ? HeldItem->UseMontage.Get() : UnarmedUseMontage.Get();
if (!Montage)
{
return EUseMontageResult::NoMontage;
}
// Le geste deja en cours est distingue AVANT l'appel, parce que
// PlayLocalArmsMontage rend false dans les deux cas et que l'appelant, lui,
// doit les separer : l'un enverra son marqueur d'impact, l'autre jamais.
const AFpsPlayer* Player = GetOwningPlayer();
const USkeletalMeshComponent* Arms = Player ? Player->GetFirstPersonArms() : nullptr;
const UAnimInstance* ArmsAnim = Arms ? Arms->GetAnimInstance() : nullptr;
if (ArmsAnim && ArmsAnim->Montage_IsPlaying(Montage))
{
return EUseMontageResult::AlreadyPlaying;
}
// Chez un pawn distant les bras ne jouent rien (voir PlayLocalArmsMontage) :
// aucun marqueur n'en sortira, d'ou NoMontage plutot qu'un etat a part. La
// garde IsLocallyControlled() du pawn reste seule juge de ce qui part sur
// le reseau.
return PlayLocalArmsMontage(Montage, /*bSkipIfAlreadyPlaying=*/true)
? EUseMontageResult::Started
: EUseMontageResult::NoMontage;
}
bool UHeldItemComponent::PlayCrouchMontage(bool bEntering)
{
UAnimMontage* const Held = bEntering ? HeldEnterCrouchMontage.Get() : HeldExitCrouchMontage.Get();
UAnimMontage* const Unarmed = bEntering ? UnarmedEnterCrouchMontage.Get() : UnarmedExitCrouchMontage.Get();
// Le repli sur la version mains vides est ce qui permet de n'en fournir
// qu'une au depart : les gestes « avec objet » arriveront plus tard sans
// qu'on ait a repasser dans le code.
//
// Le test porte sur le MESH monte et pas sur HeldItem, contrairement au clic
// gauche : ce qui change la posture des genoux, c'est un objet qu'on voit
// dans la main, pas une pomme rangee dans la case active.
UAnimMontage* const Montage = (bItemVisibleInHand && Held) ? Held : Unarmed;
// Pas de bSkipIfAlreadyPlaying : marteler la touche d'accroupissement doit
// relancer le geste, puisque la capsule, elle, change bel et bien de taille
// a chaque fois.
return PlayLocalArmsMontage(Montage, /*bSkipIfAlreadyPlaying=*/false);
}
bool UHeldItemComponent::PlayLocalArmsMontage(UAnimMontage* Montage, bool bSkipIfAlreadyPlaying)
{
if (!Montage)
{
return false;
}
AFpsPlayer* Player = GetOwningPlayer();
// Les bras sont en OnlyOwnerSee : chez l'hote, le pawn de chaque ami porte
// AUSSI un squelette de bras que personne ne rend. Y jouer un montage ferait
// tourner quatre machines a etats pour rien.
if (!Player || !Player->IsLocallyControlled())
{
return false;
}
if (bSkipIfAlreadyPlaying)
{
const USkeletalMeshComponent* Arms = Player->GetFirstPersonArms();
const UAnimInstance* ArmsAnim = Arms ? Arms->GetAnimInstance() : nullptr;
if (ArmsAnim && ArmsAnim->Montage_IsPlaying(Montage))
{
return false;
}
}
return Player->PlayArmsMontage(Montage) > 0.f;
}
void UHeldItemComponent::Server_SetHeldItem_Implementation(UItemDataAsset* NewItem)
{
if (HeldItem == NewItem)
{
return;
}
HeldItem = NewItem;
// Le serveur rend lui aussi les pawns distants -- l'hote voit ses amis. Sans
// cet appel, la hache n'existerait que chez les clients.
ApplyHeldVisuals();
}
void UHeldItemComponent::OnRep_HeldItem()
{
ApplyHeldVisuals();
}
void UHeldItemComponent::ApplyHeldVisuals()
{
EnsureHeldMeshComponents();
const UItemDataAsset* Item = HeldItem;
// Un objet peut tres bien n'avoir aucune representation en main : c'est le
// cas par defaut de toutes les ressources du jeu. On demonte alors, sans que
// ce soit une anomalie.
const bool bShow = Item != nullptr && Item->bShowInHand && Item->GetHeldMesh() != nullptr;
bItemVisibleInHand = bShow;
if (FirstPersonHeldMesh)
{
if (bShow)
{
Item->ApplyHeldVisualsTo(FirstPersonHeldMesh);
FirstPersonHeldMesh->SetRelativeTransform(Item->FirstPersonHeldOffset);
}
else
{
FirstPersonHeldMesh->SetStaticMesh(nullptr);
}
FirstPersonHeldMesh->SetVisibility(bShow);
}
if (ThirdPersonHeldMesh)
{
if (bShow)
{
Item->ApplyHeldVisualsTo(ThirdPersonHeldMesh);
ThirdPersonHeldMesh->SetRelativeTransform(Item->ThirdPersonHeldOffset);
}
else
{
ThirdPersonHeldMesh->SetStaticMesh(nullptr);
}
// Et surtout pas SetVisibility(bShow) : chez le joueur qui tient l'objet,
// ce mesh doit rester invisible tout en projetant son ombre.
UpdateLocalVisibility();
}
OnHeldItemChanged.Broadcast(HeldItem);
}
void UHeldItemComponent::UpdateLocalVisibility()
{
if (!ThirdPersonHeldMesh)
{
return;
}
const AFpsPlayer* Player = GetOwningPlayer();
// Le pawn qu'on controle est le seul dont on ne doit pas voir le corps -- donc
// le seul dont l'objet en main se cache. Chez les trois autres joueurs le meme
// pawn n'est pas controle localement, et l'outil s'y affiche normalement.
//
// Relu a CHAQUE passage plutot que pose une fois : chez un client le pawn
// arrive par replication AVANT son controller, et IsLocallyControlled() y est
// donc faux pendant les premieres images. Sans cette relecture, le joueur
// verrait sa propre pioche flotter devant lui pour le reste de la partie.
const bool bHideForLocalPlayer = Player != nullptr && Player->IsLocallyControlled();
const bool bHasMesh = ThirdPersonHeldMesh->GetStaticMesh() != nullptr;
ThirdPersonHeldMesh->SetVisibility(bHasMesh && !bHideForLocalPlayer);
}
void UHeldItemComponent::EnsureHeldMeshComponents()
{
AFpsPlayer* Player = GetOwningPlayer();
if (!Player)
{
return;
}
if (!FirstPersonHeldMesh)
{
FirstPersonHeldMesh = CreateHeldMeshComponent(
TEXT("FirstPersonHeldMesh"), Player->GetFirstPersonArms(), FirstPersonSocket,
/*bCastWorldShadow=*/false);
}
if (!ThirdPersonHeldMesh)
{
ThirdPersonHeldMesh = CreateHeldMeshComponent(
TEXT("ThirdPersonHeldMesh"), Player->GetMesh(), ThirdPersonSocket,
/*bCastWorldShadow=*/true);
}
}
UStaticMeshComponent* UHeldItemComponent::CreateHeldMeshComponent(FName ComponentName, USkeletalMeshComponent* Parent,
FName SocketName, bool bCastWorldShadow)
{
AActor* Owner = GetOwner();
if (!Owner || !Parent)
{
return nullptr;
}
// Un os introuvable n'est pas une erreur pour le moteur : il attache a la
// RACINE du composant, en silence, et l'objet apparait plante au milieu du
// torse. C'est ce log qui distingue « mal place » de « mal nomme », et la
// difference vaut une heure de recherche dans le mauvais fichier.
//
// Le test du mesh n'est pas superflu : UCharacterAppearanceComponent
// REMPLACE le mesh du corps a son BeginPlay, et l'ordre des BeginPlay entre
// composants n'est garanti par rien. Sans lui, un avertissement partirait un
// coup sur deux pour un os parfaitement valide -- soit exactement le faux
// indice que ce log est cense eviter. L'attachement, lui, se moque du
// moment : le nom est resolu a chaque lecture de la transform du socket.
if (!SocketName.IsNone() && Parent->GetSkeletalMeshAsset() && !Parent->DoesSocketExist(SocketName) && !bLoggedMissingSocket)
{
UE_LOG(LogTemp, Warning, TEXT("UHeldItemComponent : le socket ou l'os '%s' n'existe pas sur %s. L'objet en main sera attache a la racine du mesh."),
*SocketName.ToString(), *GetNameSafe(Parent->GetSkeletalMeshAsset()));
bLoggedMissingSocket = true;
}
UStaticMeshComponent* Held = NewObject<UStaticMeshComponent>(Owner, ComponentName);
if (!Held)
{
return nullptr;
}
Held->SetupAttachment(Parent, SocketName);
// AVANT l'enregistrement, et c'est le piege le plus couteux de ce fichier :
// un UStaticMeshComponent cree par NewObject nait en Mobility STATIC, alors
// qu'un sous-objet declare dans un Blueprint herite du Movable de son
// parent. Le mesh suit pourtant la main sans broncher, ce qui donne
// l'illusion que tout va bien -- mais sous Virtual Shadow Maps une primitive
// Static part dans le cache d'ombres statiques, qui n'est pas reinvalide
// quand elle bouge. Symptome : l'objet est la, son ombre non. Et il n'y a
// aucun message pour le dire.
Held->SetMobility(EComponentMobility::Movable);
// Un objet tenu en main n'est pas une surface de jeu. Sans ca, la hache
// bloquerait le trace d'interaction -- qui part exactement d'entre les bras.
Held->SetCollisionEnabled(ECollisionEnabled::NoCollision);
Held->RegisterComponent();
// Apres l'enregistrement, et pas avant : les setters d'etat de rendu
// marquent le proxy « sale », ce qui ne veut rien dire sur un composant qui
// n'est pas encore dans le monde. Les poser des deux cotes de
// RegisterComponent est le genre de subtilite qui fait qu'un drapeau prend
// et l'autre non -- on les pose donc tous ici.
// « Visible pour moi seul » se recopie du porteur : c'est ce qui tient les
// bras, et un mesh d'arme n'a pas a savoir qu'il les habille.
Held->SetOnlyOwnerSee(Parent->bOnlyOwnerSee);
// bOwnerNoSee du porteur, en revanche, ne se recopie SURTOUT PAS, alors que
// c'est par la que le Mesh du Character se cache lui-meme. Mesure sur ce
// projet : cases « Cast Shadow » et « Hidden Shadow » cochees et actives sur
// le composant, aucune ombre tant que bOwnerNoSee valait vrai, ombre correcte
// des la seconde ou on cache le mesh par « Visible » a la place.
//
// Autrement dit bCastHiddenShadow n'honore, sur un UStaticMeshComponent, que
// la dissimulation par la VISIBILITE -- pas celle par le proprietaire. Le
// corps y echappe parce qu'il est un mesh SQUELETTIQUE, qui n'emprunte pas le
// meme chemin de relevance dans le renderer. D'ou le partage : le porteur se
// cache comme il veut, l'objet en main se cache par UpdateLocalVisibility().
//
// Ce n'est pas une perte de fonctionnalite : sur une machine donnee, le seul
// pawn dont on est proprietaire est celui qu'on controle, donc les deux
// mecanismes cachent exactement les memes objets aux memes joueurs.
// L'OMBRE ne se recopie pas non plus -- les deux meshes n'ont pas du tout le
// meme role. Celui du corps doit projeter son ombre MEME invisible pour son
// proprietaire (bCastHiddenShadow), sinon on voit son propre corps au sol
// sans l'outil qu'on tient. Celui des bras ne doit rien projeter du tout :
// colle a l'objectif, il barrerait le decor d'une ombre geante.
Held->SetCastShadow(bCastWorldShadow);
Held->SetCastHiddenShadow(bCastWorldShadow);
// Rien a montrer tant qu'aucun objet n'est monte.
Held->SetVisibility(false);
// Sans ca le composant existe, se rend et s'anime, mais reste INTROUVABLE
// dans le Details panel du pawn en PIE : le panneau ne liste que les
// sous-objets du template et les composants declares comme composants
// d'instance. C'est du confort de debogage, mais c'est ce confort qui
// manquait pour diagnostiquer cette ombre.
Owner->AddInstanceComponent(Held);
UE_LOG(LogTemp, Log, TEXT("UHeldItemComponent : '%s' monte sur '%s' (socket '%s' %s, ombre %s)."),
*ComponentName.ToString(), *Parent->GetName(), *SocketName.ToString(),
Parent->DoesSocketExist(SocketName) ? TEXT("trouve") : TEXT("INTROUVABLE"),
bCastWorldShadow ? TEXT("activee") : TEXT("desactivee"));
return Held;
}
+193
View File
@@ -0,0 +1,193 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "HotbarWidget.h"
#include "Components/HorizontalBox.h"
#include "Components/HorizontalBoxSlot.h"
#include "FpsPlayer.h"
#include "GameFramework/PlayerController.h"
#include "InventoryComponent.h"
#include "InventorySlotWidget.h"
void UHotbarWidget::NativeConstruct()
{
Super::NativeConstruct();
BindToOwningPawn();
}
void UHotbarWidget::BindToOwningPawn()
{
UInventoryComponent* NewInventory = ResolveInventory();
if (BoundInventory.Get() == NewInventory)
{
return;
}
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.RemoveDynamic(this, &UHotbarWidget::HandleInventoryChanged);
BoundInventory->OnSelectedHotbarSlotChanged.RemoveDynamic(this, &UHotbarWidget::HandleSelectionChanged);
}
BoundInventory = NewInventory;
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.AddDynamic(this, &UHotbarWidget::HandleInventoryChanged);
BoundInventory->OnSelectedHotbarSlotChanged.AddDynamic(this, &UHotbarWidget::HandleSelectionChanged);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("UHotbarWidget : aucun UInventoryComponent trouve sur le pawn possede."));
}
// Les cases memorisent l'ancien composant : on les recree.
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->RemoveFromParent();
}
}
SlotWidgets.Reset();
Refresh();
}
void UHotbarWidget::NativeDestruct()
{
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.RemoveDynamic(this, &UHotbarWidget::HandleInventoryChanged);
BoundInventory->OnSelectedHotbarSlotChanged.RemoveDynamic(this, &UHotbarWidget::HandleSelectionChanged);
}
BoundInventory.Reset();
Super::NativeDestruct();
}
UInventoryComponent* UHotbarWidget::ResolveInventory() const
{
const APlayerController* OwningController = GetOwningPlayer();
AFpsPlayer* Player = OwningController ? Cast<AFpsPlayer>(OwningController->GetPawn()) : nullptr;
return Player ? Player->GetInventoryComponent() : nullptr;
}
void UHotbarWidget::HandleInventoryChanged()
{
Refresh();
}
void UHotbarWidget::SetQuickTransferTarget(UInventoryComponent* InTarget)
{
QuickTransferTarget = InTarget;
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->SetQuickTransferTarget(InTarget);
}
}
}
void UHotbarWidget::HandleSelectionChanged(int32 NewIndex)
{
// Seule la surbrillance bouge : inutile de repousser tout le contenu.
for (int32 Index = 0; Index < SlotWidgets.Num(); ++Index)
{
if (SlotWidgets[Index])
{
SlotWidgets[Index]->SetSelected(Index == NewIndex);
}
}
}
void UHotbarWidget::HandleSlotHovered(int32 SlotIndex, bool bHovered)
{
OnSlotHovered.Broadcast(BoundInventory.Get(), SlotIndex, bHovered);
}
void UHotbarWidget::Refresh()
{
if (!SlotContainer)
{
UE_LOG(LogTemp, Warning, TEXT("UHotbarWidget::Refresh : SlotContainer est nul. Le widget nomme 'SlotContainer' manque dans le Blueprint."));
return;
}
if (!BoundInventory.IsValid())
{
return;
}
const TArray<FInventorySlot>& Slots = BoundInventory->GetSlots();
const int32 Count = BoundInventory->GetHotbarSlotCount();
RebuildSlots(Count);
const int32 Selected = BoundInventory->GetSelectedHotbarIndex();
for (int32 Index = 0; Index < SlotWidgets.Num(); ++Index)
{
if (!SlotWidgets[Index] || !Slots.IsValidIndex(Index))
{
continue;
}
SlotWidgets[Index]->SetSlot(Slots[Index]);
SlotWidgets[Index]->SetSelected(Index == Selected);
}
}
void UHotbarWidget::RebuildSlots(int32 DesiredCount)
{
if (SlotWidgets.Num() == DesiredCount)
{
return;
}
if (!SlotWidgetClass)
{
UE_LOG(LogTemp, Warning, TEXT("UHotbarWidget : SlotWidgetClass n'est pas assignee, la barre restera vide."));
return;
}
while (SlotWidgets.Num() > DesiredCount)
{
const int32 Last = SlotWidgets.Num() - 1;
if (SlotWidgets[Last])
{
SlotWidgets[Last]->RemoveFromParent();
}
SlotWidgets.RemoveAt(Last);
}
while (SlotWidgets.Num() < DesiredCount)
{
UInventorySlotWidget* NewSlot = CreateWidget<UInventorySlotWidget>(this, SlotWidgetClass);
if (!NewSlot)
{
UE_LOG(LogTemp, Error, TEXT("UHotbarWidget : impossible de creer une case a partir de %s. Recompile ce Widget Blueprint."),
*GetNameSafe(SlotWidgetClass));
break;
}
const int32 Index = SlotWidgets.Num();
// Meme indexation que la grille : la case 0 de la barre EST le slot 0
// de l'inventaire. C'est ce qui rend le glisser-deposer entre les deux
// gratuit -- ils manipulent le meme tableau.
NewSlot->InitSlot(BoundInventory.Get(), Index);
NewSlot->OnHoverChanged.AddDynamic(this, &UHotbarWidget::HandleSlotHovered);
NewSlot->SetQuickTransferTarget(QuickTransferTarget.Get());
if (UHorizontalBoxSlot* BoxSlot = SlotContainer->AddChildToHorizontalBox(NewSlot))
{
BoxSlot->SetPadding(FMargin(SlotPadding));
}
SlotWidgets.Add(NewSlot);
}
}
@@ -0,0 +1,246 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "InteractionComponent.h"
#include "DrawDebugHelpers.h"
#include "Engine/World.h"
#include "GameFramework/Controller.h"
#include "GameFramework/Pawn.h"
#include "Interactable.h"
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;
if (TimeSinceLastTrace < TraceInterval)
{
return;
}
TimeSinceLastTrace = 0.f;
UpdateFocus();
}
bool UInteractionComponent::GetViewPoint(FVector& OutLocation, FRotator& OutRotation) const
{
const APawn* OwnerPawn = Cast<APawn>(GetOwner());
if (!OwnerPawn)
{
return false;
}
// On passe par le controller plutot que d'aller chercher la camera nous-memes :
// GetPlayerViewPoint renvoie exactement ce que le joueur voit a l'ecran, y
// compris les effets de camera. Le trace part donc pile du centre du viseur.
if (const AController* OwnerController = OwnerPawn->GetController())
{
OwnerController->GetPlayerViewPoint(OutLocation, OutRotation);
return true;
}
return false;
}
void UInteractionComponent::UpdateFocus()
{
FVector ViewLocation;
FRotator ViewRotation;
if (!GetViewPoint(ViewLocation, ViewRotation))
{
SetFocusedActor(nullptr);
RefreshPrompt();
return;
}
const FVector TraceEnd = ViewLocation + ViewRotation.Vector() * InteractionRange;
FCollisionQueryParams Params(SCENE_QUERY_STAT(InteractionTrace), /*bTraceComplex=*/false, GetOwner());
// Sphere balayee plutot que rayon pur : quelques cm de tolerance suffisent
// pour que viser un petit objet cesse d'etre un exercice de precision.
FHitResult Hit;
const bool bHit = GetWorld()->SweepSingleByChannel(
Hit,
ViewLocation,
TraceEnd,
FQuat::Identity,
TraceChannel,
FCollisionShape::MakeSphere(TraceRadius),
Params);
AActor* Candidate = bHit ? Hit.GetActor() : nullptr;
if (!IsValidInteractable(Candidate))
{
Candidate = nullptr;
}
SetFocusedActor(Candidate);
// Apres SetFocusedActor, jamais avant : l'ordre garantit que l'UI recoit
// d'abord "la cible a change", puis le texte correspondant.
RefreshPrompt();
#if ENABLE_DRAW_DEBUG
if (bDrawDebug)
{
const FColor Color = Candidate ? FColor::Green : FColor::Red;
DrawDebugLine(GetWorld(), ViewLocation, TraceEnd, Color, false, TraceInterval, 0, 1.f);
if (bHit)
{
DrawDebugSphere(GetWorld(), Hit.ImpactPoint, TraceRadius, 12, Color, false, TraceInterval);
}
}
#endif
}
bool UInteractionComponent::IsValidInteractable(AActor* Actor) const
{
if (!IsValid(Actor) || !Actor->Implements<UInteractable>())
{
return false;
}
// L'objet a le dernier mot : une porte verrouillee peut refuser d'etre visee.
return IInteractable::Execute_CanInteract(Actor, GetOwner());
}
void UInteractionComponent::SetFocusedActor(AActor* NewFocus)
{
AActor* OldFocus = FocusedActor.Get();
const bool bWantsFocus = (NewFocus != nullptr);
// On compare les pointeurs ET l'etat booleen. Sans le booleen, le cas
// "la cible s'est detruite toute seule" passerait inapercu : FocusedActor
// renvoie deja nullptr, donc nullptr == nullptr, et on sortirait sans
// prevenir l'UI -- le prompt resterait affiche indefiniment.
if (OldFocus == NewFocus && bHasFocusedActor == bWantsFocus)
{
return;
}
// IsValid() et pas seulement != nullptr : l'ancienne cible a pu etre detruite
// entre deux traces, typiquement un objet qu'on vient de ramasser.
if (IsValid(OldFocus) && OldFocus->Implements<UInteractable>())
{
IInteractable::Execute_OnEndFocus(OldFocus);
}
FocusedActor = NewFocus;
bHasFocusedActor = bWantsFocus;
if (NewFocus)
{
IInteractable::Execute_OnBeginFocus(NewFocus);
}
OnFocusChanged.Broadcast(NewFocus, OldFocus);
}
void UInteractionComponent::TryInteract()
{
// On retrace avant d'agir : jusqu'a TraceInterval secondes ont pu s'ecouler
// depuis le dernier trace, et interagir avec un objet qu'on ne vise plus
// serait un bug tres visible.
UpdateFocus();
AActor* Target = FocusedActor.Get();
if (!Target)
{
return;
}
// 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();
// Un seul FText::Format par trace, soit 20 fois par seconde et seulement
// quand on vise quelque chose. Recalculer ici plutot que dans le getter
// evite qu'un widget qui interroge le prompt plusieurs fois par frame
// paie le format autant de fois.
const FText NewPrompt = IsValid(Target)
? IInteractable::Execute_GetInteractionPrompt(Target, GetOwner())
: FText::GetEmpty();
// EqualTo et non IdenticalTo : on compare ce qui s'affiche, pas l'instance.
// Deux FText::Format successifs produisent toujours deux instances
// differentes, IdenticalTo redeclencherait donc l'UI a chaque trace.
if (CachedPrompt.EqualTo(NewPrompt))
{
return;
}
CachedPrompt = NewPrompt;
OnPromptChanged.Broadcast(CachedPrompt);
}
@@ -0,0 +1,136 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "InteractionPromptWidget.h"
#include "Components/TextBlock.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/LocalPlayer.h"
#include "FpsPlayer.h"
#include "GameFramework/PlayerController.h"
#include "InteractionComponent.h"
#define LOCTEXT_NAMESPACE "InteractionPrompt"
UInteractionPromptWidget::UInteractionPromptWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
PromptFormat = LOCTEXT("PromptFormat", "[{0}] {1}");
}
void UInteractionPromptWidget::NativeConstruct()
{
Super::NativeConstruct();
// NativeConstruct est l'equivalent de BeginPlay pour un widget : il est
// appele une fois que le widget est ajoute au viewport.
BindToOwningPawn();
}
void UInteractionPromptWidget::BindToOwningPawn()
{
const APlayerController* OwningController = GetOwningPlayer();
const AFpsPlayer* Player = OwningController ? Cast<AFpsPlayer>(OwningController->GetPawn()) : nullptr;
UInteractionComponent* NewComponent = Player ? Player->GetInteractionComponent() : nullptr;
if (BoundComponent.Get() == NewComponent)
{
return;
}
if (BoundComponent.IsValid())
{
BoundComponent->OnPromptChanged.RemoveDynamic(this, &UInteractionPromptWidget::HandlePromptChanged);
}
BoundComponent = NewComponent;
if (BoundComponent.IsValid())
{
// Abonnement au delegue : le widget ne se reveille que quand le texte
// change vraiment. Pas de Tick, pas de polling.
BoundComponent->OnPromptChanged.AddDynamic(this, &UInteractionPromptWidget::HandlePromptChanged);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("InteractionPromptWidget : le pawn possede n'a pas de UInteractionComponent, le prompt restera cache."));
}
Refresh();
}
void UInteractionPromptWidget::NativeDestruct()
{
// Desabonnement systematique. Sans ca, le delegue garderait une reference
// vers un widget detruit -- typiquement au changement de niveau.
if (BoundComponent.IsValid())
{
BoundComponent->OnPromptChanged.RemoveDynamic(this, &UInteractionPromptWidget::HandlePromptChanged);
}
BoundComponent.Reset();
Super::NativeDestruct();
}
void UInteractionPromptWidget::HandlePromptChanged(const FText& NewPrompt)
{
Refresh();
}
void UInteractionPromptWidget::Refresh()
{
const FText Prompt = BoundComponent.IsValid() ? BoundComponent->GetFocusedPrompt() : FText::GetEmpty();
if (Prompt.IsEmpty())
{
// Collapsed et non Hidden : Hidden garde la place reservee dans la
// mise en page, Collapsed la libere completement.
SetVisibility(ESlateVisibility::Collapsed);
return;
}
if (PromptText)
{
// Un prompt non vide est toujours une action realisable : les cibles ne
// renvoient jamais de texte purement informatif, donc la touche a
// toujours sa place devant.
PromptText->SetText(FText::Format(PromptFormat, GetInteractKeyDisplayName(), Prompt));
}
// HitTestInvisible : le prompt s'affiche mais ne capture pas la souris,
// sinon il volerait les clics destines au jeu.
SetVisibility(ESlateVisibility::HitTestInvisible);
}
FText UInteractionPromptWidget::GetInteractKeyDisplayName() const
{
const APlayerController* OwningController = GetOwningPlayer();
const AFpsPlayer* Player = OwningController ? Cast<AFpsPlayer>(OwningController->GetPawn()) : nullptr;
if (!Player)
{
return FText::GetEmpty();
}
const UInputAction* Action = Player->GetInteractAction();
if (!Action)
{
return FText::GetEmpty();
}
// On demande au sous-systeme Enhanced Input quelle touche est REELLEMENT
// mappee sur l'action. Si tu changes E en F dans IMC_Default, ou si le
// joueur remappe ses touches un jour, le texte suit tout seul.
if (const UEnhancedInputLocalPlayerSubsystem* Subsystem =
ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(OwningController->GetLocalPlayer()))
{
const TArray<FKey> Keys = Subsystem->QueryKeysMappedToAction(Action);
if (Keys.Num() > 0)
{
return Keys[0].GetDisplayName(/*bLongDisplayName=*/false);
}
}
return FText::GetEmpty();
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,753 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Net/UnrealNetwork.h"
UInventoryComponent::UInventoryComponent()
{
PrimaryComponentTick.bCanEverTick = false;
// Necessaire pour que InitializeComponent() soit appele.
bWantsInitializeComponent = true;
// Le contenu appartient au serveur et descend vers les clients.
SetIsReplicatedByDefault(true);
}
void UInventoryComponent::InitializeComponent()
{
Super::InitializeComponent();
EnsureSlotCount();
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();
if (Index < 0 || Index >= Count || Index == SelectedHotbarIndex)
{
return;
}
SelectedHotbarIndex = Index;
OnSelectedHotbarSlotChanged.Broadcast(SelectedHotbarIndex);
}
void UInventoryComponent::CycleHotbarSelection(int32 Delta)
{
const int32 Count = GetHotbarSlotCount();
if (Count <= 0 || Delta == 0)
{
return;
}
// Modulo positif : en C++, -1 % 6 vaut -1 et non 5. Sans le double
// modulo, reculer depuis le premier slot donnerait un index negatif.
const int32 NewIndex = ((SelectedHotbarIndex + Delta) % Count + Count) % Count;
SelectHotbarSlot(NewIndex);
}
FInventorySlot UInventoryComponent::GetSelectedSlot() const
{
return Slots.IsValidIndex(SelectedHotbarIndex) ? Slots[SelectedHotbarIndex] : FInventorySlot();
}
void UInventoryComponent::EnsureSlotCount()
{
const int32 Target = GetTotalSlotCount();
if (Slots.Num() != Target)
{
Slots.SetNum(Target);
}
}
int32 UInventoryComponent::AddItem(UItemDataAsset* Item, int32 Quantity, int32 RemainingUses)
{
if (!Item || Quantity <= 0)
{
return Quantity;
}
EnsureSlotCount();
const int32 MaxStack = Item->GetEffectiveMaxStack();
const int32 MaxUses = FMath::Max(1, Item->MaxUses);
// -1 = "plein" : un objet neuf, ou un ajout qui ne precise pas d'etat.
const int32 StartingUses = (RemainingUses < 0) ? MaxUses : FMath::Clamp(RemainingUses, 0, MaxUses);
int32 Remaining = Quantity;
// Premiere passe : completer les piles deja entamees. On le fait avant
// d'ouvrir un nouveau slot, sinon on gaspillerait des emplacements en
// laissant des piles a moitie vides un peu partout.
for (FInventorySlot& Slot : Slots)
{
if (Remaining <= 0)
{
break;
}
if (Slot.Item == Item && Slot.Quantity < MaxStack)
{
const int32 Added = FMath::Min(Remaining, MaxStack - Slot.Quantity);
Slot.Quantity += Added;
Remaining -= Added;
}
}
// Deuxieme passe : ouvrir de nouveaux slots. La BARRE RAPIDE est servie en
// premier, pour qu'un objet ramasse soit immediatement utilisable sans
// passer par l'inventaire.
auto FillEmptyRange = [&](int32 First, int32 Last)
{
for (int32 Index = First; Index < Last && Remaining > 0; ++Index)
{
FInventorySlot& Slot = Slots[Index];
if (Slot.IsEmpty())
{
const int32 Added = FMath::Min(Remaining, MaxStack);
Slot.Item = Item;
Slot.Quantity = Added;
Slot.RemainingUses = StartingUses;
Remaining -= Added;
}
}
};
// Inverse ces deux lignes si tu veux un jour servir l'inventaire d'abord.
const int32 BackpackStart = GetBackpackStartIndex();
FillEmptyRange(0, BackpackStart);
// Barre pleine : on deborde dans l'inventaire.
FillEmptyRange(BackpackStart, Slots.Num());
if (Remaining < Quantity)
{
BroadcastChanged();
}
return Remaining;
}
int32 UInventoryComponent::RemoveItem(UItemDataAsset* Item, int32 Quantity)
{
if (!Item || Quantity <= 0)
{
return 0;
}
int32 Remaining = Quantity;
// On vide les plus petites piles d'abord : ca libere des slots au plus
// vite au lieu de laisser trainer plusieurs piles a moitie pleines.
while (Remaining > 0)
{
FInventorySlot* Best = nullptr;
for (FInventorySlot& Slot : Slots)
{
if (Slot.Item == Item && Slot.Quantity > 0)
{
if (!Best || Slot.Quantity < Best->Quantity)
{
Best = &Slot;
}
}
}
if (!Best)
{
break;
}
const int32 Taken = FMath::Min(Remaining, Best->Quantity);
Best->Quantity -= Taken;
Remaining -= Taken;
if (Best->Quantity <= 0)
{
Best->Clear();
}
}
const int32 Removed = Quantity - Remaining;
if (Removed > 0)
{
BroadcastChanged();
}
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;
}
FInventorySlot& Slot = Slots[SlotIndex];
if (Slot.IsEmpty())
{
return false;
}
const int32 MaxUses = FMath::Max(1, Slot.Item->MaxUses);
// Objet a usage unique : un exemplaire disparait, point.
if (MaxUses <= 1)
{
Slot.Quantity -= 1;
if (Slot.Quantity <= 0)
{
Slot.Clear();
}
BroadcastChanged();
return true;
}
Slot.RemainingUses -= 1;
if (Slot.RemainingUses <= 0)
{
Slot.Quantity -= 1;
if (Slot.Quantity <= 0)
{
Slot.Clear();
}
else
{
// Cas theorique : un objet a charges ne s'empile pas. On recharge
// quand meme l'exemplaire suivant, au cas ou une pile serait
// construite autrement un jour.
Slot.RemainingUses = MaxUses;
}
}
BroadcastChanged();
return true;
}
int32 UInventoryComponent::GetItemCount(const UItemDataAsset* Item) const
{
if (!Item)
{
return 0;
}
int32 Total = 0;
for (const FInventorySlot& Slot : Slots)
{
if (Slot.Item == Item)
{
Total += Slot.Quantity;
}
}
return Total;
}
int32 UInventoryComponent::GetRoomFor(const UItemDataAsset* Item) const
{
if (!Item)
{
return 0;
}
const int32 MaxStack = Item->GetEffectiveMaxStack();
int32 Room = 0;
for (const FInventorySlot& Slot : Slots)
{
if (Slot.IsEmpty())
{
Room += MaxStack;
}
else if (Slot.Item == Item)
{
Room += FMath::Max(0, MaxStack - Slot.Quantity);
}
}
return Room;
}
int32 UInventoryComponent::GetUsedSlotCount() const
{
int32 Used = 0;
for (const FInventorySlot& Slot : Slots)
{
if (!Slot.IsEmpty())
{
++Used;
}
}
return Used;
}
int32 UInventoryComponent::FindFirstEmptySlot() const
{
for (int32 Index = 0; Index < Slots.Num(); ++Index)
{
if (Slots[Index].IsEmpty())
{
return Index;
}
}
return INDEX_NONE;
}
bool UInventoryComponent::TakeFromSlot(int32 SlotIndex, int32 Quantity, FInventorySlot& OutTaken)
{
OutTaken.Clear();
if (Quantity <= 0 || !Slots.IsValidIndex(SlotIndex))
{
return false;
}
FInventorySlot& Slot = Slots[SlotIndex];
if (Slot.IsEmpty())
{
return false;
}
const int32 Taken = FMath::Min(Quantity, Slot.Quantity);
OutTaken.Item = Slot.Item;
OutTaken.Quantity = Taken;
// L'etat d'usure part avec l'objet : jeter une canette entamee puis la
// reprendre ne doit pas la remplir.
OutTaken.RemainingUses = Slot.RemainingUses;
Slot.Quantity -= Taken;
if (Slot.Quantity <= 0)
{
Slot.Clear();
}
BroadcastChanged();
return true;
}
bool UInventoryComponent::MoveItemQuantity(int32 FromIndex, int32 ToIndex, int32 Quantity)
{
return TransferSlot(this, FromIndex, this, ToIndex, Quantity);
}
bool UInventoryComponent::MoveItem(int32 FromIndex, int32 ToIndex)
{
// MAX_int32 depasse forcement la pile : TransferSlot bascule alors sur le
// deplacement total, echange compris.
return TransferSlot(this, FromIndex, this, ToIndex, MAX_int32);
}
bool UInventoryComponent::ApplySlotMove(FInventorySlot& From, FInventorySlot& To, int32 Quantity)
{
if (Quantity <= 0 || From.IsEmpty())
{
return false;
}
const int32 MaxStack = From.Item->GetEffectiveMaxStack();
const bool bTotal = (Quantity >= From.Quantity);
if (To.IsEmpty())
{
// Deplacement total : la pile entiere part, quelle que soit sa taille --
// on ne la tronque pas a MaxStack, elle etait deja legale a la source.
if (bTotal)
{
To = From;
From.Clear();
return true;
}
const int32 Transferred = FMath::Min(Quantity, MaxStack);
To.Item = From.Item;
To.Quantity = Transferred;
// L'usure suit l'objet : diviser une pile de canettes entamees donne
// deux piles au meme niveau, jamais une neuve.
To.RemainingUses = From.RemainingUses;
From.Quantity -= Transferred;
return true;
}
// Meme objet : on fusionne dans la limite de la pile. Pour un objet a
// charges, GetEffectiveMaxStack vaut 1, donc on tombe toujours sur
// l'echange -- deux canettes entamees ne fusionnent jamais.
if (To.Item == From.Item)
{
const int32 Wanted = bTotal ? From.Quantity : Quantity;
const int32 Transferable = FMath::Min(Wanted, MaxStack - To.Quantity);
if (Transferable <= 0)
{
// Cible pleine. Sur un deplacement total on echange plutot que de
// ne rien faire, sinon le geste du joueur passerait pour un bug ;
// sur un deplacement partiel on refuse, echanger la moitie d'une
// pile contre autre chose n'ayant aucun sens.
if (!bTotal)
{
return false;
}
Swap(From, To);
return true;
}
To.Quantity += Transferable;
From.Quantity -= Transferable;
if (From.Quantity <= 0)
{
From.Clear();
}
return true;
}
// Objets differents : echange, et seulement sur un deplacement total. Il
// n'existe aucune facon sensee d'echanger la moitie d'une pile contre autre
// chose -- on refuse plutot que d'inventer un comportement incomprehensible.
if (!bTotal)
{
return false;
}
Swap(From, 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)
{
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.
if (From == To && FromIndex == ToIndex)
{
return false;
}
if (!From->Slots.IsValidIndex(FromIndex) || !To->Slots.IsValidIndex(ToIndex))
{
return false;
}
// Deux references dans le meme tableau quand From == To : legal ici parce
// qu'aucun redimensionnement n'a lieu entre les deux acces.
if (!ApplySlotMove(From->Slots[FromIndex], To->Slots[ToIndex], Quantity))
{
return false;
}
From->BroadcastChanged();
if (To != From)
{
To->BroadcastChanged();
}
return true;
}
int32 UInventoryComponent::PushSlotInto(int32 SlotIndex, UInventoryComponent& To)
{
if (!Slots.IsValidIndex(SlotIndex))
{
return 0;
}
FInventorySlot& Slot = Slots[SlotIndex];
if (Slot.IsEmpty())
{
return 0;
}
const int32 Left = To.AddItem(Slot.Item, Slot.Quantity, Slot.RemainingUses);
const int32 Moved = Slot.Quantity - Left;
if (Moved <= 0)
{
return 0;
}
Slot.Quantity = Left;
if (Slot.Quantity <= 0)
{
Slot.Clear();
}
return Moved;
}
int32 UInventoryComponent::QuickTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To)
{
if (!From || !To || From == To)
{
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)
{
From->BroadcastChanged();
}
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)
{
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;
for (int32 Index = FMath::Max(0, FirstIndex); Index < Slots.Num(); ++Index)
{
Moved += PushSlotInto(Index, *To);
}
To->EndBatch();
if (Moved > 0)
{
BroadcastChanged();
}
return Moved;
}
void UInventoryComponent::ConfigureAsContainer(int32 NewSlotCount)
{
// Aucune barre rapide : GetBackpackStartIndex() vaut alors 0, et la grille
// affiche la totalite du conteneur au lieu d'en masquer les dix premieres
// cases.
HotbarSlotCount = 0;
BonusSlotCount = 0;
BackpackSlotCount = FMath::Max(0, NewSlotCount);
SelectedHotbarIndex = 0;
EnsureSlotCount();
}
void UInventoryComponent::BroadcastChanged()
{
if (BatchDepth > 0)
{
bBatchDirty = true;
return;
}
OnInventoryChanged.Broadcast();
}
void UInventoryComponent::BeginBatch()
{
++BatchDepth;
}
void UInventoryComponent::EndBatch()
{
BatchDepth = FMath::Max(0, BatchDepth - 1);
if (BatchDepth > 0 || !bBatchDirty)
{
return;
}
bBatchDirty = false;
OnInventoryChanged.Broadcast();
}
bool UInventoryComponent::SetBonusSlotCount(int32 NewBonusSlots)
{
const int32 NewTotal = FMath::Max(0, HotbarSlotCount + BackpackSlotCount + FMath::Max(0, NewBonusSlots));
// On refuse plutot que de detruire du butin en silence. C'est a l'appelant
// (le code qui deséquipe le sac) de decider quoi faire : bloquer le retrait,
// ou vider le surplus au sol avant de reessayer.
if (GetUsedSlotCount() > NewTotal)
{
return false;
}
BonusSlotCount = FMath::Max(0, NewBonusSlots);
// Compacter avant de tronquer : sans ca, un objet range dans un slot de
// fin serait supprime alors qu'il reste de la place au debut.
CompactSlots();
Slots.SetNum(NewTotal);
BroadcastChanged();
return true;
}
void UInventoryComponent::CompactSlots()
{
const int32 Start = GetBackpackStartIndex();
const int32 Count = Slots.Num() - Start;
if (Count <= 0)
{
return;
}
// On ne compacte QUE la portion sac. Compacter tout le tableau ferait
// remonter des objets du sac dans la barre rapide sans que le joueur
// l'ait demande -- et la troncature ne retire de toute facon que la fin.
TArray<FInventorySlot> Backpack;
Backpack.Append(Slots.GetData() + Start, Count);
Backpack.StableSort([](const FInventorySlot& A, const FInventorySlot& B)
{
return !A.IsEmpty() && B.IsEmpty();
});
for (int32 Index = 0; Index < Count; ++Index)
{
Slots[Start + Index] = Backpack[Index];
}
}
@@ -0,0 +1,304 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "InventoryScreenWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#include "Components/WidgetSwitcher.h"
#include "CraftingWidget.h"
#include "FpsPlayer.h"
#include "GameFramework/PlayerController.h"
#include "InventoryComponent.h"
#include "InventoryDragDropOperation.h"
#include "InventoryWidget.h"
#include "StorageContainer.h"
void UInventoryScreenWidget::NativeConstruct()
{
Super::NativeConstruct();
if (InventoryTabButton)
{
InventoryTabButton->OnClicked.AddDynamic(this, &UInventoryScreenWidget::HandleInventoryTabClicked);
}
if (CraftingTabButton)
{
CraftingTabButton->OnClicked.AddDynamic(this, &UInventoryScreenWidget::HandleCraftingTabClicked);
}
if (StoreAllButton)
{
StoreAllButton->OnClicked.AddDynamic(this, &UInventoryScreenWidget::HandleStoreAllClicked);
}
if (TakeAllButton)
{
TakeAllButton->OnClicked.AddDynamic(this, &UInventoryScreenWidget::HandleTakeAllClicked);
}
// La grille de coffre n'affiche jamais de detail : c'est le meme
// WBP_Inventory que le sac, elle en porte donc un exemplaire qu'on coupe une
// fois pour toutes ici plutot que de compter sur une case a cocher.
if (StorageGrid)
{
StorageGrid->SetItemDetailsEnabled(false);
}
// Le panneau de coffre ne se montre qu'a l'ouverture d'un coffre. On le
// masque en C++ plutot que de dependre de ce qui a ete regle dans la
// Designer : un panneau laisse Visible par erreur afficherait une grille
// vide en permanence a cote du sac.
HideStorage();
ShowInventoryTab();
}
void UInventoryScreenWidget::BindToOwningPawn()
{
if (InventoryPage)
{
InventoryPage->BindToOwningPawn();
}
if (CraftingPage)
{
CraftingPage->BindToOwningPawn();
}
}
bool UInventoryScreenWidget::IsInventoryTabActive() const
{
return TabSwitcher && InventoryPage && TabSwitcher->GetActiveWidget() == InventoryPage;
}
int32 UInventoryScreenWidget::GetHoveredSlotIndex() const
{
if (!InventoryPage || !IsInventoryTabActive())
{
return INDEX_NONE;
}
return InventoryPage->GetHoveredSlotIndex();
}
bool UInventoryScreenWidget::GetHoveredSlot(UInventoryComponent*& OutInventory, int32& OutIndex) const
{
// Le coffre en premier : c'est le panneau le plus a droite, donc celui que
// le curseur survole quand les deux sont affiches.
if (bStorageVisible && StorageGrid && StorageGrid->GetHoveredSlot(OutInventory, OutIndex))
{
return true;
}
if (InventoryPage && IsInventoryTabActive() && InventoryPage->GetHoveredSlot(OutInventory, OutIndex))
{
return true;
}
OutInventory = nullptr;
OutIndex = INDEX_NONE;
return false;
}
void UInventoryScreenWidget::NotifyExternalSlotHovered(UInventoryComponent* Inventory, int32 SlotIndex, bool bHovered)
{
// Rien a faire sous l'onglet Fabrication : le panneau est parti avec la
// page, et la barre rapide y est de toute facon masquee.
if (InventoryPage && IsInventoryTabActive())
{
InventoryPage->NotifyExternalSlotHovered(Inventory, SlotIndex, bHovered);
}
}
UInventoryComponent* UInventoryScreenWidget::GetPlayerInventory() const
{
const APlayerController* OwningController = GetOwningPlayer();
AFpsPlayer* PlayerPawn = OwningController ? Cast<AFpsPlayer>(OwningController->GetPawn()) : nullptr;
return PlayerPawn ? PlayerPawn->GetInventoryComponent() : nullptr;
}
void UInventoryScreenWidget::ShowStorage(AStorageContainer* Container)
{
UInventoryComponent* Contents = Container ? Container->GetStorage() : nullptr;
if (!Contents)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryScreenWidget::ShowStorage : coffre nul ou sans inventaire."));
return;
}
if (!StoragePanel || !StorageGrid)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryScreenWidget : 'StoragePanel' ou 'StorageGrid' manque dans WBP_InventoryScreen, le coffre ne s'affichera pas."));
return;
}
BoundStorage = Contents;
bStorageVisible = true;
StorageGrid->SetColumnCount(Container->GetColumnCount());
StorageGrid->BindToInventory(Contents);
// Le clic droit envoie d'un cote a l'autre. Le pawn a pu changer depuis la
// derniere ouverture (respawn), d'ou la relecture plutot qu'une valeur
// gardee en cache.
StorageGrid->SetQuickTransferTarget(GetPlayerInventory());
if (InventoryPage)
{
InventoryPage->SetQuickTransferTarget(Contents);
// Le detail disparait en mode coffre : l'ecran passe deja a deux grilles
// cote a cote, une troisieme colonne le rendrait illisible.
InventoryPage->SetItemDetailsEnabled(false);
}
if (StorageTitle)
{
StorageTitle->SetText(Container->GetDisplayName());
}
StoragePanel->SetVisibility(ESlateVisibility::Visible);
// Un coffre ouvert sous l'onglet Fabrication n'aurait aucun sens : on force
// l'onglet Inventaire, et ShowPage grise le bouton Fabrication tant que
// bStorageVisible est vrai.
ShowInventoryTab();
}
void UInventoryScreenWidget::HideStorage()
{
BoundStorage.Reset();
bStorageVisible = false;
// On coupe les transferts rapides AVANT de debrancher : une case qui garde
// un coffre en memoire y enverrait encore des objets au prochain clic droit,
// panneau ferme.
if (InventoryPage)
{
InventoryPage->SetQuickTransferTarget(nullptr);
// Le sac retrouve son panneau. Repose a chaque fermeture, y compris a la
// construction : c'est l'etat par defaut de l'ecran, il ne doit dependre
// ni de la Designer ni de ce qu'a laisse la session precedente.
InventoryPage->SetItemDetailsEnabled(true);
}
if (StorageGrid)
{
StorageGrid->SetQuickTransferTarget(nullptr);
StorageGrid->BindToInventory(nullptr);
}
if (StoragePanel)
{
StoragePanel->SetVisibility(ESlateVisibility::Collapsed);
}
// Rend la main a l'onglet Fabrication, que ShowPage avait grise.
if (CraftingTabButton)
{
CraftingTabButton->SetIsEnabled(IsInventoryTabActive());
}
}
void UInventoryScreenWidget::HandleStoreAllClicked()
{
UInventoryComponent* PlayerInventory = GetPlayerInventory();
if (!PlayerInventory || !BoundStorage.IsValid())
{
return;
}
// A partir de la grille seulement : la barre rapide porte les outils qu'on
// vient d'utiliser, et les ranger a chaque retour d'expedition serait une
// punition deguisee en confort.
PlayerInventory->TransferAllTo(BoundStorage.Get(), PlayerInventory->GetBackpackStartIndex());
}
void UInventoryScreenWidget::HandleTakeAllClicked()
{
UInventoryComponent* PlayerInventory = GetPlayerInventory();
if (!PlayerInventory || !BoundStorage.IsValid())
{
return;
}
// Depuis l'index 0 : un coffre n'a pas de barre rapide. Ce qui ne rentre
// pas dans le sac reste dans le coffre.
BoundStorage->TransferAllTo(PlayerInventory, 0);
}
void UInventoryScreenWidget::ShowInventoryTab()
{
ShowPage(InventoryPage);
}
void UInventoryScreenWidget::ShowCraftingTab()
{
ShowPage(CraftingPage);
}
void UInventoryScreenWidget::HandleInventoryTabClicked()
{
ShowPage(InventoryPage);
}
void UInventoryScreenWidget::HandleCraftingTabClicked()
{
ShowPage(CraftingPage);
}
void UInventoryScreenWidget::ShowPage(UWidget* Page)
{
if (!TabSwitcher || !Page)
{
return;
}
TabSwitcher->SetActiveWidget(Page);
// Le bouton de l'onglet courant est desactive : il sert d'indicateur de
// position autant que de garde-fou contre un clic sans effet.
const bool bInventoryActive = (Page == InventoryPage);
if (InventoryTabButton)
{
InventoryTabButton->SetIsEnabled(!bInventoryActive);
}
if (CraftingTabButton)
{
// Grise aussi tant qu'un coffre est ouvert : ses ressources ne comptent
// pas encore dans les recettes, et un clic dont le resultat serait
// incomprehensible vaut mieux etre rendu impossible.
CraftingTabButton->SetIsEnabled(bInventoryActive && !bStorageVisible);
}
OnTabChanged.Broadcast(bInventoryActive);
}
bool UInventoryScreenWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
// On n'arrive ici que si ni une case ni la page d'inventaire n'ont traite le
// depot : les enfants sont consultes en premier et arretent la propagation.
const UInventoryDragDropOperation* Payload = Cast<UInventoryDragDropOperation>(InOperation);
if (!Payload || Payload->SourceSlotIndex == INDEX_NONE)
{
return true;
}
// Depuis l'inventaire SOURCE et non celui du joueur : sortir une pile du
// coffre en la lachant dans le vide doit la retirer du coffre.
if (const APlayerController* OwningController = GetOwningPlayer())
{
if (AFpsPlayer* PlayerPawn = Cast<AFpsPlayer>(OwningController->GetPawn()))
{
PlayerPawn->DropFromInventory(Payload->SourceInventory.Get(), Payload->SourceSlotIndex, Payload->Quantity);
}
}
return true;
}
@@ -0,0 +1,245 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "InventorySlotWidget.h"
#include "Components/Image.h"
#include "Components/ProgressBar.h"
#include "Components/TextBlock.h"
#include "Components/Widget.h"
#include "InventoryDragDropOperation.h"
#include "ItemDataAsset.h"
void UInventorySlotWidget::NativeConstruct()
{
Super::NativeConstruct();
// Force en C++ plutot que de dependre d'un reglage du Blueprint : une case
// non testable a la souris ne recevrait jamais NativeOnMouseButtonDown, et
// le glisser-deposer serait mysterieusement inerte.
// NativeConstruct s'execute APRES ConfigureAsDragVisual() pour un fantome
// (le widget n'est construit qu'une fois ajoute a la couche de drag),
// d'ou le test : sans lui, on remettrait le fantome en Visible et il
// intercepterait le depot destine a la case du dessous.
SetVisibility(bIsDragVisual ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Visible);
}
void UInventorySlotWidget::SetSelected(bool bSelected)
{
if (SelectionBorder)
{
SelectionBorder->SetVisibility(bSelected ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Collapsed);
}
}
void UInventorySlotWidget::ConfigureAsDragVisual()
{
bIsDragVisual = true;
// Le cadre de selection appartient a la barre, pas a l'objet.
if (SelectionBorder)
{
SelectionBorder->SetVisibility(ESlateVisibility::Collapsed);
}
// Le fond de case appartient a la grille, pas a l'objet. Le trainer sous
// le curseur donne l'impression de deplacer la case elle-meme.
if (SlotBackground)
{
SlotBackground->SetVisibility(ESlateVisibility::Collapsed);
}
SetVisibility(ESlateVisibility::HitTestInvisible);
}
void UInventorySlotWidget::InitSlot(UInventoryComponent* InInventory, int32 InSlotIndex)
{
OwningInventory = InInventory;
SlotIndex = InSlotIndex;
}
void UInventorySlotWidget::SetQuickTransferTarget(UInventoryComponent* InTarget)
{
QuickTransferTarget = InTarget;
}
void UInventorySlotWidget::NativeOnMouseEnter(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
Super::NativeOnMouseEnter(InGeometry, InMouseEvent);
OnHoverChanged.Broadcast(SlotIndex, true);
}
void UInventorySlotWidget::NativeOnMouseLeave(const FPointerEvent& InMouseEvent)
{
Super::NativeOnMouseLeave(InMouseEvent);
OnHoverChanged.Broadcast(SlotIndex, false);
}
void UInventorySlotWidget::SetSlot(const FInventorySlot& InSlot)
{
CachedSlot = InSlot;
// Filet de securite : toute mise a jour de contenu remet la case en pleine
// opacite, meme si un glisser s'est termine par un chemin imprevu.
if (!bIsDragVisual)
{
SetRenderOpacity(1.f);
}
const bool bEmpty = InSlot.IsEmpty();
if (ItemIcon)
{
// Hidden et non Collapsed : on veut que la case garde sa taille dans
// la grille meme vide, sinon la mise en page sauterait a chaque
// ramassage.
if (bEmpty || !InSlot.Item->Icon)
{
ItemIcon->SetVisibility(ESlateVisibility::Hidden);
}
else
{
ItemIcon->SetBrushFromTexture(InSlot.Item->Icon, /*bMatchSize=*/false);
ItemIcon->SetVisibility(ESlateVisibility::HitTestInvisible);
}
}
if (QuantityText)
{
// On n'affiche pas "x1" : ca alourdit la grille pour rien.
if (bEmpty || InSlot.Quantity <= 1)
{
QuantityText->SetVisibility(ESlateVisibility::Hidden);
}
else
{
QuantityText->SetText(FText::AsNumber(InSlot.Quantity));
QuantityText->SetVisibility(ESlateVisibility::HitTestInvisible);
}
}
if (UsesBar)
{
// Collapsed et non Hidden : une barre d'usure sur un objet qui n'en a
// pas ne doit meme pas reserver sa place au bas de la case.
const int32 MaxUses = bEmpty ? 1 : FMath::Max(1, InSlot.Item->MaxUses);
if (MaxUses <= 1)
{
UsesBar->SetVisibility(ESlateVisibility::Collapsed);
}
else
{
UsesBar->SetPercent(FMath::Clamp(static_cast<float>(InSlot.RemainingUses) / static_cast<float>(MaxUses), 0.f, 1.f));
UsesBar->SetVisibility(ESlateVisibility::HitTestInvisible);
}
}
}
FReply UInventorySlotWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
// Clic droit : transfert rapide vers l'inventaire d'en face, quand il y en
// a un. Traite avant le glisser pour que le bouton droit ne demarre jamais
// de DetectDrag -- ce qui laisserait une operation orpheline en cours.
if (!CachedSlot.IsEmpty() && InMouseEvent.GetEffectingButton() == EKeys::RightMouseButton)
{
if (QuickTransferTarget.IsValid() && OwningInventory.IsValid() && SlotIndex != INDEX_NONE)
{
UInventoryComponent::QuickTransferSlot(OwningInventory.Get(), SlotIndex, QuickTransferTarget.Get());
return FReply::Handled();
}
}
if (CachedSlot.IsEmpty() || !InMouseEvent.IsMouseButtonDown(EKeys::LeftMouseButton))
{
return Super::NativeOnMouseButtonDown(InGeometry, InMouseEvent);
}
// On ne demarre pas le glisser tout de suite : DetectDrag attend que la
// souris bouge reellement. Sans ca, un simple clic serait interprete comme
// un debut de glisser et on ne pourrait jamais faire autre chose.
return FReply::Handled().DetectDrag(TakeWidget(), EKeys::LeftMouseButton);
}
void UInventorySlotWidget::NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation)
{
Super::NativeOnDragDetected(InGeometry, InMouseEvent, OutOperation);
if (CachedSlot.IsEmpty() || !OwningInventory.IsValid() || SlotIndex == INDEX_NONE)
{
return;
}
// Modificateurs au moment de la saisie : Maj pour la moitie, Ctrl pour un
// seul. Conventions etablies du genre, le joueur les connait deja.
int32 DraggedQuantity = CachedSlot.Quantity;
if (InMouseEvent.IsControlDown())
{
DraggedQuantity = 1;
}
else if (InMouseEvent.IsShiftDown())
{
// Moitie arrondie au superieur : sur une pile de 7 on emporte 4.
// C'est la convention de Minecraft et consorts.
DraggedQuantity = (CachedSlot.Quantity + 1) / 2;
}
UInventoryDragDropOperation* Operation = NewObject<UInventoryDragDropOperation>();
Operation->SourceSlotIndex = SlotIndex;
Operation->Quantity = DraggedQuantity;
Operation->SourceInventory = OwningInventory;
Operation->Pivot = EDragPivot::CenterCenter;
// Le fantome sous le curseur est une copie de cette meme case : aucun
// widget supplementaire a maintenir, et l'apparence reste coherente.
// Il affiche la quantite EMPORTEE, pas la pile complete.
if (UInventorySlotWidget* Visual = CreateWidget<UInventorySlotWidget>(GetOwningPlayer(), GetClass()))
{
FInventorySlot VisualSlot = CachedSlot;
VisualSlot.Quantity = DraggedQuantity;
Visual->SetSlot(VisualSlot);
Visual->ConfigureAsDragVisual();
Visual->SetRenderOpacity(DragVisualOpacity);
Operation->DefaultDragVisual = Visual;
}
// On n'estompe la case source que si la pile part en entier. Lors d'une
// division une partie reste sur place, et la faire palir serait mentir
// sur ce qu'elle contient encore.
if (DraggedQuantity >= CachedSlot.Quantity)
{
SetRenderOpacity(DraggedSlotOpacity);
}
// Les deux issues possibles remettent l'opacite : depot reussi, ou
// abandon hors de la grille.
Operation->OnDrop.AddDynamic(this, &UInventorySlotWidget::HandleDragFinished);
Operation->OnDragCancelled.AddDynamic(this, &UInventorySlotWidget::HandleDragFinished);
OutOperation = Operation;
}
void UInventorySlotWidget::HandleDragFinished(UDragDropOperation* Operation)
{
SetRenderOpacity(1.f);
}
bool UInventorySlotWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
const UInventoryDragDropOperation* Payload = Cast<UInventoryDragDropOperation>(InOperation);
if (!Payload || SlotIndex == INDEX_NONE)
{
return Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
}
// TransferSlot diffuse OnInventoryChanged des DEUX cotes, donc les grilles
// se rafraichissent seules. Elle gere indifferemment le meme inventaire et
// deux inventaires distincts : glisser vers un coffre, vers la barre rapide
// ou vers la case d'a cote emprunte exactement le meme chemin.
return UInventoryComponent::TransferSlot(
Payload->SourceInventory.Get(), Payload->SourceSlotIndex,
OwningInventory.Get(), SlotIndex,
Payload->Quantity);
}
@@ -0,0 +1,374 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "InventoryWidget.h"
#include "Components/UniformGridPanel.h"
#include "Components/UniformGridSlot.h"
#include "FpsPlayer.h"
#include "GameFramework/PlayerController.h"
#include "InventoryComponent.h"
#include "InventoryDragDropOperation.h"
#include "InventorySlotWidget.h"
#include "ItemDetailsWidget.h"
void UInventoryWidget::NativeConstruct()
{
Super::NativeConstruct();
// Une grille de coffre n'a rien a chercher toute seule : son contenu lui
// est donne par l'ecran, apres coup.
if (bAutoBindToPawn)
{
BindToOwningPawn();
}
}
void UInventoryWidget::BindToOwningPawn()
{
UInventoryComponent* NewInventory = ResolveInventory();
if (!NewInventory)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryWidget : aucun UInventoryComponent trouve sur le pawn possede."));
}
BindToInventory(NewInventory);
}
void UInventoryWidget::BindToInventory(UInventoryComponent* InInventory)
{
if (BoundInventory.Get() == InInventory)
{
return;
}
// On annonce la sortie de survol AVANT de changer d'inventaire : le panneau
// de detail a besoin de savoir QUEL affichage devient perime, et une fois
// BoundInventory reaffecte cette information est perdue.
SetHoveredSlot(INDEX_NONE);
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.RemoveDynamic(this, &UInventoryWidget::HandleInventoryChanged);
}
BoundInventory = InInventory;
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.AddDynamic(this, &UInventoryWidget::HandleInventoryChanged);
}
// Les cases gardent l'ancien composant en memoire : on force leur
// recreation en vidant la grille.
ClearSlotWidgets();
Refresh();
}
void UInventoryWidget::NativeDestruct()
{
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.RemoveDynamic(this, &UInventoryWidget::HandleInventoryChanged);
}
BoundInventory.Reset();
Super::NativeDestruct();
}
UInventoryComponent* UInventoryWidget::ResolveInventory() const
{
const APlayerController* OwningController = GetOwningPlayer();
AFpsPlayer* Player = OwningController ? Cast<AFpsPlayer>(OwningController->GetPawn()) : nullptr;
return Player ? Player->GetInventoryComponent() : nullptr;
}
void UInventoryWidget::HandleInventoryChanged()
{
Refresh();
}
void UInventoryWidget::SetQuickTransferTarget(UInventoryComponent* InTarget)
{
QuickTransferTarget = InTarget;
// Les cases deja creees ne reliront rien d'elles-memes : on leur pousse.
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->SetQuickTransferTarget(InTarget);
}
}
}
void UInventoryWidget::SetColumnCount(int32 NewColumnCount)
{
const int32 Clamped = FMath::Max(1, NewColumnCount);
if (ColumnCount == Clamped)
{
return;
}
ColumnCount = Clamped;
// La position d'une case dans le UniformGrid est figee a son ajout : on
// repart des cases plutot que de recalculer chaque UUniformGridSlot. Ca
// n'arrive qu'a l'ouverture d'un coffre, pas a chaque rafraichissement.
ClearSlotWidgets();
Refresh();
}
void UInventoryWidget::NotifySlotHovered(int32 Index, bool bHovered)
{
if (bHovered)
{
SetHoveredSlot(Index);
}
else if (HoveredSlotIndex == Index)
{
// Le test d'egalite evite d'effacer le survol quand les evenements
// arrivent dans l'ordre "entre dans B" puis "sort de A".
SetHoveredSlot(INDEX_NONE);
}
}
void UInventoryWidget::SetHoveredSlot(int32 NewIndex)
{
if (HoveredSlotIndex == NewIndex)
{
return;
}
const int32 PreviousIndex = HoveredSlotIndex;
HoveredSlotIndex = NewIndex;
if (!ItemDetailsPanel || !bItemDetailsEnabled)
{
return;
}
if (HoveredSlotIndex != INDEX_NONE)
{
ItemDetailsPanel->ShowSlot(BoundInventory.Get(), HoveredSlotIndex);
}
else
{
// On efface la case qu'on QUITTE, pas le panneau : passer d'une case de
// la barre rapide a une case de la grille declenche "entre" puis "sort",
// et un effacement inconditionnel viderait ce qui vient d'etre affiche.
ItemDetailsPanel->ClearSlot(BoundInventory.Get(), PreviousIndex);
}
}
void UInventoryWidget::NotifyExternalSlotHovered(UInventoryComponent* InInventory, int32 InSlotIndex, bool bHovered)
{
if (!ItemDetailsPanel || !bItemDetailsEnabled || InSlotIndex == INDEX_NONE)
{
return;
}
if (bHovered)
{
ItemDetailsPanel->ShowSlot(InInventory, InSlotIndex);
}
else
{
ItemDetailsPanel->ClearSlot(InInventory, InSlotIndex);
}
}
void UInventoryWidget::UpdateItemDetails()
{
if (!ItemDetailsPanel)
{
return;
}
if (!bItemDetailsEnabled || HoveredSlotIndex == INDEX_NONE)
{
ItemDetailsPanel->Clear();
}
else
{
ItemDetailsPanel->ShowSlot(BoundInventory.Get(), HoveredSlotIndex);
}
}
void UInventoryWidget::SetItemDetailsEnabled(bool bEnabled)
{
bItemDetailsEnabled = bEnabled;
if (ItemDetailsPanel)
{
// On repose la visibilite a chaque appel, meme quand la valeur ne change
// pas : c'est ce qui evite de dependre de ce qui a ete regle dans la
// Designer, exactement comme le HideStorage() de l'ecran.
// Collapsed et non Hidden -- coupe, le panneau ne doit pas non plus
// reserver sa largeur, sinon la grille trainerait une colonne vide.
ItemDetailsPanel->SetVisibility(bEnabled ? ESlateVisibility::SelfHitTestInvisible : ESlateVisibility::Collapsed);
}
UpdateItemDetails();
}
bool UInventoryWidget::GetHoveredSlot(UInventoryComponent*& OutInventory, int32& OutIndex) const
{
OutInventory = BoundInventory.Get();
OutIndex = HoveredSlotIndex;
return OutInventory != nullptr && OutIndex != INDEX_NONE;
}
bool UInventoryWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
// On n'arrive ici que si AUCUNE case n'a traite le depot : les widgets
// enfants sont consultes en premier et arretent la propagation.
const UInventoryDragDropOperation* Payload = Cast<UInventoryDragDropOperation>(InOperation);
if (!Payload || Payload->SourceSlotIndex == INDEX_NONE)
{
return true;
}
// Lache DANS la grille mais entre deux cases (l'espacement) : on absorbe.
// Jeter un objet au sol parce que le joueur a rate une case de trois
// pixels serait impardonnable dans un jeu de survie.
if (SlotGrid && SlotGrid->GetCachedGeometry().IsUnderLocation(InDragDropEvent.GetScreenSpacePosition()))
{
return true;
}
// Lache hors de la grille : on jette au sol, comme dans Valheim ou Raft.
// Depuis l'inventaire SOURCE et non celui de cette grille : sortir une pile
// du coffre en la lachant dans le vide doit la retirer du coffre.
if (const APlayerController* OwningController = GetOwningPlayer())
{
if (AFpsPlayer* Player = Cast<AFpsPlayer>(OwningController->GetPawn()))
{
Player->DropFromInventory(Payload->SourceInventory.Get(), Payload->SourceSlotIndex, Payload->Quantity);
}
}
return true;
}
void UInventoryWidget::Refresh()
{
if (!SlotGrid)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryWidget::Refresh : SlotGrid est nul. Le widget nomme 'SlotGrid' manque dans le Blueprint."));
return;
}
// Debranchee : la grille se vide au lieu de laisser a l'ecran le contenu
// d'un conteneur qu'on ne pilote plus. Pas un avertissement -- c'est l'etat
// normal d'une grille de coffre entre deux ouvertures.
if (!BoundInventory.IsValid())
{
ClearSlotWidgets();
return;
}
const TArray<FInventorySlot>& Slots = BoundInventory->GetSlots();
// La grille commence APRES la barre rapide : les deux conteneurs sont
// independants, un objet dans la barre n'apparait pas ici. Sur un coffre,
// ConfigureAsContainer() met la barre a zero, donc l'offset vaut 0 et tout
// s'affiche.
const int32 Offset = BoundInventory->GetBackpackStartIndex();
const int32 GridCount = FMath::Max(0, Slots.Num() - Offset);
// Le nombre de slots peut changer en cours de partie (sac equipe).
// On ne reconstruit que si la taille a bouge : recreer toutes les cases
// a chaque ramassage serait du gaspillage pur.
RebuildGrid(GridCount);
for (int32 Index = 0; Index < SlotWidgets.Num(); ++Index)
{
const int32 Absolute = Offset + Index;
if (SlotWidgets[Index] && Slots.IsValidIndex(Absolute))
{
SlotWidgets[Index]->SetSlot(Slots[Absolute]);
}
}
}
void UInventoryWidget::ClearSlotWidgets()
{
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->RemoveFromParent();
}
}
SlotWidgets.Reset();
// La case survolee vient de disparaitre : sans cette remise a zero, la
// touche "jeter" agirait sur un index qui ne designe plus rien -- et le
// panneau de detail continuerait de decrire un objet qui n'est plus la.
// NativeOnMouseLeave n'arrive JAMAIS quand le widget s'efface sous le
// curseur, ce qui est exactement ce qui se passe en fermant un coffre.
SetHoveredSlot(INDEX_NONE);
}
void UInventoryWidget::RebuildGrid(int32 DesiredCount)
{
if (SlotWidgets.Num() == DesiredCount)
{
return;
}
if (!SlotWidgetClass)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryWidget : SlotWidgetClass n'est pas assignee, la grille restera vide."));
return;
}
// Trop de cases : on retire le surplus par la fin.
while (SlotWidgets.Num() > DesiredCount)
{
const int32 Last = SlotWidgets.Num() - 1;
if (SlotWidgets[Last])
{
SlotWidgets[Last]->RemoveFromParent();
}
SlotWidgets.RemoveAt(Last);
}
if (!BoundInventory.IsValid())
{
return;
}
// Pas assez : on en cree, en les placant en ligne / colonne.
const int32 Columns = FMath::Max(1, ColumnCount);
while (SlotWidgets.Num() < DesiredCount)
{
UInventorySlotWidget* NewSlot = CreateWidget<UInventorySlotWidget>(this, SlotWidgetClass);
if (!NewSlot)
{
// Arrive typiquement quand le Widget Blueprint de case ne compile
// plus, apres un changement de BindWidget cote C++ par exemple.
UE_LOG(LogTemp, Error, TEXT("UInventoryWidget : impossible de creer une case a partir de %s. Recompile ce Widget Blueprint."),
*GetNameSafe(SlotWidgetClass));
break;
}
const int32 Index = SlotWidgets.Num();
// On donne l'index ABSOLU dans le tableau, pas la position dans la
// grille. C'est ce qui rend le glisser entre la barre et la grille
// gratuit : les deux manipulent les memes index.
const int32 Absolute = BoundInventory->GetBackpackStartIndex() + Index;
NewSlot->InitSlot(BoundInventory.Get(), Absolute);
NewSlot->OnHoverChanged.AddDynamic(this, &UInventoryWidget::NotifySlotHovered);
NewSlot->SetQuickTransferTarget(QuickTransferTarget.Get());
SlotGrid->AddChildToUniformGrid(NewSlot, Index / Columns, Index % Columns);
SlotWidgets.Add(NewSlot);
}
}
+127
View File
@@ -0,0 +1,127 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "ItemDataAsset.h"
#include "Components/StaticMeshComponent.h"
#if WITH_EDITOR
#include "Misc/DataValidation.h"
#endif
#define LOCTEXT_NAMESPACE "ItemData"
namespace
{
/**
* Pose un mesh et son eventuel materiau de surcharge sur un composant.
*
* Prefixe du fichier oblige : le build unity concatene les .cpp du module,
* un namespace anonyme n'isole rien.
*/
void ItemDataApplyMeshAndMaterial(UStaticMeshComponent* Component, UStaticMesh* Mesh, UMaterialInterface* Material)
{
// OverrideMaterials appartient au COMPOSANT, pas au mesh : il survit donc
// a un changement d'asset. Sans ce vidage, un objet sans surcharge monte
// apres un objet qui en avait une garderait le materiau du precedent --
// le meme piege que les meshes de corps de la customisation. Il ne se
// voit que sur un composant REUTILISE, donc jamais sur un pickup fraichement
// engendre, et systematiquement sur l'objet en main ou sur un grill.
Component->EmptyOverrideMaterials();
Component->SetStaticMesh(Mesh);
// SetMaterial(0, nullptr) ne "retire" pas le materiau, il pose un slot vide
// et le mesh devient gris damier. Sans override, on ne touche a rien et le
// mesh garde le sien.
if (Material)
{
Component->SetMaterial(0, Material);
}
}
}
void UItemDataAsset::ApplyWorldVisualsTo(UStaticMeshComponent* Component) const
{
if (!Component)
{
return;
}
ItemDataApplyMeshAndMaterial(Component, WorldMesh, WorldMeshMaterial);
}
void UItemDataAsset::ApplyHeldVisualsTo(UStaticMeshComponent* Component) const
{
if (!Component)
{
return;
}
// Le materiau de surcharge est celui du monde, sans equivalent "en main" :
// il sert a distinguer un cru d'un cuit, et cette distinction vaut aussi
// dans la main. Un second champ n'aurait fait que se desynchroniser.
ItemDataApplyMeshAndMaterial(Component, GetHeldMesh(), WorldMeshMaterial);
}
#if WITH_EDITOR
EDataValidationResult UItemDataAsset::IsDataValid(FDataValidationContext& Context) const
{
EDataValidationResult Result = Super::IsDataValid(Context);
if (bCanBeCooked && !CookedResult)
{
Context.AddError(LOCTEXT("CookNoResult", "'Can Be Cooked' is checked but no 'Cooked Result' is assigned: this item would sit on the fire forever without ever changing."));
Result = EDataValidationResult::Invalid;
}
if (bCanBurn && !BurntResult)
{
Context.AddError(LOCTEXT("BurnNoResult", "'Can Burn' is checked but no 'Burnt Result' is assigned."));
Result = EDataValidationResult::Invalid;
}
// Une chaine qui reboucle sur elle-meme ne plante pas, elle tourne : la
// station re-arme un timer identique a chaque etape et le joueur voit un
// objet qui ne finit jamais de cuire. Impossible a diagnostiquer en jeu.
if (CookedResult == this)
{
Context.AddError(LOCTEXT("CookSelfResult", "'Cooked Result' points back to this asset: the item would cook into itself, endlessly."));
Result = EDataValidationResult::Invalid;
}
if (BurntResult == this)
{
Context.AddError(LOCTEXT("BurnSelfResult", "'Burnt Result' points back to this asset: the item would burn into itself, endlessly."));
Result = EDataValidationResult::Invalid;
}
// Un objet a charges ne s'empile pas (voir GetEffectiveMaxStack). Le poser
// sur le feu reste possible, mais la charge entamee suit l'exemplaire et le
// resultat en herite : autant que ce soit un choix, pas une surprise.
if (MaxUses > 1 && bCanBeCooked)
{
Context.AddWarning(LOCTEXT("CookableWithCharges", "This item has multiple uses AND can be cooked. The remaining charges follow the item through cooking, which is rarely what you want for food."));
}
// Le symptome serait une main vide sans le moindre message : le composant
// aurait bien monte son mesh, avec un asset nul.
if (bShowInHand && !GetHeldMesh())
{
Context.AddError(LOCTEXT("HeldNoMesh", "'Show In Hand' is checked but neither 'Held Mesh' nor 'World Mesh' is assigned: nothing would appear in the player's hand."));
Result = EDataValidationResult::Invalid;
}
// Un outil invisible se remarque tout de suite en jeu, mais on cherche la
// cause du mauvais cote -- dans le composant, dans le socket, dans le rig --
// avant de penser a une case a cocher.
if (IsTool() && !bShowInHand)
{
Context.AddWarning(LOCTEXT("ToolNotShownInHand", "This item is a tool but 'Show In Hand' is unchecked: the player will harvest with an empty hand."));
}
return Result;
}
#endif
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,181 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "ItemDetailsWidget.h"
#include "Components/Image.h"
#include "Components/ProgressBar.h"
#include "Components/TextBlock.h"
#include "Components/Widget.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
void UItemDetailsWidget::NativeConstruct()
{
Super::NativeConstruct();
// Etat vide des la construction : sans ca le panneau afficherait les valeurs
// placeholder saisies dans la Designer jusqu'au premier survol.
Clear();
}
void UItemDetailsWidget::NativeDestruct()
{
BindInventory(nullptr);
Super::NativeDestruct();
}
void UItemDetailsWidget::BindInventory(UInventoryComponent* InInventory)
{
if (BoundInventory.Get() == InInventory)
{
return;
}
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.RemoveDynamic(this, &UItemDetailsWidget::HandleInventoryChanged);
}
BoundInventory = InInventory;
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.AddDynamic(this, &UItemDetailsWidget::HandleInventoryChanged);
}
}
void UItemDetailsWidget::ShowSlot(UInventoryComponent* InInventory, int32 InSlotIndex)
{
BindInventory(InInventory);
SlotIndex = InSlotIndex;
Refresh();
}
void UItemDetailsWidget::Clear()
{
BindInventory(nullptr);
SlotIndex = INDEX_NONE;
Refresh();
}
void UItemDetailsWidget::ClearSlot(const UInventoryComponent* InInventory, int32 InSlotIndex)
{
if (BoundInventory.Get() == InInventory && SlotIndex == InSlotIndex)
{
Clear();
}
}
void UItemDetailsWidget::HandleInventoryChanged()
{
// La case survolee a change sous le curseur : une charge consommee, une pile
// deplacee. On relit plutot que de garder une copie perimee a l'ecran.
Refresh();
}
void UItemDetailsWidget::Refresh()
{
// Surtout pas "Slot" : UWidget a deja un membre de ce nom, et le masquer est
// une erreur de compilation (C4458) traitee comme telle par le moteur.
const FInventorySlot* HoveredSlot = nullptr;
if (BoundInventory.IsValid())
{
const TArray<FInventorySlot>& Slots = BoundInventory->GetSlots();
if (Slots.IsValidIndex(SlotIndex) && !Slots[SlotIndex].IsEmpty())
{
HoveredSlot = &Slots[SlotIndex];
}
}
if (ContentRoot)
{
// Hidden et non Collapsed : le panneau garde sa taille, donc rien ne
// bouge dans la mise en page quand le curseur quitte une case.
ContentRoot->SetVisibility(HoveredSlot ? ESlateVisibility::SelfHitTestInvisible : ESlateVisibility::Hidden);
}
if (!HoveredSlot)
{
ClearDisplay();
return;
}
const UItemDataAsset* Item = HoveredSlot->Item;
if (ItemIcon)
{
if (Item->Icon)
{
ItemIcon->SetBrushFromTexture(Item->Icon, /*bMatchSize=*/false);
ItemIcon->SetVisibility(ESlateVisibility::HitTestInvisible);
}
else
{
ItemIcon->SetVisibility(ESlateVisibility::Hidden);
}
}
if (ItemNameText)
{
ItemNameText->SetText(Item->DisplayName);
}
if (ItemDescriptionText)
{
ItemDescriptionText->SetText(Item->Description);
}
// --- Usure ---
// Meme regle que sur la case de la grille : rien a montrer sur un objet a
// usage unique, et le bloc se replie plutot que de laisser un vide.
const int32 MaxUses = FMath::Max(1, Item->MaxUses);
const bool bHasUses = MaxUses > 1;
if (UsesPanel)
{
UsesPanel->SetVisibility(bHasUses ? ESlateVisibility::SelfHitTestInvisible : ESlateVisibility::Collapsed);
}
if (bHasUses)
{
if (UsesBar)
{
UsesBar->SetPercent(FMath::Clamp(static_cast<float>(HoveredSlot->RemainingUses) / static_cast<float>(MaxUses), 0.f, 1.f));
}
if (UsesText)
{
UsesText->SetText(FText::Format(INVTEXT("{0} / {1}"),
FText::AsNumber(HoveredSlot->RemainingUses), FText::AsNumber(MaxUses)));
}
}
}
void UItemDetailsWidget::ClearDisplay()
{
if (ItemIcon)
{
// Hidden et non Collapsed : l'icone garde sa place, donc le nom et la
// description ne remontent pas d'un cran au moment de disparaitre.
ItemIcon->SetVisibility(ESlateVisibility::Hidden);
}
if (ItemNameText)
{
ItemNameText->SetText(FText::GetEmpty());
}
if (ItemDescriptionText)
{
ItemDescriptionText->SetText(FText::GetEmpty());
}
if (UsesPanel)
{
UsesPanel->SetVisibility(ESlateVisibility::Collapsed);
}
}
@@ -0,0 +1,173 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "KeyCaptureWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#define LOCTEXT_NAMESPACE "Reglages"
UKeyCaptureWidget::UKeyCaptureWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Sans ce drapeau le widget ne peut pas prendre le focus clavier, et
// NativeOnKeyDown ne serait jamais appele -- le voile resterait affiche sans
// jamais rien capturer. Meme piege que UConfirmDialogWidget.
SetIsFocusable(true);
PromptFormat = LOCTEXT("CapturePrompt", "Appuie sur une touche pour « {0} »\nÉchap pour annuler");
}
void UKeyCaptureWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (ConfirmButton)
{
ConfirmButton->OnClicked.AddDynamic(this, &UKeyCaptureWidget::HandleConfirmClicked);
}
if (CancelButton)
{
CancelButton->OnClicked.AddDynamic(this, &UKeyCaptureWidget::HandleCancelClicked);
}
}
void UKeyCaptureWidget::BeginCapture(const FText& ActionLabel)
{
State = EKeyCaptureState::Capturing;
if (PromptText)
{
PromptText->SetText(FText::Format(PromptFormat, ActionLabel));
}
UpdateButtonVisibility();
SetVisibility(ESlateVisibility::Visible);
// Le focus se reprend a CHAQUE ouverture : le refermer le rend a l'ecran de
// reglages, et un voile visible mais sans focus ignorerait le clavier.
SetKeyboardFocus();
}
void UKeyCaptureWidget::ShowConflict(const FText& Message)
{
State = EKeyCaptureState::Conflict;
if (PromptText)
{
PromptText->SetText(Message);
}
UpdateButtonVisibility();
// Le voile n'a pas bouge, mais le focus a pu partir sur un bouton qui vient
// d'apparaitre : on le reprend pour qu'Echap continue de repondre.
SetKeyboardFocus();
}
void UKeyCaptureWidget::EndCapture()
{
State = EKeyCaptureState::Idle;
UpdateButtonVisibility();
SetVisibility(ESlateVisibility::Collapsed);
}
void UKeyCaptureWidget::UpdateButtonVisibility()
{
// Collapsed et non Hidden : replies, les boutons ne doivent pas reserver la
// place qu'ils occuperont, sinon la consigne de capture serait decentree.
const ESlateVisibility ButtonVisibility = State == EKeyCaptureState::Conflict
? ESlateVisibility::Visible
: ESlateVisibility::Collapsed;
if (ConfirmButton)
{
ConfirmButton->SetVisibility(ButtonVisibility);
}
if (CancelButton)
{
CancelButton->SetVisibility(ButtonVisibility);
}
}
FReply UKeyCaptureWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
const FKey Key = InKeyEvent.GetKey();
if (State == EKeyCaptureState::Conflict)
{
// Echap vaut Annuler, comme partout ailleurs dans les menus.
if (Key == EKeys::Escape)
{
OnConflictAnswered.Broadcast(false);
}
// Aucune autre touche n'est lue : on ne capture plus, on attend une
// reponse. Sans ce garde-fou, appuyer sur une touche pour "repondre"
// relancerait un remappage a l'insu du joueur.
return FReply::Handled();
}
return CaptureKey(Key);
}
FReply UKeyCaptureWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (State == EKeyCaptureState::Conflict)
{
// Les clics sur les boutons ne remontent pas jusqu'ici, ils sont traites
// avant. Tout le reste est avale pour garder le voile modal.
return FReply::Handled();
}
// La souris passe par le meme chemin que le clavier : Souris 4 et Souris 5
// sont des touches comme les autres pour Enhanced Input.
return CaptureKey(InMouseEvent.GetEffectingButton());
}
FReply UKeyCaptureWidget::CaptureKey(const FKey& Key)
{
if (Key == EKeys::Escape)
{
OnCaptureCancelled.Broadcast();
return FReply::Handled();
}
// Les touches de modification seules ne sont pas capturees : Enhanced Input
// les traite comme des touches a part entiere, mais assigner "Maj" tout court
// rendrait inutilisable toute combinaison la contenant.
if (Key.IsModifierKey())
{
return FReply::Handled();
}
// Un axe analogique n'est pas assignable a une action Digital : on ignore
// plutot que de laisser MapPlayerKey echouer plus loin sans explication.
if (Key.IsAxis1D() || Key.IsAxis2D() || Key.IsAxis3D())
{
return FReply::Handled();
}
OnKeyCaptured.Broadcast(Key);
// Handled dans tous les cas : le voile est modal, rien ne doit passer a
// travers pendant qu'il attend une touche.
return FReply::Handled();
}
void UKeyCaptureWidget::HandleConfirmClicked()
{
OnConflictAnswered.Broadcast(true);
}
void UKeyCaptureWidget::HandleCancelClicked()
{
OnConflictAnswered.Broadcast(false);
}
#undef LOCTEXT_NAMESPACE
+171
View File
@@ -0,0 +1,171 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "KeyboardLayout.h"
#if PLATFORM_WINDOWS
// WindowsHWrapper et pas un <Windows.h> nu : il pose lui-meme les gardes
// Pre/PostWindowsApi qui neutralisent les macros et les typedefs de l'API Win32
// entrant en collision avec ceux du moteur (INT, FLOAT, TEXT...).
#include "Windows/WindowsHWrapper.h"
#endif
namespace KeyboardLayout
{
#if PLATFORM_WINDOWS
/**
* Position physique de chaque touche de reference, en scancode PS/2 set 1.
*
* Ces codes decrivent l'EMPLACEMENT sur le clavier, pas le caractere grave
* dessus : c'est ce qui rend la table independante de la disposition. Ils sont
* figes depuis le clavier IBM de 1984 et ne bougeront plus.
*
* Table ecrite a la main plutot que deduite d'un HKL americain charge a la
* volee : LoadKeyboardLayout ajoute la disposition a celles du systeme, et un
* jeu n'a pas a toucher a la configuration clavier de la machine.
*
* Construite au PREMIER APPEL et pas en variable globale : les constantes de
* EKeys sont initialisees par le moteur au demarrage, et une table globale
* pourrait naitre avant elles.
*/
static const TMap<FName, uint32>& GetReferenceScanCodes()
{
static const TMap<FName, uint32> ScanCodes = []
{
TMap<FName, uint32> Map;
Map.Reserve(48);
// Rangee des chiffres
Map.Add(EKeys::Tilde.GetFName(), 0x29);
Map.Add(EKeys::One.GetFName(), 0x02);
Map.Add(EKeys::Two.GetFName(), 0x03);
Map.Add(EKeys::Three.GetFName(), 0x04);
Map.Add(EKeys::Four.GetFName(), 0x05);
Map.Add(EKeys::Five.GetFName(), 0x06);
Map.Add(EKeys::Six.GetFName(), 0x07);
Map.Add(EKeys::Seven.GetFName(), 0x08);
Map.Add(EKeys::Eight.GetFName(), 0x09);
Map.Add(EKeys::Nine.GetFName(), 0x0A);
Map.Add(EKeys::Zero.GetFName(), 0x0B);
Map.Add(EKeys::Hyphen.GetFName(), 0x0C);
Map.Add(EKeys::Equals.GetFName(), 0x0D);
// Rangee du haut
Map.Add(EKeys::Q.GetFName(), 0x10);
Map.Add(EKeys::W.GetFName(), 0x11);
Map.Add(EKeys::E.GetFName(), 0x12);
Map.Add(EKeys::R.GetFName(), 0x13);
Map.Add(EKeys::T.GetFName(), 0x14);
Map.Add(EKeys::Y.GetFName(), 0x15);
Map.Add(EKeys::U.GetFName(), 0x16);
Map.Add(EKeys::I.GetFName(), 0x17);
Map.Add(EKeys::O.GetFName(), 0x18);
Map.Add(EKeys::P.GetFName(), 0x19);
Map.Add(EKeys::LeftBracket.GetFName(), 0x1A);
Map.Add(EKeys::RightBracket.GetFName(), 0x1B);
Map.Add(EKeys::Backslash.GetFName(), 0x2B);
// Rangee de repos
Map.Add(EKeys::A.GetFName(), 0x1E);
Map.Add(EKeys::S.GetFName(), 0x1F);
Map.Add(EKeys::D.GetFName(), 0x20);
Map.Add(EKeys::F.GetFName(), 0x21);
Map.Add(EKeys::G.GetFName(), 0x22);
Map.Add(EKeys::H.GetFName(), 0x23);
Map.Add(EKeys::J.GetFName(), 0x24);
Map.Add(EKeys::K.GetFName(), 0x25);
Map.Add(EKeys::L.GetFName(), 0x26);
Map.Add(EKeys::Semicolon.GetFName(), 0x27);
Map.Add(EKeys::Apostrophe.GetFName(), 0x28);
// Rangee du bas
Map.Add(EKeys::Z.GetFName(), 0x2C);
Map.Add(EKeys::X.GetFName(), 0x2D);
Map.Add(EKeys::C.GetFName(), 0x2E);
Map.Add(EKeys::V.GetFName(), 0x2F);
Map.Add(EKeys::B.GetFName(), 0x30);
Map.Add(EKeys::N.GetFName(), 0x31);
Map.Add(EKeys::M.GetFName(), 0x32);
Map.Add(EKeys::Comma.GetFName(), 0x33);
Map.Add(EKeys::Period.GetFName(), 0x34);
Map.Add(EKeys::Slash.GetFName(), 0x35);
return Map;
}();
return ScanCodes;
}
#endif // PLATFORM_WINDOWS
FKey RemapKeyToActiveLayout(const FKey& ReferenceKey)
{
#if PLATFORM_WINDOWS
const uint32* ScanCode = GetReferenceScanCodes().Find(ReferenceKey.GetFName());
if (!ScanCode)
{
// Touche insensible a la disposition : espace, modificateur, manette.
return ReferenceKey;
}
// GetKeyboardLayout(0) = la disposition du thread appelant, donc celle que
// le joueur a reellement active -- et pas celle par defaut du systeme, qui
// differe des qu'il en a plusieurs et bascule au clavier.
const HKL ActiveLayout = ::GetKeyboardLayout(0);
const UINT VirtualKey = ::MapVirtualKeyExW(*ScanCode, MAPVK_VSC_TO_VK_EX, ActiveLayout);
if (VirtualKey == 0)
{
// La disposition n'a rien a cette position. Rare, mais possible sur les
// dispositions non latines : on garde la touche d'origine.
return ReferenceKey;
}
// On REPRODUIT ici, a l'identique, le couple que le moteur recevra au
// runtime : FWindowsApplication passe le virtual key ET le caractere non
// modifie rendu par MAPVK_VK_TO_CHAR. Deduire le CharCode du virtual key
// serait faux des que la touche ne porte pas le caractere de son code --
// et c'est precisement le cas de la rangee des chiffres en AZERTY, ou
// Windows garde VK_1 pour une touche qui produit "&". Le moteur y repond
// EKeys::Ampersand ; simuler EKeys::One nous ferait conclure qu'il n'y a
// rien a convertir, et les dix touches de la barre resteraient muettes.
//
// Le bit de poids fort signale une touche morte (l'accent circonflexe d'un
// AZERTY, par exemple) : on le masque, seul le caractere nous interesse.
const UINT CharCode = ::MapVirtualKeyExW(VirtualKey, MAPVK_VK_TO_CHAR, ActiveLayout) & 0x7FFFFFFF;
const FKey MappedKey = FInputKeyManager::Get().GetKeyFromCodes(VirtualKey, CharCode);
// Une touche introuvable dans les deux tables est SYNTHETISEE par le moteur,
// et elle sort marquee non bindable -- l'assigner donnerait une action
// definitivement muette. C'est le seul cas ou l'on prefere une touche fausse
// a une touche morte.
if (!MappedKey.IsValid() || !MappedKey.IsBindableToActions())
{
return ReferenceKey;
}
return MappedKey;
#else
return ReferenceKey;
#endif
}
FString GetActiveLayoutId()
{
#if PLATFORM_WINDOWS
// 16 et pas KL_NAMELENGTH : l'API en ecrit 9 au plus, et un buffer en dur
// evite de dependre d'une macro que WIN32_LEAN_AND_MEAN pourrait retirer.
WCHAR LayoutName[16] = {};
if (::GetKeyboardLayoutNameW(LayoutName))
{
return FString(LayoutName);
}
return TEXT("inconnue");
#else
return TEXT("non gere sur cette plateforme");
#endif
}
}
@@ -0,0 +1,23 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "MainMenuGameMode.h"
#include "MainMenuPlayerController.h"
AMainMenuGameMode::AMainMenuGameMode()
{
PlayerControllerClass = AMainMenuPlayerController::StaticClass();
// Aucun corps a piloter dans le menu : le controller pose sa vue sur une
// camera de la map.
DefaultPawnClass = nullptr;
// Va de pair avec DefaultPawnClass nul. Sans ce booleen, le GameMode tente
// quand meme de faire apparaitre un pawn a chaque connexion et remplit le
// log d'avertissements "Couldn't spawn Pawn of type NULL".
bStartPlayersAsSpectators = true;
// Pas de AHUD : tout l'affichage passe par UMG.
HUDClass = nullptr;
}
@@ -0,0 +1,628 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "MainMenuPlayerController.h"
#include "CharacterAppearanceComponent.h"
#include "CharacterCustomizationWidget.h"
#include "CharacterPreviewActor.h"
#include "ConfirmDialogWidget.h"
#include "EngineUtils.h"
#include "Kismet/GameplayStatics.h"
#include "Kismet/KismetSystemLibrary.h"
#include "MainMenuWidget.h"
#include "MenuCameraSpot.h"
#include "ScreenFadeComponent.h"
#include "SessionSubsystem.h"
#include "SettingsMenuWidget.h"
#include "SurvivalGameInstance.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "MainMenu"
namespace
{
// Le menu reste sous le voile de fondu, qui est en 1000.
constexpr int32 ZOrderMainMenu = 0;
// L'ecran de reglages recouvre le menu, la boite de confirmation passe
// devant les deux, et le fondu reste au-dessus de tout a 1000.
constexpr int32 ZOrderMainMenuSettings = 50;
constexpr int32 ZOrderQuitDialog = 100;
}
AMainMenuPlayerController::AMainMenuPlayerController()
{
// Meme composant que dans le jeu : un seul fondu a maintenir.
ScreenFade = CreateDefaultSubobject<UScreenFadeComponent>(TEXT("ScreenFade"));
bShowMouseCursor = true;
QuitDialogMessage = LOCTEXT("QuitConfirm", "Quitter le jeu ?");
}
void AMainMenuPlayerController::BeginPlay()
{
Super::BeginPlay();
// L'ecran part du noir : sans ca on verrait une frame de la map, cadree
// n'importe comment, avant que le fondu ne commence.
ScreenFade->SnapToBlack();
if (AMenuCameraSpot* Spot = PickCameraSpot())
{
// Sans blend : on arrive directement sur l'angle tire au sort.
SetViewTarget(Spot);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : aucune AMenuCameraSpot active dans la map, le menu s'affichera sur la vue par defaut."));
}
// APRES SetViewTarget, jamais avant : les images de chauffe doivent etre
// rendues depuis l'angle qu'on va reellement montrer, sinon elles raffinent
// un decor qu'on ne verra pas. Le voile est deja noir, donc rien ne fuit.
USurvivalGameInstance* SurvivalGameInstance = GetGameInstance<USurvivalGameInstance>();
if (SurvivalGameInstance)
{
SurvivalGameInstance->ResumeWorldRendering();
}
if (MainMenuClass)
{
MainMenu = CreateWidget<UMainMenuWidget>(this, MainMenuClass);
if (MainMenu)
{
MainMenu->AddToViewport(ZOrderMainMenu);
}
}
else
{
UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : MainMenuClass n'est pas assignee, aucun menu ne s'affichera."));
}
// GameAndUI et non UIOnly : UIOnly avale aussi la console et les raccourcis
// du moteur, ce qui rend le debug penible. Il n'y a de toute facon aucun
// input de jeu dans cette map, rien ne peut passer a travers.
FInputModeGameAndUI Mode;
Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
Mode.SetHideCursorDuringCapture(false);
SetInputMode(Mode);
SetShowMouseCursor(true);
ScreenFade->OnFadeFinished.AddDynamic(this, &AMainMenuPlayerController::HandleFadeFinished);
// Meme raison qu'en jeu : on laisse le decor rendre quelques images derriere
// le voile noir avant de l'eclaircir, le temps que Nanite et les textures
// virtuelles se raffinent. Sans ca on voit le terrain passer de sa version
// grossiere a la vraie, et les modeles apparaitre un a un.
if (SurvivalGameInstance)
{
SurvivalGameInstance->BeginSceneWarmup(
FSimpleDelegate::CreateUObject(this, &AMainMenuPlayerController::StartOpeningFade));
}
else
{
StartOpeningFade();
}
}
void AMainMenuPlayerController::StartOpeningFade()
{
ScreenFade->FadeIn(OpeningFadeDuration);
if (USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr)
{
Sessions->OnHostFailed.AddDynamic(this, &AMainMenuPlayerController::HandleHostFailed);
Sessions->OnJoinFailed.AddDynamic(this, &AMainMenuPlayerController::HandleJoinFailed);
Sessions->OnTravelPending.AddDynamic(this, &AMainMenuPlayerController::HandleTravelPending);
// 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());
}
// On est arrive : le filet de secours reseau redevient actif pour la
// prochaine partie. Sans cette remise a zero, un hote qui relance apres
// avoir quitte ne serait plus jamais ramene au menu en cas de coupure.
if (USurvivalGameInstance* SurvivalGameInstance = GetGameInstance<USurvivalGameInstance>())
{
SurvivalGameInstance->ClearReturnToMainMenu();
}
}
AMenuCameraSpot* AMainMenuPlayerController::PickCameraSpot()
{
UWorld* World = GetWorld();
if (!World)
{
return nullptr;
}
USurvivalGameInstance* SurvivalGameInstance = GetGameInstance<USurvivalGameInstance>();
const FName LastUsed = SurvivalGameInstance ? SurvivalGameInstance->GetLastMenuCameraName() : NAME_None;
// TActorIterator plutot que GetAllActorsOfClass : pas de tableau d'AActor*
// intermediaire, pas de cast par element.
TArray<AMenuCameraSpot*> AllSpots;
TArray<AMenuCameraSpot*> Candidates;
for (TActorIterator<AMenuCameraSpot> It(World); It; ++It)
{
AMenuCameraSpot* Spot = *It;
if (!Spot->bEnabled)
{
continue;
}
AllSpots.Add(Spot);
// On ecarte l'angle du dernier passage pour ne pas revoir deux fois de
// suite le meme decor en revenant au menu.
if (Spot->GetFName() != LastUsed)
{
Candidates.Add(Spot);
}
}
// Une seule camera dans la map : on la reprend plutot que de n'afficher
// aucune vue.
if (Candidates.Num() == 0)
{
Candidates = MoveTemp(AllSpots);
}
if (Candidates.Num() == 0)
{
return nullptr;
}
AMenuCameraSpot* Chosen = Candidates[FMath::RandRange(0, Candidates.Num() - 1)];
if (SurvivalGameInstance)
{
SurvivalGameInstance->SetLastMenuCameraName(Chosen->GetFName());
}
return Chosen;
}
void AMainMenuPlayerController::StartGame()
{
// Le fondu est deja lance : on ignore le second clic plutot que d'empiler
// deux chargements.
if (PendingAction != EMenuPendingAction::None)
{
return;
}
// L'ecran de customisation s'intercale ICI, entre le clic et le fondu :
// c'est le choix fait pour ce projet, on ne lance pas une partie sans etre
// passe par son personnage. Sans classe assignee, on retombe sur l'ancien
// comportement -- pratique pour tester le reste sans le traverser.
if (CustomizationMenuClass && !CustomizationMenu)
{
PendingAction = EMenuPendingAction::OpenCustomization;
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::HitTestInvisible);
}
// On fond au noir AVANT de bouger la camera : le mannequin est ailleurs
// dans la meme map, et un travelling jusqu'a lui traverserait le decor.
ScreenFade->FadeOut(CustomizationFadeDuration);
return;
}
if (GameLevel.IsNull())
{
UE_LOG(LogTemp, Error, TEXT("AMainMenuPlayerController : GameLevel n'est pas assignee, impossible de lancer la partie."));
return;
}
PendingAction = EMenuPendingAction::StartGame;
// Le menu reste visible pendant le fondu mais cesse de repondre : cliquer
// Quitter pendant le fondu vers la partie serait un piege.
//
// Le test de visibilite est indispensable : quand on arrive ici depuis
// l'ecran de customisation, le menu a ete Collapsed a son ouverture, et
// HitTestInvisible le RESSUSCITERAIT -- on verrait reapparaitre Jouer,
// Reglages et Quitter par-dessus le personnage qu'on vient de valider.
// HitTestInvisible rend non cliquable, il ne cache pas.
if (MainMenu && MainMenu->GetVisibility() != ESlateVisibility::Collapsed)
{
MainMenu->SetVisibility(ESlateVisibility::HitTestInvisible);
}
ScreenFade->FadeOut(LeaveFadeDuration);
}
// ----------------------------------------------------------------------
// Ecran de customisation
// ----------------------------------------------------------------------
void AMainMenuPlayerController::OpenCustomization()
{
// Cherche au moment de l'ouverture et pas au BeginPlay : c'est le seul
// endroit ou l'on est sur que la map est entierement chargee, et un
// mannequin absent doit rester rattrapable.
if (!CharacterPreview)
{
for (TActorIterator<ACharacterPreviewActor> It(GetWorld()); It; ++It)
{
CharacterPreview = *It;
break;
}
}
if (!CharacterPreview)
{
UE_LOG(LogTemp, Warning,
TEXT("AMainMenuPlayerController : aucun ACharacterPreviewActor dans la map, l'ecran de customisation est saute."));
// On ne bloque pas le joueur pour un acteur manquant : meme logique
// qu'un echec de session, il a clique Jouer, il veut jouer.
CustomizationMenuClass = nullptr;
StartGame();
return;
}
CustomizationMenu = CreateWidget<UCharacterCustomizationWidget>(this, CustomizationMenuClass);
if (!CustomizationMenu)
{
return;
}
// TOUJOURS l'apparence par defaut, jamais celle persistee : cet ecran est
// une etape de CREATION, pas une reprise. Le jour ou un lobby verifiera que
// les quatre joueurs ont valide leur personnage, il lui faudra un point de
// depart identique pour tout le monde.
//
// La persistance ne devient pas inutile pour autant : elle sert au respawn,
// et surtout a qui arrive par invitation Steam -- celui-la ne passe jamais
// par ici et doit bien avoir un visage.
//
// « Par defaut » veut dire le DefaultAppearance du mannequin, pas les zeros
// du C++ : le personnage de depart se regle ainsi dans BP_CharacterPreview,
// a l'oeil et sans recompiler. C'est le meme reglage que celui pose au
// BeginPlay, donc l'ouverture de l'ecran ne change rien a ce qu'on voyait.
const UCharacterAppearanceComponent* PreviewAppearance = CharacterPreview->GetAppearanceComponent();
CustomizationMenu->SetupCustomization(
CharacterPreview,
PreviewAppearance ? PreviewAppearance->DefaultAppearance : FCharacterAppearance());
CustomizationMenu->OnConfirmed.AddDynamic(this, &AMainMenuPlayerController::HandleCustomizationConfirmed);
CustomizationMenu->OnCancelled.AddDynamic(this, &AMainMenuPlayerController::HandleCustomizationCancelled);
// Sans blend : l'ecran est noir a cet instant, personne ne voit la bascule.
// C'est justement ce que le fondu achete -- un travelling depuis l'angle de
// menu jusqu'au mannequin traverserait le decor de la map.
SetViewTarget(CharacterPreview);
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::Collapsed);
}
CustomizationMenu->AddToViewport();
// L'ecran a des boutons ET un clic-glisser : il lui faut la souris, et le
// mode UI-only l'empeche de tomber dans le jeu.
SetInputMode(FInputModeUIOnly().SetWidgetToFocus(CustomizationMenu->TakeWidget()));
SetShowMouseCursor(true);
ScreenFade->FadeIn(CustomizationFadeDuration);
}
void AMainMenuPlayerController::HandleCustomizationConfirmed()
{
// Persiste ET annonce -- mais nous sommes dans le menu, aucun serveur
// n'ecoute encore. Seule la persistance compte ici ; l'annonce partira du
// AnnounceCharacterAppearance() d'AFpsPlayerController, une fois la partie
// chargee et le PlayerState en place.
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetCharacterAppearance(CustomizationMenu->GetAppearance());
Settings->SaveSettings();
}
CloseCustomization();
// La classe est mise de cote le temps du lancement : sans ca, StartGame
// rouvrirait l'ecran qu'on vient de fermer.
CustomizationMenuClass = nullptr;
StartGame();
}
void AMainMenuPlayerController::HandleCustomizationCancelled()
{
// Le fondu de retour est deja lance : on ignore la demande plutot que de le
// relancer depuis le debut. La garde compte plus depuis qu'Echap declenche
// ce chemin -- une touche se repete bien plus facilement qu'un clic.
if (PendingAction != EMenuPendingAction::None)
{
return;
}
// Meme mecanique qu'a l'aller : on fond, PUIS on bascule la vue. Le retour
// se termine dans HandleFadeFinished.
PendingAction = EMenuPendingAction::CloseCustomization;
if (CustomizationMenu)
{
CustomizationMenu->SetVisibility(ESlateVisibility::HitTestInvisible);
}
ScreenFade->FadeOut(CustomizationFadeDuration);
}
void AMainMenuPlayerController::CloseCustomization()
{
if (CustomizationMenu)
{
CustomizationMenu->RemoveFromParent();
CustomizationMenu = nullptr;
}
// Le menu principal reprend la main : son propre mode d'input est pose par
// le Blueprint, on remet simplement le curseur dans un etat coherent.
SetInputMode(FInputModeUIOnly());
SetShowMouseCursor(true);
}
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::HandleTravelPending()
{
USessionSubsystem* Sessions = GetGameInstance() ? GetGameInstance()->GetSubsystem<USessionSubsystem>() : nullptr;
if (!Sessions)
{
return;
}
// Le menu reste lisible pendant le fondu mais cesse de repondre : cliquer
// Jouer alors qu'on part deja rejoindre un ami serait un piege.
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::HitTestInvisible);
}
// Exactement la duree que le sous-systeme attend : fondre plus lentement
// laisserait voyager avant que l'ecran soit noir, plus vite laisserait un
// ecran noir immobile.
ScreenFade->FadeOut(Sessions->GetTravelFadeDuration());
// PendingAction reste a None VOLONTAIREMENT : ce n'est pas nous qui
// chargerons la map, c'est le sous-systeme. HandleFadeFinished doit donc ne
// rien faire quand ce fondu-la se termine.
}
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.
if (PendingAction != EMenuPendingAction::None)
{
return;
}
// Sans boite assignee on ne bloque pas le joueur dans son menu : on ferme.
if (!QuitDialogClass)
{
UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : QuitDialogClass n'est pas assignee, fermeture sans confirmation."));
CloseGame();
return;
}
// Creee au premier clic seulement, puis reutilisee : une boite qu'on ouvre
// et ferme plusieurs fois n'a aucune raison d'etre reconstruite.
if (!QuitDialog)
{
QuitDialog = CreateWidget<UConfirmDialogWidget>(this, QuitDialogClass);
if (!QuitDialog)
{
CloseGame();
return;
}
QuitDialog->OnConfirmed.AddDynamic(this, &AMainMenuPlayerController::HandleQuitConfirmed);
QuitDialog->OnCancelled.AddDynamic(this, &AMainMenuPlayerController::HandleQuitCancelled);
QuitDialog->AddToViewport(ZOrderQuitDialog);
}
QuitDialog->Open(QuitDialogMessage);
// Le menu reste lisible derriere mais cesse de repondre : sans ca, on
// pourrait relancer une partie alors que la question est encore posee.
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::HitTestInvisible);
}
}
void AMainMenuPlayerController::OpenSettings()
{
// Une partie se charge deja : ouvrir un ecran maintenant serait un piege.
if (PendingAction != EMenuPendingAction::None)
{
return;
}
if (!SettingsMenuClass)
{
UE_LOG(LogTemp, Warning, TEXT("AMainMenuPlayerController : SettingsMenuClass n'est pas assignee, les reglages ne s'ouvriront pas."));
return;
}
// Creee a la premiere ouverture seulement, puis reutilisee.
if (!SettingsMenu)
{
SettingsMenu = CreateWidget<USettingsMenuWidget>(this, SettingsMenuClass);
if (!SettingsMenu)
{
return;
}
SettingsMenu->OnCloseRequested.AddDynamic(this, &AMainMenuPlayerController::HandleSettingsCloseRequested);
SettingsMenu->AddToViewport(ZOrderMainMenuSettings);
// Meme ecran qu'en jeu, mais notre pile est plus courte : sa boite se
// pose a 60, devant les reglages et derriere celle du bouton Quitter.
SettingsMenu->SetDialogZOrder(ZOrderMainMenuSettings + 10);
}
SettingsMenu->Open();
// Le menu reste lisible derriere mais cesse de repondre : sans ca on
// pourrait lancer une partie a travers l'ecran de reglages.
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::HitTestInvisible);
}
}
void AMainMenuPlayerController::HandleSettingsCloseRequested()
{
if (SettingsMenu)
{
SettingsMenu->Close();
}
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::Visible);
}
}
void AMainMenuPlayerController::HandleQuitConfirmed()
{
// Pas de fondu : le joueur vient de confirmer, le faire patienter une
// seconde de plus ne serait qu'agacant.
CloseGame();
}
void AMainMenuPlayerController::HandleQuitCancelled()
{
if (QuitDialog)
{
QuitDialog->Close();
}
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::Visible);
}
}
void AMainMenuPlayerController::CloseGame()
{
UKismetSystemLibrary::QuitGame(this, this, EQuitPreference::Quit, /*bIgnorePlatformRestrictions=*/false);
}
void AMainMenuPlayerController::HandleFadeFinished()
{
// L'ecran est noir : c'est ICI qu'on bascule la vue sur le mannequin, et
// pas au clic. PendingAction est remise a None tout de suite, sinon le
// fondu d'eclaircissement qui suit repasserait par la meme branche.
if (PendingAction == EMenuPendingAction::OpenCustomization)
{
PendingAction = EMenuPendingAction::None;
OpenCustomization();
return;
}
if (PendingAction == EMenuPendingAction::CloseCustomization)
{
PendingAction = EMenuPendingAction::None;
CloseCustomization();
if (MainMenu)
{
MainMenu->SetVisibility(ESlateVisibility::Visible);
}
// Un nouvel angle au retour plutot que celui d'avant : PickCameraSpot
// evite deja le dernier utilise, donc le menu ne se rouvre jamais sur
// exactement la meme image.
if (AMenuCameraSpot* Spot = PickCameraSpot())
{
SetViewTarget(Spot);
}
ScreenFade->FadeIn(CustomizationFadeDuration);
return;
}
// 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 cas.
if (PendingAction != EMenuPendingAction::StartGame)
{
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"));
}
}
void AMainMenuPlayerController::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (SettingsMenu)
{
SettingsMenu->RemoveFromParent();
SettingsMenu = nullptr;
}
if (QuitDialog)
{
QuitDialog->RemoveFromParent();
QuitDialog = nullptr;
}
if (MainMenu)
{
MainMenu->RemoveFromParent();
MainMenu = nullptr;
}
Super::EndPlay(EndPlayReason);
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,52 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "MainMenuWidget.h"
#include "Components/Button.h"
#include "MainMenuPlayerController.h"
void UMainMenuWidget::NativeConstruct()
{
Super::NativeConstruct();
if (PlayButton)
{
PlayButton->OnClicked.AddDynamic(this, &UMainMenuWidget::HandlePlayClicked);
}
if (QuitButton)
{
QuitButton->OnClicked.AddDynamic(this, &UMainMenuWidget::HandleQuitClicked);
}
if (SettingsButton)
{
SettingsButton->OnClicked.AddDynamic(this, &UMainMenuWidget::HandleSettingsClicked);
}
}
void UMainMenuWidget::HandlePlayClicked()
{
if (AMainMenuPlayerController* Controller = GetOwningPlayer<AMainMenuPlayerController>())
{
Controller->StartGame();
}
}
void UMainMenuWidget::HandleQuitClicked()
{
if (AMainMenuPlayerController* Controller = GetOwningPlayer<AMainMenuPlayerController>())
{
Controller->QuitGame();
}
}
void UMainMenuWidget::HandleSettingsClicked()
{
if (AMainMenuPlayerController* Controller = GetOwningPlayer<AMainMenuPlayerController>())
{
Controller->OpenSettings();
}
}
@@ -0,0 +1,27 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "MenuCameraSpot.h"
#include "Camera/CameraComponent.h"
AMenuCameraSpot::AMenuCameraSpot()
{
// La camera est fixe et c'est le controller qui lui donne la vue : rien a
// calculer par frame.
PrimaryActorTick.bCanEverTick = false;
// A NE PAS TOUCHER dans le detail panel : "Auto Activate for Player" doit
// rester sur Disabled, sa valeur par defaut. C'est AMainMenuPlayerController
// qui decide quelle camera prend la vue, apres tirage au sort ; une camera
// auto-activee courtcircuiterait le tirage et gagnerait toujours.
// Le champ est prive dans ACameraActor, on ne peut que le laisser tel quel.
if (UCameraComponent* Camera = GetCameraComponent())
{
// ACameraActor contraint le ratio d'image a 16/9 par defaut. Sans ca, le
// menu s'affiche avec des bandes noires des que la fenetre a un autre
// format -- un ultra-large, ou simplement l'apercu PIE redimensionne.
Camera->bConstrainAspectRatio = false;
}
}
@@ -0,0 +1,30 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "MusicDirector.h"
#include "MusicSubsystem.h"
AMusicDirector::AMusicDirector()
{
PrimaryActorTick.bCanEverTick = false;
}
void AMusicDirector::BeginPlay()
{
Super::BeginPlay();
UMusicSubsystem* Music = UMusicSubsystem::Get(this);
if (!Music)
{
return;
}
// La consigne d'abord : elle doit etre posee meme si cette map ne change pas
// de playlist, puisque c'est elle qui vaudra pour toutes les scenes suivantes.
Music->SetLevelChangeMode(LevelChangeMode);
if (Playlist)
{
Music->PlayPlaylist(Playlist);
}
}
+447
View File
@@ -0,0 +1,447 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "MusicSubsystem.h"
#include "Components/AudioComponent.h"
#include "Engine/Engine.h"
#include "Engine/GameInstance.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "MusicPlaylistDataAsset.h"
#include "Sound/SoundBase.h"
#include "Sound/SoundClass.h"
#include "SurvivalAudioSettings.h"
#include "UObject/UObjectGlobals.h"
UMusicSubsystem* UMusicSubsystem::Get(const UObject* WorldContextObject)
{
if (!GEngine || !WorldContextObject)
{
return nullptr;
}
const UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull);
UGameInstance* GameInstance = World ? World->GetGameInstance() : nullptr;
return GameInstance ? GameInstance->GetSubsystem<UMusicSubsystem>() : nullptr;
}
void UMusicSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
// Le seul endroit d'ou l'on voit passer un changement de scene : le lecteur
// survit aux maps, donc aucun BeginPlay ni aucun EndPlay ne peut jouer ce
// role -- et la map d'arrivee n'a justement pas a connaître le sujet.
PostLoadMapHandle = FCoreUObjectDelegates::PostLoadMapWithWorld.AddUObject(this, &UMusicSubsystem::HandlePostLoadMap);
}
void UMusicSubsystem::Deinitialize()
{
if (PostLoadMapHandle.IsValid())
{
FCoreUObjectDelegates::PostLoadMapWithWorld.Remove(PostLoadMapHandle);
PostLoadMapHandle.Reset();
}
CancelPendingGap();
// Fondu a zero : on quitte le jeu, plus personne n'ecoute.
FadeOutAndRelease(MusicComponent, 0.f);
if (FadingOutComponent)
{
FadingOutComponent->Stop();
FadingOutComponent = nullptr;
}
Super::Deinitialize();
}
void UMusicSubsystem::PlayPlaylist(UMusicPlaylistDataAsset* Playlist, bool bForceRestart)
{
if (!Playlist || Playlist->Tracks.IsEmpty())
{
StopMusic();
return;
}
// Deja en cours : on la laisse tranquille. C'est tout l'interet de faire
// pointer la map du menu et celle du jeu vers le meme asset -- le chargement
// devient inaudible.
if (CurrentPlaylist == Playlist && bIsPlaying && !bForceRestart)
{
return;
}
CancelPendingGap();
// L'ancienne musique s'eteint sur sa propre voie pendant que la nouvelle
// entre : un composant audio ne peut pas se croiser avec lui-meme.
FadeOutAndRelease(MusicComponent, CurrentPlaylist ? CurrentPlaylist->FadeOutDuration : Playlist->FadeOutDuration);
CurrentPlaylist = Playlist;
bShuffleEnabled = Playlist->bShuffle;
LastPlayedTrackIndex = INDEX_NONE;
bIsPlaying = true;
BuildPlayOrder();
if (PlayOrder.IsEmpty())
{
// Que des cases vides dans la liste.
bIsPlaying = false;
return;
}
PlayTrackAtCursor(Playlist->FadeInDuration);
}
void UMusicSubsystem::StopMusic(float FadeOutDuration)
{
CancelPendingGap();
const float Duration = (FadeOutDuration >= 0.f)
? FadeOutDuration
: (CurrentPlaylist ? CurrentPlaylist->FadeOutDuration : 0.f);
FadeOutAndRelease(MusicComponent, Duration);
bIsPlaying = false;
CurrentPlaylist = nullptr;
PlayOrder.Reset();
OrderCursor = INDEX_NONE;
OnMusicTrackChanged.Broadcast(nullptr);
}
void UMusicSubsystem::SkipToNextTrack()
{
if (!bIsPlaying || !CurrentPlaylist)
{
return;
}
CancelPendingGap();
// On LIBERE le composant au lieu de le reutiliser. Stop() et FadeOut()
// rediffusent OnAudioFinished, et cette diffusion transite par le thread audio :
// elle peut donc arriver une frame plus tard, soit APRES le demarrage du morceau
// suivant, ce qui en sauterait un second. Un composant neuf, celui d'avant
// detache, rend ce retour tardif sans effet.
const float SkipFade = FMath::Min(CurrentPlaylist->FadeOutDuration, 1.f);
FadeOutAndRelease(MusicComponent, SkipFade);
AdvanceAndPlay(SkipFade);
}
void UMusicSubsystem::SetShuffleEnabled(bool bEnabled)
{
if (bShuffleEnabled == bEnabled)
{
return;
}
bShuffleEnabled = bEnabled;
if (!CurrentPlaylist)
{
return;
}
// Le morceau en cours continue ; seule la SUITE change. On recale le curseur
// dessus pour qu'en mode ordonne la lecture reprenne juste apres lui, et non
// au debut de la liste.
BuildPlayOrder();
const int32 Found = PlayOrder.IndexOfByKey(LastPlayedTrackIndex);
OrderCursor = (Found != INDEX_NONE) ? Found : 0;
}
void UMusicSubsystem::SetMusicVolume(float NewVolume)
{
MusicVolume = FMath::Clamp(NewVolume, 0.f, 1.f);
if (MusicComponent)
{
// SetVolumeMultiplier est independant du fader utilise par FadeIn/FadeOut :
// regler le volume au milieu d'un fondu ne l'interrompt pas.
MusicComponent->SetVolumeMultiplier(GetEffectiveVolume());
}
}
USoundBase* UMusicSubsystem::GetCurrentTrack() const
{
return MusicComponent ? MusicComponent->Sound : nullptr;
}
void UMusicSubsystem::HandleTrackFinished()
{
// N'est atteint que par une fin naturelle : toute coupure manuelle passe par
// FadeOutAndRelease, qui detache le delegue avant d'arreter le son.
if (!bIsPlaying || !CurrentPlaylist)
{
return;
}
const float MinGap = FMath::Max(0.f, CurrentPlaylist->MinGapBetweenTracks);
const float MaxGap = FMath::Max(MinGap, CurrentPlaylist->MaxGapBetweenTracks);
const float Gap = FMath::FRandRange(MinGap, MaxGap);
if (Gap > KINDA_SMALL_NUMBER)
{
ScheduleGap(Gap);
}
else
{
AdvanceAndPlay(0.f);
}
}
void UMusicSubsystem::HandlePostLoadMap(UWorld* LoadedWorld)
{
// En PIE plusieurs instances peuvent charger des maps en parallele : on ne
// reagit qu'a la nôtre.
if (!LoadedWorld || LoadedWorld->GetGameInstance() != GetGameInstance())
{
return;
}
if (LevelChangeMode != EMusicLevelChangeMode::New || !bIsPlaying)
{
return;
}
// Volontairement apres le chargement et non avant : le nouveau morceau entre
// en meme temps que la nouvelle scene s'affiche, pas pendant l'ecran de
// chargement ou personne ne l'associe a quoi que ce soit.
SkipToNextTrack();
}
void UMusicSubsystem::BuildPlayOrder()
{
PlayOrder.Reset();
OrderCursor = 0;
if (!CurrentPlaylist)
{
return;
}
PlayOrder.Reserve(CurrentPlaylist->Tracks.Num());
for (int32 Index = 0; Index < CurrentPlaylist->Tracks.Num(); ++Index)
{
// Filtre en amont : une case vide ne doit jamais devenir un morceau a jouer,
// sinon il faudrait la sauter au moment de la lecture -- et une liste
// entierement vide y bouclerait a l'infini.
if (CurrentPlaylist->Tracks[Index])
{
PlayOrder.Add(Index);
}
}
if (!bShuffleEnabled || PlayOrder.Num() < 2)
{
return;
}
// Fisher-Yates : chaque permutation equiprobable, une seule passe.
for (int32 Index = PlayOrder.Num() - 1; Index > 0; --Index)
{
PlayOrder.Swap(Index, FMath::RandRange(0, Index));
}
// Un nouveau cycle ne redemarre jamais sur le morceau qui vient de finir.
// C'est la difference entre "aleatoire" et "aleatoire agreable a ecouter".
if (PlayOrder[0] == LastPlayedTrackIndex)
{
PlayOrder.Swap(0, FMath::RandRange(1, PlayOrder.Num() - 1));
}
}
void UMusicSubsystem::AdvanceAndPlay(float FadeInDuration)
{
if (!CurrentPlaylist)
{
return;
}
++OrderCursor;
if (!PlayOrder.IsValidIndex(OrderCursor))
{
if (!CurrentPlaylist->bLoop)
{
StopMusic(CurrentPlaylist->FadeOutDuration);
return;
}
BuildPlayOrder();
if (PlayOrder.IsEmpty())
{
StopMusic(0.f);
return;
}
}
PlayTrackAtCursor(FadeInDuration);
}
void UMusicSubsystem::PlayTrackAtCursor(float FadeInDuration)
{
if (!CurrentPlaylist || !PlayOrder.IsValidIndex(OrderCursor))
{
return;
}
const int32 TrackIndex = PlayOrder[OrderCursor];
USoundBase* Track = CurrentPlaylist->Tracks.IsValidIndex(TrackIndex) ? CurrentPlaylist->Tracks[TrackIndex].Get() : nullptr;
if (!Track)
{
// La playlist a ete editee pendant la lecture : on reconstruit plutot que
// de jouer un index perime.
BuildPlayOrder();
return;
}
// Un curseur de volume ne doit RIEN faire d'autre que changer le volume.
//
// Sans PlayWhenSilent, une source devenue inaudible -- curseur Musique a zero --
// est declaree TERMINEE par le moteur : OnAudioFinished part, HandleTrackFinished
// enchaîne, et le silence entre deux morceaux (MinGapBetweenTracks) demarre. Le
// joueur qui remonte son volume n'entend alors plus rien jusqu'a la piste
// suivante, alors qu'il n'a fait que bouger un reglage. Avec ce mode le morceau
// continue d'avancer sans etre rendu, et redevient simplement audible.
//
// Pose ICI et pas sur les assets, pour la meme raison que la SoundClass dans
// CreateMusicComponent : ajouter une piste a une playlist ne doit rien demander
// de plus. Ecrire ce champ ne salit pas l'asset -- rien n'appelle MarkPackageDirty.
Track->VirtualizationMode = EVirtualizationMode::PlayWhenSilent;
if (!MusicComponent)
{
MusicComponent = CreateMusicComponent(Track);
if (!MusicComponent)
{
bIsPlaying = false;
return;
}
}
else
{
MusicComponent->SetSound(Track);
}
MusicComponent->SetVolumeMultiplier(GetEffectiveVolume());
if (FadeInDuration > 0.f)
{
MusicComponent->FadeIn(FadeInDuration, 1.f);
}
else
{
MusicComponent->Play();
}
LastPlayedTrackIndex = TrackIndex;
OnMusicTrackChanged.Broadcast(Track);
}
UAudioComponent* UMusicSubsystem::CreateMusicComponent(USoundBase* Sound)
{
const UGameInstance* GameInstance = GetGameInstance();
UWorld* World = GameInstance ? GameInstance->GetWorld() : nullptr;
if (!World || !Sound)
{
return nullptr;
}
// bPersistAcrossLevelTransition : le composant est cree sans monde ni acteur
// proprietaire et marque bIgnoreForFlushing, le seul moyen de traverser un
// OpenLevel sans que le peripherique audio ne le coupe.
// bAutoDestroy a faux : on veut le reutiliser d'un morceau au suivant, et
// surtout recevoir son OnAudioFinished plutot que de le voir disparaître.
UAudioComponent* Component = UGameplayStatics::CreateSound2D(
World, Sound,
/*VolumeMultiplier*/ 1.f, /*PitchMultiplier*/ 1.f, /*StartTime*/ 0.f,
/*ConcurrencySettings*/ nullptr,
/*bPersistAcrossLevelTransition*/ true,
/*bAutoDestroy*/ false);
if (!Component)
{
return nullptr;
}
// La classe est imposee ICI plutot que sur chaque asset de morceau : ajouter une
// piste a une playlist ne doit pas demander de penser a la taguer, sinon le
// curseur Musique cesse d'agir sur elle sans le moindre signe.
if (USoundClass* MusicClass = USurvivalAudioSettings::Get()->GetMusicSoundClass())
{
Component->SoundClassOverride = MusicClass;
}
Component->bIsMusic = true;
Component->OnAudioFinished.AddDynamic(this, &UMusicSubsystem::HandleTrackFinished);
return Component;
}
void UMusicSubsystem::FadeOutAndRelease(TObjectPtr<UAudioComponent>& Component, float FadeOutDuration)
{
if (!Component)
{
return;
}
// Detacher AVANT d'arreter : Stop() comme FadeOut() finissent par rediffuser
// OnAudioFinished, qui enchainerait sur le morceau suivant alors qu'on cherche
// justement a couper.
Component->OnAudioFinished.RemoveDynamic(this, &UMusicSubsystem::HandleTrackFinished);
// Une seule voie de sortie : si un croisement precedent n'est pas fini, il est
// coupe net. Empiler les fondus ferait monter le nombre de voix pour rien.
if (FadingOutComponent && FadingOutComponent != Component)
{
FadingOutComponent->Stop();
FadingOutComponent = nullptr;
}
if (FadeOutDuration > 0.f && Component->IsPlaying())
{
Component->FadeOut(FadeOutDuration, 0.f);
FadingOutComponent = Component;
}
else
{
Component->Stop();
}
Component = nullptr;
}
void UMusicSubsystem::ScheduleGap(float DelaySeconds)
{
CancelPendingGap();
TWeakObjectPtr<UMusicSubsystem> WeakThis(this);
GapTickerHandle = FTSTicker::GetCoreTicker().AddTicker(TEXT("MusicSubsystem.Gap"), DelaySeconds,
[WeakThis](float /*DeltaTime*/)
{
if (UMusicSubsystem* Self = WeakThis.Get())
{
Self->GapTickerHandle.Reset();
Self->AdvanceAndPlay(0.f);
}
return false; // un seul declenchement
});
}
void UMusicSubsystem::CancelPendingGap()
{
if (GapTickerHandle.IsValid())
{
FTSTicker::RemoveTicker(GapTickerHandle);
GapTickerHandle.Reset();
}
}
float UMusicSubsystem::GetEffectiveVolume() const
{
return MusicVolume * (CurrentPlaylist ? CurrentPlaylist->VolumeMultiplier : 1.f);
}
@@ -0,0 +1,154 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "PauseMenuWidget.h"
#include "Components/Button.h"
#include "FpsPlayerController.h"
UPauseMenuWidget::UPauseMenuWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Sans ce drapeau le widget ne peut pas prendre le focus clavier, et
// NativeOnKeyDown ne serait jamais appele. Il ne se change qu'a la
// construction, d'ou sa presence ici et pas dans Open().
SetIsFocusable(true);
}
void UPauseMenuWidget::NativeConstruct()
{
Super::NativeConstruct();
if (ResumeButton)
{
ResumeButton->OnClicked.AddDynamic(this, &UPauseMenuWidget::HandleResumeClicked);
}
if (QuitToMenuButton)
{
QuitToMenuButton->OnClicked.AddDynamic(this, &UPauseMenuWidget::HandleQuitToMenuClicked);
}
if (SettingsButton)
{
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()
{
if (ResumeButton)
{
ResumeButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleResumeClicked);
}
if (SettingsButton)
{
SettingsButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleSettingsClicked);
}
if (QuitToMenuButton)
{
QuitToMenuButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleQuitToMenuClicked);
}
if (InviteButton)
{
InviteButton->OnClicked.RemoveDynamic(this, &UPauseMenuWidget::HandleInviteClicked);
}
Super::NativeDestruct();
}
void UPauseMenuWidget::Open()
{
SetInteractive(true);
}
void UPauseMenuWidget::Close()
{
SetVisibility(ESlateVisibility::Collapsed);
}
void UPauseMenuWidget::SetInteractive(bool bInteractive)
{
// HitTestInvisible et non Collapsed : le menu reste lisible derriere l'ecran
// qui passe devant, mais on ne peut plus cliquer Reprendre a travers.
SetVisibility(bInteractive ? ESlateVisibility::Visible : ESlateVisibility::HitTestInvisible);
if (bInteractive)
{
// Le focus doit etre repris a CHAQUE fois que le menu redevient la couche
// active, pas seulement a sa premiere ouverture : l'ecran de reglages et
// la boite de confirmation le prennent pour eux et ne le rendent pas.
SetKeyboardFocus();
}
}
FReply UPauseMenuWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
const FKey Key = InKeyEvent.GetKey();
// GetVirtualKey() et non la constante, depreciee en 5.8 : la resolution se
// fait a l'appel, donc selon la manette reellement branchee.
if (Key == EKeys::Escape || Key == EKeys::Virtual_Gamepad_Back.GetVirtualKey())
{
if (AFpsPlayerController* Controller = GetOwningPlayer<AFpsPlayerController>())
{
// HandleEscapeInput et non ResumeGame : c'est le controller qui tient
// la priorite entre boite, reglages, coffre, inventaire et pause. Un
// second chemin qui refermerait directement le menu laisserait
// l'ecran pose par-dessus affiche dans le vide.
Controller->HandleEscapeInput();
// Handled : sans ca la touche continue de remonter et serait
// consommee une seconde fois par ce qui se trouve derriere.
return FReply::Handled();
}
}
return Super::NativeOnKeyDown(InGeometry, InKeyEvent);
}
void UPauseMenuWidget::HandleSettingsClicked()
{
if (AFpsPlayerController* Controller = GetOwningPlayer<AFpsPlayerController>())
{
Controller->OpenSettings();
}
}
void UPauseMenuWidget::HandleResumeClicked()
{
if (AFpsPlayerController* Controller = GetOwningPlayer<AFpsPlayerController>())
{
Controller->ResumeGame();
}
}
void UPauseMenuWidget::HandleQuitToMenuClicked()
{
if (AFpsPlayerController* Controller = GetOwningPlayer<AFpsPlayerController>())
{
Controller->RequestQuitToMainMenu();
}
}
void UPauseMenuWidget::HandleInviteClicked()
{
if (AFpsPlayerController* Controller = GetOwningPlayer<AFpsPlayerController>())
{
Controller->ShowInviteFriends();
}
}
+212
View File
@@ -0,0 +1,212 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "PickupItem.h"
#include "Components/BoxComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Net/UnrealNetwork.h"
#define LOCTEXT_NAMESPACE "PickupItem"
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 boite qui porte la collision.
Mesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// Le trace d'interaction passe par le canal Visibility. On ne bloque QUE
// ce canal : le pickup reste traversable par le joueur et les projectiles.
InteractionBox = CreateDefaultSubobject<UBoxComponent>(TEXT("InteractionBox"));
InteractionBox->SetupAttachment(Mesh);
// Valeur de repli seulement : FitInteractionBox l'ecrase des qu'un mesh est
// pose. Elle ne sert qu'aux pickups sans ItemData, et au cas ou
// bAutoFitInteractionBox serait decoche sans qu'on regle la boite.
InteractionBox->SetBoxExtent(FVector(25.f));
InteractionBox->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
InteractionBox->SetCollisionResponseToAllChannels(ECR_Ignore);
InteractionBox->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;
Quantity = FMath::Max(1, InQuantity);
RemainingUses = InRemainingUses;
}
void APickupItem::ApplyItemVisuals()
{
if (bUseMeshFromItemData && ItemData && ItemData->WorldMesh)
{
ItemData->ApplyWorldVisualsTo(Mesh);
}
// Hors du if, et apres lui : la boite doit suivre le mesh REELLEMENT pose,
// que celui-ci vienne de l'ItemData ou qu'il ait ete impose a la main.
if (bAutoFitInteractionBox)
{
FitInteractionBox();
}
}
void APickupItem::FitInteractionBox()
{
const UStaticMesh* CurrentMesh = Mesh->GetStaticMesh();
if (!CurrentMesh)
{
// Pickup non configure, ou client dont l'ItemData n'est pas encore arrive :
// on garde la boite du Blueprint plutot que de l'ecraser par un volume nul.
// OnRep_ItemData repassera ici quand la donnee sera la.
return;
}
// La bounding box de l'ASSET, jamais Mesh->Bounds : ces dernieres sont en
// espace monde et deja multipliees par l'echelle de l'acteur. Or SetBoxExtent
// recoit une extension LOCALE que le moteur remultiplie a son tour -- une
// instance posee a l'echelle 2 aurait une boite quatre fois trop grande.
const FBox LocalBounds = CurrentMesh->GetBoundingBox();
// Le pivot d'un mesh d'asset pack tombe rarement au centre de sa geometrie :
// il est presque toujours au sol, parfois franchement decale. Une boite
// laissee a l'origine deborderait d'un cote et laisserait l'autre invisable.
InteractionBox->SetRelativeLocation(LocalBounds.GetCenter());
// ComponentMax et non un Max sur la norme : le plancher s'applique axe par
// axe, sinon relever l'epaisseur d'une branche rallongerait aussi la boite
// dans le sens ou elle etait deja bien assez grande.
const FVector Fitted = LocalBounds.GetExtent() + FVector(InteractionBoxPadding);
// bUpdateOverlaps a false : cette boite ne bloque que le canal Visibility et
// ne genere aucun evenement d'overlap. Le defaut (true) declencherait une
// requete physique par pickup a chaque spawn, pour rien.
InteractionBox->SetBoxExtent(
Fitted.ComponentMax(FVector(MinInteractionExtent)),
/*bUpdateOverlaps=*/ false);
}
void APickupItem::OnRep_ItemData()
{
ApplyItemVisuals();
}
void APickupItem::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(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.
//
// 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
{
// Interactor ne sert pas ici : ramasser dit la meme chose a tout le monde.
if (!ItemData)
{
return LOCTEXT("PickupUnconfigured", "Unconfigured item");
}
if (Quantity > 1)
{
return FText::Format(LOCTEXT("PickupPromptStack", "Pick up {0} x{1}"),
ItemData->DisplayName, FText::AsNumber(Quantity));
}
return FText::Format(LOCTEXT("PickupPrompt", "Pick up {0}"), ItemData->DisplayName);
}
bool APickupItem::CanInteract_Implementation(AActor* Interactor) const
{
// Un pickup sans donnee est une erreur de configuration : on le rend
// inerte plutot que de laisser le joueur ramasser du vide.
return ItemData != nullptr;
}
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)
{
UE_LOG(LogTemp, Warning, TEXT("APickupItem : %s n'a pas d'UInventoryComponent."), *GetNameSafe(Interactor));
return;
}
const int32 Leftover = Inventory->AddItem(ItemData, Quantity, RemainingUses);
// Inventaire plein : on ne detruit surtout pas le tas, on retire seulement
// ce qui est rentre. Le joueur peut revenir chercher le reste.
if (Leftover >= Quantity)
{
return;
}
// 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)
{
Quantity = Leftover;
return;
}
Destroy();
}
void APickupItem::OnBeginFocus_Implementation()
{
if (bHighlightOnFocus)
{
Mesh->SetRenderCustomDepth(true);
}
}
void APickupItem::OnEndFocus_Implementation()
{
if (bHighlightOnFocus)
{
Mesh->SetRenderCustomDepth(false);
}
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,93 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "PlayerBodyAnimInstance.h"
#include "FpsPlayer.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "KismetAnimationLibrary.h"
void UPlayerBodyAnimInstance::NativeInitializeAnimation()
{
Super::NativeInitializeAnimation();
OwningPlayer = Cast<AFpsPlayer>(TryGetPawnOwner());
}
void UPlayerBodyAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
// Chez un CLIENT, le mesh arrive par replication et peut commencer a
// s'animer avant que le pawn soit completement initialise : le Cast du
// NativeInitialize rend alors nullptr, une fois pour toutes, et le corps de
// l'ami reste fige en pose de reference pour le reste de la partie. On
// reessaie donc tant qu'on n'a rien -- le cout est un Cast par frame
// uniquement pendant les quelques frames ou le pawn manque encore.
if (!OwningPlayer.IsValid())
{
OwningPlayer = Cast<AFpsPlayer>(TryGetPawnOwner());
if (!OwningPlayer.IsValid())
{
return;
}
}
const AFpsPlayer* BodyOwner = OwningPlayer.Get();
const UCharacterMovementComponent* Movement = BodyOwner->GetCharacterMovement();
if (!Movement)
{
return;
}
// Composante horizontale seule : en pleine chute la vitesse verticale
// depasse largement la vitesse de course, et un BlendSpace indexe dessus
// jouerait un sprint en l'air.
const FVector Velocity = BodyOwner->GetVelocity();
const FVector HorizontalVelocity(Velocity.X, Velocity.Y, 0.f);
GroundSpeed = HorizontalVelocity.Size();
// L'acceleration, et pas seulement la vitesse : elle tombe a zero des que la
// touche est relachee, alors que la vitesse decroit encore pendant tout le
// freinage. Sans elle le cycle de marche continuerait de tourner sur un
// personnage qui glisse jusqu'a l'arret.
bIsMoving = GroundSpeed > MovingSpeedThreshold
&& !Movement->GetCurrentAcceleration().IsNearlyZero();
bIsFalling = Movement->IsFalling();
// Prise telle quelle, sans seuil : c'est le Blueprint qui decide ou placer la
// bascule saut / chute. Une valeur brute reste une valeur brute -- lui donner
// un seuil ici, ce serait decider a la place de la machine a etats, et il
// faudrait recompiler pour le regler.
VerticalSpeed = Velocity.Z;
// Les deux etats du pawn, repliques chacun de leur cote : bIsCrouched par le
// moteur, bIsSprinting par AFpsPlayer. On les lit ici plutot que de deviner
// l'accroupissement a la hauteur de capsule, qui est en pleine interpolation
// pendant toute la transition.
bIsCrouching = BodyOwner->bIsCrouched;
bIsSprinting = BodyOwner->IsSprinting();
// Direction relative au CORPS et non a la vitesse du monde : c'est ce que
// demande un BlendSpace directionnel. GetActorRotation() suffit comme
// reference parce que bUseControllerRotationYaw garde le corps aligne sur le
// regard -- sur un jeu a la 3e personne libre, il faudrait la rotation de
// controle a la place.
//
// UKismetAnimationLibrary et pas UAnimInstance::CalculateDirection, depreciee
// en 5.8 : le calcul est le meme, mais l'ancienne cassera la compilation a la
// prochaine version du moteur.
LocomotionDirection = UKismetAnimationLibrary::CalculateDirection(HorizontalVelocity, BodyOwner->GetActorRotation());
// GetBaseAimRotation et pas GetControlRotation : le second n'existe que sur
// la machine qui possede le pawn, le premier retombe sur RemoteViewPitch
// pour les pawns des autres joueurs. NormalizeAxis ramene les 350 degres
// d'un regard vers le bas a -10, seule forme exploitable par un AimOffset.
AimPitch = FRotator::NormalizeAxis(BodyOwner->GetBaseAimRotation().Pitch);
LocomotionPlayRate = FMath::Clamp(
GroundSpeed / ReferenceLocomotionSpeed,
MinLocomotionPlayRate,
MaxLocomotionPlayRate);
}
@@ -0,0 +1,158 @@
// 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"
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 decidera. Sans ca, une etiquette
// sans nom clignote pendant la frame qui precede l'arrivee du PlayerState.
SetVisibility(false);
PrimaryComponentTick.bCanEverTick = true;
}
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)
{
SetComponentTickEnabled(false);
return;
}
const APawn* OwnerPawn = Cast<APawn>(GetOwner());
// Son propre pseudo : on eteint definitivement, tick compris. 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);
SetComponentTickEnabled(false);
}
}
APlayerState* UPlayerNameplateComponent::GetOwnerPlayerState() const
{
const APawn* OwnerPawn = Cast<APawn>(GetOwner());
return OwnerPawn ? OwnerPawn->GetPlayerState() : nullptr;
}
void UPlayerNameplateComponent::RefreshName()
{
// Le PlayerState arrive APRES le pawn chez un client : la premiere passe
// trouve donc un nom vide, et il faut continuer a regarder.
const APlayerState* State = GetOwnerPlayerState();
if (!State)
{
return;
}
const FString CurrentName = State->GetPlayerName();
if (CurrentName.IsEmpty() || CurrentName == LastShownName)
{
return;
}
if (UPlayerNameplateWidget* Nameplate = Cast<UPlayerNameplateWidget>(GetUserWidgetObject()))
{
Nameplate->SetPlayerName(FText::FromString(CurrentName));
LastShownName = CurrentName;
}
}
void UPlayerNameplateComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// 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 Distance = FVector::Dist(
Viewer->PlayerCameraManager->GetCameraLocation(),
GetComponentLocation());
// Au-dela de la portee on eteint le composant lui-meme, pas seulement son
// opacite : un widget transparent continue d'etre projete et dessine.
if (Distance > MaxDrawDistance)
{
SetVisibility(false);
return;
}
RefreshName();
// Sans nom a montrer, rien a montrer du tout.
if (LastShownName.IsEmpty())
{
SetVisibility(false);
return;
}
UPlayerNameplateWidget* Nameplate = Cast<UPlayerNameplateWidget>(GetUserWidgetObject());
if (!Nameplate)
{
SetVisibility(false);
return;
}
// Echelle en 1/d : c'est la loi de la perspective, et c'est ce que l'oeil
// attend. A ReferenceDistance l'etiquette vaut exactement ce qui est dessine
// dans le WBP ; deux fois plus pres elle double, deux fois plus loin elle
// est de moitie -- borne des deux cotes.
const float SafeDistance = FMath::Max(Distance, 1.f);
const float Scale = FMath::Clamp(ReferenceDistance / SafeDistance, MinScale, MaxScale);
Nameplate->SetRenderScale(FVector2D(Scale, Scale));
// Fondu sur la derniere bande avant la portee maximale. FadeLength est
// borne a MaxDrawDistance : un fondu plus long que la portee elle-meme
// donnerait une etiquette jamais totalement opaque.
const float EffectiveFade = FMath::Clamp(FadeLength, 0.f, MaxDrawDistance);
float Opacity = 1.f;
if (EffectiveFade > KINDA_SMALL_NUMBER)
{
const float FadeStart = MaxDrawDistance - EffectiveFade;
Opacity = FMath::Clamp(1.f - (Distance - FadeStart) / EffectiveFade, 0.f, 1.f);
}
Nameplate->SetRenderOpacity(Opacity);
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,200 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "ScreenFadeComponent.h"
#include "Blueprint/UserWidget.h"
#include "Camera/PlayerCameraManager.h"
#include "GameFramework/PlayerController.h"
namespace
{
// Le voile doit passer devant absolument tout, y compris les widgets qu'on
// ajouterait plus tard sans y penser.
constexpr int32 ZOrderFade = 1000;
}
UScreenFadeComponent::UScreenFadeComponent()
{
// Le composant ne tourne QUE pendant un fondu : on allume le tick dans
// FadeIn/FadeOut et on l'eteint des la cible atteinte. Ticker en permanence
// pour une valeur qui ne bouge qu'une seconde par mort serait du gaspillage.
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = false;
// Le fondu doit continuer d'avancer quand le monde est fige : c'est le seul
// moyen de partir au noir depuis le menu de pause. Sans ce drapeau, l'ecran
// resterait bloque a mi-chemin et le delegue ne se declencherait jamais.
PrimaryComponentTick.bTickEvenWhenPaused = true;
}
void UScreenFadeComponent::BeginPlay()
{
Super::BeginPlay();
// Cree des maintenant pour que le voile puisse couvrir des la premiere
// frame, avant meme que le reste du HUD existe.
EnsureWidget();
}
APlayerController* UScreenFadeComponent::GetOwningController() const
{
return Cast<APlayerController>(GetOwner());
}
void UScreenFadeComponent::EnsureWidget()
{
if (FadeWidget)
{
return;
}
APlayerController* Controller = GetOwningController();
if (!Controller)
{
UE_LOG(LogTemp, Error, TEXT("UScreenFadeComponent : doit etre pose sur un PlayerController, pas sur %s."),
*GetNameSafe(GetOwner()));
return;
}
// Un widget n'existe que sur la machine du joueur local : inutile et
// incorrect d'en creer un sur un serveur dedie.
// ATTENTION : chez un client, ce test est FAUX au BeginPlay du controller --
// son ULocalPlayer ne lui est attache qu'ensuite, dans SetPlayer(). Un
// SnapToBlack() fait trop tot noircit donc un voile inexistant, en silence.
if (!Controller->IsLocalController())
{
return;
}
if (!FadeWidgetClass)
{
UE_LOG(LogTemp, Warning, TEXT("UScreenFadeComponent : FadeWidgetClass n'est pas assignee, aucun fondu ne s'affichera."));
return;
}
FadeWidget = CreateWidget<UUserWidget>(Controller, FadeWidgetClass);
if (FadeWidget)
{
FadeWidget->AddToViewport(ZOrderFade);
SetCoverage(1.f - Alpha);
}
}
void UScreenFadeComponent::SetCoverage(float Coverage)
{
if (!FadeWidget)
{
return;
}
const float Clamped = FMath::Clamp(Coverage, 0.f, 1.f);
FadeWidget->SetRenderOpacity(Clamped);
// HitTestInvisible et jamais Visible : un voile qui capture la souris
// bloquerait les boutons du menu meme completement transparent.
FadeWidget->SetVisibility(FMath::IsNearlyZero(Clamped)
? ESlateVisibility::Collapsed
: ESlateVisibility::HitTestInvisible);
}
void UScreenFadeComponent::FadeIn(float Duration)
{
EnsureWidget();
if (APlayerController* Controller = GetOwningController())
{
if (Controller->PlayerCameraManager)
{
Controller->PlayerCameraManager->StartCameraFade(
/*FromAlpha=*/1.f, /*ToAlpha=*/0.f,
Duration, FadeColor,
/*bShouldFadeAudio=*/true);
}
}
CurrentDuration = FMath::Max(Duration, 0.01f);
Alpha = 0.f;
Target = 1.f;
bFading = true;
SetCoverage(1.f);
SetComponentTickEnabled(true);
}
void UScreenFadeComponent::FadeOut(float Duration)
{
EnsureWidget();
if (APlayerController* Controller = GetOwningController())
{
if (Controller->PlayerCameraManager)
{
// bHoldWhenFinished : l'ecran reste noir au lieu de revenir tout seul.
Controller->PlayerCameraManager->StartCameraFade(
/*FromAlpha=*/0.f, /*ToAlpha=*/1.f,
Duration, FadeColor,
/*bShouldFadeAudio=*/true, /*bHoldWhenFinished=*/true);
}
}
CurrentDuration = FMath::Max(Duration, 0.01f);
Target = 0.f;
bFading = true;
SetComponentTickEnabled(true);
}
void UScreenFadeComponent::SnapToBlack()
{
EnsureWidget();
if (APlayerController* Controller = GetOwningController())
{
if (Controller->PlayerCameraManager)
{
Controller->PlayerCameraManager->SetManualCameraFade(1.f, FadeColor, /*bFadeAudio=*/true);
}
}
Alpha = 0.f;
Target = 0.f;
bFading = false;
SetCoverage(1.f);
SetComponentTickEnabled(false);
}
void UScreenFadeComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!bFading)
{
return;
}
const float Step = DeltaTime / CurrentDuration;
Alpha = FMath::Clamp(Alpha + Step * FMath::Sign(Target - Alpha), 0.f, 1.f);
// Alpha va de 0 (ecran couvert) a 1 (jeu visible) : le voile suit l'inverse.
SetCoverage(1.f - Alpha);
if (FMath::IsNearlyEqual(Alpha, Target))
{
bFading = false;
// On eteint le tick AVANT de prevenir : un abonne qui relance un fondu
// dans son handler doit pouvoir le rallumer.
SetComponentTickEnabled(false);
OnFadeFinished.Broadcast();
}
}
void UScreenFadeComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (FadeWidget)
{
FadeWidget->RemoveFromParent();
FadeWidget = nullptr;
}
Super::EndPlay(EndPlayReason);
}
@@ -0,0 +1,478 @@
// 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;
}
// On ne voyage PAS tout de suite : on annonce, on laisse le controller
// courant fondre au noir, puis on part. Sans ce delai, accepter une
// invitation depuis le menu coupe l'image d'un coup -- le seul endroit du
// jeu ou une transition n'etait pas fondue.
PendingTravelURL = ConnectString;
OnTravelPending.Broadcast();
UGameInstance* OwningGameInstance = GetGameInstance();
if (TravelFadeDuration <= 0.f || !OwningGameInstance)
{
CommitPendingTravel();
return;
}
// Le TimerManager du GameInstance et non celui du monde : le monde courant
// est justement celui qu'on s'apprete a quitter.
OwningGameInstance->GetTimerManager().SetTimer(
TravelTimer, this, &USessionSubsystem::CommitPendingTravel,
TravelFadeDuration, /*bLoop=*/false);
}
void USessionSubsystem::CommitPendingTravel()
{
if (PendingTravelURL.IsEmpty())
{
return;
}
const FString TravelURL = MoveTemp(PendingTravelURL);
PendingTravelURL.Reset();
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]."), *TravelURL);
Controller->ClientTravel(TravelURL, 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
@@ -0,0 +1,62 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingKeyRowWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#define LOCTEXT_NAMESPACE "Reglages"
void USettingKeyRowWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
// Comme USettingRowWidget : l'onglet appelle Setup depuis SON NativeConstruct,
// et rien ne garantit l'ordre entre le construct du parent et celui de ses
// enfants. Initialize, lui, tourne une fois a la creation de l'arbre.
if (KeyButton)
{
KeyButton->OnClicked.AddDynamic(this, &USettingKeyRowWidget::HandleKeyButtonClicked);
}
}
void USettingKeyRowWidget::Setup(FName InMappingName, const FText& Label, const FKey& Key)
{
MappingName = InMappingName;
if (LabelText)
{
LabelText->SetText(Label);
}
SetKey(Key);
}
void USettingKeyRowWidget::SetKey(const FKey& Key)
{
if (!KeyText)
{
return;
}
// Une touche invalide n'est pas une erreur : c'est l'etat normal d'une action
// dont on vient de voler la touche. Le rouge est le signal qu'il reste
// quelque chose a reparer avant de quitter l'ecran.
const bool bBound = Key.IsValid();
KeyText->SetText(bBound
? Key.GetDisplayName()
: LOCTEXT("ToucheNonAssignee", "Non assignée"));
KeyText->SetColorAndOpacity(FSlateColor(bBound ? BoundColor : UnboundColor));
}
void USettingKeyRowWidget::HandleKeyButtonClicked()
{
// On ne fait rien de plus que transmettre : la ligne ignore ce qu'est un
// profil de touches, et c'est tres bien ainsi.
OnRebindRequested.Broadcast(MappingName);
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,267 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingRowWidget.h"
#include "Components/Button.h"
#include "Components/ProgressBar.h"
#include "Components/Slider.h"
#include "Components/TextBlock.h"
void USettingRowWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
// NativeOnInitialized et non NativeConstruct : l'onglet parent appelle
// SetupAs... depuis SON NativeConstruct, et rien ne garantit l'ordre entre
// le construct du parent et celui de ses enfants. Initialize, lui, tourne
// une seule fois a la creation de l'arbre, avant tout le monde.
if (LeftArrowButton)
{
LeftArrowButton->OnClicked.AddDynamic(this, &USettingRowWidget::HandleLeftClicked);
}
if (RightArrowButton)
{
RightArrowButton->OnClicked.AddDynamic(this, &USettingRowWidget::HandleRightClicked);
}
if (ValueSlider)
{
ValueSlider->OnValueChanged.AddDynamic(this, &USettingRowWidget::HandleSliderValueChanged);
}
// La valeur est POSEE SUR le curseur : si elle restait cliquable, elle
// avalerait le clic au milieu de la course et le glissement ne partirait
// jamais. Force ici plutot que dans le WBP : c'est le genre de case qu'on
// oublie et dont le symptome (une zone morte au centre) est illisible.
if (ValueText)
{
ValueText->SetVisibility(ESlateVisibility::HitTestInvisible);
}
}
void USettingRowWidget::SetupAsChoice(const FText& Label, const TArray<FText>& Options, int32 Index)
{
bNumeric = false;
ChoiceLabels = Options;
StepCount = FMath::Max(Options.Num(), 1);
ArrowIndexDelta = 1;
MaxSelectableIndex = INDEX_NONE;
CurrentIndex = FMath::Clamp(Index, 0, StepCount - 1);
if (LabelText)
{
LabelText->SetText(Label);
}
// Une jauge sur "Maintien / Bascule" ne veut rien dire : deux positions et
// rien a doser. On replie l'empilement plutot que d'imposer un second WBP.
if (ValueBar)
{
ValueBar->SetVisibility(ESlateVisibility::Collapsed);
}
if (ValueSlider)
{
ValueSlider->SetVisibility(ESlateVisibility::Collapsed);
}
Refresh();
}
void USettingRowWidget::SetupAsNumber(const FText& Label, float Min, float Max, float Step, float ArrowStep, float Value, const FText& Suffix)
{
bNumeric = true;
ChoiceLabels.Reset();
NumericMin = Min;
NumericStep = FMath::Max(Step, KINDA_SMALL_NUMBER);
NumericSuffix = Suffix;
// +1 : les deux extremites comptent. 70 a 120 par 1 fait 51 paliers, pas 50.
StepCount = FMath::Max(FMath::FloorToInt((Max - Min) / NumericStep) + 1, 1);
// Le bond des fleches ne vit qu'en nombre de paliers fins : tout le reste du
// widget raisonne en index, y compris le curseur.
ArrowIndexDelta = FMath::Max(FMath::RoundToInt(ArrowStep / NumericStep), 1);
MaxSelectableIndex = INDEX_NONE;
if (LabelText)
{
LabelText->SetText(Label);
}
// HitTestInvisible : le remplissage est un decor pose SOUS le curseur, il ne
// doit surtout pas intercepter le clic qui devrait lancer le glissement.
if (ValueBar)
{
ValueBar->SetVisibility(ESlateVisibility::HitTestInvisible);
}
if (ValueSlider)
{
ValueSlider->SetVisibility(ESlateVisibility::Visible);
// La course du curseur est graduee en INDEX, pas dans l'unite du
// reglage : 0 a 10 pour un FOV de 70 a 120 par pas de 5. Le glissement
// ne peut donc produire qu'un palier valide, et le meme curseur sert a
// n'importe quelle plage sans conversion.
ValueSlider->SetMinValue(0.f);
ValueSlider->SetMaxValue(static_cast<float>(FMath::Max(StepCount - 1, 1)));
ValueSlider->SetStepSize(1.f);
}
SetNumericValueSilently(Value);
}
float USettingRowWidget::GetNumericValue() const
{
return NumericMin + CurrentIndex * NumericStep;
}
void USettingRowWidget::SetIndexSilently(int32 NewIndex)
{
// Borne sur StepCount et non sur MaxSelectable : le code a le droit
// d'afficher un etat que le joueur ne peut pas choisir.
CurrentIndex = FMath::Clamp(NewIndex, 0, StepCount - 1);
Refresh();
}
void USettingRowWidget::SetMaxSelectableIndex(int32 InMaxSelectableIndex)
{
MaxSelectableIndex = InMaxSelectableIndex;
Refresh();
}
int32 USettingRowWidget::MaxSelectable() const
{
return MaxSelectableIndex == INDEX_NONE
? StepCount - 1
: FMath::Clamp(MaxSelectableIndex, 0, StepCount - 1);
}
void USettingRowWidget::SetNumericValueSilently(float Value)
{
SetIndexSilently(FMath::RoundToInt((Value - NumericMin) / NumericStep));
}
void USettingRowWidget::HandleLeftClicked()
{
StepIndex(ArrowTargetIndex(-1) - CurrentIndex);
}
void USettingRowWidget::HandleRightClicked()
{
StepIndex(ArrowTargetIndex(1) - CurrentIndex);
}
int32 USettingRowWidget::ArrowTargetIndex(int32 Direction) const
{
// Pas de bond a arrondir : une ligne de choix, ou un reglage dont les deux
// pas sont identiques.
if (ArrowIndexDelta <= 1)
{
return CurrentIndex + Direction;
}
// On vise le multiple du bond suivant, pas CurrentIndex + bond : arrive a 37
// au curseur, la fleche droite doit donner 40 et la gauche 35. Sinon les
// fleches perdent leur reperage des qu'on a glisse une fois.
if (Direction > 0)
{
return ((CurrentIndex / ArrowIndexDelta) + 1) * ArrowIndexDelta;
}
// Division entiere arrondie au superieur, pour que 35 redescende bien a 30
// et pas a 35 (ce qui n'aurait rien fait).
return ((CurrentIndex + ArrowIndexDelta - 1) / ArrowIndexDelta - 1) * ArrowIndexDelta;
}
void USettingRowWidget::HandleSliderValueChanged(float NewValue)
{
if (bRefreshingSlider)
{
return;
}
// On passe par StepIndex plutot que d'ecrire CurrentIndex : le glissement
// et les fleches doivent produire exactement le meme effet, y compris le
// grisage des fleches et la diffusion du delegue.
StepIndex(FMath::RoundToInt(NewValue) - CurrentIndex);
// Puis on recale la poignee sur le cran, meme si l'index n'a pas bouge :
// StepIndex sort sans rien faire dans ce cas, et la poignee resterait
// plantee entre deux paliers.
Refresh();
}
void USettingRowWidget::StepIndex(int32 Delta)
{
const int32 NewIndex = FMath::Clamp(CurrentIndex + Delta, 0, MaxSelectable());
// Pas de bouclage, volontairement : passer de 100 a 5 de sensibilite d'un
// clic de trop rendrait le jeu injouable sans qu'on comprenne pourquoi.
if (NewIndex == CurrentIndex)
{
return;
}
CurrentIndex = NewIndex;
Refresh();
OnIndexChanged.Broadcast(CurrentIndex);
}
void USettingRowWidget::Refresh()
{
if (ValueText)
{
if (bNumeric)
{
FNumberFormattingOptions Format;
Format.MaximumFractionalDigits = 0;
const FText Number = FText::AsNumber(GetNumericValue(), &Format);
// FormatOrdered plutot qu'une concatenation : selon la langue, l'unite
// ne se colle pas forcement du meme cote ni avec la meme espace.
ValueText->SetText(NumericSuffix.IsEmpty()
? Number
: FText::FormatOrdered(NSLOCTEXT("Reglages", "ValeurAvecUnite", "{0}{1}"), Number, NumericSuffix));
}
else if (ChoiceLabels.IsValidIndex(CurrentIndex))
{
ValueText->SetText(ChoiceLabels[CurrentIndex]);
}
}
if (ValueBar)
{
// Un seul palier : la jauge n'a aucun sens, on la remplit entierement
// plutot que de diviser par zero.
ValueBar->SetPercent(StepCount > 1
? static_cast<float>(CurrentIndex) / static_cast<float>(StepCount - 1)
: 1.f);
}
if (ValueSlider && bNumeric)
{
// Le drapeau evite qu'un repositionnement venant du code soit relu comme
// un geste du joueur.
bRefreshingSlider = true;
ValueSlider->SetValue(static_cast<float>(CurrentIndex));
bRefreshingSlider = false;
}
// Les fleches se grisent aux extremites : le joueur voit qu'il est au bout
// au lieu de cliquer dans le vide.
if (LeftArrowButton)
{
LeftArrowButton->SetIsEnabled(CurrentIndex > 0);
}
if (RightArrowButton)
{
RightArrowButton->SetIsEnabled(CurrentIndex < MaxSelectable());
}
}
@@ -0,0 +1,181 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsAudioWidget.h"
#include "SettingRowWidget.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
namespace SettingsAudioTab
{
/** La seule liste de choix de cet onglet. */
TArray<FText> MakeMuteAudibleLabels()
{
return { LOCTEXT("Muet", "Muet"), LOCTEXT("Audible", "Audible") };
}
}
void USettingsAudioWidget::NativeConstruct()
{
Super::NativeConstruct();
using namespace SurvivalSettingsRange;
using namespace SettingsAudioTab;
const FText PercentSuffix = LOCTEXT("Pourcent", " %");
if (MasterVolumeRow)
{
MasterVolumeRow->SetupAsNumber(LOCTEXT("VolumeGeneral", "Volume général"),
VolumeMin, VolumeMax, VolumeStep, VolumeArrowStep, VolumeMin, PercentSuffix);
MasterVolumeRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleMasterVolumeChanged);
}
if (MusicVolumeRow)
{
MusicVolumeRow->SetupAsNumber(LOCTEXT("Musique", "Musique"),
VolumeMin, VolumeMax, VolumeStep, VolumeArrowStep, VolumeMin, PercentSuffix);
MusicVolumeRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleMusicVolumeChanged);
}
if (EffectsVolumeRow)
{
// Cle "EffetsSonores" et pas "Effets" : l'onglet Graphismes utilise deja
// cette derniere pour la qualite des effets visuels, dans le meme namespace.
EffectsVolumeRow->SetupAsNumber(LOCTEXT("EffetsSonores", "Effets sonores"),
VolumeMin, VolumeMax, VolumeStep, VolumeArrowStep, VolumeMin, PercentSuffix);
EffectsVolumeRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleEffectsVolumeChanged);
}
if (UiVolumeRow)
{
UiVolumeRow->SetupAsNumber(LOCTEXT("Interface", "Interface"),
VolumeMin, VolumeMax, VolumeStep, VolumeArrowStep, VolumeMin, PercentSuffix);
UiVolumeRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleUiVolumeChanged);
}
if (BackgroundSoundRow)
{
BackgroundSoundRow->SetupAsChoice(LOCTEXT("SonEnArrierePlan", "Jeu en arrière-plan"),
MakeMuteAudibleLabels(), 0);
BackgroundSoundRow->OnIndexChanged.AddDynamic(this, &USettingsAudioWidget::HandleBackgroundSoundChanged);
}
RefreshFromSettings();
}
void USettingsAudioWidget::RefreshFromSettings()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
UE_LOG(LogTemp, Error, TEXT("USettingsAudioWidget : GameUserSettingsClassName n'est pas configure dans DefaultEngine.ini, l'onglet restera inerte."));
return;
}
// SetNumericValueSilently : diffuser ici ferait boucler l'onglet sur lui-meme.
if (MasterVolumeRow)
{
MasterVolumeRow->SetNumericValueSilently(Settings->GetMasterVolume());
}
if (MusicVolumeRow)
{
MusicVolumeRow->SetNumericValueSilently(Settings->GetMusicVolume());
}
if (EffectsVolumeRow)
{
EffectsVolumeRow->SetNumericValueSilently(Settings->GetEffectsVolume());
}
if (UiVolumeRow)
{
UiVolumeRow->SetNumericValueSilently(Settings->GetUiVolume());
}
if (BackgroundSoundRow)
{
BackgroundSoundRow->SetIndexSilently(Settings->GetPlayInBackground() ? 1 : 0);
}
}
void USettingsAudioWidget::ResetToDefaults()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
Settings->SetSurvivalAudioToDefaults();
Settings->ApplyAudioSettings();
RefreshFromSettings();
}
void USettingsAudioWidget::HandleMasterVolumeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !MasterVolumeRow)
{
return;
}
Settings->SetMasterVolume(MasterVolumeRow->GetNumericValue());
// Pas de SaveSettings ici : c'est l'ecran de reglages qui ecrit le .ini en se
// fermant. Meme regle que les autres onglets.
Settings->ApplyAudioSettings();
}
void USettingsAudioWidget::HandleMusicVolumeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !MusicVolumeRow)
{
return;
}
Settings->SetMusicVolume(MusicVolumeRow->GetNumericValue());
Settings->ApplyAudioSettings();
}
void USettingsAudioWidget::HandleEffectsVolumeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !EffectsVolumeRow)
{
return;
}
Settings->SetEffectsVolume(EffectsVolumeRow->GetNumericValue());
Settings->ApplyAudioSettings();
}
void USettingsAudioWidget::HandleUiVolumeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !UiVolumeRow)
{
return;
}
// Aucun son de test a jouer : les fleches de la ligne portent deja le son de
// clic de l'interface, donc le joueur entend le reglage qu'il est en train de
// faire, sur le son exact qu'il regle.
Settings->SetUiVolume(UiVolumeRow->GetNumericValue());
Settings->ApplyAudioSettings();
}
void USettingsAudioWidget::HandleBackgroundSoundChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetPlayInBackground(NewIndex == 1);
Settings->ApplyAudioSettings();
}
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,355 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsControlsWidget.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/LocalPlayer.h"
#include "GameFramework/PlayerController.h"
#include "SettingKeyRowWidget.h"
#include "SettingsMenuWidget.h"
#include "SurvivalGameInstance.h"
#include "UserSettings/EnhancedInputUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
USettingsControlsWidget::USettingsControlsWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Dans le CONSTRUCTEUR et non dans NativeConstruct : ce dernier tourne a
// chaque ouverture et ecraserait le texte que tu aurais regle dans les Class
// Defaults de WBP_SettingsControls, rendant la UPROPERTY inutile.
//
// Trois informations, pas deux : la touche, qui la possede aujourd'hui, et a
// qui on la donne. Sans la troisieme, un joueur qui a ouvert le voile puis
// hesite ne sait plus quelle ligne il etait en train de modifier.
ConflictMessage = LOCTEXT("ConflitTouche",
"« {0} » est déjà utilisée par « {1} ».\nL'assigner à « {2} » ?\n\n« {1} » n'aura plus de touche.");
}
void USettingsControlsWidget::NativeConstruct()
{
Super::NativeConstruct();
// Les identifiants doivent correspondre EXACTEMENT au champ "Name" des
// Player Mappable Key Settings de IMC_Default. Une faute de frappe donne une
// ligne perpetuellement "Non assignée" -- c'est le seul point de contact
// entre le C++ et l'editeur, et il n'est verifiable qu'au runtime.
SetupRow(MoveForwardRow, TEXT("MoveForward"), LOCTEXT("Avancer", "Avancer"));
SetupRow(MoveBackwardRow, TEXT("MoveBackward"), LOCTEXT("Reculer", "Reculer"));
SetupRow(MoveLeftRow, TEXT("MoveLeft"), LOCTEXT("AllerGauche", "Aller à gauche"));
SetupRow(MoveRightRow, TEXT("MoveRight"), LOCTEXT("AllerDroite", "Aller à droite"));
SetupRow(JumpRow, TEXT("Jump"), LOCTEXT("Sauter", "Sauter"));
SetupRow(SprintRow, TEXT("Sprint"), LOCTEXT("Sprinter", "Sprinter"));
SetupRow(CrouchRow, TEXT("Crouch"), LOCTEXT("SAccroupir", "S'accroupir"));
SetupRow(InteractRow, TEXT("Interact"), LOCTEXT("Interagir", "Interagir"));
SetupRow(UseItemRow, TEXT("UseItem"), LOCTEXT("UtiliserObjet", "Utiliser l'objet"));
SetupRow(ToggleInventoryRow, TEXT("ToggleInventory"), LOCTEXT("Inventaire", "Inventaire"));
SetupRow(ToggleCraftingRow, TEXT("ToggleCrafting"), LOCTEXT("Fabrication", "Fabrication"));
SetupRow(DropItemRow, TEXT("DropItem"), LOCTEXT("Jeter", "Jeter"));
RefreshFromSettings();
}
void USettingsControlsWidget::SetupRow(USettingKeyRowWidget* Row, FName MappingName, const FText& Label)
{
if (!Row)
{
return;
}
MappingLabels.Add(MappingName, Label);
// Touche invalide au depart : RefreshFromSettings ira chercher la vraie juste
// apres. Poser une valeur en dur ici mentirait le temps d'une frame.
Row->Setup(MappingName, Label, FKey());
// Un seul handler pour les onze lignes : chacune renvoie son propre nom de
// mapping, il n'y a rien a distinguer ici.
Row->OnRebindRequested.AddDynamic(this, &USettingsControlsWidget::HandleRebindRequested);
}
UEnhancedInputUserSettings* USettingsControlsWidget::GetInputSettings() const
{
const ULocalPlayer* LocalPlayer = GetOwningLocalPlayer();
if (!LocalPlayer)
{
return nullptr;
}
UEnhancedInputLocalPlayerSubsystem* Subsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
return Subsystem ? Subsystem->GetUserSettings() : nullptr;
}
TArray<USettingKeyRowWidget*> USettingsControlsWidget::GetAllRows() const
{
// L'ordre est celui de l'affichage : il sert aussi a la reinitialisation.
TArray<USettingKeyRowWidget*> Rows;
Rows.Reserve(12);
for (USettingKeyRowWidget* Row : { MoveForwardRow.Get(), MoveBackwardRow.Get(), MoveLeftRow.Get(),
MoveRightRow.Get(), JumpRow.Get(), SprintRow.Get(), CrouchRow.Get(), InteractRow.Get(),
UseItemRow.Get(), ToggleInventoryRow.Get(), ToggleCraftingRow.Get(), DropItemRow.Get() })
{
if (Row)
{
Rows.Add(Row);
}
}
return Rows;
}
USettingKeyRowWidget* USettingsControlsWidget::FindRow(FName MappingName) const
{
for (USettingKeyRowWidget* Row : GetAllRows())
{
if (Row->GetMappingName() == MappingName)
{
return Row;
}
}
return nullptr;
}
FText USettingsControlsWidget::GetMappingDisplayName(FName MappingName) const
{
if (const FText* Label = MappingLabels.Find(MappingName))
{
return *Label;
}
// Repli sur l'identifiant technique : moins joli, mais toujours plus utile
// qu'un libelle vide dans un message de conflit.
return FText::FromName(MappingName);
}
void USettingsControlsWidget::RefreshFromSettings()
{
// On declare les contextes JUSTE AVANT de lire : c'est le seul moment ou
// l'on est certain que le joueur local a son PlayerController, donc que le
// sous-systeme a cree ses UserSettings. Tout enregistrement plus precoce
// tombe dans le vide -- ULocalPlayer previent ses abonnes avant ses
// sous-systemes. L'appel est idempotent, le repeter ne coute rien.
if (USurvivalGameInstance* GameInstance = GetGameInstance<USurvivalGameInstance>())
{
GameInstance->RegisterMappableContexts();
}
const UEnhancedInputUserSettings* Settings = GetInputSettings();
const UEnhancedPlayerMappableKeyProfile* Profile = Settings ? Settings->GetActiveKeyProfile() : nullptr;
if (!Profile)
{
UE_LOG(LogTemp, Warning, TEXT("USettingsControlsWidget : aucun profil de touches. Verifie 'Enable User Settings' dans Project Settings > Enhanced Input."));
return;
}
// On parcourt les lignes DECLAREES et on va chercher leur mapping, plutot que
// l'inverse : le profil contient aussi des mappings qu'on n'expose pas, et
// l'ordre d'affichage est le notre, pas le sien.
for (const TPair<FName, FKeyMappingRow>& Pair : Profile->GetPlayerMappingRows())
{
USettingKeyRowWidget* Row = FindRow(Pair.Key);
if (!Row)
{
continue;
}
FKey CurrentKey;
for (const FPlayerKeyMapping& Mapping : Pair.Value.Mappings)
{
CurrentKey = Mapping.GetCurrentKey();
break;
}
Row->SetKey(CurrentKey);
}
}
void USettingsControlsWidget::ResetToDefaults()
{
UEnhancedInputUserSettings* Settings = GetInputSettings();
if (!Settings)
{
return;
}
for (const USettingKeyRowWidget* Row : GetAllRows())
{
FMapPlayerKeyArgs Args;
Args.MappingName = Row->GetMappingName();
FGameplayTagContainer FailureReason;
Settings->ResetAllPlayerKeysInRow(Args, FailureReason);
}
Settings->ApplySettings();
// Reinitialiser rend les touches de l'IMC, donc du QWERTY : sur un clavier
// AZERTY le joueur se retrouverait avec un WASD qu'il ne peut pas atteindre.
// On repose donc la disposition juste apres. "Par defaut" veut dire "par defaut
// POUR TON CLAVIER", pas "par defaut pour le clavier du developpeur".
if (USurvivalGameInstance* GameInstance = GetGameInstance<USurvivalGameInstance>())
{
GameInstance->ApplyKeyboardLayoutToDefaultKeys();
}
RefreshFromSettings();
}
void USettingsControlsWidget::HandleRebindRequested(FName MappingName)
{
if (!OwningMenu.IsValid())
{
return;
}
PendingMappingName = MappingName;
PendingKey = FKey();
ConflictingMappingName = NAME_None;
FOnSettingsKeyCaptureResult Callback;
Callback.BindDynamic(this, &USettingsControlsWidget::HandleKeyCaptured);
OwningMenu->RequestKeyCapture(GetMappingDisplayName(MappingName), Callback);
}
void USettingsControlsWidget::HandleKeyCaptured(FKey CapturedKey)
{
UEnhancedInputUserSettings* Settings = GetInputSettings();
// Touche invalide = le joueur a annule avec Echap.
if (!CapturedKey.IsValid() || PendingMappingName.IsNone() || !Settings)
{
PendingMappingName = NAME_None;
return;
}
PendingKey = CapturedKey;
const UEnhancedPlayerMappableKeyProfile* Profile = Settings->GetActiveKeyProfile();
if (!Profile)
{
return;
}
// Le moteur sait deja repondre a "qui utilise cette touche ?" : pas de
// comparaison a la main sur toutes les lignes.
TArray<FName> Owners;
Profile->GetMappingNamesForKey(CapturedKey, Owners);
ConflictingMappingName = NAME_None;
for (const FName& Owner : Owners)
{
// Se voler sa propre touche n'a rien d'un conflit.
if (Owner != PendingMappingName && FindRow(Owner))
{
ConflictingMappingName = Owner;
break;
}
}
if (ConflictingMappingName.IsNone())
{
// Rien a arbitrer : on applique et on referme le voile.
ApplyPendingRebind();
if (OwningMenu.IsValid())
{
OwningMenu->CloseKeyCapture();
}
return;
}
if (!OwningMenu.IsValid())
{
return;
}
FOnSettingsConfirmationResult Callback;
Callback.BindDynamic(this, &USettingsControlsWidget::HandleConflictAnswered);
const FText Message = FText::Format(ConflictMessage,
PendingKey.GetDisplayName(),
GetMappingDisplayName(ConflictingMappingName),
GetMappingDisplayName(PendingMappingName));
// Le voile reste ouvert et change simplement d'etat : pour le joueur c'est
// le meme geste qui continue, pas un second ecran qui apparait.
OwningMenu->ShowKeyCaptureConflict(Message, Callback);
}
void USettingsControlsWidget::HandleConflictAnswered(bool bConfirmed)
{
// Le voile s'est deja referme de lui-meme en repondant : il n'y a plus qu'a
// appliquer, ou a oublier ce qu'on avait mis de cote.
if (!bConfirmed)
{
PendingMappingName = NAME_None;
ConflictingMappingName = NAME_None;
PendingKey = FKey();
return;
}
ApplyPendingRebind();
}
void USettingsControlsWidget::ApplyPendingRebind()
{
UEnhancedInputUserSettings* Settings = GetInputSettings();
if (!Settings || PendingMappingName.IsNone() || !PendingKey.IsValid())
{
return;
}
FGameplayTagContainer FailureReason;
// L'ancien proprietaire perd sa touche AVANT qu'on assigne la notre : deux
// actions sur la meme touche laisseraient un doublon actif, et le joueur
// declencherait les deux d'un seul appui.
//
// On lui assigne une touche VIDE au lieu d'appeler UnMapPlayerKey : ce
// dernier fait un ResetToDefault(), donc il rendrait a l'action sa touche
// d'origine -- exactement celle qu'on est en train de lui prendre. Le nom
// trompe, il annule la surcharge du joueur, il ne libere pas la touche.
if (!ConflictingMappingName.IsNone())
{
FMapPlayerKeyArgs ClearArgs;
ClearArgs.MappingName = ConflictingMappingName;
ClearArgs.Slot = EPlayerMappableKeySlot::First;
ClearArgs.NewKey = FKey();
Settings->MapPlayerKey(ClearArgs, FailureReason);
}
FMapPlayerKeyArgs MapArgs;
MapArgs.MappingName = PendingMappingName;
MapArgs.Slot = EPlayerMappableKeySlot::First;
MapArgs.NewKey = PendingKey;
Settings->MapPlayerKey(MapArgs, FailureReason);
if (!FailureReason.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("USettingsControlsWidget : remappage de '%s' refuse (%s)."),
*PendingMappingName.ToString(), *FailureReason.ToString());
}
// ApplySettings pousse les surcharges dans les mapping contexts deja
// enregistres : le pawn n'a rien a rebrancher, la touche est active tout de
// suite. La sauvegarde, elle, attend la fermeture de l'ecran.
Settings->ApplySettings();
PendingMappingName = NAME_None;
ConflictingMappingName = NAME_None;
PendingKey = FKey();
// Les deux lignes concernees ont bouge : on relit tout plutot que de tenir
// une comptabilite qui finirait par diverger.
RefreshFromSettings();
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,506 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsDisplayWidget.h"
#include "Kismet/KismetSystemLibrary.h"
#include "SettingRowWidget.h"
#include "SettingsMenuWidget.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
namespace SettingsDisplayTab
{
// Ordre des modes dans la ligne. Il doit correspondre a MakeWindowMode et a
// FindDisplayModeIndex : trois listes a garder alignees, d'ou leur voisinage.
constexpr int32 ModeFullscreen = 0;
constexpr int32 ModeBorderless = 1;
constexpr int32 ModeWindowed = 2;
EWindowMode::Type MakeWindowMode(int32 Index)
{
switch (Index)
{
case ModeBorderless: return EWindowMode::WindowedFullscreen;
case ModeWindowed: return EWindowMode::Windowed;
default: return EWindowMode::Fullscreen;
}
}
int32 FindDisplayModeIndex(EWindowMode::Type Mode)
{
switch (Mode)
{
case EWindowMode::WindowedFullscreen: return ModeBorderless;
case EWindowMode::Windowed: return ModeWindowed;
default: return ModeFullscreen;
}
}
/**
* Limites de FPS proposees. 0 = illimitee, c'est la convention de
* SetFrameRateLimit et non une valeur sentinelle a nous.
*/
const TArray<float>& FrameRateLimits()
{
static const TArray<float> Limits = { 30.f, 60.f, 90.f, 120.f, 144.f, 165.f, 240.f, 0.f };
return Limits;
}
int32 FindFrameRateIndex(float Limit)
{
const TArray<float>& Limits = FrameRateLimits();
// Comparaison a la tolerance pres : la valeur relue du .ini a pu passer
// par une conversion et ne plus etre strictement egale.
for (int32 Index = 0; Index < Limits.Num(); ++Index)
{
if (FMath::IsNearlyEqual(Limits[Index], Limit, 0.5f))
{
return Index;
}
}
// Valeur exotique posee a la main dans le .ini : on retombe sur
// "Illimitee" plutot que d'afficher n'importe quoi.
return Limits.Num() - 1;
}
}
USettingsDisplayWidget::USettingsDisplayWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Dans le constructeur, pour la meme raison que ConflictMessage : ecrit dans
// NativeConstruct, ce texte ecraserait a chaque ouverture celui regle dans le
// Blueprint.
VideoConfirmMessage = LOCTEXT("VideoConfirm",
"Garder ce mode d'affichage ?\nRetour automatique dans {0} s.");
}
void USettingsDisplayWidget::NativeConstruct()
{
Super::NativeConstruct();
using namespace SurvivalSettingsRange;
if (DisplayModeRow)
{
DisplayModeRow->SetupAsChoice(LOCTEXT("ModeAffichage", "Mode d'affichage"),
{ LOCTEXT("PleinEcran", "Plein écran"),
LOCTEXT("SansBordure", "Sans bordure"),
LOCTEXT("Fenetre", "Fenêtré") }, 0);
DisplayModeRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleDisplayModeChanged);
}
if (ResolutionRow)
{
ResolutionRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleResolutionChanged);
}
if (FrameRateLimitRow)
{
TArray<FText> Labels;
Labels.Reserve(SettingsDisplayTab::FrameRateLimits().Num());
for (const float Limit : SettingsDisplayTab::FrameRateLimits())
{
Labels.Add(FMath::IsNearlyZero(Limit)
? LOCTEXT("Illimitee", "Illimitée")
: FText::AsNumber(FMath::RoundToInt(Limit)));
}
FrameRateLimitRow->SetupAsChoice(LOCTEXT("LimiteFPS", "Limite de FPS"), Labels, 0);
FrameRateLimitRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleFrameRateLimitChanged);
}
if (VSyncRow)
{
VSyncRow->SetupAsChoice(LOCTEXT("VSync", "Synchronisation verticale"),
{ LOCTEXT("Non", "Non"), LOCTEXT("Oui", "Oui") }, 0);
VSyncRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleVSyncChanged);
}
if (ResolutionScaleRow)
{
ResolutionScaleRow->SetupAsNumber(LOCTEXT("EchelleResolution", "Échelle de résolution"),
ResolutionScaleMin, ResolutionScaleMax, ResolutionScaleStep, ResolutionScaleArrowStep,
ResolutionScaleMax, LOCTEXT("Pourcent", " %"));
ResolutionScaleRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleResolutionScaleChanged);
}
if (BrightnessRow)
{
BrightnessRow->SetupAsNumber(LOCTEXT("Luminosite", "Luminosité"),
BrightnessMin, BrightnessMax, BrightnessStep, BrightnessArrowStep,
BrightnessMin, LOCTEXT("Pourcent", " %"));
BrightnessRow->OnIndexChanged.AddDynamic(this, &USettingsDisplayWidget::HandleBrightnessChanged);
}
RebuildResolutionList();
RefreshFromSettings();
}
void USettingsDisplayWidget::RebuildResolutionList()
{
AvailableResolutions.Reset();
// La liste depend du mode : en fenetre on veut des tailles qui tiennent dans
// l'ecran, pas les modes exclusifs de la carte graphique.
const USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
const bool bWindowed = Settings && Settings->GetFullscreenMode() == EWindowMode::Windowed;
if (bWindowed)
{
UKismetSystemLibrary::GetConvenientWindowedResolutions(AvailableResolutions);
}
else
{
UKismetSystemLibrary::GetSupportedFullscreenResolutions(AvailableResolutions);
}
if (AvailableResolutions.Num() == 0)
{
// Ecran non interroge (editeur, machine sans mode expose) : on propose au
// moins la resolution courante pour ne pas afficher une ligne vide.
UE_LOG(LogTemp, Warning, TEXT("USettingsDisplayWidget : aucune resolution retournee par le systeme, repli sur la resolution courante."));
AvailableResolutions.Add(Settings ? Settings->GetScreenResolution() : FIntPoint(1920, 1080));
}
FilterResolutions(Settings ? Settings->GetScreenResolution() : FIntPoint(1920, 1080));
if (!ResolutionRow)
{
return;
}
TArray<FText> Labels;
Labels.Reserve(AvailableResolutions.Num());
for (const FIntPoint& Resolution : AvailableResolutions)
{
// FString::Printf et non FText::AsNumber : ce dernier formate selon la
// locale et insererait un separateur de milliers -- "1 920 x 1 080".
// Une resolution s'ecrit "1920x1080", sans espace ni separateur : c'est
// une reference technique, pas une quantite.
Labels.Add(FText::FromString(FString::Printf(TEXT("%dx%d"), Resolution.X, Resolution.Y)));
}
ResolutionRow->SetupAsChoice(LOCTEXT("Resolution", "Résolution"),
Labels, FindCurrentResolutionIndex(Settings));
}
void USettingsDisplayWidget::FilterResolutions(const FIntPoint& CurrentResolution)
{
// Des PLAGES et non un rapport exact : les resolutions commerciales ne
// tombent pas juste. En 16:9, 1366x768 vaut 1.779 ; en "21:9", 2560x1080
// vaut 2.370, 3440x1440 vaut 2.389 et 3840x1600 vaut 2.400 -- trois valeurs
// differentes pour un format qu'on annonce pourtant comme un seul.
constexpr float WideMin = 1.74f;
constexpr float WideMax = 1.81f;
constexpr float UltrawideMin = 2.30f;
constexpr float UltrawideMax = 2.42f;
TArray<FIntPoint> Kept;
Kept.Reserve(AvailableResolutions.Num());
for (const FIntPoint& Resolution : AvailableResolutions)
{
if (Resolution.X < MinimumResolution.X || Resolution.Y < MinimumResolution.Y)
{
continue;
}
const float Ratio = static_cast<float>(Resolution.X) / static_cast<float>(Resolution.Y);
const bool bIsWide = Ratio >= WideMin && Ratio <= WideMax;
const bool bIsUltrawide = bAllowUltrawide && Ratio >= UltrawideMin && Ratio <= UltrawideMax;
// Tout le reste degage : 4:3, 5:4, 16:10, et le 32:9 des ecrans doubles,
// qui deformerait le HUD sans qu'on ait rien prevu pour lui.
if (!bIsWide && !bIsUltrawide)
{
continue;
}
Kept.Add(Resolution);
}
// La resolution courante doit rester selectionnable meme si elle ne passe pas
// le filtre : sinon un joueur en 16:10 verrait la ligne afficher autre chose
// que ce qu'il utilise vraiment.
if (!Kept.Contains(CurrentResolution) && CurrentResolution.X > 0 && CurrentResolution.Y > 0)
{
Kept.Add(CurrentResolution);
}
// Un ecran qui ne proposerait aucun 16:9 vaut mieux non filtre que vide.
if (Kept.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("USettingsDisplayWidget : aucune resolution ne passe le filtre, la liste complete est conservee."));
return;
}
// Tri croissant par surface : la plus petite en haut, la native en bas.
Kept.Sort([](const FIntPoint& A, const FIntPoint& B)
{
return static_cast<int64>(A.X) * A.Y < static_cast<int64>(B.X) * B.Y;
});
AvailableResolutions = MoveTemp(Kept);
}
int32 USettingsDisplayWidget::FindCurrentResolutionIndex(const USurvivalUserSettings* Settings) const
{
if (!Settings)
{
return 0;
}
const FIntPoint Current = Settings->GetScreenResolution();
const int32 Found = AvailableResolutions.IndexOfByKey(Current);
// Resolution absente de la liste (fenetre redimensionnee a la main) : on
// retombe sur la plus grande, en fin de liste.
return Found != INDEX_NONE ? Found : AvailableResolutions.Num() - 1;
}
int32 USettingsDisplayWidget::FindLargestResolutionIndex() const
{
int32 BestIndex = INDEX_NONE;
int64 BestArea = -1;
// On compare la SURFACE et non l'index : rien ne garantit que le systeme
// renvoie ses modes triés, et un 2560x1080 ultralarge ne doit pas passer
// devant un 1920x1200.
for (int32 Index = 0; Index < AvailableResolutions.Num(); ++Index)
{
const int64 Area = static_cast<int64>(AvailableResolutions[Index].X) * AvailableResolutions[Index].Y;
if (Area > BestArea)
{
BestArea = Area;
BestIndex = Index;
}
}
return BestIndex;
}
void USettingsDisplayWidget::RefreshFromSettings()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
if (DisplayModeRow)
{
DisplayModeRow->SetIndexSilently(SettingsDisplayTab::FindDisplayModeIndex(Settings->GetFullscreenMode()));
}
if (ResolutionRow)
{
ResolutionRow->SetIndexSilently(FindCurrentResolutionIndex(Settings));
}
if (FrameRateLimitRow)
{
FrameRateLimitRow->SetIndexSilently(SettingsDisplayTab::FindFrameRateIndex(Settings->GetFrameRateLimit()));
}
if (VSyncRow)
{
VSyncRow->SetIndexSilently(Settings->IsVSyncEnabled() ? 1 : 0);
}
if (ResolutionScaleRow)
{
// On lit la valeur BRUTE en pourcentage et non la normalisee : la plage
// autorisee par le moteur ne commence pas a 50 %, une valeur normalisee
// ne correspondrait donc pas a ce que la ligne affiche.
float CurrentNormalized = 0.f;
float CurrentValue = 100.f;
float MinValue = 0.f;
float MaxValue = 100.f;
Settings->GetResolutionScaleInformationEx(CurrentNormalized, CurrentValue, MinValue, MaxValue);
ResolutionScaleRow->SetNumericValueSilently(CurrentValue);
}
if (BrightnessRow)
{
BrightnessRow->SetNumericValueSilently(Settings->GetBrightness());
}
}
void USettingsDisplayWidget::ResetToDefaults()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
// On ne touche PAS a la resolution ni au mode d'affichage : les remettre
// "par defaut" pourrait basculer sur un mode que l'ecran ne gere pas, et le
// joueur n'a pas demande ca en cliquant Reinitialiser.
Settings->SetFrameRateLimit(0.f);
Settings->SetVSyncEnabled(false);
Settings->SetResolutionScaleNormalized(1.f);
Settings->SetSurvivalDisplayToDefaults();
Settings->ApplyNonResolutionSettings();
RefreshFromSettings();
}
void USettingsDisplayWidget::HandleDisplayModeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
Settings->SetFullscreenMode(SettingsDisplayTab::MakeWindowMode(NewIndex));
// Passer en fenetre change la liste des tailles proposables.
RebuildResolutionList();
// En plein ecran ou sans bordure, on bascule d'office sur la plus grande
// resolution disponible -- c'est-a-dire celle de l'ecran.
//
// Sans ca, un jeu lance en Standalone dans une fenetre de 1280x720 garderait
// cette resolution en plein ecran : l'image serait etiree sur tout l'ecran et
// visiblement floue, alors que le joueur vient de demander le contraire. En
// fenetre on ne touche a rien, la taille choisie est celle qu'il veut.
if (NewIndex != SettingsDisplayTab::ModeWindowed)
{
const int32 LargestIndex = FindLargestResolutionIndex();
if (AvailableResolutions.IsValidIndex(LargestIndex))
{
Settings->SetScreenResolution(AvailableResolutions[LargestIndex]);
if (ResolutionRow)
{
ResolutionRow->SetIndexSilently(LargestIndex);
}
}
}
ApplyVideoModeAndConfirm();
}
void USettingsDisplayWidget::HandleResolutionChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !AvailableResolutions.IsValidIndex(NewIndex))
{
return;
}
Settings->SetScreenResolution(AvailableResolutions[NewIndex]);
ApplyVideoModeAndConfirm();
}
void USettingsDisplayWidget::ApplyVideoModeAndConfirm()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
// ApplyResolutionSettings et non ApplySettings : on ne veut surtout pas
// repousser la qualite graphique au passage, ce serait une saccade a chaque
// clic de fleche.
Settings->ApplyResolutionSettings(/*bCheckForCommandLineOverrides=*/false);
if (!OwningMenu.IsValid())
{
// Sans ecran parent, personne ne peut poser la question. On valide, au
// prix du filet de securite -- mieux que de laisser un etat en suspens.
Settings->ConfirmVideoMode();
return;
}
FOnSettingsConfirmationResult Callback;
Callback.BindDynamic(this, &USettingsDisplayWidget::HandleVideoConfirmAnswered);
if (!OwningMenu->RequestConfirmation(VideoConfirmMessage, VideoConfirmSeconds, Callback))
{
Settings->ConfirmVideoMode();
}
}
void USettingsDisplayWidget::HandleVideoConfirmAnswered(bool bConfirmed)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
if (bConfirmed)
{
// Ce mode devient la reference vers laquelle RevertVideoMode ramenera la
// prochaine fois.
Settings->ConfirmVideoMode();
}
else
{
Settings->RevertVideoMode();
Settings->ApplyResolutionSettings(/*bCheckForCommandLineOverrides=*/false);
RebuildResolutionList();
}
RefreshFromSettings();
}
void USettingsDisplayWidget::HandleFrameRateLimitChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !SettingsDisplayTab::FrameRateLimits().IsValidIndex(NewIndex))
{
return;
}
Settings->SetFrameRateLimit(SettingsDisplayTab::FrameRateLimits()[NewIndex]);
Settings->ApplyNonResolutionSettings();
}
void USettingsDisplayWidget::HandleVSyncChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetVSyncEnabled(NewIndex == 1);
Settings->ApplyNonResolutionSettings();
}
}
void USettingsDisplayWidget::HandleResolutionScaleChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !ResolutionScaleRow)
{
return;
}
Settings->SetResolutionScaleValueEx(ResolutionScaleRow->GetNumericValue());
Settings->ApplyNonResolutionSettings();
}
void USettingsDisplayWidget::HandleBrightnessChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !BrightnessRow)
{
return;
}
Settings->SetBrightness(BrightnessRow->GetNumericValue());
// ApplySurvivalSettings et non ApplyNonResolutionSettings : la luminosite
// est a nous, elle ne passe pas par le volet video du moteur.
Settings->ApplySurvivalSettings();
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,278 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsGameplayWidget.h"
#include "SettingRowWidget.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
namespace
{
/** Les deux seules listes de choix de cet onglet. */
TArray<FText> MakeNoYesLabels()
{
return { LOCTEXT("Non", "Non"), LOCTEXT("Oui", "Oui") };
}
TArray<FText> MakeHoldToggleLabels()
{
return { LOCTEXT("Maintien", "Maintien"), LOCTEXT("Bascule", "Bascule") };
}
}
void USettingsGameplayWidget::NativeConstruct()
{
Super::NativeConstruct();
// Les libelles et les bornes sont poses une seule fois ; seules les valeurs
// bougent ensuite, via RefreshFromSettings.
using namespace SurvivalSettingsRange;
if (SensitivityRow)
{
SensitivityRow->SetupAsNumber(LOCTEXT("Sensibilite", "Sensibilité"),
SensitivityMin, SensitivityMax, SensitivityStep, SensitivityArrowStep, SensitivityMin, FText::GetEmpty());
SensitivityRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleSensitivityChanged);
}
if (SeparateVerticalRow)
{
SeparateVerticalRow->SetupAsChoice(LOCTEXT("SensibiliteSeparee", "Sensibilité verticale séparée"),
MakeNoYesLabels(), 0);
SeparateVerticalRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleSeparateVerticalChanged);
}
if (SensitivityVerticalRow)
{
SensitivityVerticalRow->SetupAsNumber(LOCTEXT("SensibiliteVerticale", "Sensibilité verticale"),
SensitivityMin, SensitivityMax, SensitivityStep, SensitivityArrowStep, SensitivityMin, FText::GetEmpty());
SensitivityVerticalRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleSensitivityVerticalChanged);
}
if (InvertLookRow)
{
InvertLookRow->SetupAsChoice(LOCTEXT("InverserY", "Inverser l'axe vertical"), MakeNoYesLabels(), 0);
InvertLookRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleInvertLookChanged);
}
if (FieldOfViewRow)
{
FieldOfViewRow->SetupAsNumber(LOCTEXT("ChampDeVision", "Champ de vision"),
FieldOfViewMin, FieldOfViewMax, FieldOfViewStep, FieldOfViewArrowStep, FieldOfViewMin, LOCTEXT("Degres", "°"));
FieldOfViewRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleFieldOfViewChanged);
}
if (CameraShakeRow)
{
CameraShakeRow->SetupAsNumber(LOCTEXT("BougeCamera", "Bougé de caméra"),
CameraShakeMin, CameraShakeMax, CameraShakeStep, CameraShakeArrowStep, CameraShakeMin, LOCTEXT("Pourcent", " %"));
CameraShakeRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleCameraShakeChanged);
}
if (SprintModeRow)
{
SprintModeRow->SetupAsChoice(LOCTEXT("Sprint", "Sprint"), MakeHoldToggleLabels(), 0);
SprintModeRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleSprintModeChanged);
}
if (CrouchModeRow)
{
CrouchModeRow->SetupAsChoice(LOCTEXT("Accroupi", "Accroupi"), MakeHoldToggleLabels(), 0);
CrouchModeRow->OnIndexChanged.AddDynamic(this, &USettingsGameplayWidget::HandleCrouchModeChanged);
}
RefreshFromSettings();
}
void USettingsGameplayWidget::RefreshFromSettings()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
UE_LOG(LogTemp, Error, TEXT("USettingsGameplayWidget : GameUserSettingsClassName n'est pas configure dans DefaultEngine.ini, l'onglet restera inerte."));
return;
}
// SetNumericValueSilently et non un setter classique : diffuser ici ferait
// boucler l'onglet sur lui-meme.
if (SensitivityRow)
{
SensitivityRow->SetNumericValueSilently(Settings->GetLookSensitivity());
}
const bool bSeparate = Settings->GetSeparateVerticalSensitivity();
if (SeparateVerticalRow)
{
SeparateVerticalRow->SetIndexSilently(bSeparate ? 1 : 0);
}
if (SensitivityVerticalRow)
{
SensitivityVerticalRow->SetNumericValueSilently(Settings->GetLookSensitivityVertical());
}
UpdateVerticalRowVisibility(bSeparate);
if (InvertLookRow)
{
InvertLookRow->SetIndexSilently(Settings->GetInvertLookY() ? 1 : 0);
}
if (FieldOfViewRow)
{
FieldOfViewRow->SetNumericValueSilently(Settings->GetFieldOfView());
}
if (CameraShakeRow)
{
CameraShakeRow->SetNumericValueSilently(Settings->GetCameraShakeScale());
}
if (SprintModeRow)
{
SprintModeRow->SetIndexSilently(Settings->GetSprintToggle() ? 1 : 0);
}
if (CrouchModeRow)
{
CrouchModeRow->SetIndexSilently(Settings->GetCrouchToggle() ? 1 : 0);
}
}
void USettingsGameplayWidget::ResetToDefaults()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
Settings->SetSurvivalGameplayToDefaults();
PushToPawn(Settings);
RefreshFromSettings();
}
void USettingsGameplayWidget::UpdateVerticalRowVisibility(bool bSeparate)
{
if (SensitivityVerticalRow)
{
// Collapsed et non Hidden : la ligne ne doit pas laisser un trou dans la
// liste quand elle est inutile.
SensitivityVerticalRow->SetVisibility(bSeparate ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);
}
}
void USettingsGameplayWidget::PushToPawn(USurvivalUserSettings* Settings)
{
// Pas de SaveSettings ici : ecrire le .ini a chaque clic de fleche est du
// gaspillage. C'est l'ecran de reglages qui sauvegarde en se fermant.
Settings->ApplySurvivalSettings();
}
void USettingsGameplayWidget::HandleSensitivityChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !SensitivityRow)
{
return;
}
Settings->SetLookSensitivity(SensitivityRow->GetNumericValue());
// Sans axe separe, le vertical suit : on garde les deux lignes coherentes
// pour que le joueur ne decouvre pas un ecart en activant la separation.
if (!Settings->GetSeparateVerticalSensitivity() && SensitivityVerticalRow)
{
SensitivityVerticalRow->SetNumericValueSilently(Settings->GetLookSensitivityVertical());
}
PushToPawn(Settings);
}
void USettingsGameplayWidget::HandleSeparateVerticalChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
const bool bSeparate = NewIndex == 1;
Settings->SetSeparateVerticalSensitivity(bSeparate);
if (SensitivityVerticalRow)
{
SensitivityVerticalRow->SetNumericValueSilently(Settings->GetLookSensitivityVertical());
}
UpdateVerticalRowVisibility(bSeparate);
PushToPawn(Settings);
}
void USettingsGameplayWidget::HandleSensitivityVerticalChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !SensitivityVerticalRow)
{
return;
}
Settings->SetLookSensitivityVertical(SensitivityVerticalRow->GetNumericValue());
PushToPawn(Settings);
}
void USettingsGameplayWidget::HandleInvertLookChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetInvertLookY(NewIndex == 1);
PushToPawn(Settings);
}
}
void USettingsGameplayWidget::HandleFieldOfViewChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !FieldOfViewRow)
{
return;
}
Settings->SetFieldOfView(FieldOfViewRow->GetNumericValue());
PushToPawn(Settings);
}
void USettingsGameplayWidget::HandleCameraShakeChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !CameraShakeRow)
{
return;
}
Settings->SetCameraShakeScale(CameraShakeRow->GetNumericValue());
PushToPawn(Settings);
}
void USettingsGameplayWidget::HandleSprintModeChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetSprintToggle(NewIndex == 1);
PushToPawn(Settings);
}
}
void USettingsGameplayWidget::HandleCrouchModeChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetCrouchToggle(NewIndex == 1);
PushToPawn(Settings);
}
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,250 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsGraphicsWidget.h"
#include "SettingRowWidget.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
namespace SettingsGraphicsTab
{
/**
* Index de "Personnalise" dans la ligne de qualite globale.
*
* Le moteur renvoie -1 quand les curseurs ne correspondent a aucun preset.
* On l'affiche comme un cinquieme choix, mais SetMaxSelectableIndex(3)
* empeche le joueur de le selectionner : c'est un etat dans lequel on tombe,
* pas un reglage qu'on choisit.
*/
constexpr int32 CustomQualityIndex = 4;
/** Bas / Moyen / Eleve / Epique. Le moteur accepte aussi 4 (cinematique), qu'on n'expose pas. */
constexpr int32 MaxQualityLevel = 3;
}
void USettingsGraphicsWidget::SetupQualityRow(USettingRowWidget* Row, const FText& Label)
{
if (!Row)
{
return;
}
Row->SetupAsChoice(Label,
{ LOCTEXT("QualiteBas", "Bas"),
LOCTEXT("QualiteMoyen", "Moyen"),
LOCTEXT("QualiteEleve", "Élevé"),
LOCTEXT("QualiteEpique", "Épique") }, 0);
}
void USettingsGraphicsWidget::NativeConstruct()
{
Super::NativeConstruct();
if (OverallQualityRow)
{
OverallQualityRow->SetupAsChoice(LOCTEXT("QualiteGlobale", "Qualité globale"),
{ LOCTEXT("QualiteBas", "Bas"),
LOCTEXT("QualiteMoyen", "Moyen"),
LOCTEXT("QualiteEleve", "Élevé"),
LOCTEXT("QualiteEpique", "Épique"),
LOCTEXT("QualitePerso", "Personnalisé") }, 0);
OverallQualityRow->SetMaxSelectableIndex(SettingsGraphicsTab::MaxQualityLevel);
OverallQualityRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleOverallQualityChanged);
}
SetupQualityRow(ViewDistanceRow, LOCTEXT("DistanceAffichage", "Distance d'affichage"));
SetupQualityRow(FoliageRow, LOCTEXT("Vegetation", "Végétation"));
SetupQualityRow(ShadowRow, LOCTEXT("Ombres", "Ombres"));
SetupQualityRow(TextureRow, LOCTEXT("Textures", "Textures"));
SetupQualityRow(VisualEffectRow, LOCTEXT("Effets", "Effets"));
SetupQualityRow(PostProcessRow, LOCTEXT("PostProcess", "Post-process"));
SetupQualityRow(AntiAliasingRow, LOCTEXT("AntiAliasing", "Anti-aliasing"));
SetupQualityRow(ReflectionRow, LOCTEXT("Reflexions", "Réflexions"));
SetupQualityRow(GlobalIlluminationRow, LOCTEXT("EclairageGlobal", "Éclairage global"));
SetupQualityRow(ShadingRow, LOCTEXT("Ombrage", "Ombrage"));
if (ViewDistanceRow) { ViewDistanceRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleViewDistanceChanged); }
if (FoliageRow) { FoliageRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleFoliageChanged); }
if (ShadowRow) { ShadowRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleShadowChanged); }
if (TextureRow) { TextureRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleTextureChanged); }
if (VisualEffectRow) { VisualEffectRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleVisualEffectChanged); }
if (PostProcessRow) { PostProcessRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandlePostProcessChanged); }
if (AntiAliasingRow) { AntiAliasingRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleAntiAliasingChanged); }
if (ReflectionRow) { ReflectionRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleReflectionChanged); }
if (GlobalIlluminationRow) { GlobalIlluminationRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleGlobalIlluminationChanged); }
if (ShadingRow) { ShadingRow->OnIndexChanged.AddDynamic(this, &USettingsGraphicsWidget::HandleShadingChanged); }
RefreshFromSettings();
}
void USettingsGraphicsWidget::RefreshFromSettings()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
if (OverallQualityRow)
{
// -1 quand les curseurs ne collent a aucun preset : c'est la que
// "Personnalise" s'affiche.
const int32 Overall = Settings->GetOverallScalabilityLevel();
OverallQualityRow->SetIndexSilently(Overall >= 0 ? Overall : SettingsGraphicsTab::CustomQualityIndex);
}
if (ViewDistanceRow) { ViewDistanceRow->SetIndexSilently(Settings->GetViewDistanceQuality()); }
if (FoliageRow) { FoliageRow->SetIndexSilently(Settings->GetFoliageQuality()); }
if (ShadowRow) { ShadowRow->SetIndexSilently(Settings->GetShadowQuality()); }
if (TextureRow) { TextureRow->SetIndexSilently(Settings->GetTextureQuality()); }
if (VisualEffectRow) { VisualEffectRow->SetIndexSilently(Settings->GetVisualEffectQuality()); }
if (PostProcessRow) { PostProcessRow->SetIndexSilently(Settings->GetPostProcessingQuality()); }
if (AntiAliasingRow) { AntiAliasingRow->SetIndexSilently(Settings->GetAntiAliasingQuality()); }
if (ReflectionRow) { ReflectionRow->SetIndexSilently(Settings->GetReflectionQuality()); }
if (GlobalIlluminationRow) { GlobalIlluminationRow->SetIndexSilently(Settings->GetGlobalIlluminationQuality()); }
if (ShadingRow) { ShadingRow->SetIndexSilently(Settings->GetShadingQuality()); }
}
void USettingsGraphicsWidget::ResetToDefaults()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
// "Eleve" plutot que le benchmark automatique : sur une machine modeste,
// RunHardwareBenchmark peut descendre tres bas et le joueur ne comprendrait
// pas pourquoi "Reinitialiser" a degrade son jeu.
Settings->SetOverallScalabilityLevel(2);
Settings->ApplyNonResolutionSettings();
RefreshFromSettings();
}
void USettingsGraphicsWidget::ApplyAndRefreshOverall()
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings || !OverallQualityRow)
{
return;
}
Settings->ApplyNonResolutionSettings();
// Toucher un curseur individuel fait presque toujours basculer la qualite
// globale sur "Personnalise" : la ligne du haut doit suivre immediatement,
// sinon elle affiche un preset qui ne correspond plus a rien.
const int32 Overall = Settings->GetOverallScalabilityLevel();
OverallQualityRow->SetIndexSilently(Overall >= 0 ? Overall : SettingsGraphicsTab::CustomQualityIndex);
}
void USettingsGraphicsWidget::HandleOverallQualityChanged(int32 NewIndex)
{
USurvivalUserSettings* Settings = USurvivalUserSettings::Get();
if (!Settings)
{
return;
}
Settings->SetOverallScalabilityLevel(FMath::Clamp(NewIndex, 0, SettingsGraphicsTab::MaxQualityLevel));
Settings->ApplyNonResolutionSettings();
// Un preset repositionne les dix autres lignes d'un coup.
RefreshFromSettings();
}
void USettingsGraphicsWidget::HandleViewDistanceChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetViewDistanceQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleFoliageChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetFoliageQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleShadowChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetShadowQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleTextureChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetTextureQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleVisualEffectChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetVisualEffectQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandlePostProcessChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetPostProcessingQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleAntiAliasingChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetAntiAliasingQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleReflectionChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetReflectionQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleGlobalIlluminationChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetGlobalIlluminationQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
void USettingsGraphicsWidget::HandleShadingChanged(int32 NewIndex)
{
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SetShadingQuality(NewIndex);
ApplyAndRefreshOverall();
}
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,517 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsMenuWidget.h"
#include "Components/Button.h"
#include "Components/WidgetSwitcher.h"
#include "ConfirmDialogWidget.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/LocalPlayer.h"
#include "InputCoreTypes.h"
#include "KeyCaptureWidget.h"
#include "UserSettings/EnhancedInputUserSettings.h"
#include "SettingsTabWidget.h"
#include "SurvivalUserSettings.h"
#define LOCTEXT_NAMESPACE "Reglages"
namespace
{
// Ordre des pages dans le WidgetSwitcher. Un bouton dont la page manque se
// grise tout seul, ce qui reste vrai si un onglet vient a etre retire.
constexpr int32 TabIndexGameplay = 0;
constexpr int32 TabIndexDisplay = 1;
constexpr int32 TabIndexGraphics = 2;
constexpr int32 TabIndexControls = 3;
constexpr int32 TabIndexAudio = 4;
}
USettingsMenuWidget::USettingsMenuWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// Sans ce drapeau le widget ne peut pas prendre le focus clavier, et
// NativeOnKeyDown ne serait jamais appele. Il ne se change qu'a la
// construction, d'ou sa presence ici et pas dans Open().
SetIsFocusable(true);
ResetDialogMessage = LOCTEXT("ResetConfirm", "Réinitialiser cet onglet ?");
}
void USettingsMenuWidget::NativeConstruct()
{
Super::NativeConstruct();
if (BackButton)
{
BackButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleBackClicked);
}
if (ResetButton)
{
ResetButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleResetClicked);
}
if (GameplayTabButton)
{
GameplayTabButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleGameplayTabClicked);
}
if (DisplayTabButton)
{
DisplayTabButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleDisplayTabClicked);
}
if (GraphicsTabButton)
{
GraphicsTabButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleGraphicsTabClicked);
}
if (ControlsTabButton)
{
ControlsTabButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleControlsTabClicked);
}
if (AudioTabButton)
{
AudioTabButton->OnClicked.AddDynamic(this, &USettingsMenuWidget::HandleAudioTabClicked);
}
// Chaque onglet recoit un pointeur vers nous : c'est par la qu'il demandera
// une confirmation, sans avoir a fabriquer sa propre boite.
if (TabSwitcher)
{
for (int32 Index = 0; Index < TabSwitcher->GetNumWidgets(); ++Index)
{
if (USettingsTabWidget* Tab = Cast<USettingsTabWidget>(TabSwitcher->GetWidgetAtIndex(Index)))
{
Tab->SetOwningMenu(this);
}
}
}
UpdateTabButtonStates();
}
void USettingsMenuWidget::NativeDestruct()
{
if (BackButton)
{
BackButton->OnClicked.RemoveDynamic(this, &USettingsMenuWidget::HandleBackClicked);
}
if (ResetButton)
{
ResetButton->OnClicked.RemoveDynamic(this, &USettingsMenuWidget::HandleResetClicked);
}
// La boite et le voile vivent au viewport, pas dans notre arbre : ils ne
// disparaitraient pas avec nous.
if (ResetDialog)
{
ResetDialog->RemoveFromParent();
ResetDialog = nullptr;
}
if (KeyCapture)
{
KeyCapture->RemoveFromParent();
KeyCapture = nullptr;
}
Super::NativeDestruct();
}
void USettingsMenuWidget::Open()
{
SetVisibility(ESlateVisibility::Visible);
// L'ecran a pu etre referme sur un autre onglet que celui d'ouverture.
UpdateTabButtonStates();
// L'ecran a pu etre ouvert, referme, puis rouvert apres un changement fait
// ailleurs : on relit toujours plutot que de faire confiance a l'affichage.
if (USettingsTabWidget* ActiveTab = GetActiveTab())
{
ActiveTab->RefreshFromSettings();
}
// Le focus doit etre repris a CHAQUE ouverture, sinon Echap reste sans effet.
SetKeyboardFocus();
}
void USettingsMenuWidget::Close()
{
// Fermer l'ecran avec une question en suspens vaut un refus : sinon le
// demandeur -- typiquement un changement de resolution en attente de
// confirmation -- resterait dans un etat non tranche.
if (bResetDialogOpen)
{
FinishConfirmation(false);
}
if (bKeyCaptureOpen)
{
CloseKeyCapture();
}
SetVisibility(ESlateVisibility::Collapsed);
// La sauvegarde a lieu ici et pas a chaque clic de fleche : ecrire le .ini
// vingt fois pendant qu'on cherche la bonne sensibilite ne sert a rien.
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->SaveSettings();
}
// Les touches remappees vivent ailleurs : Enhanced Input les serialise pour
// son compte, dans son propre fichier. Deux systemes de persistance, mais un
// seul moment ou l'on ecrit.
if (const ULocalPlayer* LocalPlayer = GetOwningLocalPlayer())
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
{
if (UEnhancedInputUserSettings* InputSettings = Subsystem->GetUserSettings())
{
InputSettings->SaveSettings();
}
}
}
}
FReply USettingsMenuWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
const FKey Key = InKeyEvent.GetKey();
if (Key == EKeys::Escape)
{
// La boite et le voile gerent Echap eux-memes quand ils ont le focus.
// S'ils l'ont perdu, on annule ici plutot que de fermer tout l'ecran en
// laissant la question ou le voile affiche.
if (bKeyCaptureOpen)
{
CloseKeyCapture();
return FReply::Handled();
}
if (bResetDialogOpen)
{
FinishConfirmation(false);
return FReply::Handled();
}
OnCloseRequested.Broadcast();
// Handled : sans ca la touche continue de remonter et le menu de pause
// la consommerait une seconde fois.
return FReply::Handled();
}
return Super::NativeOnKeyDown(InGeometry, InKeyEvent);
}
USettingsTabWidget* USettingsMenuWidget::GetActiveTab() const
{
return TabSwitcher ? Cast<USettingsTabWidget>(TabSwitcher->GetActiveWidget()) : nullptr;
}
void USettingsMenuWidget::ShowTab(int32 Index)
{
if (!TabSwitcher || Index < 0 || Index >= TabSwitcher->GetNumWidgets())
{
return;
}
TabSwitcher->SetActiveWidgetIndex(Index);
// L'onglet a change, donc le bouton a griser aussi.
UpdateTabButtonStates();
if (USettingsTabWidget* ActiveTab = GetActiveTab())
{
ActiveTab->RefreshFromSettings();
}
}
void USettingsMenuWidget::UpdateTabButtonStates()
{
const int32 PageCount = TabSwitcher ? TabSwitcher->GetNumWidgets() : 0;
const int32 ActiveIndex = TabSwitcher ? TabSwitcher->GetActiveWidgetIndex() : INDEX_NONE;
// Un bouton est cliquable si sa page existe ET qu'on n'y est pas deja.
// Griser l'onglet courant lui sert d'indicateur de position : sans ca, rien
// ne distingue visuellement l'onglet affiche des autres.
auto ApplyState = [PageCount, ActiveIndex](UButton* Button, int32 TabIndex)
{
if (Button)
{
Button->SetIsEnabled(PageCount > TabIndex && ActiveIndex != TabIndex);
}
};
ApplyState(GameplayTabButton, TabIndexGameplay);
ApplyState(DisplayTabButton, TabIndexDisplay);
ApplyState(GraphicsTabButton, TabIndexGraphics);
ApplyState(ControlsTabButton, TabIndexControls);
ApplyState(AudioTabButton, TabIndexAudio);
}
void USettingsMenuWidget::HandleBackClicked()
{
OnCloseRequested.Broadcast();
}
UConfirmDialogWidget* USettingsMenuWidget::EnsureDialog()
{
if (ResetDialog)
{
return ResetDialog;
}
if (!ResetDialogClass)
{
return nullptr;
}
ResetDialog = CreateWidget<UConfirmDialogWidget>(GetOwningPlayer(), ResetDialogClass);
if (!ResetDialog)
{
return nullptr;
}
ResetDialog->OnConfirmed.AddDynamic(this, &USettingsMenuWidget::HandleDialogConfirmed);
ResetDialog->OnCancelled.AddDynamic(this, &USettingsMenuWidget::HandleDialogCancelled);
ResetDialog->AddToViewport(DialogZOrder);
return ResetDialog;
}
bool USettingsMenuWidget::RequestConfirmation(const FText& MessageOrFormat, float CountdownSeconds, const FOnSettingsConfirmationResult& OnResult)
{
UConfirmDialogWidget* Dialog = EnsureDialog();
if (!Dialog)
{
UE_LOG(LogTemp, Warning, TEXT("USettingsMenuWidget : ResetDialogClass n'est pas assignee, la question ne peut pas etre posee."));
return false;
}
// Une seule boite pour tout l'ecran : si une question etait deja posee, la
// nouvelle l'ecrase. On repond "non" a l'ancienne pour que son demandeur ne
// reste pas suspendu a une reponse qui n'arrivera plus.
if (PendingConfirmation.IsBound())
{
const FOnSettingsConfirmationResult Superseded = PendingConfirmation;
PendingConfirmation.Unbind();
Superseded.ExecuteIfBound(false);
}
PendingConfirmation = OnResult;
bResetDialogOpen = true;
if (CountdownSeconds > 0.f)
{
Dialog->OpenWithCountdown(MessageOrFormat, CountdownSeconds);
}
else
{
Dialog->Open(MessageOrFormat);
}
return true;
}
void USettingsMenuWidget::FinishConfirmation(bool bConfirmed)
{
bResetDialogOpen = false;
if (ResetDialog)
{
ResetDialog->Close();
}
// Detache AVANT d'appeler : le demandeur a parfaitement le droit de reposer
// une question dans son propre handler.
const FOnSettingsConfirmationResult Answered = PendingConfirmation;
PendingConfirmation.Unbind();
SetKeyboardFocus();
Answered.ExecuteIfBound(bConfirmed);
}
void USettingsMenuWidget::HandleDialogConfirmed()
{
FinishConfirmation(true);
}
void USettingsMenuWidget::HandleDialogCancelled()
{
FinishConfirmation(false);
}
void USettingsMenuWidget::HandleResetClicked()
{
FOnSettingsConfirmationResult Callback;
Callback.BindDynamic(this, &USettingsMenuWidget::HandleResetAnswered);
// Sans boite configuree on ne bloque pas le joueur : on reinitialise.
if (!RequestConfirmation(ResetDialogMessage, 0.f, Callback))
{
HandleResetAnswered(true);
}
}
void USettingsMenuWidget::HandleResetAnswered(bool bConfirmed)
{
if (!bConfirmed)
{
return;
}
// Uniquement l'onglet visible : un "Reinitialiser" qui remettrait aussi la
// resolution et le volume serait une mauvaise surprise.
if (USettingsTabWidget* ActiveTab = GetActiveTab())
{
ActiveTab->ResetToDefaults();
}
}
void USettingsMenuWidget::HandleGameplayTabClicked()
{
ShowTab(TabIndexGameplay);
}
void USettingsMenuWidget::HandleDisplayTabClicked()
{
ShowTab(TabIndexDisplay);
}
void USettingsMenuWidget::HandleGraphicsTabClicked()
{
ShowTab(TabIndexGraphics);
}
void USettingsMenuWidget::HandleControlsTabClicked()
{
ShowTab(TabIndexControls);
}
void USettingsMenuWidget::HandleAudioTabClicked()
{
ShowTab(TabIndexAudio);
}
bool USettingsMenuWidget::RequestKeyCapture(const FText& ActionLabel, const FOnSettingsKeyCaptureResult& OnResult)
{
if (!KeyCaptureClass)
{
UE_LOG(LogTemp, Warning, TEXT("USettingsMenuWidget : KeyCaptureClass n'est pas assignee, aucun remappage possible."));
return false;
}
// Cree au premier remappage seulement, puis reutilise -- comme la boite.
if (!KeyCapture)
{
KeyCapture = CreateWidget<UKeyCaptureWidget>(GetOwningPlayer(), KeyCaptureClass);
if (!KeyCapture)
{
return false;
}
KeyCapture->OnKeyCaptured.AddDynamic(this, &USettingsMenuWidget::HandleKeyCaptured);
KeyCapture->OnCaptureCancelled.AddDynamic(this, &USettingsMenuWidget::HandleKeyCaptureCancelled);
KeyCapture->OnConflictAnswered.AddDynamic(this, &USettingsMenuWidget::HandleKeyConflictAnswered);
// Au-dessus de la boite : une confirmation de conflit s'ouvre APRES la
// capture, mais le voile doit couvrir l'ecran pendant qu'il attend.
KeyCapture->AddToViewport(DialogZOrder + 1);
}
// Une capture deja en cours est abandonnee : son demandeur recoit une touche
// invalide plutot que de rester suspendu.
if (PendingKeyCapture.IsBound())
{
const FOnSettingsKeyCaptureResult Superseded = PendingKeyCapture;
PendingKeyCapture.Unbind();
Superseded.ExecuteIfBound(FKey());
}
PendingKeyCapture = OnResult;
bKeyCaptureOpen = true;
KeyCapture->BeginCapture(ActionLabel);
return true;
}
void USettingsMenuWidget::ShowKeyCaptureConflict(const FText& Message, const FOnSettingsConfirmationResult& OnAnswer)
{
if (!KeyCapture || !bKeyCaptureOpen)
{
// Le voile a ete referme entre-temps : on repond "non" plutot que de
// laisser le demandeur suspendu.
OnAnswer.ExecuteIfBound(false);
return;
}
PendingConflictAnswer = OnAnswer;
KeyCapture->ShowConflict(Message);
}
void USettingsMenuWidget::CloseKeyCapture()
{
bKeyCaptureOpen = false;
if (KeyCapture)
{
KeyCapture->EndCapture();
}
// On detache tout : si quelqu'un attendait encore une reponse, il la recoit
// negative plutot que jamais.
const FOnSettingsKeyCaptureResult AbandonedCapture = PendingKeyCapture;
const FOnSettingsConfirmationResult AbandonedConflict = PendingConflictAnswer;
PendingKeyCapture.Unbind();
PendingConflictAnswer.Unbind();
SetKeyboardFocus();
AbandonedCapture.ExecuteIfBound(FKey());
AbandonedConflict.ExecuteIfBound(false);
}
void USettingsMenuWidget::HandleKeyCaptured(FKey CapturedKey)
{
// Le voile RESTE ouvert : c'est l'onglet qui decide s'il y a conflit, et
// donc s'il faut le basculer sur la question ou le refermer.
const FOnSettingsKeyCaptureResult Answered = PendingKeyCapture;
PendingKeyCapture.Unbind();
Answered.ExecuteIfBound(CapturedKey);
}
void USettingsMenuWidget::HandleKeyCaptureCancelled()
{
// Echap pendant la capture : rien a arbitrer, on referme.
CloseKeyCapture();
}
void USettingsMenuWidget::HandleKeyConflictAnswered(bool bConfirmed)
{
// Detache AVANT d'appeler : le demandeur applique le remappage dans son
// handler et pourrait vouloir rouvrir une capture derriere.
const FOnSettingsConfirmationResult Answered = PendingConflictAnswer;
PendingConflictAnswer.Unbind();
bKeyCaptureOpen = false;
if (KeyCapture)
{
KeyCapture->EndCapture();
}
SetKeyboardFocus();
Answered.ExecuteIfBound(bConfirmed);
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,11 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SettingsTabWidget.h"
#include "SettingsMenuWidget.h"
void USettingsTabWidget::SetOwningMenu(USettingsMenuWidget* InMenu)
{
OwningMenu = InMenu;
}
@@ -0,0 +1,249 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "StorageContainer.h"
#include "Components/BoxComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/CollisionProfile.h"
#include "Engine/StaticMesh.h"
#include "FpsPlayerController.h"
#include "GameFramework/Pawn.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Kismet/GameplayStatics.h"
#include "Net/UnrealNetwork.h"
#define LOCTEXT_NAMESPACE "StorageContainer"
AStorageContainer::AStorageContainer()
{
// Le tick n'existe que pour animer le couvercle : il demarre eteint et ne
// s'allume que le temps du mouvement.
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);
// Les meshes ne portent AUCUNE collision : elle vit entierement dans la
// boite. Un mesh de pack sans collision simple rendrait le coffre muet sans
// qu'aucune erreur ne le signale, et celle du couvercle partirait avec lui
// des l'ouverture.
BaseMesh->SetCollisionProfileName(UCollisionProfile::NoCollision_ProfileName);
InteractionBox = CreateDefaultSubobject<UBoxComponent>(TEXT("InteractionBox"));
InteractionBox->SetupAttachment(BaseMesh);
// Explicite plutot que de compter sur le profil par defaut : ce composant a
// deux roles indissociables, empecher le joueur de traverser le coffre et
// bloquer le canal Visibility pour que le trace d'interaction le trouve.
// Un profil qui laisserait passer Visibility rendrait le coffre inutilisable
// sans qu'aucune erreur ne le signale.
InteractionBox->SetCollisionProfileName(UCollisionProfile::BlockAll_ProfileName);
// Valeur de depart pour un coffre qui n'aurait pas encore de mesh :
// FitInteractionBox() l'ecrase des qu'il y en a un.
InteractionBox->SetBoxExtent(FVector(40.f, 30.f, 30.f));
// Enfant direct de la caisse : c'est le mesh lui-meme qu'on tourne, donc la
// charniere est le pivot que le pack a donne a SM_Chest_Top.
LidMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("LidMesh"));
LidMesh->SetupAttachment(BaseMesh);
LidMesh->SetCollisionProfileName(UCollisionProfile::NoCollision_ProfileName);
Storage = CreateDefaultSubobject<UInventoryComponent>(TEXT("Storage"));
DisplayName = LOCTEXT("ChestName", "Chest");
InteractionPrompt = LOCTEXT("OpenChest", "Ouvrir le coffre");
}
void AStorageContainer::PreInitializeComponents()
{
// Avant InitializeComponents(), donc avant que le composant fige sa taille
// par defaut de 30 slots avec une barre rapide.
if (Storage)
{
Storage->ConfigureAsContainer(SlotCount);
}
Super::PreInitializeComponents();
}
void AStorageContainer::BeginPlay()
{
Super::BeginPlay();
// Le couvercle repose a zero, quelle que soit la rotation laissee dans le
// Blueprint : le repos EST la reference de l'animation.
LidAlpha = 0.f;
ApplyLidRotation();
if (!Storage)
{
return;
}
for (const FStorageStartingItem& Entry : StartingItems)
{
if (!Entry.Item || Entry.Quantity <= 0)
{
continue;
}
const int32 Left = Storage->AddItem(Entry.Item, Entry.Quantity);
if (Left > 0)
{
UE_LOG(LogTemp, Warning, TEXT("%s : %d x %s n'ont pas tenu dans le coffre (%d cases)."),
*GetName(), Left, *GetNameSafe(Entry.Item), SlotCount);
}
}
}
void AStorageContainer::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
// Tourne aussi dans l'editeur : la boite se recale a l'instant ou tu
// changes de mesh, sans avoir a lancer le jeu pour t'en assurer.
if (bAutoFitInteractionBox)
{
FitInteractionBox();
}
}
void AStorageContainer::FitInteractionBox()
{
if (!InteractionBox || !BaseMesh)
{
return;
}
// Volume combine des deux meshes, exprime dans le repere de la caisse --
// celui de la boite, qui en est fille.
FBox Combined(ForceInit);
const UStaticMeshComponent* Meshes[] = { BaseMesh, LidMesh };
for (const UStaticMeshComponent* Component : Meshes)
{
const UStaticMesh* Mesh = Component ? Component->GetStaticMesh() : nullptr;
if (!Mesh)
{
continue;
}
// Transform RELATIVE a la racine et non absolue : la boite doit rester
// juste quel que soit l'endroit ou l'acteur est pose dans le niveau.
const FTransform Relative = Component->GetComponentTransform().GetRelativeTransform(BaseMesh->GetComponentTransform());
Combined += Mesh->GetBoundingBox().TransformBy(Relative);
}
if (!Combined.IsValid)
{
return;
}
InteractionBox->SetRelativeLocation(Combined.GetCenter());
InteractionBox->SetBoxExtent(Combined.GetExtent() + FVector(InteractionBoxPadding));
}
FText AStorageContainer::GetInteractionPrompt_Implementation(AActor* Interactor) const
{
return InteractionPrompt;
}
void AStorageContainer::Interact_Implementation(AActor* Interactor)
{
// L'ecran vit sur le controller, pas sur le pawn : c'est lui qu'on cherche.
const APawn* InteractingPawn = Cast<APawn>(Interactor);
AFpsPlayerController* PlayerController = InteractingPawn ? Cast<AFpsPlayerController>(InteractingPawn->GetController()) : nullptr;
if (!PlayerController)
{
return;
}
PlayerController->OpenStorage(this);
}
void AStorageContainer::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AStorageContainer, bOpen);
}
void AStorageContainer::SetOpen(bool 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());
}
// Le tick s'eteint tout seul en fin de course. Le rallumer ici suffit, y
// compris si l'animation precedente n'etait pas terminee : l'alpha repart
// de sa valeur courante, donc refermer un coffre a moitie ouvert ne saute
// pas.
SetActorTickEnabled(true);
}
void AStorageContainer::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
const float Target = bOpen ? 1.f : 0.f;
const float Speed = 1.f / FMath::Max(0.05f, LidDuration);
LidAlpha = FMath::FInterpConstantTo(LidAlpha, Target, DeltaTime, Speed);
ApplyLidRotation();
if (FMath::IsNearlyEqual(LidAlpha, Target))
{
// On force la valeur exacte avant de se rendormir : sans ca le couvercle
// resterait a un poil de sa position finale pour le reste de la partie.
LidAlpha = Target;
ApplyLidRotation();
SetActorTickEnabled(false);
}
}
void AStorageContainer::ApplyLidRotation()
{
if (!LidMesh)
{
return;
}
// Lissage aux extremites : un couvercle a vitesse constante demarre et
// s'arrete d'un coup, ce qui se lit comme une saccade sur un objet lourd.
const float Eased = FMath::SmoothStep(0.f, 1.f, LidAlpha);
LidMesh->SetRelativeRotation(OpenRotation * Eased);
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,61 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SurvivalAudioSettings.h"
#include "Sound/SoundClass.h"
FName USurvivalAudioSettings::GetCategoryName() const
{
return TEXT("Game");
}
USurvivalAudioSettings* USurvivalAudioSettings::Get()
{
// GetMutableDefault et non GetDefault : les champs Cached* sont remplis a la
// demande, donc les accesseurs ne peuvent pas etre const. Ce ne sont ni des
// UPROPERTY ni des donnees serialisees -- muter un CDO n'est un probleme que
// pour ce qui part sur le disque.
return GetMutableDefault<USurvivalAudioSettings>();
}
USoundClass* USurvivalAudioSettings::Resolve(const TSoftObjectPtr<USoundClass>& Asset, TStrongObjectPtr<USoundClass>& Cache)
{
if (Cache.IsValid())
{
return Cache.Get();
}
if (Asset.IsNull())
{
return nullptr;
}
// Synchrone assume : une SoundClass est un objet minuscule, et le chargement
// n'a lieu qu'une fois par lancement.
//
// Reset et pas une affectation : c'est ce Reset qui pose l'ancre GC, et la
// raison pour laquelle ces caches ne sont pas des UPROPERTY (voir le header).
Cache.Reset(Asset.LoadSynchronous());
return Cache.Get();
}
USoundClass* USurvivalAudioSettings::GetMasterSoundClass()
{
return Resolve(MasterSoundClass, CachedMasterSoundClass);
}
USoundClass* USurvivalAudioSettings::GetMusicSoundClass()
{
return Resolve(MusicSoundClass, CachedMusicSoundClass);
}
USoundClass* USurvivalAudioSettings::GetEffectsSoundClass()
{
return Resolve(EffectsSoundClass, CachedEffectsSoundClass);
}
USoundClass* USurvivalAudioSettings::GetUiSoundClass()
{
return Resolve(UiSoundClass, CachedUiSoundClass);
}
@@ -0,0 +1,364 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SurvivalGameInstance.h"
#include "ContentStreaming.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/Engine.h"
#include "Engine/GameViewportClient.h"
#include "Engine/LocalPlayer.h"
#include "GameMapsSettings.h"
#include "InputMappingContext.h"
#include "KeyboardLayout.h"
#include "Kismet/GameplayStatics.h"
#include "SessionSubsystem.h"
#include "SurvivalUserSettings.h"
#include "UserSettings/EnhancedInputUserSettings.h"
void USurvivalGameInstance::Init()
{
Super::Init();
// Pas d'enregistrement des IMC ici : a ce stade il n'y a ni joueur local ni
// PlayerController, donc pas encore de UserSettings a qui parler.
// RegisterMappableContexts() est appelee par l'ecran de reglages.
// Les reglages sont deja LUS depuis le .ini par le moteur, mais rien ne les
// a encore APPLIQUES : sans cet appel, la resolution et la qualite
// choisies au dernier lancement resteraient lettre morte jusqu'au premier
// passage dans le menu.
//
// C'est le bon endroit : le GameInstance est le seul objet qui naisse avant
// la premiere map et survive a tous les changements de niveau.
if (USurvivalUserSettings* Settings = USurvivalUserSettings::Get())
{
Settings->ApplySettings(/*bCheckForCommandLineOverrides=*/false);
}
else
{
UE_LOG(LogTemp, Error, TEXT("USurvivalGameInstance : GameUserSettingsClassName n'est pas configure dans DefaultEngine.ini, aucun reglage ne sera pris en compte."));
}
// Filet de securite reseau. On s'abonne ici, une fois pour toutes : ces
// evenements arrivent au pire moment, typiquement quand le monde est en
// train d'etre detruit, et un abonnement porte par un acteur mourrait
// justement avec lui.
if (GEngine)
{
NetworkFailureHandle = GEngine->OnNetworkFailure().AddUObject(this, &USurvivalGameInstance::HandleNetworkFailure);
TravelFailureHandle = GEngine->OnTravelFailure().AddUObject(this, &USurvivalGameInstance::HandleTravelFailure);
}
PreLoadMapHandle = FCoreUObjectDelegates::PreLoadMap.AddUObject(this, &USurvivalGameInstance::HandlePreLoadMap);
}
void USurvivalGameInstance::HandlePreLoadMap(const FString& MapName)
{
// On coupe AVANT que quoi que ce soit du nouveau monde n'existe. C'est le
// premier controller pret qui rallumera, une fois son voile noir en place --
// et il naitra forcement apres nous, ce qui garantit le bon ordre.
SetWorldRenderingSuspended(true);
UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : chargement de [%s], rendu du monde suspendu."), *MapName);
// Le timer du GameInstance et non celui du monde : celui-ci est justement en
// train d'etre detruit.
GetTimerManager().SetTimer(
RenderingResumeTimeout, this, &USurvivalGameInstance::ResumeWorldRendering,
20.f, /*bLoop=*/false);
}
void USurvivalGameInstance::BeginSceneWarmup(FSimpleDelegate OnComplete)
{
SceneWarmupCompleted = OnComplete;
RemainingWarmupFrames = FMath::Max(SceneWarmupFrames, 0);
WarmupStartTime = FPlatformTime::Seconds();
if (RemainingWarmupFrames <= 0)
{
SceneWarmupCompleted.ExecuteIfBound();
SceneWarmupCompleted.Unbind();
return;
}
TickSceneWarmup();
}
void USurvivalGameInstance::TickSceneWarmup()
{
const double Elapsed = FPlatformTime::Seconds() - WarmupStartTime;
const bool bCapReached = Elapsed >= SceneWarmupMaxSeconds;
if (RemainingWarmupFrames > 0 && !bCapReached)
{
--RemainingWarmupFrames;
// SetTimerForNextTick et non un timer en secondes : une image rendue,
// un tick. C'est l'unite dans laquelle Nanite et Lumen progressent.
GetTimerManager().SetTimerForNextTick(this, &USurvivalGameInstance::TickSceneWarmup);
return;
}
UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : chauffe terminee -- %d images rendues en %.0f ms%s."),
SceneWarmupFrames - RemainingWarmupFrames, Elapsed * 1000.0,
bCapReached ? TEXT(" (plafond atteint)") : TEXT(""));
// Copie avant execution : le rappel declenche un fondu, qui pourrait
// theoriquement relancer une chauffe et ecraser le delegue en pleine main.
FSimpleDelegate Completed = SceneWarmupCompleted;
SceneWarmupCompleted.Unbind();
Completed.ExecuteIfBound();
}
void USurvivalGameInstance::SetWorldRenderingSuspended(bool bSuspended)
{
if (UGameViewportClient* Viewport = GetGameViewportClient())
{
Viewport->bDisableWorldRendering = bSuspended;
}
}
void USurvivalGameInstance::ResumeWorldRendering()
{
GetTimerManager().ClearTimer(RenderingResumeTimeout);
// Streaming vide AVANT de rallumer. UpdateResourceStreaming(0, true) traite
// tout sans avoir besoin d'un point de vue -- ce qui tombe bien, le rendu
// est encore coupe donc personne ne lui en a fourni -- puis on bloque le
// temps que les requetes aboutissent.
//
// Ce blocage a lieu ECRAN NOIR : c'est du temps de chargement, pas une
// saccade. C'est exactement ce que le moteur fait lui-meme a l'ouverture
// d'une map, et c'est ce qui evite de voir le terrain se raffiner et les
// modeles apparaitre un a un.
const double StartTime = FPlatformTime::Seconds();
const int32 PendingRequests = IStreamingManager::Get().StreamAllResources(FMath::Max(StreamingFlushTimeLimit, 0.f));
const double ElapsedMs = (FPlatformTime::Seconds() - StartTime) * 1000.0;
SetWorldRenderingSuspended(false);
UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : rendu du monde repris apres %.0f ms de streaming (%d requete(s) restante(s))."),
ElapsedMs, PendingRequests);
}
void USurvivalGameInstance::Shutdown()
{
if (GEngine)
{
GEngine->OnNetworkFailure().Remove(NetworkFailureHandle);
GEngine->OnTravelFailure().Remove(TravelFailureHandle);
}
FCoreUObjectDelegates::PreLoadMap.Remove(PreLoadMapHandle);
Super::Shutdown();
}
void USurvivalGameInstance::HandleNetworkFailure(UWorld* World, UNetDriver* NetDriver, ENetworkFailure::Type FailureType, const FString& ErrorString)
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : echec reseau [%s] -- %s"),
ENetworkFailure::ToString(FailureType), *ErrorString);
// Depart deja en cours : c'est le cas NORMAL quand l'hote quitte, puisqu'il
// a prevenu tout le monde avant de couper. On laisse les fondus finir.
if (bReturningToMainMenu)
{
UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : depart deja en cours, echec ignore."));
return;
}
// Ici en revanche personne n'a prevenu : hote qui plante, cable arrache,
// relais Steam qui lache. C'est le filet de secours, sans fondu -- mieux
// vaut un retour brutal au menu qu'un monde mort.
ReturnToMainMenu();
}
void USurvivalGameInstance::HandleTravelFailure(UWorld* World, ETravelFailure::Type FailureType, const FString& ErrorString)
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : echec de voyage [%s] -- %s"),
ETravelFailure::ToString(FailureType), *ErrorString);
if (bReturningToMainMenu)
{
return;
}
ReturnToMainMenu();
}
void USurvivalGameInstance::ReturnToMainMenu()
{
bReturningToMainMenu = true;
// La session est morte avec la connexion : la detruire ici evite qu'elle
// reste annoncee a tes amis, et surtout qu'elle bloque la creation de la
// suivante.
if (USessionSubsystem* Sessions = GetSubsystem<USessionSubsystem>())
{
Sessions->LeaveSession();
}
// GetGameDefaultMap et pas un chemin en dur : c'est deja MenuScene dans
// DefaultEngine.ini, et une seconde copie du chemin finirait par diverger.
const FString MenuMap = UGameMapsSettings::GetGameDefaultMap();
if (MenuMap.IsEmpty())
{
UE_LOG(LogTemp, Error, TEXT("USurvivalGameInstance : aucune GameDefaultMap, impossible de revenir au menu."));
return;
}
UGameplayStatics::OpenLevel(this, FName(*MenuMap), /*bAbsolute=*/true);
}
void USurvivalGameInstance::RegisterMappableContexts()
{
ULocalPlayer* LocalPlayer = GetFirstGamePlayer();
if (!LocalPlayer)
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : aucun joueur local, enregistrement des touches remis a plus tard."));
return;
}
if (MappableContexts.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : MappableContexts est vide, l'onglet Touches affichera 'Non assignee' partout. Ajoute IMC_Default dans le Blueprint de GameInstance."));
return;
}
UEnhancedInputLocalPlayerSubsystem* Subsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
UEnhancedInputUserSettings* InputSettings = Subsystem ? Subsystem->GetUserSettings() : nullptr;
if (!InputSettings)
{
UE_LOG(LogTemp, Error, TEXT("USurvivalGameInstance : reglages d'input introuvables. Coche 'Enable User Settings' dans Project Settings > Enhanced Input."));
return;
}
bool bRegisteredAnyContext = false;
for (const TObjectPtr<UInputMappingContext>& Context : MappableContexts)
{
if (Context)
{
bRegisteredAnyContext = true;
// Idempotent : le moteur ignore un contexte deja connu, on peut donc
// rappeler cette fonction sans precaution.
InputSettings->RegisterInputMappingContext(Context);
}
}
// APRES l'enregistrement et jamais avant : le profil de touches ne connait un
// mapping -- donc sa touche par defaut -- qu'une fois son IMC declare. Appeler
// la conversion plus tot la ferait tourner sur un profil vide, sans erreur ni
// effet, ce qui est le pire des cas a diagnostiquer.
//
// UNE SEULE FOIS par session, en revanche. Cette fonction est aussi le point
// d'entree du rafraichissement de l'onglet Touches, appele apres CHAQUE
// remappage : y laisser la conversion reprendrait sa touche au joueur qui a
// choisi celle de l'IMC -- la lettre W sur un AZERTY est identique au defaut,
// donc indiscernable d'un mapping auquel personne n'a touche.
if (bRegisteredAnyContext && !bKeyboardLayoutApplied)
{
bKeyboardLayoutApplied = true;
ApplyKeyboardLayoutToDefaultKeys();
}
}
void USurvivalGameInstance::ApplyKeyboardLayoutToDefaultKeys()
{
const ULocalPlayer* LocalPlayer = GetFirstGamePlayer();
if (!LocalPlayer)
{
return;
}
UEnhancedInputLocalPlayerSubsystem* Subsystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>();
UEnhancedInputUserSettings* InputSettings = Subsystem ? Subsystem->GetUserSettings() : nullptr;
const UEnhancedPlayerMappableKeyProfile* Profile = InputSettings ? InputSettings->GetActiveKeyProfile() : nullptr;
if (!Profile)
{
return;
}
// On COLLECTE avant d'appliquer, au lieu de mapper au fil de la lecture.
// MapPlayerKey ecrit dans les memes lignes que celles qu'on parcourt : muter un
// conteneur pendant son iteration invalide les iterateurs des que le stockage
// est reorganise. Le bug qui en sort est intermittent et ne ressemble a rien.
struct FPendingRemap
{
FName MappingName;
EPlayerMappableKeySlot Slot;
FKey LayoutKey;
};
TArray<FPendingRemap> PendingRemaps;
for (const TPair<FName, FKeyMappingRow>& Row : Profile->GetPlayerMappingRows())
{
for (const FPlayerKeyMapping& Mapping : Row.Value.Mappings)
{
// Le joueur a deja choisi cette touche : son choix fait autorite, meme
// s'il est incoherent avec sa disposition. C'est aussi ce qui rend la
// fonction idempotente -- notre propre conversion compte comme un choix
// des qu'elle est posee, donc on ne repasse jamais dessus.
if (Mapping.IsCustomized())
{
continue;
}
const FKey DefaultKey = Mapping.GetDefaultKey();
const FKey LayoutKey = KeyboardLayout::RemapKeyToActiveLayout(DefaultKey);
// Clavier QWERTY, touche insensible a la disposition, ou manette : la
// conversion rend la touche d'origine et il n'y a rien a ecrire. Sans ce
// test on poserait une surcharge identique au defaut sur chaque mapping,
// et "Reinitialiser" n'aurait plus rien a reinitialiser.
if (LayoutKey == DefaultKey)
{
continue;
}
PendingRemaps.Add({ Row.Key, Mapping.GetSlot(), LayoutKey });
}
}
// Journalise AVANT de sortir, et donc meme quand il n'y a rien a faire. C'est
// la seule trace lisible quand un testeur signale des touches inattendues, et
// le cas "0 touche" est justement celui qu'il faut pouvoir distinguer du
// silence : sans cette ligne, un clavier QWERTY et un systeme qui n'a pas
// tourne du tout produisent exactement le meme log, c'est-a-dire aucun.
//
// Un KLID se lit en deux temps : "0000040C" est un AZERTY francais, "00000409"
// un QWERTY US. Un utilisateur francais sur clavier QWERTY rend bien 00000409,
// parce que c'est la DISPOSITION qui est journalisee, pas la langue -- et c'est
// exactement la distinction sur laquelle une detection par pays se tromperait.
UE_LOG(LogTemp, Log, TEXT("USurvivalGameInstance : disposition clavier [%s], %d touche(s) a traduire depuis le QWERTY de reference."),
*KeyboardLayout::GetActiveLayoutId(), PendingRemaps.Num());
if (PendingRemaps.IsEmpty())
{
return;
}
for (const FPendingRemap& Remap : PendingRemaps)
{
FMapPlayerKeyArgs Args;
Args.MappingName = Remap.MappingName;
Args.Slot = Remap.Slot;
Args.NewKey = Remap.LayoutKey;
FGameplayTagContainer FailureReason;
InputSettings->MapPlayerKey(Args, FailureReason);
if (!FailureReason.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalGameInstance : conversion de '%s' vers '%s' refusee (%s)."),
*Remap.MappingName.ToString(), *Remap.LayoutKey.ToString(), *FailureReason.ToString());
}
}
// Pousse les surcharges dans les contextes deja actifs : le pawn n'a rien a
// rebrancher, les touches sont bonnes des la premiere image.
InputSettings->ApplySettings();
}
@@ -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);
}
@@ -0,0 +1,74 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SurvivalPlayerState.h"
#include "CharacterAppearanceComponent.h"
#include "FpsPlayer.h"
#include "Net/UnrealNetwork.h"
void ASurvivalPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// Aucune condition : COND_OwnerOnly serait exactement le contraire de ce
// qu'on veut. L'interet d'une apparence, c'est que les trois autres la
// voient ; celle du proprietaire est la seule dont il pourrait se passer,
// puisqu'il ne voit pas son propre corps en vue premiere personne.
DOREPLIFETIME(ASurvivalPlayerState, CharacterAppearance);
}
void ASurvivalPlayerState::SetCharacterAppearance(const FCharacterAppearance& InAppearance)
{
if (!HasAuthority())
{
UE_LOG(LogTemp, Warning, TEXT("ASurvivalPlayerState : SetCharacterAppearance appelee sans autorite, ignoree."));
return;
}
// L'apparence n'est PAS validee ici, et c'est deliberé : borner un index
// demande le catalogue, que seul UCharacterAppearanceComponent connait. Or
// celui-ci passe deja tout ce qu'il monte par Catalog->Sanitize(), donc un
// index aberrant venu du reseau est corrige au montage, sur chaque machine.
// Dupliquer la validation ici demanderait de donner le catalogue au
// PlayerState pour un resultat identique -- et cette coop entre amis n'a de
// toute facon aucune anti-triche a faire respecter.
CharacterAppearance = InAppearance;
// Un OnRep ne se declenche jamais sur la machine qui ecrit la propriete.
// Chez l'hote, qui est serveur ET client local, sans cette ligne son propre
// personnage resterait au visage par defaut pendant que les autres le
// verraient correctement.
ApplyAppearanceToPawn();
}
void ASurvivalPlayerState::OnRep_CharacterAppearance()
{
ApplyAppearanceToPawn();
}
void ASurvivalPlayerState::ApplyAppearanceToPawn()
{
// Le pawn peut manquer, et ce n'est pas une erreur : chez un client, le
// PlayerState et le pawn arrivent par deux chemins de replication
// independants, dans un ordre qu'on ne controle pas. Quand c'est le
// PlayerState qui gagne, on ne fait rien -- c'est le BeginPlay du pawn qui
// viendra chercher l'apparence a son tour.
if (AFpsPlayer* Player = Cast<AFpsPlayer>(GetPawn()))
{
if (UCharacterAppearanceComponent* Appearance = Player->GetAppearanceComponent())
{
Appearance->ApplyAppearance(CharacterAppearance);
}
}
}
void ASurvivalPlayerState::CopyProperties(APlayerState* PlayerState)
{
Super::CopyProperties(PlayerState);
if (ASurvivalPlayerState* SurvivalState = Cast<ASurvivalPlayerState>(PlayerState))
{
SurvivalState->CharacterAppearance = CharacterAppearance;
}
}
@@ -0,0 +1,189 @@
// Fill out your copyright notice in the Description page of Project Settings.
#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;
bDead = false;
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(
UpdateTimerHandle, this, &USurvivalStatsComponent::UpdateStats,
FMath::Max(UpdateInterval, 0.05f), /*bLoop=*/true);
}
OnStatsChanged.Broadcast();
}
void USurvivalStatsComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// Un timer laisse en vie apres la destruction du composant rappellerait
// une methode sur un objet mort.
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(UpdateTimerHandle);
}
Super::EndPlay(EndPlayReason);
}
void USurvivalStatsComponent::UpdateStats()
{
if (bDead)
{
return;
}
// Les vitesses sont par minute, l'intervalle en secondes.
const float Minutes = FMath::Max(UpdateInterval, 0.05f) / 60.f;
Hunger = FMath::Max(0.f, Hunger - HungerDecayPerMinute * Minutes);
Thirst = FMath::Max(0.f, Thirst - ThirstDecayPerMinute * Minutes);
// Faim ET soif a zero cumulent leurs degats : mourir de soif pendant
// qu'on meurt de faim doit aller plus vite.
float Damage = 0.f;
if (Hunger <= 0.f)
{
Damage += StarvationDamagePerMinute * Minutes;
}
if (Thirst <= 0.f)
{
Damage += DehydrationDamagePerMinute * Minutes;
}
if (Damage > 0.f)
{
Health = FMath::Max(0.f, Health - Damage);
}
OnStatsChanged.Broadcast();
if (Health <= 0.f && !bDead)
{
bDead = true;
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(UpdateTimerHandle);
}
UE_LOG(LogTemp, Log, TEXT("USurvivalStatsComponent : %s est mort."), *GetNameSafe(GetOwner()));
OnDied.Broadcast();
}
}
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;
}
Health = FMath::Clamp(Health + HealthAmount, 0.f, MaxHealth);
Hunger = FMath::Clamp(Hunger + HungerAmount, 0.f, MaxHunger);
Thirst = FMath::Clamp(Thirst + ThirstAmount, 0.f, MaxThirst);
OnStatsChanged.Broadcast();
}
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;
}
Health = FMath::Max(0.f, Health - Amount);
OnStatsChanged.Broadcast();
if (Health <= 0.f)
{
bDead = true;
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(UpdateTimerHandle);
}
OnDied.Broadcast();
}
}
@@ -0,0 +1,104 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SurvivalStatsWidget.h"
#include "Components/ProgressBar.h"
#include "FpsPlayer.h"
#include "GameFramework/PlayerController.h"
#include "SurvivalStatsComponent.h"
void USurvivalStatsWidget::NativeConstruct()
{
Super::NativeConstruct();
// Releve des couleurs telles que reglees dans le Designer, avant que le
// code ne commence a les remplacer.
if (HealthBar)
{
HealthBaseColor = HealthBar->GetFillColorAndOpacity();
}
if (HungerBar)
{
HungerBaseColor = HungerBar->GetFillColorAndOpacity();
}
if (ThirstBar)
{
ThirstBaseColor = ThirstBar->GetFillColorAndOpacity();
}
BindToOwningPawn();
}
void USurvivalStatsWidget::BindToOwningPawn()
{
USurvivalStatsComponent* NewStats = ResolveStats();
if (BoundStats.Get() == NewStats)
{
return;
}
if (BoundStats.IsValid())
{
BoundStats->OnStatsChanged.RemoveDynamic(this, &USurvivalStatsWidget::HandleStatsChanged);
}
BoundStats = NewStats;
if (BoundStats.IsValid())
{
BoundStats->OnStatsChanged.AddDynamic(this, &USurvivalStatsWidget::HandleStatsChanged);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalStatsWidget : aucun USurvivalStatsComponent trouve sur le pawn possede."));
}
Refresh();
}
void USurvivalStatsWidget::NativeDestruct()
{
if (BoundStats.IsValid())
{
BoundStats->OnStatsChanged.RemoveDynamic(this, &USurvivalStatsWidget::HandleStatsChanged);
}
BoundStats.Reset();
Super::NativeDestruct();
}
USurvivalStatsComponent* USurvivalStatsWidget::ResolveStats() const
{
const APlayerController* OwningController = GetOwningPlayer();
AFpsPlayer* Player = OwningController ? Cast<AFpsPlayer>(OwningController->GetPawn()) : nullptr;
return Player ? Player->GetSurvivalStats() : nullptr;
}
void USurvivalStatsWidget::HandleStatsChanged()
{
Refresh();
}
void USurvivalStatsWidget::Refresh()
{
if (!BoundStats.IsValid())
{
return;
}
ApplyBar(HealthBar, BoundStats->GetHealthPercent(), HealthBaseColor);
ApplyBar(HungerBar, BoundStats->GetHungerPercent(), HungerBaseColor);
ApplyBar(ThirstBar, BoundStats->GetThirstPercent(), ThirstBaseColor);
}
void USurvivalStatsWidget::ApplyBar(UProgressBar* Bar, float Percent, const FLinearColor& BaseColor) const
{
if (!Bar)
{
return;
}
Bar->SetPercent(FMath::Clamp(Percent, 0.f, 1.f));
Bar->SetFillColorAndOpacity(Percent <= CriticalThreshold ? CriticalColor : BaseColor);
}
@@ -0,0 +1,351 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SurvivalUserSettings.h"
#include "AudioDevice.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "Misc/App.h"
#include "Sound/AudioSettings.h"
#include "Sound/SoundClass.h"
#include "Sound/SoundMix.h"
#include "SurvivalAudioSettings.h"
namespace
{
/** Arrondit sur le palier le plus proche, puis borne. */
float SnapToStep(float Value, float Min, float Max, float Step)
{
const float Snapped = Min + FMath::RoundToFloat((Value - Min) / Step) * Step;
return FMath::Clamp(Snapped, Min, Max);
}
/** Pourcentage affiche -> amplitude reelle, courbe perceptive comprise. */
float VolumePercentToAmplitude(float Percent)
{
using namespace SurvivalSettingsRange;
const float Normalized = FMath::Clamp(Percent, VolumeMin, VolumeMax) / 100.f;
return FMath::Pow(Normalized, VolumeCurveExponent);
}
/**
* Le peripherique audio a qui parler.
*
* En PIE le monde de jeu peut avoir le sien, distinct de celui de l'editeur :
* viser systematiquement le peripherique principal ferait regler le volume de
* l'editeur pendant que le jeu, lui, ne bouge pas.
*/
FAudioDevice* GetSettingsAudioDevice()
{
if (!GEngine)
{
return nullptr;
}
if (const UWorld* PlayWorld = GEngine->GetCurrentPlayWorld())
{
if (FAudioDevice* WorldDevice = PlayWorld->GetAudioDeviceRaw())
{
return WorldDevice;
}
}
return GEngine->GetMainAudioDeviceRaw();
}
/**
* Le mix par lequel passent les volumes du joueur.
*
* C'est celui de Project Settings > Audio > Default Base Sound Mix, et pas une
* reference a nous. Deux raisons : le moteur le REPOSE a chaque chargement de
* map (UWorld::UpdateWorldComponents -> SetDefaultBaseSoundMix), donc il est
* toujours actif sans qu'on ait a le pousser nous-memes ; et un mix de base est
* evalue a plein regime en permanence, alors qu'un mix pousse a la main
* s'eteindrait au bout de sa duree.
*/
USoundMix* GetSettingsSoundMix()
{
const FSoftObjectPath& MixPath = GetDefault<UAudioSettings>()->DefaultBaseSoundMix;
return MixPath.IsValid() ? Cast<USoundMix>(MixPath.TryLoad()) : nullptr;
}
/**
* Pose le volume d'une SoundClass, en pourcentage.
*
* bApplyToChildren merite une explication, parce que se tromper la donne un
* volume maitre qui n'agit que sur les sons non tagues : le moteur propage les
* proprietes du parent vers les enfants (ParseSoundClasses) AVANT d'appliquer
* les ajusteurs du mix (ApplyClassAdjusters). Un ajusteur pose sur la classe
* maitre arrive donc trop tard pour redescendre tout seul -- il faut demander
* explicitement la recursion. Les feuilles, elles, gardent false : leur
* ajusteur n'a personne a qui redescendre.
*
* Aucun fondu non plus, et ce n'est pas un oubli : toute duree non nulle rend le
* curseur inutilisable. Quand on repose un override alors que le precedent n'a
* pas fini son fondu, UpdateClassAdjustorOverrideEntry relance l'interpolation
* (FDynamicParameter::Set remet CurrTimeSec a zero) mais n'appelle PAS
* Update(DeltaTime) cette frame-la. Un curseur qu'on glisse repose l'override a
* chaque image : la rampe repart indefiniment de zero et GetValue() rend toujours
* la valeur precedente. Le volume ne rattrape son retard qu'une fois le geste
* ARRETE. A zero, Set() ecrit directement la valeur -- et le mixeur lisse deja le
* gain de chaque source dans son buffer de rendu, donc pas de clic pour autant.
*/
void ApplyClassVolume(FAudioDevice& AudioDevice, USoundMix& Mix, USoundClass* SoundClass, float Percent, bool bApplyToChildren)
{
if (!SoundClass)
{
return;
}
AudioDevice.SetSoundMixClassOverride(&Mix, SoundClass,
VolumePercentToAmplitude(Percent), /*Pitch*/ 1.f, /*FadeInTime*/ 0.f, bApplyToChildren);
}
}
USurvivalUserSettings::USurvivalUserSettings()
{
// Le constructeur tourne sur le CDO, avant toute lecture du .ini : ces
// valeurs sont donc bien les defauts d'usine, ecrasees ensuite par le
// fichier s'il existe.
SetSurvivalGameplayToDefaults();
SetSurvivalDisplayToDefaults();
SetSurvivalAudioToDefaults();
}
USurvivalUserSettings* USurvivalUserSettings::Get()
{
return GEngine ? Cast<USurvivalUserSettings>(GEngine->GetGameUserSettings()) : nullptr;
}
void USurvivalUserSettings::SetSurvivalGameplayToDefaults()
{
LookSensitivity = 35.f;
bSeparateVerticalSensitivity = false;
LookSensitivityVertical = 35.f;
bInvertLookY = false;
FieldOfView = 90.f;
CameraShakeScale = 100.f;
bSprintToggle = false;
bCrouchToggle = false;
}
void USurvivalUserSettings::SetSurvivalDisplayToDefaults()
{
Brightness = 100.f;
}
void USurvivalUserSettings::SetSurvivalAudioToDefaults()
{
// 80 et non 100 sur le maitre : il faut laisser au joueur de la marge vers le
// haut, sinon le seul reglage possible est de baisser.
MasterVolume = 80.f;
// La musique sous les effets, comme partout : elle est continue, eux sont des
// evenements, et un evenement doit passer par-dessus le fond.
MusicVolume = 60.f;
EffectsVolume = 100.f;
UiVolume = 80.f;
// Le defaut du moteur, qu'on se contente d'exposer.
bPlayInBackground = false;
}
void USurvivalUserSettings::SetToDefaults()
{
// Le "Reinitialiser" global du moteur doit aussi remettre NOS valeurs :
// sans cette surcharge, elles resteraient seules inchangees.
Super::SetToDefaults();
SetSurvivalGameplayToDefaults();
SetSurvivalDisplayToDefaults();
SetSurvivalAudioToDefaults();
}
void USurvivalUserSettings::SetBrightness(float Value)
{
using namespace SurvivalSettingsRange;
Brightness = SnapToStep(Value, BrightnessMin, BrightnessMax, BrightnessStep);
}
void USurvivalUserSettings::ApplySurvivalSettings()
{
// Le gamma n'appartient pas a UGameUserSettings : il vit sur GEngine, donc
// sur un objet GLOBAL au processus. Deux consequences :
//
// 1. DisplayGamma est un EXPOSANT dont la valeur moteur est 2.2, pas un
// multiplicateur neutre a 1. On module la reference, on ne la remplace
// pas.
// 2. En editeur, cet objet est celui de l'editeur : y toucher assombrit
// toute l'interface, y compris apres avoir quitte le PIE. On ne l'applique
// donc qu'en jeu. La luminosite ne se teste pas en PIE, mais en
// Standalone -- qui est un processus separe.
if (GEngine && !GIsEditor)
{
using namespace SurvivalSettingsRange;
const float Normalized = FMath::Clamp(Brightness, BrightnessMin, BrightnessMax) / 100.f;
GEngine->DisplayGamma = BaseDisplayGamma * Normalized;
}
OnSurvivalSettingsApplied.Broadcast();
}
void USurvivalUserSettings::ApplyAudioSettings()
{
// Ce reglage-la ne passe pas par le mix : c'est l'application entiere qui est
// baissee quand la fenetre perd le focus, bien en amont des SoundClass. Le
// moteur le stocke dans son propre .ini, d'ou une valeur reposee a chaque
// application plutot que memorisee ailleurs.
FApp::SetUnfocusedVolumeMultiplier(bPlayInBackground ? 1.f : 0.f);
FAudioDevice* AudioDevice = GetSettingsAudioDevice();
if (!AudioDevice)
{
// Normal tres tot au demarrage, ou en serveur dedie : les valeurs sont
// deja enregistrees, le prochain passage les posera.
return;
}
USoundMix* Mix = GetSettingsSoundMix();
if (!Mix)
{
UE_LOG(LogTemp, Warning, TEXT("USurvivalUserSettings : aucun Default Base Sound Mix dans Project Settings > Audio, les curseurs de volume resteront sans effet."));
return;
}
USurvivalAudioSettings* AudioAssets = USurvivalAudioSettings::Get();
// Le maitre EN PREMIER et avec recursion : il redescend sur les trois autres,
// qui reappliquent ensuite la leur par-dessus. Les deux se multiplient, ce qui
// est exactement ce qu'on attend d'un volume general.
ApplyClassVolume(*AudioDevice, *Mix, AudioAssets->GetMasterSoundClass(), MasterVolume, /*bApplyToChildren*/ true);
ApplyClassVolume(*AudioDevice, *Mix, AudioAssets->GetMusicSoundClass(), MusicVolume, /*bApplyToChildren*/ false);
ApplyClassVolume(*AudioDevice, *Mix, AudioAssets->GetEffectsSoundClass(), EffectsVolume, /*bApplyToChildren*/ false);
ApplyClassVolume(*AudioDevice, *Mix, AudioAssets->GetUiSoundClass(), UiVolume, /*bApplyToChildren*/ false);
}
void USurvivalUserSettings::ApplyNonResolutionSettings()
{
Super::ApplyNonResolutionSettings();
// ApplySettings() passe forcement par ici : brancher la diffusion a ce
// niveau garantit que nos reglages suivent, quel que soit le chemin.
ApplySurvivalSettings();
ApplyAudioSettings();
}
float USurvivalUserSettings::GetLookMultiplierX() const
{
using namespace SurvivalSettingsRange;
const float Normalized = FMath::GetRangePct(SensitivityMin, SensitivityMax, LookSensitivity);
return FMath::Lerp(LookMultiplierAtMin, LookMultiplierAtMax, Normalized);
}
float USurvivalUserSettings::GetLookMultiplierY() const
{
using namespace SurvivalSettingsRange;
// Sans axe separe, le vertical suit l'horizontal : c'est ce que veulent
// 95 % des joueurs, et deux curseurs desynchronises par inadvertance
// donnent une visee bancale sans qu'on comprenne pourquoi.
if (!bSeparateVerticalSensitivity)
{
return GetLookMultiplierX();
}
const float Normalized = FMath::GetRangePct(SensitivityMin, SensitivityMax, LookSensitivityVertical);
return FMath::Lerp(LookMultiplierAtMin, LookMultiplierAtMax, Normalized);
}
float USurvivalUserSettings::GetCameraShakeMultiplier() const
{
return FMath::Clamp(CameraShakeScale / 100.f, 0.f, 1.f);
}
void USurvivalUserSettings::SetLookSensitivity(float Value)
{
using namespace SurvivalSettingsRange;
LookSensitivity = SnapToStep(Value, SensitivityMin, SensitivityMax, SensitivityStep);
}
void USurvivalUserSettings::SetSeparateVerticalSensitivity(bool bValue)
{
// En repassant sur "non separe" on recale le vertical sur l'horizontal :
// sinon la valeur oubliee ressortirait telle quelle a la reactivation.
if (!bValue)
{
LookSensitivityVertical = LookSensitivity;
}
bSeparateVerticalSensitivity = bValue;
}
void USurvivalUserSettings::SetLookSensitivityVertical(float Value)
{
using namespace SurvivalSettingsRange;
LookSensitivityVertical = SnapToStep(Value, SensitivityMin, SensitivityMax, SensitivityStep);
}
void USurvivalUserSettings::SetInvertLookY(bool bValue)
{
bInvertLookY = bValue;
}
void USurvivalUserSettings::SetFieldOfView(float Value)
{
using namespace SurvivalSettingsRange;
FieldOfView = SnapToStep(Value, FieldOfViewMin, FieldOfViewMax, FieldOfViewStep);
}
void USurvivalUserSettings::SetCameraShakeScale(float Value)
{
using namespace SurvivalSettingsRange;
CameraShakeScale = SnapToStep(Value, CameraShakeMin, CameraShakeMax, CameraShakeStep);
}
void USurvivalUserSettings::SetSprintToggle(bool bValue)
{
bSprintToggle = bValue;
}
void USurvivalUserSettings::SetCrouchToggle(bool bValue)
{
bCrouchToggle = bValue;
}
void USurvivalUserSettings::SetMasterVolume(float Value)
{
using namespace SurvivalSettingsRange;
MasterVolume = SnapToStep(Value, VolumeMin, VolumeMax, VolumeStep);
}
void USurvivalUserSettings::SetMusicVolume(float Value)
{
using namespace SurvivalSettingsRange;
MusicVolume = SnapToStep(Value, VolumeMin, VolumeMax, VolumeStep);
}
void USurvivalUserSettings::SetEffectsVolume(float Value)
{
using namespace SurvivalSettingsRange;
EffectsVolume = SnapToStep(Value, VolumeMin, VolumeMax, VolumeStep);
}
void USurvivalUserSettings::SetUiVolume(float Value)
{
using namespace SurvivalSettingsRange;
UiVolume = SnapToStep(Value, VolumeMin, VolumeMax, VolumeStep);
}
void USurvivalUserSettings::SetPlayInBackground(bool bValue)
{
bPlayInBackground = bValue;
}
void USurvivalUserSettings::SetCharacterAppearance(const FCharacterAppearance& InAppearance)
{
CharacterAppearance = InAppearance;
}