(Feat) Add Animation Fps Charater
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class EmberWild : ModuleRules
|
||||
{
|
||||
public EmberWild(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
// GameplayTags : Enhanced Input rend ses erreurs de remappage sous forme
|
||||
// de FGameplayTagContainer. Sans ce module on peut declarer le type mais
|
||||
// pas l'inspecter, et on perdrait la raison d'un echec dans le log.
|
||||
// Niagara : ACookingStation pilote sa flamme depuis le C++ (allumage,
|
||||
// extinction). Passer par un evenement Blueprint marcherait aussi, mais
|
||||
// l'etat du feu appartient au C++ -- le Blueprint ne doit brancher que
|
||||
// l'asset, pas decider quand il s'allume.
|
||||
// DeveloperSettings : USurvivalAudioSettings derive de UDeveloperSettings dans
|
||||
// un header public, donc la dependance doit l'etre aussi. C'est ce qui donne
|
||||
// la page "Survival Audio" dans les Project Settings.
|
||||
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GameplayTags", "UMG", "Niagara", "DeveloperSettings" });
|
||||
|
||||
// EngineSettings : UGameMapsSettings::GetGameDefaultMap(), qui evite de
|
||||
// redeclarer le chemin de la map de menu dans le C++ alors qu'il est
|
||||
// deja dans DefaultEngine.ini.
|
||||
// AnimGraphRuntime : UKismetAnimationLibrary::CalculateDirection, qui remplace
|
||||
// la methode homonyme d'UAnimInstance depreciee en 5.8. Prive et pas public --
|
||||
// seul le .cpp de UPlayerBodyAnimInstance l'appelle, son header n'en montre rien.
|
||||
// PhysicsCore : EPhysicalSurface, la cle de la table de UFootstepSoundsDataAsset.
|
||||
// Le header passe sans, Engine l'exposant deja, mais le LIEN echoue --
|
||||
// l'enum est REFLECHIE, donc UHT genere un appel a son constructeur
|
||||
// Z_Construct_UEnum_PhysicsCore_EPhysicalSurface, qui vit dans ce module.
|
||||
// Symptome : ca compile et ca casse a l'edition de liens (LNK2019).
|
||||
// Public parce que FootstepSoundsDataAsset.h montre le type dans sa TMap.
|
||||
PublicDependencyModuleNames.Add("PhysicsCore");
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "EngineSettings", "AnimGraphRuntime" });
|
||||
|
||||
// Coop Steam : on ne depend QUE de l'API generique (OnlineSubsystem) et de
|
||||
// ses helpers (OnlineSubsystemUtils, qui porte le resolveur d'adresses).
|
||||
// Surtout PAS de OnlineSubsystemSteam en dur -- le code appellerait alors
|
||||
// du Steam explicitement et ne compilerait plus sur une plateforme sans
|
||||
// Steam. Le choix du service se fait par ini.
|
||||
//
|
||||
// Public et non Private : SessionSubsystem.h expose IOnlineSessionPtr et
|
||||
// FOnlineSessionSearchResult dans sa declaration. Un header public qui
|
||||
// montre les types d'un module prive ne compile pas chez celui qui l'inclut.
|
||||
PublicDependencyModuleNames.AddRange(new string[] { "OnlineSubsystem", "OnlineSubsystemUtils" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#include "EmberWild.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
|
||||
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, EmberWild, "EmberWild" );
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Animation/AnimNotifies/AnimNotify.h"
|
||||
#include "AnimNotify_ItemAction.generated.h"
|
||||
|
||||
/**
|
||||
* Declenche l'effet de l'objet en main a l'instant precis ou le geste frappe.
|
||||
*
|
||||
* ------------------------------------------------------------------
|
||||
* Pourquoi un notify plutot que le clic
|
||||
* ------------------------------------------------------------------
|
||||
*
|
||||
* Le clic gauche et le COUP sont deux choses distinctes, separees par le temps
|
||||
* que met le bras a descendre. Faire partir le trace de recolte sur l'appui
|
||||
* donne une hache qui abat l'arbre avant de l'avoir touche : le joueur voit le
|
||||
* resultat, puis l'animation qui aurait du le produire. C'est le meme
|
||||
* decalage qu'un bruit de pas joue au debut de la foulee.
|
||||
*
|
||||
* L'instant de l'impact n'est pas une valeur qu'on puisse ecrire en C++ : il
|
||||
* appartient a l'ANIMATION, il change d'un montage a l'autre, et il rebouge a
|
||||
* chaque fois qu'on retouche la courbe. Un delai en secondes sur le DataAsset
|
||||
* serait a re-regler a la main a chaque nouvelle animation livree -- et faux
|
||||
* des qu'on toucherait au Play Rate. Le marqueur, lui, suit le montage.
|
||||
*
|
||||
* ------------------------------------------------------------------
|
||||
* Reseau : ce notify ne tombe QUE chez le joueur local
|
||||
* ------------------------------------------------------------------
|
||||
*
|
||||
* Le montage d'usage est joue sur les BRAS, qui sont en OnlyOwnerSee et
|
||||
* n'existent visuellement que chez le proprietaire du pawn
|
||||
* (UHeldItemComponent::PlayLocalArmsMontage). Le notify part donc sur une
|
||||
* seule machine, exactement comme le clic qu'il remplace, et le routage vers
|
||||
* le serveur reste celui d'avant : AFpsPlayer::TriggerHeldItemAction envoie la
|
||||
* RPC, le serveur fait autorite.
|
||||
*
|
||||
* Corollaire a ne pas oublier le jour ou un montage d'usage sera joue aussi sur
|
||||
* le CORPS (visible des autres joueurs) : ce montage-la tournerait sur les
|
||||
* quatre machines, et sans la garde IsLocallyControlled() de
|
||||
* TriggerHeldItemAction chaque coup de hache partirait en quatre exemplaires.
|
||||
*/
|
||||
UCLASS(const, hidecategories = Object, collapsecategories, meta = (DisplayName = "Item Action"))
|
||||
class EMBERWILD_API UAnimNotify_ItemAction : public UAnimNotify
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,
|
||||
const FAnimNotifyEventReference& EventReference) override;
|
||||
|
||||
virtual FString GetNotifyName_Implementation() const override;
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "CharacterAppearanceTypes.h"
|
||||
#include "CharacterAppearanceComponent.generated.h"
|
||||
|
||||
class UCharacterPartsDataAsset;
|
||||
class USkeletalMeshComponent;
|
||||
struct FCharacterBodySet;
|
||||
|
||||
/** Diffuse a chaque apparence appliquee, pour que l'UI se rafraichisse. */
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnAppearanceApplied);
|
||||
|
||||
/**
|
||||
* Monte un personnage a partir d'une FCharacterAppearance : assigne les meshes,
|
||||
* les met en leader pose, et pose les couleurs sur des materiaux dynamiques.
|
||||
*
|
||||
* Un systeme = un composant, comme le reste du projet. Mais celui-ci a une
|
||||
* particularite qui vaut d'etre tenue : il ne suppose PAS vivre sur le pawn. Il
|
||||
* cherche le mesh d'un ACharacter s'il en trouve un, accepte qu'on lui en
|
||||
* designe un autre, et fonctionne donc a l'identique sur le mannequin de l'ecran
|
||||
* de customisation. C'est ce qui garantit que le personnage montre au joueur et
|
||||
* celui que ses amis verront ne peuvent pas diverger : c'est le meme code.
|
||||
*
|
||||
* Il ne replique rien -- l'apparence vivra sur ASurvivalPlayerState, comme le
|
||||
* pseudo, et pour la meme raison : elle doit survivre a la mort du pawn.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
|
||||
class EMBERWILD_API UCharacterAppearanceComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UCharacterAppearanceComponent();
|
||||
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Appearance")
|
||||
FOnAppearanceApplied OnAppearanceApplied;
|
||||
|
||||
/** Ton DA_CharacterParts. Sans lui le composant ne monte rien et le dit. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Appearance")
|
||||
TObjectPtr<UCharacterPartsDataAsset> Catalog;
|
||||
|
||||
/**
|
||||
* L'apparence posee au BeginPlay, tant que rien ne l'a remplacee.
|
||||
*
|
||||
* Elle sert au mannequin de l'ecran, aux tests en PIE, et de repli si les
|
||||
* reglages du joueur sont vides -- c'est-a-dire au tout premier lancement.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Appearance")
|
||||
FCharacterAppearance DefaultAppearance;
|
||||
|
||||
/**
|
||||
* Designe le mesh porteur. A appeler AVANT la premiere application.
|
||||
*
|
||||
* Inutile sur un ACharacter, dont le mesh est trouve tout seul. Necessaire
|
||||
* sur un acteur quelconque -- le mannequin de l'ecran de customisation.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Appearance")
|
||||
void SetTargetMesh(USkeletalMeshComponent* InTargetMesh);
|
||||
|
||||
/**
|
||||
* Monte l'apparence demandee. Idempotente, et bon marche a la repetition :
|
||||
* les meshes ne sont retouches que si une PIECE a change, jamais pour une
|
||||
* simple couleur. C'est ce qui permet a un curseur d'appeler cette fonction
|
||||
* a chaque image sans engendrer un materiau par frame.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Appearance")
|
||||
void ApplyAppearance(const FCharacterAppearance& InAppearance);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Appearance")
|
||||
const FCharacterAppearance& GetAppearance() const { return Appearance; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Reglage par nom -- ce qui permet de tester le montage avant qu'il
|
||||
// existe la moindre interface.
|
||||
//
|
||||
// Ces fonctions ne portent PAS le specificateur Exec, et ce n'est pas un
|
||||
// oubli : la chaine de routage des commandes console (voir Player.cpp)
|
||||
// interroge le monde, le PlayerInput, le controller, le pawn, le HUD, le
|
||||
// GameMode, le CheatManager, le GameState et le camera manager -- jamais
|
||||
// les composants, AActor ne surchargeant pas ProcessConsoleExec pour les
|
||||
// parcourir. Une UFUNCTION(Exec) posee ici serait silencieusement ignoree.
|
||||
// Les commandes vivent donc sur AFpsPlayer, qui delegue ici.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Cherche, parmi les slots de materiau du mesh de tete, celui dont le nom
|
||||
* contient SlotHint. INDEX_NONE si aucun -- l'appelant ne pose alors rien.
|
||||
*/
|
||||
static int32 FindEyeMaterialSlot(const USkeletalMeshComponent* HeadComponent, FName SlotHint);
|
||||
|
||||
/** Categories : body, head, hair, eyebrows, beard, mustache, eyes. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Appearance|Debug")
|
||||
void ApplyPartByName(const FString& Part, int32 Index);
|
||||
|
||||
/** Categories : skin, hair, eye, underwear. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Appearance|Debug")
|
||||
void ApplyColorByName(const FString& Part, int32 ColorIndex);
|
||||
|
||||
/** Ecrit l'apparence courante et le nombre d'options de chaque categorie. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Appearance|Debug")
|
||||
void LogAppearance() const;
|
||||
|
||||
protected:
|
||||
virtual void OnUnregister() override;
|
||||
|
||||
private:
|
||||
/**
|
||||
* Trouve le mesh porteur : celui de l'ACharacter en priorite, sinon le
|
||||
* premier SkeletalMesh de l'acteur.
|
||||
*
|
||||
* L'ordre compte et n'est pas anodin : AFpsPlayer porte AUSSI FirstPersonArms,
|
||||
* et prendre « le premier trouve » y monterait la tete du personnage sur les
|
||||
* bras vus a l'ecran.
|
||||
*/
|
||||
USkeletalMeshComponent* ResolveTargetMesh();
|
||||
|
||||
/** Cree le composant d'une categorie s'il manque, et le rend. */
|
||||
USkeletalMeshComponent* EnsurePartComponent(ECharacterPartCategory Category);
|
||||
|
||||
/** Cree un composant enfant deja attache, leader-pose et configure. */
|
||||
USkeletalMeshComponent* CreateFollowerComponent(FName ComponentName);
|
||||
|
||||
/** Assigne les meshes de corps, l'AnimBP, les pieces et le materiau d'iris. */
|
||||
void RebuildMeshes(const FCharacterBodySet& BodySet);
|
||||
|
||||
/** Pose les quatre couleurs sur les materiaux dynamiques. */
|
||||
void ApplyColors(const FCharacterBodySet& BodySet);
|
||||
|
||||
/** Pose un parametre vectoriel sur TOUS les elements d'un mesh. */
|
||||
/**
|
||||
* Assigne un mesh a un composant en vidant d'abord ses materiaux surcharges.
|
||||
*
|
||||
* Indispensable : OverrideMaterials appartient au COMPOSANT, pas au mesh, et
|
||||
* survit donc a un changement d'asset. Sans ce vidage, le corps B garderait
|
||||
* la peau du corps A.
|
||||
*/
|
||||
static void SetPartMesh(USkeletalMeshComponent* MeshComponent, USkeletalMesh* NewMesh);
|
||||
|
||||
static void TintAllElements(USkeletalMeshComponent* MeshComponent, FName Parameter, const FLinearColor& Color);
|
||||
|
||||
/** Le mesh porteur, leader de tous les autres. */
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<USkeletalMeshComponent> TargetMesh;
|
||||
|
||||
/**
|
||||
* Un composant par categorie, indexe par ECharacterPartCategory.
|
||||
*
|
||||
* Un tableau plutot qu'un membre nomme par piece : le montage tient alors
|
||||
* dans une boucle, et ajouter une categorie ne demande qu'une valeur
|
||||
* d'enum. Une case peut rester nulle -- une silhouette sans barbe n'engendre
|
||||
* jamais le composant correspondant.
|
||||
*/
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<USkeletalMeshComponent>> PartComponents;
|
||||
|
||||
/**
|
||||
* Les morceaux de corps au-dela du porteur. Vide aujourd'hui : le catalogue
|
||||
* n'a qu'un mesh par silhouette. C'est ici qu'atterriront les segments le
|
||||
* jour des armures, sans que la logique change.
|
||||
*/
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<USkeletalMeshComponent>> ExtraBodyComponents;
|
||||
|
||||
FCharacterAppearance Appearance;
|
||||
|
||||
/** Faux tant qu'aucun montage n'a eu lieu : force la premiere reconstruction. */
|
||||
bool bBuilt = false;
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "CharacterAppearanceTypes.generated.h"
|
||||
|
||||
class USkeletalMesh;
|
||||
class UTexture2D;
|
||||
|
||||
/** Les deux silhouettes livrees par le pack. */
|
||||
UENUM(BlueprintType)
|
||||
enum class ECharacterBodyType : uint8
|
||||
{
|
||||
BodyA UMETA(DisplayName = "Body A"),
|
||||
BodyB UMETA(DisplayName = "Body B"),
|
||||
|
||||
/** Sentinelle, comme ECraftingStation::Count. Jamais une valeur valide. */
|
||||
Count UMETA(Hidden)
|
||||
};
|
||||
|
||||
/**
|
||||
* Les emplacements de pieces montees sur le corps.
|
||||
*
|
||||
* Sert d'index dans le tableau de composants du monteur : une seule fonction de
|
||||
* montage plutot qu'un membre nomme par piece, et ajouter une categorie ne
|
||||
* demande qu'une entree ici.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class ECharacterPartCategory : uint8
|
||||
{
|
||||
Head,
|
||||
Hair,
|
||||
Eyebrows,
|
||||
Beard,
|
||||
Mustache,
|
||||
|
||||
Count UMETA(Hidden)
|
||||
};
|
||||
|
||||
/**
|
||||
* Une entree du catalogue : la piece que le joueur choisit dans une categorie.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FCharacterPartEntry
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Part")
|
||||
TObjectPtr<USkeletalMesh> Mesh;
|
||||
|
||||
/**
|
||||
* La vignette du bouton, dans la grille de l'ecran de customisation.
|
||||
* Meme type que l'icone d'UItemDataAsset, pour que les deux grilles du jeu
|
||||
* se dessinent de la meme facon.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Part")
|
||||
TObjectPtr<UTexture2D> Icon;
|
||||
};
|
||||
|
||||
/**
|
||||
* L'apparence d'un personnage, en 11 octets.
|
||||
*
|
||||
* Des INDEX et jamais des chemins d'assets, pour deux raisons qui vont dans le
|
||||
* meme sens : un chemin coute une chaine de caracteres a chaque replication,
|
||||
* alors qu'un index tient dans un octet ; et un index se VALIDE contre le
|
||||
* catalogue, quand un chemin envoye par un client pourrait designer n'importe
|
||||
* quel asset du jeu.
|
||||
*
|
||||
* Les couleurs suivent la meme logique : un index dans la palette, pas un
|
||||
* FLinearColor. C'est ce qui permet a la struct entiere de tenir dans une
|
||||
* poignee d'octets, et de se sauvegarder telle quelle dans le .ini de
|
||||
* USurvivalUserSettings.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FCharacterAppearance
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
/** Index qui signifie « aucune piece » -- barbe rasee, crane nu. */
|
||||
static constexpr uint8 NoPart = 255;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance")
|
||||
ECharacterBodyType BodyType = ECharacterBodyType::BodyA;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance")
|
||||
uint8 HeadIndex = 0;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance")
|
||||
uint8 HairIndex = 0;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance")
|
||||
uint8 EyebrowsIndex = 0;
|
||||
|
||||
/** NoPart par defaut : un visage glabre est le point de depart naturel. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance")
|
||||
uint8 BeardIndex = NoPart;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance")
|
||||
uint8 MustacheIndex = NoPart;
|
||||
|
||||
/**
|
||||
* Les yeux ne sont pas un mesh mais un MATERIAU pose sur un slot du mesh de
|
||||
* tete -- le pack ne livre aucun `SKM_Eyes`. D'ou une categorie a part,
|
||||
* absente d'ECharacterPartCategory qui ne parle que de composants.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance")
|
||||
uint8 EyesIndex = 0;
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Couleurs : un index dans la palette correspondante du catalogue.
|
||||
// Un bouton de l'ecran = un index, sans reglage intermediaire.
|
||||
// --------------------------------------------------------------
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance|Colors")
|
||||
uint8 SkinColorIndex = 0;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance|Colors")
|
||||
uint8 HairColorIndex = 0;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance|Colors")
|
||||
uint8 EyeColorIndex = 0;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance|Colors")
|
||||
uint8 UnderwearColorIndex = 0;
|
||||
|
||||
bool operator==(const FCharacterAppearance& Other) const
|
||||
{
|
||||
return BodyType == Other.BodyType
|
||||
&& HeadIndex == Other.HeadIndex
|
||||
&& HairIndex == Other.HairIndex
|
||||
&& EyebrowsIndex == Other.EyebrowsIndex
|
||||
&& BeardIndex == Other.BeardIndex
|
||||
&& MustacheIndex == Other.MustacheIndex
|
||||
&& EyesIndex == Other.EyesIndex
|
||||
&& SkinColorIndex == Other.SkinColorIndex
|
||||
&& HairColorIndex == Other.HairColorIndex
|
||||
&& EyeColorIndex == Other.EyeColorIndex
|
||||
&& UnderwearColorIndex == Other.UnderwearColorIndex;
|
||||
}
|
||||
|
||||
bool operator!=(const FCharacterAppearance& Other) const { return !(*this == Other); }
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "CharacterAppearanceTypes.h"
|
||||
#include "CharacterPreviewActor.h"
|
||||
#include "CharacterCustomizationWidget.generated.h"
|
||||
|
||||
class ACharacterPreviewActor;
|
||||
class UButton;
|
||||
class UCharacterPartsDataAsset;
|
||||
class UCustomizationCategoryWidget;
|
||||
class UCustomizationOptionWidget;
|
||||
class UPanelWidget;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCustomizationConfirmed);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCustomizationCancelled);
|
||||
|
||||
/**
|
||||
* Ce que le joueur peut regler, dans l'ordre d'affichage de la colonne.
|
||||
*
|
||||
* Pieces et couleurs sont dans le MEME enum : ce sont onze rangees de la meme
|
||||
* colonne, et les distinguer par deux listes obligerait a dessiner deux fois la
|
||||
* meme chose. La difference ne vit que dans BuildOptions, qui remplit la grille
|
||||
* de vignettes ou de pastilles selon le cas.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class ECustomizationCategory : uint8
|
||||
{
|
||||
Body,
|
||||
Head,
|
||||
Hair,
|
||||
Eyebrows,
|
||||
Beard,
|
||||
Mustache,
|
||||
Eyes,
|
||||
SkinColor,
|
||||
HairColor,
|
||||
EyeColor,
|
||||
UnderwearColor,
|
||||
|
||||
Count UMETA(Hidden)
|
||||
};
|
||||
|
||||
/**
|
||||
* L'ecran de customisation : la colonne de reglages a gauche, le personnage a
|
||||
* droite.
|
||||
*
|
||||
* Le widget ne monte rien lui-meme -- il pousse une FCharacterAppearance sur
|
||||
* ACharacterPreviewActor et lit le catalogue pour savoir quoi proposer. C'est
|
||||
* le meme partage des roles que partout ailleurs dans le projet : l'interface
|
||||
* affiche et demande, le systeme decide.
|
||||
*
|
||||
* Classe Abstract : on cree un WBP_CharacterCustomization qui en herite.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UCharacterCustomizationWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UCharacterCustomizationWidget(const FObjectInitializer& ObjectInitializer);
|
||||
|
||||
virtual void NativeOnInitialized() override;
|
||||
virtual void NativeConstruct() override;
|
||||
|
||||
/**
|
||||
* Echap = Retour, comme le bouton.
|
||||
*
|
||||
* Obligatoire ici et pas seulement confortable : dans la map du menu, le
|
||||
* controller ne binde AUCUNE action d'input. Tout Echap qui fonctionne passe
|
||||
* donc par le NativeOnKeyDown d'un widget -- il n'y a pas d'autre chemin.
|
||||
*/
|
||||
virtual FReply NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent) override;
|
||||
|
||||
/** Le joueur valide : l'ecran a fini, l'apparence est dans GetAppearance(). */
|
||||
UPROPERTY(BlueprintAssignable, Category = "Customization")
|
||||
FOnCustomizationConfirmed OnConfirmed;
|
||||
|
||||
/** Le joueur renonce : rien n'a ete valide, on rend la main au menu. */
|
||||
UPROPERTY(BlueprintAssignable, Category = "Customization")
|
||||
FOnCustomizationCancelled OnCancelled;
|
||||
|
||||
/**
|
||||
* Branche l'ecran sur son mannequin et son point de depart.
|
||||
*
|
||||
* A appeler AVANT d'ajouter le widget au viewport : NativeConstruct
|
||||
* reconstruit les listes, et il les batirait vides sans catalogue.
|
||||
*
|
||||
* Surtout PAS nommee Initialize : UUserWidget en a deja une, virtuelle, et
|
||||
* une surcharge de signature differente la masquerait au lieu de la
|
||||
* surcharger (C4263/C4264). Meme famille de piege que les membres nommes
|
||||
* Player ou Slot dans une classe derivee du moteur.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Customization")
|
||||
void SetupCustomization(ACharacterPreviewActor* InPreview, const FCharacterAppearance& InAppearance);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Customization")
|
||||
const FCharacterAppearance& GetAppearance() const { return Appearance; }
|
||||
|
||||
protected:
|
||||
/**
|
||||
* Le catalogue. Assigne-lui le meme DA_CharacterParts que BP_FpsPlayer.
|
||||
*
|
||||
* Sur le widget et pas pris sur le mannequin : l'ecran doit pouvoir batir
|
||||
* ses listes meme si le mannequin manque encore, et un catalogue different
|
||||
* des deux cotes se verrait immediatement a l'ecran.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Customization")
|
||||
TObjectPtr<UCharacterPartsDataAsset> Catalog;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Customization")
|
||||
TSubclassOf<UCustomizationCategoryWidget> CategoryWidgetClass;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Customization")
|
||||
TSubclassOf<UCustomizationOptionWidget> OptionWidgetClass;
|
||||
|
||||
/** La colonne des categories. Un Vertical Box nomme exactement ainsi. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UPanelWidget> CategoryList;
|
||||
|
||||
/** La grille d'options. Un Wrap Box va tres bien. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UPanelWidget> OptionGrid;
|
||||
|
||||
/** Valide et lance la partie. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> PlayButton;
|
||||
|
||||
/** Retour au menu principal, sans rien valider. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> BackButton;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Rotation du personnage au clic-glisser
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
|
||||
virtual FReply NativeOnMouseButtonUp(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
|
||||
virtual FReply NativeOnMouseMove(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
|
||||
|
||||
private:
|
||||
/** Reconstruit la colonne de gauche. Une seule fois, les categories sont fixes. */
|
||||
void BuildCategories();
|
||||
|
||||
/** Reconstruit la grille selon la categorie active. */
|
||||
void BuildOptions();
|
||||
|
||||
/** Repose l'apparence sur le mannequin et rafraichit la selection affichee. */
|
||||
void RefreshPreview();
|
||||
|
||||
/** Encadre la case qui porte le choix courant, efface les autres. */
|
||||
void RefreshSelection();
|
||||
|
||||
/** L'index courant dans la categorie active, pour l'encadrement. */
|
||||
int32 GetCurrentIndexForCategory(ECustomizationCategory Category) const;
|
||||
|
||||
/** Ecrit un choix dans l'apparence. Le seul endroit qui la modifie. */
|
||||
void SetIndexForCategory(ECustomizationCategory Category, int32 NewIndex);
|
||||
|
||||
/** Combien d'options a proposer, catalogue en main. */
|
||||
int32 GetOptionCountForCategory(ECustomizationCategory Category) const;
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCategoryClicked(int32 CategoryIndex);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleOptionClicked(int32 OptionIndex);
|
||||
|
||||
UFUNCTION()
|
||||
void HandlePlayClicked();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleBackClicked();
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<ACharacterPreviewActor> Preview;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<UCustomizationCategoryWidget>> CategoryWidgets;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<UCustomizationOptionWidget>> OptionWidgets;
|
||||
|
||||
FCharacterAppearance Appearance;
|
||||
|
||||
ECustomizationCategory ActiveCategory = ECustomizationCategory::Body;
|
||||
|
||||
/** Vrai entre l'appui et le relachement, pendant qu'on fait tourner. */
|
||||
bool bDragging = false;
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "CharacterAppearanceTypes.h"
|
||||
#include "CharacterPartsDataAsset.generated.h"
|
||||
|
||||
class UAnimInstance;
|
||||
class UMaterialInterface;
|
||||
class USkeletalMesh;
|
||||
class UTexture2D;
|
||||
|
||||
/**
|
||||
* Tout ce qui compose une silhouette : ses meshes de corps, son AnimBP, et les
|
||||
* pieces disponibles pour chaque categorie.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FCharacterBodySet
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
/** La vignette du bouton de silhouette, meme role que celle d'une piece. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Body")
|
||||
TObjectPtr<UTexture2D> Icon;
|
||||
|
||||
/**
|
||||
* Les morceaux du corps, dans l'ordre. Le PREMIER est le porteur : c'est lui
|
||||
* qui va sur le composant Mesh du Character et qui joue l'animation, les
|
||||
* suivants deviennent des composants enfants en leader pose.
|
||||
*
|
||||
* Une LISTE alors qu'il n'y a qu'une case aujourd'hui, et ce n'est pas de la
|
||||
* sur-conception : le pack livre exprès `SKM_BodyA_torso`, `_arms`,
|
||||
* `_legs_01/02/03`, `_hands`, `_feet`, precisement pour qu'une armure puisse
|
||||
* eteindre les segments qu'elle recouvre. Le jour venu, la case 0 deviendra
|
||||
* `SKM_BodyA_empty` -- invisible, porteur d'animation -- et les huit segments
|
||||
* suivront derriere, SANS que ce code change. Un TObjectPtr unique, lui,
|
||||
* imposerait de tout reprendre au premier plastron.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Body")
|
||||
TArray<TObjectPtr<USkeletalMesh>> BodyMeshes;
|
||||
|
||||
/** L'AnimBP du porteur. Il change avec la silhouette, d'ou sa place ici. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Body")
|
||||
TSubclassOf<UAnimInstance> AnimClass;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
|
||||
TArray<FCharacterPartEntry> Heads;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
|
||||
TArray<FCharacterPartEntry> Hairstyles;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
|
||||
TArray<FCharacterPartEntry> Eyebrows;
|
||||
|
||||
/** Vide pour une silhouette qui n'en porte pas -- le pack n'en donne qu'a A. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
|
||||
TArray<FCharacterPartEntry> Beards;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
|
||||
TArray<FCharacterPartEntry> Mustaches;
|
||||
|
||||
/**
|
||||
* Fragment cherche dans le NOM des slots de materiau du mesh de tete pour y
|
||||
* trouver celui des yeux.
|
||||
*
|
||||
* Les yeux ne sont pas un mesh : le pack les livre comme un element du mesh
|
||||
* de tete. Il ne suffit donc pas de poser un parametre de couleur, il faut
|
||||
* REMPLACER le materiau de ce slot -- encore faut-il savoir lequel.
|
||||
*
|
||||
* Un fragment de nom et pas un index, parce qu'un index est faux deux fois :
|
||||
* il oblige a le verifier a la main dans l'editeur, et il suppose surtout
|
||||
* que les six tetes rangent leurs slots dans le MEME ordre. Les slots du
|
||||
* pack s'appellent `M_Eye_A_01`, `M_Eye`, `M_Eyes_02`... tous contiennent
|
||||
* « Eye », aucun slot de peau ne le contient.
|
||||
*
|
||||
* Si le fragment ne trouve rien, le materiau n'est PAS pose et un
|
||||
* avertissement part dans le log. C'est volontaire : poser l'oeil sur un
|
||||
* slot au hasard repeindrait le visage entier, un symptome qu'on met bien
|
||||
* plus longtemps a diagnostiquer qu'une ligne de log.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
|
||||
FName EyeMaterialSlotHint = TEXT("Eye");
|
||||
};
|
||||
|
||||
/**
|
||||
* Le catalogue : la table de correspondance entre les index d'une
|
||||
* FCharacterAppearance et les assets reels.
|
||||
*
|
||||
* Un seul asset a remplir, et le SEUL endroit a toucher pour ajouter une
|
||||
* coiffure. C'est aussi lui qui borne ce qu'un client peut demander : une
|
||||
* apparence arrivee du reseau est passee a Sanitize(), qui ramene tout index
|
||||
* hors bornes a une valeur sure.
|
||||
*/
|
||||
UCLASS(BlueprintType)
|
||||
class EMBERWILD_API UCharacterPartsDataAsset : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Une entree par valeur d'ECharacterBodyType, dans l'ordre de l'enum.
|
||||
* L'ordre EST le contrat : c'est lui qui donne son sens a BodyType.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog")
|
||||
TArray<FCharacterBodySet> BodySets;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Palettes : une couleur = un bouton a l'ecran, sans reglage entre les
|
||||
// deux. L'ordre EST l'ordre d'affichage, et l'index qui part sur le reseau.
|
||||
//
|
||||
// Partagees par les deux silhouettes : deux nuanciers de peau a tenir a
|
||||
// jour divergeraient au premier ajout.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
|
||||
TArray<FLinearColor> SkinColors;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
|
||||
TArray<FLinearColor> HairColors;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
|
||||
TArray<FLinearColor> EyeColors;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
|
||||
TArray<FLinearColor> UnderwearColors;
|
||||
|
||||
/** Les materiaux d'iris, un par forme d'oeil proposee. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
|
||||
TArray<TObjectPtr<UMaterialInterface>> EyeMaterials;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Noms des parametres des materiaux du pack.
|
||||
//
|
||||
// Ici et pas en dur dans le C++ : ils appartiennent aux assets, pas au
|
||||
// code. Changer de pack de personnages ne doit demander qu'un nouveau
|
||||
// catalogue -- et ces noms contiennent des ESPACES, ce qu'on n'invente
|
||||
// pas de memoire.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** `M_Body_Base`. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters")
|
||||
FName SkinColorParameter = TEXT("Skin color");
|
||||
|
||||
/**
|
||||
* `M_Head_Base` -- le meme role que ci-dessus, mais l'autre master, et
|
||||
* surtout un NOM DIFFERENT : `Skin` cote tete, `Skin color` cote corps.
|
||||
*
|
||||
* Le master contient bien la chaine « Skin Base color », mais ce n'est pas
|
||||
* le nom du parametre expose -- verifie dans `MI_HeadA_01_a`, ou le chercher
|
||||
* ne rend aucun resultat alors que `Skin` apparait dans le groupe
|
||||
* « Base colors ». Morale : un nom de parametre se lit dans l'editeur du
|
||||
* Material Instance, jamais dans les chaines du binaire du master.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters")
|
||||
FName HeadSkinColorParameter = TEXT("Skin");
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters")
|
||||
FName UnderwearColorParameter = TEXT("Underware color");
|
||||
|
||||
/** `M_Hairstyle_Base`, parent des cheveux, barbes, moustaches ET sourcils. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters")
|
||||
FName HairColorParameter = TEXT("Color");
|
||||
|
||||
/** `M_Eyes_Base`. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters")
|
||||
FName EyeColorParameter = TEXT("Eye color");
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Lecture
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Le jeu de pieces d'une silhouette, ou nullptr si l'index est hors bornes. */
|
||||
const FCharacterBodySet* GetBodySet(ECharacterBodyType BodyType) const;
|
||||
|
||||
/** La liste d'une categorie pour une silhouette, vide si rien n'est defini. */
|
||||
const TArray<FCharacterPartEntry>& GetParts(ECharacterBodyType BodyType, ECharacterPartCategory Category) const;
|
||||
|
||||
/** Nombre d'options d'une categorie. Sert a borner l'UI comme la console. */
|
||||
UFUNCTION(BlueprintPure, Category = "Catalog")
|
||||
int32 GetPartCount(ECharacterBodyType BodyType, ECharacterPartCategory Category) const;
|
||||
|
||||
/**
|
||||
* Ramene une apparence dans les bornes du catalogue.
|
||||
*
|
||||
* Appelee sur le SERVEUR a la reception d'une apparence client, et au
|
||||
* chargement des reglages -- un catalogue ampute entre deux versions du jeu
|
||||
* laisserait sinon des index qui ne designent plus rien. Les pieces
|
||||
* facultatives (barbe, moustache) retombent sur NoPart, les obligatoires
|
||||
* sur 0 : mieux vaut la mauvaise tete que pas de tete.
|
||||
*/
|
||||
void Sanitize(FCharacterAppearance& InOut) const;
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "CharacterAppearanceTypes.h"
|
||||
#include "CharacterPreviewActor.generated.h"
|
||||
|
||||
class UCameraComponent;
|
||||
class UCharacterAppearanceComponent;
|
||||
class USkeletalMeshComponent;
|
||||
|
||||
/**
|
||||
* Les deux cadrages de l'ecran de customisation.
|
||||
*
|
||||
* Deux et pas un par categorie : ce qu'on veut, c'est voir le visage quand on
|
||||
* choisit une barbe et la silhouette entiere quand on choisit un corps. Un
|
||||
* cadrage par categorie donnerait onze reglages a poser a la main pour onze
|
||||
* fois le meme resultat.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class ECharacterPreviewFraming : uint8
|
||||
{
|
||||
FullBody,
|
||||
Face,
|
||||
|
||||
Count UMETA(Hidden)
|
||||
};
|
||||
|
||||
/**
|
||||
* Le mannequin de l'ecran de customisation : un personnage pose dans la map de
|
||||
* menu, qu'on fait tourner a la souris et que la camera cadre selon ce que le
|
||||
* joueur est en train de regler.
|
||||
*
|
||||
* Il porte le MEME UCharacterAppearanceComponent que le pawn de jeu, et c'est
|
||||
* tout l'interet : le personnage qu'on regarde ici est monte par exactement le
|
||||
* code qui montera celui que les autres joueurs verront. Deux chemins de
|
||||
* montage finiraient par diverger, et le joueur s'en apercevrait au pire
|
||||
* moment -- une fois en partie.
|
||||
*/
|
||||
UCLASS()
|
||||
class EMBERWILD_API ACharacterPreviewActor : public AActor
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
ACharacterPreviewActor();
|
||||
|
||||
virtual void Tick(float DeltaSeconds) override;
|
||||
|
||||
/** Monte l'apparence sur le mannequin. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Preview")
|
||||
void ApplyAppearance(const FCharacterAppearance& InAppearance);
|
||||
|
||||
/** La camera glisse vers le cadrage demande. Sans effet s'il y est deja. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Preview")
|
||||
void SetFraming(ECharacterPreviewFraming NewFraming);
|
||||
|
||||
/** Fait pivoter le personnage. Appelee pendant un clic-glisser. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Preview")
|
||||
void AddPreviewYaw(float DeltaDegrees);
|
||||
|
||||
/** Remet le personnage face a la camera. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Preview")
|
||||
void ResetPreviewYaw();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Preview")
|
||||
UCameraComponent* GetPreviewCamera() const { return PreviewCamera; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Preview")
|
||||
UCharacterAppearanceComponent* GetAppearanceComponent() const { return AppearanceComponent; }
|
||||
|
||||
/** Le reglage vit ici et pas sur le widget : c'est une sensibilite de camera. */
|
||||
UFUNCTION(BlueprintPure, Category = "Preview")
|
||||
float GetYawPerPixel() const { return YawPerPixel; }
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
/** Le porteur d'animation et de pose ; les pieces s'y accrochent. */
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Preview")
|
||||
TObjectPtr<USkeletalMeshComponent> BodyMesh;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Preview")
|
||||
TObjectPtr<UCharacterAppearanceComponent> AppearanceComponent;
|
||||
|
||||
/**
|
||||
* Pivot de rotation, entre la racine et le mesh.
|
||||
*
|
||||
* Le personnage tourne en faisant pivoter CE composant et jamais l'acteur :
|
||||
* la camera est fille de l'acteur, la faire tourner avec lui donnerait un
|
||||
* decor qui defile pendant qu'on regarde un personnage immobile.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Preview")
|
||||
TObjectPtr<USceneComponent> TurntablePivot;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Preview")
|
||||
TObjectPtr<UCameraComponent> PreviewCamera;
|
||||
|
||||
/**
|
||||
* Les deux positions de camera, a placer A LA SOURIS dans le viewport du
|
||||
* Blueprint plutot qu'a taper en coordonnees.
|
||||
*
|
||||
* Ce sont des reperes vides : la camera n'y est pas accrochee, elle glisse
|
||||
* vers eux. Sans quoi changer de categorie la ferait sauter d'un cadrage a
|
||||
* l'autre, ce qui se lit comme une coupure de montage.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Preview|Framing")
|
||||
TObjectPtr<USceneComponent> FullBodyViewSpot;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Preview|Framing")
|
||||
TObjectPtr<USceneComponent> FaceViewSpot;
|
||||
|
||||
/**
|
||||
* Vitesse de glissement de la camera entre deux cadrages.
|
||||
*
|
||||
* Une vitesse d'interpolation et pas une duree : le mouvement freine en
|
||||
* arrivant au lieu de s'arreter net, et deux changements de categorie
|
||||
* coup sur coup s'enchainent sans a-coup -- la camera repart d'ou elle est.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Preview|Framing", meta = (ClampMin = "0.5"))
|
||||
float FramingInterpSpeed = 6.f;
|
||||
|
||||
/** Degres de rotation par pixel de deplacement horizontal de la souris. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Preview|Turntable")
|
||||
float YawPerPixel = 0.35f;
|
||||
|
||||
private:
|
||||
/** Le repere vise par la camera, selon le cadrage courant. */
|
||||
USceneComponent* GetSpotForFraming(ECharacterPreviewFraming InFraming) const;
|
||||
|
||||
/**
|
||||
* Coupe le Tick quand la camera est arrivee.
|
||||
*
|
||||
* Meme principe que le couvercle d'AStorageContainer : cet acteur ne tick
|
||||
* que pendant qu'il bouge. Un mannequin immobile dans un menu n'a aucune
|
||||
* raison de couter une frame.
|
||||
*/
|
||||
void UpdateFramingInterpolation(float DeltaSeconds);
|
||||
|
||||
ECharacterPreviewFraming CurrentFraming = ECharacterPreviewFraming::FullBody;
|
||||
|
||||
/** Rotation accumulee par le clic-glisser, en degres. */
|
||||
float TurntableYaw = 0.f;
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "Containers/Ticker.h"
|
||||
#include "ConfirmDialogWidget.generated.h"
|
||||
|
||||
class UButton;
|
||||
class UTextBlock;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnConfirmDialogConfirmed);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnConfirmDialogCancelled);
|
||||
|
||||
/**
|
||||
* Boite de dialogue "es-tu sur ?", volontairement generique.
|
||||
*
|
||||
* Elle ne sait pas ce qu'elle confirme : elle pose la question et previent via
|
||||
* ses delegues. C'est l'appelant qui decide de l'action. Ainsi la meme boite
|
||||
* servira pour quitter, effacer une sauvegarde ou abandonner une partie.
|
||||
*
|
||||
* Classe Abstract : on cree un WBP_ConfirmDialog qui en herite.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UConfirmDialogWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UConfirmDialogWidget(const FObjectInitializer& ObjectInitializer);
|
||||
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
virtual FReply NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent) override;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Dialogue")
|
||||
FOnConfirmDialogConfirmed OnConfirmed;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Dialogue")
|
||||
FOnConfirmDialogCancelled OnCancelled;
|
||||
|
||||
/**
|
||||
* Affiche la boite et lui donne le focus clavier.
|
||||
*
|
||||
* Passe par ici et jamais par SetVisibility directement : sans le focus,
|
||||
* la boite ne recevrait aucune touche et Echap resterait sans effet.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Dialogue")
|
||||
void Open(const FText& InMessage);
|
||||
|
||||
/**
|
||||
* Meme chose, mais avec un retour automatique passe le delai.
|
||||
*
|
||||
* @param InMessageFormat doit contenir {0}, remplace par les secondes
|
||||
* restantes. Exemple : "Garder ce mode ?\nRetour
|
||||
* automatique dans {0} s."
|
||||
*
|
||||
* A l'expiration, OnCancelled est diffuse -- comme si le joueur avait
|
||||
* refuse. C'est le seul filet quand la question porte sur un changement qui
|
||||
* peut rendre l'ecran illisible.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Dialogue")
|
||||
void OpenWithCountdown(const FText& InMessageFormat, float Seconds);
|
||||
|
||||
/** Referme la boite. Ne previent personne : l'appelant sait ce qu'il fait. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Dialogue")
|
||||
void Close();
|
||||
|
||||
protected:
|
||||
/** Echap equivaut a cliquer Annuler. Decoche pour une question incontournable. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dialogue")
|
||||
bool bCancelOnEscape = true;
|
||||
|
||||
/**
|
||||
* Entree equivaut a cliquer Confirmer.
|
||||
*
|
||||
* Desactive par defaut, volontairement : sur une question destructrice, un
|
||||
* joueur qui martele Entree validerait sans avoir lu.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dialogue")
|
||||
bool bConfirmOnEnter = false;
|
||||
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> ConfirmButton;
|
||||
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> CancelButton;
|
||||
|
||||
/** Optionnel : une boite dont le texte est fige dans le Blueprint reste valide. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UTextBlock> MessageText;
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void HandleConfirmClicked();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCancelClicked();
|
||||
|
||||
/** Un tick par frame tant que le rebours tourne. Renvoie false pour s'arreter. */
|
||||
bool TickCountdown(float DeltaTime);
|
||||
|
||||
void StopCountdown();
|
||||
|
||||
void RefreshCountdownText();
|
||||
|
||||
/**
|
||||
* Le rebours passe par le ticker du moteur et non par un timer du monde.
|
||||
*
|
||||
* Deux raisons : l'ecran de reglages s'ouvre depuis le menu de PAUSE, ou les
|
||||
* timers du monde sont geles -- le rebours ne repartirait jamais ; et un
|
||||
* changement de resolution rate doit se defaire meme si le jeu bloque.
|
||||
*/
|
||||
FTSTicker::FDelegateHandle CountdownTickerHandle;
|
||||
|
||||
FText CountdownMessageFormat;
|
||||
|
||||
float CountdownRemaining = 0.f;
|
||||
|
||||
/** Evite de reecrire le texte 60 fois par seconde pour rien. */
|
||||
int32 LastDisplayedSeconds = -1;
|
||||
};
|
||||
@@ -0,0 +1,508 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "Interactable.h"
|
||||
#include "CookingStation.generated.h"
|
||||
|
||||
class UInventoryComponent;
|
||||
class UItemDataAsset;
|
||||
class UNiagaraComponent;
|
||||
class UPointLightComponent;
|
||||
class USoundBase;
|
||||
class USphereComponent;
|
||||
class UStaticMeshComponent;
|
||||
|
||||
/**
|
||||
* Ce qu'un emplacement du grill porte a l'instant t.
|
||||
*
|
||||
* Uniquement de l'etat : le DataAsset dit ce que l'objet EST, cette struct dit
|
||||
* ou il en est. C'est la meme separation que FInventorySlot, pour la meme raison.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FCookingSlotState
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
/** L'objet pose, ou nullptr. Un seul exemplaire par emplacement, comme dans Raft. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Cooking")
|
||||
TObjectPtr<UItemDataAsset> Item = nullptr;
|
||||
|
||||
/**
|
||||
* Charges restantes de l'exemplaire pose. -1 = plein.
|
||||
* L'usure traverse la cuisson comme elle traverse le sol : ce qu'on
|
||||
* recupere est le meme exemplaire, pas un neuf.
|
||||
*/
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Cooking")
|
||||
int32 RemainingUses = -1;
|
||||
|
||||
/**
|
||||
* Compte a rebours de l'etape en cours. Pas une UPROPERTY : un FTimerHandle
|
||||
* n'est qu'un index dans le gestionnaire de timers, il n'y a rien a
|
||||
* collecter ni a serialiser.
|
||||
*/
|
||||
FTimerHandle Timer;
|
||||
|
||||
bool IsEmpty() const { return Item == nullptr; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Une buche dans le foyer : QUEL objet a ete mis, et combien de temps il tient
|
||||
* encore.
|
||||
*
|
||||
* C'est le coeur du modele : le carburant n'est pas une jauge de secondes mais
|
||||
* une pile d'objets. Cinq buches, cinq objets -- une branche qui tient dix
|
||||
* secondes occupe exactement la meme place qu'une planche qui en tient cent
|
||||
* quatre-vingts. Le joueur voit donc ce qu'il a donne au feu, pas un
|
||||
* pourcentage abstrait, et melanger les combustibles reste lisible.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FFuelSlotState
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Cooking")
|
||||
TObjectPtr<UItemDataAsset> Item = nullptr;
|
||||
|
||||
/** Secondes de combustion restantes pour CE morceau. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Cooking")
|
||||
float RemainingSeconds = 0.f;
|
||||
};
|
||||
|
||||
/** L'etat de la station a change : allumage, depot, transformation, retrait. */
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCookingStationChanged);
|
||||
|
||||
/**
|
||||
* Ce que l'appui sur Interagir va declencher, compte tenu de ce que le joueur
|
||||
* vise et de ce qu'il tient.
|
||||
*
|
||||
* Pas un UENUM : c'est un detail d'implementation, rien a l'exterieur n'a
|
||||
* besoin de le nommer.
|
||||
*/
|
||||
enum class ECookingAction : uint8
|
||||
{
|
||||
None,
|
||||
TakeItem,
|
||||
PlaceItem,
|
||||
AddFuel
|
||||
};
|
||||
|
||||
/**
|
||||
* Resultat complet de la decision. Calcule UNE fois, par ResolveAction(), et
|
||||
* servi a la fois au texte du prompt et a l'execution : deux cascades de `if`
|
||||
* separees finiraient par diverger, et le joueur lirait "Light the fire" en
|
||||
* obtenant autre chose.
|
||||
*
|
||||
* Struct de pile a duree de vie tres courte, d'ou les pointeurs bruts : rien
|
||||
* ici ne peut etre collecte entre le calcul et son usage.
|
||||
*/
|
||||
struct FCookingInteraction
|
||||
{
|
||||
ECookingAction Action = ECookingAction::None;
|
||||
|
||||
/** Emplacement de cuisson vise, ou INDEX_NONE si le joueur regarde le foyer. */
|
||||
int32 SlotIndex = INDEX_NONE;
|
||||
|
||||
/** Objet actif de la barre rapide. */
|
||||
UItemDataAsset* HeldItem = nullptr;
|
||||
|
||||
/** Slot d'inventaire d'ou sortira le bois, et l'objet correspondant. */
|
||||
int32 FuelSourceSlot = INDEX_NONE;
|
||||
UItemDataAsset* FuelItem = nullptr;
|
||||
};
|
||||
|
||||
/**
|
||||
* Poste de cuisson pose dans le monde : feu de camp, et plus tard fourneau ou
|
||||
* sechoir en changeant simplement ses reglages.
|
||||
*
|
||||
* Modele Raft assume : AUCUNE interface. Le joueur tient sa viande crue dans la
|
||||
* barre rapide, vise un emplacement du grill, appuie sur Interagir, et regarde
|
||||
* le mesh changer.
|
||||
*
|
||||
* Le poste ne commente jamais son propre etat : le prompt n'affiche QUE des
|
||||
* actions realisables, exactement comme un objet a ramasser. Ce qui cuit, ce
|
||||
* qui manque et ce qui brule se lit sur les flammes, sur le nombre de buches
|
||||
* et sur le mesh de la nourriture -- l'ecrire en plus reconstruirait une
|
||||
* interface par-dessus un monde qui se suffit a lui-meme.
|
||||
*
|
||||
* Aucun Tick. Un timer par emplacement de cuisson, un seul pour le carburant :
|
||||
* celui de la buche en train de bruler. Eteindre le feu met ces timers en
|
||||
* pause, et la cuisson reprend exactement ou elle s'etait arretee sans qu'une
|
||||
* ligne ne compte le temps a la main.
|
||||
*
|
||||
* Le feu ne s'allume PAS a la main : il part des qu'il a du bois et quelque
|
||||
* chose a transformer, et s'arrete des qu'il n'a plus rien a faire. Le bois ne
|
||||
* part donc jamais en fumee pour rien -- ce qui evite au joueur d'avoir a
|
||||
* penser a eteindre derriere lui, une corvee qui n'apporte aucune decision
|
||||
* interessante.
|
||||
*/
|
||||
UCLASS()
|
||||
class EMBERWILD_API ACookingStation : public AActor, public IInteractable
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
ACookingStation();
|
||||
|
||||
// --- IInteractable ---
|
||||
virtual FText GetInteractionPrompt_Implementation(AActor* Interactor) const override;
|
||||
virtual bool CanInteract_Implementation(AActor* Interactor) const override;
|
||||
virtual void Interact_Implementation(AActor* Interactor) override;
|
||||
virtual void OnBeginFocus_Implementation() override;
|
||||
virtual void OnEndFocus_Implementation() override;
|
||||
|
||||
/**
|
||||
* Allume le feu s'il reste du bois dans le foyer.
|
||||
*
|
||||
* En temps normal, personne n'appelle ceci : UpdateFireState() decide seul.
|
||||
* Reste exposee pour un futur allume-feu ou un evenement scenarise.
|
||||
* @return false si le foyer est vide.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Cooking")
|
||||
bool Light();
|
||||
|
||||
/** Eteint le feu. Les cuissons en cours se figent, elles ne repartent pas de zero. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Cooking")
|
||||
void Extinguish();
|
||||
|
||||
/** Vrai si au moins un emplacement porte quelque chose qui a encore une etape a franchir. */
|
||||
UFUNCTION(BlueprintPure, Category = "Cooking")
|
||||
bool HasPendingWork() const;
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Cooking")
|
||||
bool IsLit() const { return bIsLit; }
|
||||
|
||||
/**
|
||||
* Combien de morceaux de bois le foyer peut contenir.
|
||||
*
|
||||
* C'est le nombre de buches taguees dans le Blueprint : la capacite est une
|
||||
* decision de mise en scene, et la regler a deux endroits garantirait qu'ils
|
||||
* finissent par se contredire. FallbackFuelCapacity ne sert qu'aux postes
|
||||
* sans buche visible, un four ferme par exemple.
|
||||
*/
|
||||
UFUNCTION(BlueprintPure, Category = "Cooking")
|
||||
int32 GetFuelCapacity() const { return FuelIndicators.Num() > 0 ? FuelIndicators.Num() : FallbackFuelCapacity; }
|
||||
|
||||
/** Morceaux de bois actuellement dans le foyer. */
|
||||
UFUNCTION(BlueprintPure, Category = "Cooking")
|
||||
int32 GetFuelCount() const { return Fuel.Num(); }
|
||||
|
||||
/** Secondes de combustion cumulees sur tout ce qui est dans le foyer. */
|
||||
UFUNCTION(BlueprintPure, Category = "Cooking")
|
||||
float GetFuelRemaining() const;
|
||||
|
||||
/** Secondes avant la prochaine transformation, ou 0 si l'emplacement n'attend rien. */
|
||||
UFUNCTION(BlueprintPure, Category = "Cooking")
|
||||
float GetSlotTimeRemaining(int32 SlotIndex) const;
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Cooking")
|
||||
const TArray<FCookingSlotState>& GetSlots() const { return Slots; }
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Cooking")
|
||||
FOnCookingStationChanged OnStationChanged;
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
|
||||
|
||||
/**
|
||||
* Nombre d'emplacements de cuisson existant dans la classe. Les composants
|
||||
* sont crees au constructeur, donc ce nombre est fige a la compilation --
|
||||
* c'est le prix a payer pour pouvoir les positionner A LA SOURIS dans le
|
||||
* viewport du Blueprint. Des composants engendres a l'execution ne seraient
|
||||
* reglables qu'en tapant des coordonnees a l'aveugle.
|
||||
*/
|
||||
static constexpr int32 MaxCookingSlots = 4;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Composants
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Le foyer lui-meme : bois, pierres, structure. */
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Cooking")
|
||||
TObjectPtr<UStaticMeshComponent> BaseMesh;
|
||||
|
||||
/**
|
||||
* Ce que le trace d'interaction vient toucher. Sphere dediee et non le mesh,
|
||||
* pour la meme raison que sur APickupItem : rien ne garantit qu'un mesh
|
||||
* d'asset pack ait une collision.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Cooking")
|
||||
TObjectPtr<USphereComponent> InteractionSphere;
|
||||
|
||||
/**
|
||||
* Un mesh par emplacement de cuisson, qui porte la nourriture posee. Sa
|
||||
* transform sert a la fois a l'afficher et a savoir ce que le joueur vise :
|
||||
* pas besoin d'une collision par emplacement, donc pas une requete physique
|
||||
* de plus.
|
||||
*
|
||||
* Positionne-les dans le viewport du Blueprint. Ils sont caches au demarrage
|
||||
* tant qu'ils sont vides, mais restent visibles dans l'editeur pour que tu
|
||||
* puisses les placer.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Cooking")
|
||||
TArray<TObjectPtr<UStaticMeshComponent>> SlotMeshes;
|
||||
|
||||
/** Flamme. Active tant que le feu brule, coupee des qu'il s'eteint. */
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Cooking")
|
||||
TObjectPtr<UNiagaraComponent> FireEffect;
|
||||
|
||||
/**
|
||||
* Halo du foyer, allume en meme temps que la flamme.
|
||||
*
|
||||
* Regle intensite, couleur et rayon directement sur le composant dans le
|
||||
* Blueprint : ce sont des proprietes natives, les redeclarer ici en
|
||||
* doublon garantirait qu'un jour les deux se contredisent.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Cooking")
|
||||
TObjectPtr<UPointLightComponent> FireLight;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Reglages
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Emplacements de cuisson reellement utilises. Les autres restent caches. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking", meta = (ClampMin = "1", ClampMax = "4"))
|
||||
int32 CookingSlotCount = 2;
|
||||
|
||||
/**
|
||||
* Echelle appliquee au mesh de la nourriture posee.
|
||||
* Les meshes d'objets sont dimensionnes pour etre poses au sol ; sur un
|
||||
* grill ils sont souvent trop gros.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking", meta = (ClampMin = "0.01"))
|
||||
float CookingMeshScale = 1.f;
|
||||
|
||||
/**
|
||||
* Demi-angle du cone de visee autour de chaque emplacement, en degres.
|
||||
*
|
||||
* Au-dela, on considere que le joueur vise le foyer et pas un emplacement :
|
||||
* c'est ce qui lui permet d'ajouter du bois ou d'allumer le feu sans devoir
|
||||
* eviter le grill du regard.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking", meta = (ClampMin = "1", ClampMax = "89"))
|
||||
float SlotAimHalfAngle = 25.f;
|
||||
|
||||
/** Multiplie la vitesse de cuisson. Un fourneau va plus vite que des braises. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking", meta = (ClampMin = "0.01"))
|
||||
float CookSpeedMultiplier = 1.f;
|
||||
|
||||
/**
|
||||
* Autorise a reprendre un objet qui n'a pas fini de cuire.
|
||||
*
|
||||
* Decoche par defaut : ce qu'on a mis a cuire est engage, on attend. Un
|
||||
* objet qui a fini sa cuisson reste toujours recuperable, y compris pendant
|
||||
* qu'il se dirige vers le brule -- sans quoi il serait impossible de sauver
|
||||
* sa viande a temps, ce qui viderait la sur-cuisson de tout son sens.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking")
|
||||
bool bAllowTakingRawItems = false;
|
||||
|
||||
/**
|
||||
* Ajoute les secondes restantes aux textes du prompt.
|
||||
*
|
||||
* Decoche en jeu : un chiffre qui s'egrene est de l'interface deguisee, et
|
||||
* le joueur doit lire l'avancement sur le mesh, pas sur un compteur. Coche
|
||||
* le temps d'un reglage pour verifier tes durees.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking")
|
||||
bool bShowRemainingTimeInPrompt = false;
|
||||
|
||||
/** Active le Custom Depth quand le poste est vise, comme les ramassables. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking")
|
||||
bool bHighlightOnFocus = true;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Reglages : carburant
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tag a poser sur les meshes de buches qui figurent le contenu du foyer.
|
||||
* Leur NOMBRE fait la capacite du feu, et leur ordre alphabetique l'ordre
|
||||
* d'apparition : la derniere posee est la premiere a bruler.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking|Fuel")
|
||||
FName FuelIndicatorTag = TEXT("FuelLog");
|
||||
|
||||
/** Capacite des postes qui n'ont aucune buche visible. Ignore des qu'il y en a une. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking|Fuel", meta = (ClampMin = "1"))
|
||||
int32 FallbackFuelCapacity = 5;
|
||||
|
||||
/**
|
||||
* Chaque buche prend le mesh de l'objet qu'elle represente.
|
||||
*
|
||||
* Decoche par defaut : les buches d'un feu de camp sont posees a la main
|
||||
* dans le Blueprint, avec leur orientation et leur imbrication soignees, et
|
||||
* y substituer un mesh d'objet gacherait ce travail. Coche-le pour un
|
||||
* support ou l'on veut distinguer d'un coup d'oeil branches et planches.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking|Fuel")
|
||||
bool bFuelIndicatorsUseItemMesh = false;
|
||||
|
||||
/** Bois deja dans le foyer au demarrage. Laisse vide pour un feu a alimenter. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking|Fuel")
|
||||
TObjectPtr<UItemDataAsset> StartingFuelItem;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking|Fuel", meta = (ClampMin = "0"))
|
||||
int32 StartingFuelCount = 0;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Sons. Tous facultatifs : un champ vide ne joue rien.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking|Sound")
|
||||
TObjectPtr<USoundBase> LightSound;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking|Sound")
|
||||
TObjectPtr<USoundBase> ExtinguishSound;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking|Sound")
|
||||
TObjectPtr<USoundBase> PlaceItemSound;
|
||||
|
||||
private:
|
||||
/**
|
||||
* Quel emplacement de cuisson le joueur vise, ou INDEX_NONE s'il regarde le
|
||||
* foyer. Se base sur le point de vue reel du pawn, donc sur ce qu'il voit a
|
||||
* l'ecran, et non sur la position de son corps.
|
||||
*/
|
||||
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||
|
||||
int32 ResolveAimedSlot(const AActor* Interactor) const;
|
||||
|
||||
/** Decide ce que ferait un appui sur Interagir, sans rien modifier. */
|
||||
FCookingInteraction ResolveAction(AActor* Interactor) const;
|
||||
|
||||
/** Depose l'objet actif de la barre rapide sur l'emplacement de cuisson. */
|
||||
bool PlaceItemFromHand(int32 SlotIndex, AActor* Interactor);
|
||||
|
||||
/** Rend l'objet a l'inventaire. Refuse et laisse tout en place si c'est plein. */
|
||||
bool TakeItemToInventory(int32 SlotIndex, AActor* Interactor);
|
||||
|
||||
/**
|
||||
* Met UN morceau de bois dans le foyer, pris dans un slot d'inventaire.
|
||||
*
|
||||
* Un exemplaire par appui, jamais la pile entiere : donner du bois est un
|
||||
* geste irreversible, et vider vingt branches d'un coup sur un feu qui n'en
|
||||
* demandait qu'une ne se rattrape pas. Le joueur dose lui-meme.
|
||||
*/
|
||||
bool AddFuelFrom(int32 InventorySlotIndex, AActor* Interactor);
|
||||
|
||||
/**
|
||||
* Ou prendre du bois : la main d'abord, sinon le premier slot qui en
|
||||
* contient. La main est prioritaire parce que c'est le seul moyen pour le
|
||||
* joueur de CHOISIR ce qu'il brule.
|
||||
*/
|
||||
static int32 FindFuelSource(const UInventoryComponent* Inventory, UItemDataAsset*& OutItem);
|
||||
|
||||
/** Peut-on retirer ce qui se trouve sur cet emplacement de cuisson. */
|
||||
bool CanTakeFromSlot(int32 SlotIndex) const;
|
||||
|
||||
/** Arme le compte a rebours de l'etape courante de l'emplacement. */
|
||||
void StartSlotTimer(int32 SlotIndex);
|
||||
|
||||
/** Fin d'etape : l'objet devient son resultat, et on enchaine s'il y a une suite. */
|
||||
void HandleSlotFinished(int32 SlotIndex);
|
||||
|
||||
/** Recopie le WorldMesh de l'objet pose, ou cache le composant si l'emplacement est vide. */
|
||||
void RefreshSlotMesh(int32 SlotIndex);
|
||||
|
||||
/**
|
||||
* Allume ou eteint selon qu'il y ait, ou non, du bois ET du travail.
|
||||
*
|
||||
* Point de decision UNIQUE : toutes les actions qui changent le contenu du
|
||||
* foyer ou du grill finissent ici. Laisser chaque action decider elle-meme
|
||||
* d'allumer aurait fini par oublier un cas -- typiquement le dernier objet
|
||||
* retire, qui laisserait le feu bruler dans le vide.
|
||||
*/
|
||||
void UpdateFireState();
|
||||
|
||||
/** La buche du dessus a fini de bruler : on passe a la suivante. */
|
||||
void HandleFuelConsumed();
|
||||
|
||||
/** Arme le timer sur la buche en cours de combustion. */
|
||||
void StartFuelTimer();
|
||||
|
||||
/** Recopie le restant du timer dans la buche courante avant de le modifier. */
|
||||
void SyncFuelFromTimer();
|
||||
|
||||
/**
|
||||
* Allume ou coupe tout ce qui se voit quand le feu brule.
|
||||
*
|
||||
* Un seul endroit pour la flamme et la lumiere : le jour ou s'ajoute une
|
||||
* boucle sonore ou un panache de fumee, il n'y aura toujours qu'un point a
|
||||
* modifier au lieu de deux appels a retrouver dans Light() et Extinguish().
|
||||
*/
|
||||
void ApplyFireVisuals(bool bActive);
|
||||
|
||||
/** Retrouve les buches par leur tag, une fois pour toutes, au demarrage. */
|
||||
void CacheFuelIndicators();
|
||||
|
||||
/**
|
||||
* Montre exactement autant de buches qu'il y a de morceaux dans le foyer.
|
||||
*
|
||||
* Plus aucun calcul de palier ni de timer d'affichage depuis que le
|
||||
* carburant est une pile d'objets : une buche visible EST un objet stocke.
|
||||
*/
|
||||
void RefreshFuelIndicators();
|
||||
|
||||
/**
|
||||
* Ce que cet objet va devenir sur le feu, et en combien de temps.
|
||||
* La cuisson passe avant la combustion : voir le commentaire de bCanBurn
|
||||
* sur UItemDataAsset.
|
||||
*/
|
||||
static UItemDataAsset* GetNextStage(const UItemDataAsset* Item, float& OutDuration);
|
||||
|
||||
/** Inventaire du pawn qui interagit, ou nullptr. */
|
||||
static UInventoryComponent* GetInventoryOf(AActor* Interactor);
|
||||
|
||||
/**
|
||||
* Etat courant de chaque emplacement de cuisson. Toujours dimensionne a
|
||||
* MaxCookingSlots.
|
||||
*
|
||||
* Replique : c'est ce qui fait changer le mesh de la nourriture sous les
|
||||
* yeux de tous. Le FTimerHandle que porte chaque slot n'est pas une
|
||||
* UPROPERTY et ne part donc pas sur le reseau -- c'est voulu, la cuisson se
|
||||
* compte uniquement sur le serveur et les clients n'en voient que le
|
||||
* resultat.
|
||||
*/
|
||||
UPROPERTY(Transient, ReplicatedUsing = OnRep_Slots)
|
||||
TArray<FCookingSlotState> Slots;
|
||||
|
||||
/**
|
||||
* Le contenu du foyer, dans l'ordre ou il a ete donne. L'index 0 est celui
|
||||
* qui brule ; quand il est consomme, tout le monde avance d'un cran et la
|
||||
* derniere buche disparait du foyer.
|
||||
*/
|
||||
/**
|
||||
* Replique : le nombre de buches visibles EST la jauge de carburant, c'est
|
||||
* la seule facon pour un joueur de savoir combien il reste de feu. Les
|
||||
* secondes restantes partent avec, mais seul le serveur les decompte.
|
||||
*/
|
||||
UPROPERTY(Transient, ReplicatedUsing = OnRep_Fuel)
|
||||
TArray<FFuelSlotState> Fuel;
|
||||
|
||||
FTimerHandle FuelTimer;
|
||||
|
||||
/**
|
||||
* Buches de mise en scene, resolues au BeginPlay et triees par nom.
|
||||
* Transient : ces pointeurs designent des composants du Blueprint, il n'y a
|
||||
* rien a sauvegarder, seulement a garder du GC le temps de la partie.
|
||||
*/
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<UStaticMeshComponent>> FuelIndicators;
|
||||
|
||||
UPROPERTY(ReplicatedUsing = OnRep_IsLit)
|
||||
bool bIsLit = false;
|
||||
|
||||
/** Le contenu du grill a change cote serveur : on repose tous les meshes. */
|
||||
UFUNCTION()
|
||||
void OnRep_Slots();
|
||||
|
||||
UFUNCTION()
|
||||
void OnRep_Fuel();
|
||||
|
||||
UFUNCTION()
|
||||
void OnRep_IsLit();
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "CraftingRecipeDataAsset.h"
|
||||
#include "CraftingComponent.generated.h"
|
||||
|
||||
class UCraftingRecipeBook;
|
||||
class UInventoryComponent;
|
||||
class UItemDataAsset;
|
||||
|
||||
/**
|
||||
* Pourquoi une fabrication a echoue. Sert a l'interface, pour dire au joueur
|
||||
* ce qui manque au lieu de laisser un bouton grise sans explication.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class ECraftFailureReason : uint8
|
||||
{
|
||||
None,
|
||||
InvalidRecipe,
|
||||
NoRecipeBook,
|
||||
StationUnavailable,
|
||||
MissingIngredients
|
||||
};
|
||||
|
||||
/**
|
||||
* Etat d'un ingredient pour une recette donnee : ce qu'il faut, ce qu'on a.
|
||||
*
|
||||
* C'est le composant qui fait le rapprochement, pas le widget. Une ligne
|
||||
* d'ingredient n'a plus qu'a afficher deux nombres et choisir une couleur --
|
||||
* elle n'interroge jamais l'inventaire elle-meme.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FCraftIngredientStatus
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Crafting")
|
||||
TObjectPtr<UItemDataAsset> Item = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Crafting")
|
||||
int32 RequiredQuantity = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Crafting")
|
||||
int32 OwnedQuantity = 0;
|
||||
|
||||
bool IsSatisfied() const { return OwnedQuantity >= RequiredQuantity; }
|
||||
};
|
||||
|
||||
/** Une fabrication vient d'aboutir. Pour le son, l'animation, une notification. */
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnCraftSucceeded, UCraftingRecipeDataAsset*, Recipe);
|
||||
|
||||
/** Les postes accessibles ont change : l'interface doit revoir sa liste. */
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnAvailableStationsChanged);
|
||||
|
||||
/**
|
||||
* Le resultat n'est pas entre dans l'inventaire. A quelqu'un de le poser
|
||||
* dans le monde -- voir le commentaire de Craft().
|
||||
*/
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnCraftOverflow, UItemDataAsset*, Item, int32, Quantity, int32, RemainingUses);
|
||||
|
||||
/**
|
||||
* Fabrication d'objets a partir d'un livre de recettes et de l'inventaire du
|
||||
* meme acteur.
|
||||
*
|
||||
* Sur le pawn : fabriquer est une capacite du corps, au meme titre que porter
|
||||
* un sac. Le composant ne connait ni l'interface ni la classe du pawn -- un
|
||||
* futur etabli pourra porter le meme composant avec son propre livre.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
|
||||
class EMBERWILD_API UCraftingComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UCraftingComponent();
|
||||
|
||||
virtual void InitializeComponent() override;
|
||||
|
||||
/**
|
||||
* Recettes a afficher, dans l'ordre du livre.
|
||||
*
|
||||
* Ne renvoie que celles dont le poste est ACCESSIBLE : les mains vides, on
|
||||
* ne voit pas les recettes d'etabli. C'est le modele choisi -- une seule
|
||||
* fenetre de craft dont le contenu depend d'ou l'on se trouve.
|
||||
*
|
||||
* @param StationFilter filtre d'affichage supplementaire, purement visuel.
|
||||
* Passe Count pour tout voir. Un poste demande ici mais non
|
||||
* accessible ne fait rien apparaitre : le filtre restreint, il
|
||||
* n'autorise jamais.
|
||||
*
|
||||
* Pas de valeur par defaut sur StationFilter : UHT refuse une entree
|
||||
* UMETA(Hidden) comme defaut, et dé-cacher Count le rendrait selectionnable
|
||||
* dans le champ Required Station d'une recette. L'appelant l'ecrit.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
void GatherRecipes(TArray<UCraftingRecipeDataAsset*>& OutRecipes, ECraftingStation StationFilter) const;
|
||||
|
||||
/** Peut-on fabriquer cette recette maintenant, et sinon pourquoi. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
bool CanCraft(const UCraftingRecipeDataAsset* Recipe, ECraftFailureReason& OutReason) const;
|
||||
|
||||
/** Le detail requis / possede de chaque ingredient, dans l'ordre de la recette. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
void GatherIngredientStatus(const UCraftingRecipeDataAsset* Recipe, TArray<FCraftIngredientStatus>& OutStatus) const;
|
||||
|
||||
/**
|
||||
* Consomme les ingredients et ajoute le resultat a l'inventaire.
|
||||
*
|
||||
* L'ordre compte : on consomme AVANT d'ajouter. Refuser une fabrication
|
||||
* parce que l'inventaire est plein serait faux -- consommer trois branches
|
||||
* libere justement la case ou poser le resultat. Et si malgre tout il ne
|
||||
* rentre pas, l'objet part dans OnCraftOverflow pour etre pose au sol :
|
||||
* aucune ressource ne disparait jamais, ni a l'entree ni a la sortie.
|
||||
*
|
||||
* @return true si la fabrication a eu lieu.
|
||||
*/
|
||||
/**
|
||||
* Fabrique la recette. Chez un client, envoie la demande au serveur et rend
|
||||
* true : le resultat redescendra par la replication de l'inventaire.
|
||||
*
|
||||
* Le serveur refait TOUJOURS CanCraft de son cote, avec sa propre liste de
|
||||
* postes accessibles -- celle que le controller tient a jour par
|
||||
* Client_OpenCraftingAtStation et Server_ReleaseStation.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
bool Craft(UCraftingRecipeDataAsset* Recipe);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Postes accessibles
|
||||
//
|
||||
// Etat courant, pas de la configuration : {Hands} en permanence, plus
|
||||
// Workbench tant qu'on est devant un etabli. C'est une CONDITION de
|
||||
// fabrication, a ne pas confondre avec le filtre d'affichage de
|
||||
// GatherRecipes -- melanger les deux rendrait un jour un craft impossible
|
||||
// a cause d'un bouton d'interface.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Crafting|Stations")
|
||||
bool HasStation(ECraftingStation Station) const;
|
||||
|
||||
/**
|
||||
* A appeler quand le joueur entre dans le rayon d'un poste.
|
||||
*
|
||||
* Les appels se COMPTENT : deux etablis dont les portees se chevauchent, ce
|
||||
* qui arrive des qu'on construit une base un peu dense, s'ajoutent tous les
|
||||
* deux. Sortir du premier ne doit pas retirer l'acces alors qu'on est encore
|
||||
* devant le second.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting|Stations")
|
||||
void AddAvailableStation(ECraftingStation Station);
|
||||
|
||||
/** A appeler quand il s'en eloigne. Hands ne peut pas etre retire. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting|Stations")
|
||||
void RemoveAvailableStation(ECraftingStation Station);
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Crafting")
|
||||
FOnCraftSucceeded OnCraftSucceeded;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Crafting")
|
||||
FOnAvailableStationsChanged OnAvailableStationsChanged;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Crafting")
|
||||
FOnCraftOverflow OnCraftOverflow;
|
||||
|
||||
protected:
|
||||
/** Ton DA_RecipeBook. Sans lui le composant ne connait aucune recette. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
|
||||
TObjectPtr<UCraftingRecipeBook> RecipeBook;
|
||||
|
||||
/** Resolu une fois a l'initialisation : chercher le composant a chaque clic serait absurde. */
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "Crafting")
|
||||
TObjectPtr<UInventoryComponent> Inventory;
|
||||
|
||||
private:
|
||||
/** La fabrication demandee par un client, arbitree par le serveur. */
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_Craft(UCraftingRecipeDataAsset* Recipe);
|
||||
|
||||
/**
|
||||
* Combien de postes de chaque type sont a portee, et non un simple
|
||||
* "present / absent" -- voir AddAvailableStation.
|
||||
*
|
||||
* Pas une UPROPERTY : ni la cle ni la valeur ne sont des references a
|
||||
* collecter, et cet etat n'a rien a regler dans l'editeur.
|
||||
*/
|
||||
TMap<ECraftingStation, int32> StationRefCounts;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "CraftingComponent.h"
|
||||
#include "CraftingIngredientRowWidget.generated.h"
|
||||
|
||||
class UImage;
|
||||
class UTextBlock;
|
||||
|
||||
/**
|
||||
* Une ligne de cout dans le panneau de detail : l'icone d'un ingredient, son
|
||||
* nom, et "2/3" colore selon qu'on l'a ou non.
|
||||
*
|
||||
* Elle ne consulte jamais l'inventaire : UCraftingComponent lui remet un
|
||||
* FCraftIngredientStatus deja calcule. Une ligne qui irait compter elle-meme
|
||||
* referait le travail autant de fois qu'il y a d'ingredients, et pourrait
|
||||
* afficher autre chose que ce sur quoi le bouton Fabriquer s'appuie.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UCraftingIngredientRowWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** Affiche un ingredient et applique la couleur qui va avec. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
void SetIngredient(const FCraftIngredientStatus& Status);
|
||||
|
||||
protected:
|
||||
/** Facultatif : l'icone de la ressource demandee. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UImage> IngredientIcon;
|
||||
|
||||
/** Facultatif : le nom de la ressource. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UTextBlock> IngredientNameText;
|
||||
|
||||
/** Obligatoire : c'est lui qui porte le "2/3". A nommer "IngredientCountText". */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UTextBlock> IngredientCountText;
|
||||
|
||||
/** Couleur du compteur quand la ressource est en quantite suffisante. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Crafting")
|
||||
FLinearColor EnoughColor = FLinearColor(0.35f, 0.85f, 0.35f, 1.f);
|
||||
|
||||
/** Couleur du compteur quand il en manque. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Crafting")
|
||||
FLinearColor MissingColor = FLinearColor(0.9f, 0.3f, 0.3f, 1.f);
|
||||
|
||||
/**
|
||||
* Mise en forme du compteur. {0} = possede, {1} = requis.
|
||||
* Exposee pour pouvoir passer a "2 / 3" ou "2 sur 3" sans recompiler.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Crafting")
|
||||
FText CountFormat = NSLOCTEXT("Crafting", "IngredientCountFormat", "{0}/{1}");
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "CraftingRecipeBook.generated.h"
|
||||
|
||||
class UCraftingRecipeDataAsset;
|
||||
|
||||
/**
|
||||
* La liste ordonnee des recettes du jeu.
|
||||
*
|
||||
* Un catalogue plutot qu'un scan de l'AssetManager : le contenu est
|
||||
* deterministe, rien ne se charge par surprise au runtime, et surtout l'ORDRE
|
||||
* d'affichage de la grille devient une donnee de design qu'on regle a la
|
||||
* souris. Un scan d'assets rendrait cet ordre dependant du nom des fichiers.
|
||||
*
|
||||
* Un catalogue plutot qu'une liste posee sur le composant, aussi : un etabli
|
||||
* devra pointer son propre livre, et le pawn n'a pas a etre l'endroit ou vit
|
||||
* la table de craft du jeu.
|
||||
*/
|
||||
UCLASS(BlueprintType)
|
||||
class EMBERWILD_API UCraftingRecipeBook : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** L'ordre de ce tableau est l'ordre d'affichage dans la grille de craft. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
|
||||
TArray<TObjectPtr<UCraftingRecipeDataAsset>> Recipes;
|
||||
|
||||
// Pas de UFUNCTION ici : UHT refuse un TObjectPtr en valeur de retour. Le
|
||||
// Blueprint lit de toute facon Recipes directement, c'est une UPROPERTY.
|
||||
const TArray<TObjectPtr<UCraftingRecipeDataAsset>>& GetRecipes() const { return Recipes; }
|
||||
|
||||
#if WITH_EDITOR
|
||||
virtual EDataValidationResult IsDataValid(class FDataValidationContext& Context) const override;
|
||||
#endif
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "CraftingRecipeDataAsset.generated.h"
|
||||
|
||||
class UItemDataAsset;
|
||||
class UTexture2D;
|
||||
|
||||
/**
|
||||
* Poste de travail necessaire pour realiser une recette.
|
||||
*
|
||||
* Ce n'est PAS un filtre d'affichage : c'est une condition de realisation.
|
||||
* Le UCraftingComponent tient la liste des stations dont le joueur dispose a
|
||||
* l'instant t -- {Hands} tout seul, {Hands, Workbench} devant un etabli -- et
|
||||
* une recette n'est realisable que si la sienne s'y trouve. Le filtre de
|
||||
* l'interface est une notion separee, purement visuelle : melanger les deux
|
||||
* finirait par rendre un craft impossible parce qu'un bouton d'affichage est
|
||||
* mal positionne, et ce genre de bug ne se comprend jamais sur le moment.
|
||||
*
|
||||
* Fourneau et feu de cuisson n'ont volontairement PAS leur entree ici : ils ne
|
||||
* passent par aucun menu. Ce sont des acteurs poses dans le monde, facon Raft,
|
||||
* ou l'on depose une ressource et ou l'on voit le mesh changer d'etat. Leur
|
||||
* donnee et leur file d'attente leur appartiendront, une recette de menu n'a
|
||||
* rien a y faire.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class ECraftingStation : uint8
|
||||
{
|
||||
Hands,
|
||||
Workbench,
|
||||
|
||||
// Sentinelle, jamais assignee a une recette. Elle sert a parcourir l'enum
|
||||
// pour construire les boutons de filtre sans avoir a les enumerer a la main.
|
||||
Count UMETA(Hidden)
|
||||
};
|
||||
|
||||
/** Une ligne de cout : tant d'exemplaires de tel objet. */
|
||||
USTRUCT(BlueprintType)
|
||||
struct FCraftIngredient
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe")
|
||||
TObjectPtr<UItemDataAsset> Item = nullptr;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe", meta = (ClampMin = "1"))
|
||||
int32 Quantity = 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Une recette : ce qu'il faut, ce que ca donne, ou ca se fabrique.
|
||||
*
|
||||
* Un asset par recette, comme un DataAsset par objet. Trois comportements de
|
||||
* craft n'existeront jamais : la recette est de la donnee pure, c'est le
|
||||
* composant qui l'execute.
|
||||
*
|
||||
* Elle ne contient aucun etat -- ni "deja decouverte", ni "en cours de
|
||||
* fabrication". Le jour ou les recettes se debloqueront en jeu, la liste des
|
||||
* RecipeId connus vivra dans la sauvegarde, pas ici : sinon decouvrir une
|
||||
* recette la debloquerait pour toutes les parties a la fois.
|
||||
*/
|
||||
UCLASS(BlueprintType)
|
||||
class EMBERWILD_API UCraftingRecipeDataAsset : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Identifiant stable, independant du nom de l'asset.
|
||||
* Servira a la sauvegarde des recettes decouvertes : renommer un asset ne
|
||||
* doit pas faire oublier au joueur ce qu'il avait debloque.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe")
|
||||
FName RecipeId;
|
||||
|
||||
/** Ce que la fabrication consomme. Une recette sans cout est acceptee (objet gratuit). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe")
|
||||
TArray<FCraftIngredient> Ingredients;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe")
|
||||
TObjectPtr<UItemDataAsset> ResultItem;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe", meta = (ClampMin = "1"))
|
||||
int32 ResultQuantity = 1;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe")
|
||||
ECraftingStation RequiredStation = ECraftingStation::Hands;
|
||||
|
||||
// Pas de duree de fabrication ici : le craft de menu est instantane, comme
|
||||
// dans Valheim ou Raft. La fabrication qui prend du temps est celle des
|
||||
// postes qui travaillent SANS le joueur, et ceux-la ne sont pas des recettes.
|
||||
|
||||
/**
|
||||
* Nom affiche, quand celui du resultat ne suffit pas.
|
||||
* Laisse vide dans l'immense majorite des cas : dupliquer le nom de l'objet
|
||||
* dans la recette, c'est se garantir deux textes qui divergeront un jour.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe|Display")
|
||||
FText DisplayNameOverride;
|
||||
|
||||
/** Icone affichee dans la grille de craft. Vide = celle du resultat. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Recipe|Display")
|
||||
TObjectPtr<UTexture2D> IconOverride;
|
||||
|
||||
/** Le nom a afficher, override compris. Passe toujours par ici, jamais par ResultItem directement. */
|
||||
UFUNCTION(BlueprintPure, Category = "Recipe")
|
||||
FText GetDisplayName() const;
|
||||
|
||||
/** L'icone a afficher, override compris. */
|
||||
UFUNCTION(BlueprintPure, Category = "Recipe")
|
||||
UTexture2D* GetIcon() const;
|
||||
|
||||
#if WITH_EDITOR
|
||||
/**
|
||||
* Une recette sans resultat ne planterait pas : elle ne ferait simplement
|
||||
* RIEN, en consommant les ingredients. C'est exactement le genre de bug qu'on
|
||||
* cherche pendant une heure -- autant que l'editeur le refuse a la sauvegarde.
|
||||
*/
|
||||
virtual EDataValidationResult IsDataValid(class FDataValidationContext& Context) const override;
|
||||
#endif
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "CraftingRecipeSlotWidget.generated.h"
|
||||
|
||||
class UCraftingRecipeDataAsset;
|
||||
class UCraftingWidget;
|
||||
class UImage;
|
||||
class UTextBlock;
|
||||
|
||||
/**
|
||||
* Une case de la grille de craft : l'icone d'une recette, cliquable.
|
||||
*
|
||||
* Passive comme la case d'inventaire : elle n'interroge jamais le composant de
|
||||
* fabrication, c'est la page parente qui lui pousse son etat. Elle ne gere que
|
||||
* son propre clic, parce que c'est elle qui recoit l'evenement souris.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UCraftingRecipeSlotWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** Appele une fois a la creation : dit a la case quelle recette elle represente. */
|
||||
void InitSlot(UCraftingRecipeDataAsset* InRecipe, UCraftingWidget* InOwningGrid);
|
||||
|
||||
/** Affiche ou masque le cadre de selection. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
void SetSelected(bool bSelected);
|
||||
|
||||
/**
|
||||
* Assombrit la case quand les ressources manquent.
|
||||
* La recette reste visible et cliquable : le joueur doit pouvoir consulter
|
||||
* ce qui lui manque, sinon il ne sait pas quoi aller chercher.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
void SetCraftable(bool bCraftable);
|
||||
|
||||
UCraftingRecipeDataAsset* GetRecipe() const { return Recipe; }
|
||||
|
||||
protected:
|
||||
virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
|
||||
|
||||
/** A nommer exactement "RecipeIcon" dans le Widget Blueprint. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UImage> RecipeIcon;
|
||||
|
||||
/** Facultatif : le nom sous l'icone, si tu veux une grille legendee. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UTextBlock> RecipeNameText;
|
||||
|
||||
/** Facultatif : cadre affiche quand la case est selectionnee. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UWidget> SelectionBorder;
|
||||
|
||||
/** Teinte de l'icone quand la recette est realisable. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Crafting")
|
||||
FLinearColor CraftableTint = FLinearColor::White;
|
||||
|
||||
/** Teinte quand il manque des ressources. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Crafting")
|
||||
FLinearColor UncraftableTint = FLinearColor(0.3f, 0.3f, 0.3f, 1.f);
|
||||
|
||||
private:
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<UCraftingRecipeDataAsset> Recipe;
|
||||
|
||||
/** La page parente, a prevenir du clic. */
|
||||
TWeakObjectPtr<UCraftingWidget> OwningGrid;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "CraftingRecipeDataAsset.h"
|
||||
#include "Interactable.h"
|
||||
#include "CraftingStation.generated.h"
|
||||
|
||||
class UStaticMeshComponent;
|
||||
|
||||
/**
|
||||
* Un poste de travail pose dans le monde : etabli, et ce qui suivra.
|
||||
*
|
||||
* Il ne fabrique rien lui-meme et ne contient aucune recette. Tout ce qu'il
|
||||
* fait, c'est ouvrir l'ecran de fabrication en annoncant SON type de poste --
|
||||
* le reste appartient au UCraftingComponent du joueur, qui filtrera les
|
||||
* recettes en consequence.
|
||||
*
|
||||
* L'acces au poste ne dure que le temps de l'ecran : c'est le controller qui
|
||||
* l'accorde a l'ouverture et le reprend a la fermeture. Un etabli qui donnerait
|
||||
* son acces "par proximite" obligerait a surveiller la distance en permanence,
|
||||
* pour un resultat que le joueur comprendrait moins bien -- ici on fabrique a
|
||||
* l'etabli parce qu'on est en train de s'en servir.
|
||||
*
|
||||
* Un seul BP_CraftingStation suffit pour tous les postes : c'est StationType
|
||||
* qui change par instance, comme ItemData sur BP_Pickup.
|
||||
*/
|
||||
UCLASS()
|
||||
class EMBERWILD_API ACraftingStation : public AActor, public IInteractable
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
ACraftingStation();
|
||||
|
||||
// --- IInteractable ---
|
||||
virtual FText GetInteractionPrompt_Implementation(AActor* Interactor) const override;
|
||||
virtual void Interact_Implementation(AActor* Interactor) override;
|
||||
|
||||
protected:
|
||||
/**
|
||||
* Le visuel ET la surface d'interaction.
|
||||
*
|
||||
* Pas de sphere dediee ici, contrairement a APickupItem : un poste est un
|
||||
* meuble construit, dont le mesh a une vraie collision simple. Une sphere
|
||||
* autour d'un volume pareil serait touchee AVANT lui -- le trace s'arrete au
|
||||
* premier bloqueur -- et le prompt apparaitrait en visant le vide autour, ou
|
||||
* masquerait un objet pose au pied du meuble.
|
||||
*
|
||||
* Corollaire : le mesh assigne doit avoir une collision simple. Sans elle,
|
||||
* le trace le traverse, puisqu'il est lance en bTraceComplex = false.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Crafting")
|
||||
TObjectPtr<UStaticMeshComponent> Mesh;
|
||||
|
||||
/** Le poste que cet acteur rend accessible pendant qu'on s'en sert. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
|
||||
ECraftingStation StationType = ECraftingStation::Workbench;
|
||||
|
||||
/** Texte affiche quand le joueur vise le poste. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
|
||||
FText InteractionPrompt;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "CraftingRecipeDataAsset.h"
|
||||
#include "CraftingWidget.generated.h"
|
||||
|
||||
class UButton;
|
||||
class UCraftingComponent;
|
||||
class UCraftingIngredientRowWidget;
|
||||
class UCraftingRecipeSlotWidget;
|
||||
class UImage;
|
||||
class UInventoryComponent;
|
||||
class UPanelWidget;
|
||||
class UTextBlock;
|
||||
class UUniformGridPanel;
|
||||
|
||||
/**
|
||||
* La page de fabrication : la grille de recettes a gauche, le detail de la
|
||||
* recette choisie a droite.
|
||||
*
|
||||
* Elle ne calcule rien. UCraftingComponent lui dit quelles recettes afficher,
|
||||
* lesquelles sont realisables, et fabrique quand on clique. Elle s'abonne a
|
||||
* l'inventaire et aux postes accessibles pour se rafraichir -- jamais de Tick
|
||||
* pour relire un etat.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UCraftingWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
/**
|
||||
* (Re)branche la page sur le pawn actuellement possede.
|
||||
* Indispensable apres un respawn : le nouveau pawn a de nouveaux composants,
|
||||
* et les anciens abonnements pointeraient vers des objets detruits.
|
||||
*/
|
||||
void BindToOwningPawn();
|
||||
|
||||
/** Relit la liste, l'etat de chaque case et le panneau de detail. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
void Refresh();
|
||||
|
||||
/** Appele par une case quand on clique dessus. */
|
||||
void NotifyRecipeClicked(UCraftingRecipeDataAsset* InRecipe);
|
||||
|
||||
/**
|
||||
* Restreint l'affichage a un seul poste. Count = tout ce qui est accessible.
|
||||
* Purement visuel : ce filtre ne peut jamais rendre une recette realisable.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Crafting")
|
||||
void SetStationFilter(ECraftingStation InFilter);
|
||||
|
||||
protected:
|
||||
/** A nommer exactement "RecipeGrid" dans le Widget Blueprint. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UUniformGridPanel> RecipeGrid;
|
||||
|
||||
/** L'icone du resultat, dans le panneau de droite. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UImage> ResultIcon;
|
||||
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UTextBlock> ResultNameText;
|
||||
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> CraftButton;
|
||||
|
||||
/** Facultatif : la description de l'objet produit. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UTextBlock> ResultDescriptionText;
|
||||
|
||||
/** Facultatif : "x2" quand la recette produit plusieurs exemplaires. Masque sinon. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UTextBlock> ResultQuantityText;
|
||||
|
||||
/**
|
||||
* Facultatif : le panneau de droite dans son ensemble. Masque tant qu'aucune
|
||||
* recette n'est choisie, pour ne pas afficher un cadre vide au premier
|
||||
* affichage.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UWidget> DetailsPanel;
|
||||
|
||||
/** Facultatif : message affiche quand aucune recette n'est disponible ici. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UWidget> EmptyGridMessage;
|
||||
|
||||
/**
|
||||
* Le conteneur des lignes de cout. Declare en UPanelWidget et non en
|
||||
* UVerticalBox : ca laisse le choix d'un Wrap Box ou d'un Grid Panel dans le
|
||||
* Blueprint sans toucher au C++, puisqu'on ne fait qu'y ajouter des enfants.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UPanelWidget> IngredientList;
|
||||
|
||||
/** Ton WBP_RecipeSlot. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
|
||||
TSubclassOf<UCraftingRecipeSlotWidget> RecipeSlotClass;
|
||||
|
||||
/** Ton WBP_IngredientRow. Sans elle, la liste de cout reste vide. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
|
||||
TSubclassOf<UCraftingIngredientRowWidget> IngredientRowClass;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting", meta = (ClampMin = "1"))
|
||||
int32 ColumnCount = 5;
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void HandleInventoryChanged();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleStationsChanged();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCraftClicked();
|
||||
|
||||
/** Recree les cases uniquement si la liste affichee a change. */
|
||||
void RebuildGrid(const TArray<UCraftingRecipeDataAsset*>& NewRecipes);
|
||||
|
||||
/** Pousse l'etat realisable/non a chaque case et met a jour le panneau. */
|
||||
void RefreshStates();
|
||||
|
||||
void RefreshDetails();
|
||||
|
||||
/**
|
||||
* Remplit la liste de cout de la recette choisie.
|
||||
*
|
||||
* Les lignes sont RECYCLEES et non recreees : cette fonction repasse a
|
||||
* chaque changement d'inventaire, donc a chaque ramassage. Detruire puis
|
||||
* reconstruire trois widgets par branche ramassee serait du gaspillage.
|
||||
*/
|
||||
void RefreshIngredients();
|
||||
|
||||
UCraftingComponent* ResolveCrafting() const;
|
||||
|
||||
/** Detache les deux abonnements. Appele avant de rebrancher, et a la destruction. */
|
||||
void UnbindDelegates();
|
||||
|
||||
TWeakObjectPtr<UCraftingComponent> BoundCrafting;
|
||||
|
||||
TWeakObjectPtr<UInventoryComponent> BoundInventory;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<UCraftingRecipeDataAsset> SelectedRecipe;
|
||||
|
||||
/** UPROPERTY obligatoire : sans ca le GC collecterait les cases. */
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<UCraftingRecipeSlotWidget>> RecipeSlots;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<UCraftingIngredientRowWidget>> IngredientRows;
|
||||
|
||||
ECraftingStation StationFilter = ECraftingStation::Count;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "CustomizationCategoryWidget.generated.h"
|
||||
|
||||
class UBorder;
|
||||
class UButton;
|
||||
class UTextBlock;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnCustomizationCategoryClicked, int32, CategoryIndex);
|
||||
|
||||
/**
|
||||
* Une ligne de la colonne de gauche : « Corps », « Cheveux », « Peau »...
|
||||
*
|
||||
* Elle ne sait pas ce qu'elle designe -- juste un libelle et un index, comme
|
||||
* USettingRowWidget ignore ce qu'il regle. C'est l'ecran qui traduit l'index en
|
||||
* categorie, et c'est ce qui permet de n'avoir qu'UN widget a dessiner pour les
|
||||
* onze entrees.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UCustomizationCategoryWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void NativeOnInitialized() override;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Customization")
|
||||
FOnCustomizationCategoryClicked OnCategoryClicked;
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Customization")
|
||||
void Setup(int32 InIndex, const FText& InLabel);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Customization")
|
||||
void SetSelected(bool bInSelected);
|
||||
|
||||
/**
|
||||
* L'index de la categorie que porte cette rangee.
|
||||
*
|
||||
* Indispensable a l'ecran : les categories vides etant sautees, le rang de
|
||||
* la rangee dans la colonne ne vaut PAS son index de categorie.
|
||||
*/
|
||||
UFUNCTION(BlueprintPure, Category = "Customization")
|
||||
int32 GetCategoryIndex() const { return CategoryIndex; }
|
||||
|
||||
protected:
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> CategoryButton;
|
||||
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UTextBlock> CategoryLabel;
|
||||
|
||||
/** Optionnel : on peut aussi marquer la selection par la couleur du texte. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UBorder> SelectionBorder;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Customization")
|
||||
FLinearColor SelectedLabelColor = FLinearColor(1.f, 0.78f, 0.35f, 1.f);
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Customization")
|
||||
FLinearColor UnselectedLabelColor = FLinearColor(0.8f, 0.8f, 0.8f, 1.f);
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Customization")
|
||||
FLinearColor SelectedBorderColor = FLinearColor(1.f, 1.f, 1.f, 0.12f);
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Customization")
|
||||
FLinearColor UnselectedBorderColor = FLinearColor(1.f, 1.f, 1.f, 0.f);
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void HandleButtonClicked();
|
||||
|
||||
int32 CategoryIndex = 0;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "CustomizationOptionWidget.generated.h"
|
||||
|
||||
class UBorder;
|
||||
class UButton;
|
||||
class UImage;
|
||||
class UTextBlock;
|
||||
class UTexture2D;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnCustomizationOptionClicked, int32, OptionIndex);
|
||||
|
||||
/**
|
||||
* Une case de la grille de customisation.
|
||||
*
|
||||
* La MEME classe sert les vignettes de pieces et les pastilles de couleur :
|
||||
* dans les deux cas c'est un bouton carre qui porte un visuel et sait s'il est
|
||||
* selectionne. Deux widgets auraient eu la meme logique de selection a tenir a
|
||||
* jour en double, pour la seule difference d'afficher une texture plutot qu'un
|
||||
* aplat.
|
||||
*
|
||||
* Classe Abstract : on cree un WBP_CustomizationOption qui en herite.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UCustomizationOptionWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void NativeOnInitialized() override;
|
||||
|
||||
/** Previent d'un clic du joueur, jamais d'un rafraichissement. */
|
||||
UPROPERTY(BlueprintAssignable, Category = "Customization")
|
||||
FOnCustomizationOptionClicked OnOptionClicked;
|
||||
|
||||
/**
|
||||
* Mode piece : affiche la vignette du catalogue.
|
||||
*
|
||||
* @param Icon peut etre nul -- la case retombe alors sur son numero. Une
|
||||
* option sans vignette doit rester choisissable, sinon le catalogue
|
||||
* est inutilisable tant que les trente-six textures ne sont pas
|
||||
* rendues, et on veut justement les capturer depuis cet ecran.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Customization")
|
||||
void SetupAsPart(int32 InIndex, UTexture2D* Icon);
|
||||
|
||||
/** Mode couleur : la pastille EST le visuel, aucune texture necessaire. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Customization")
|
||||
void SetupAsColor(int32 InIndex, const FLinearColor& Color);
|
||||
|
||||
/** Encadre la case si elle porte le choix courant. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Customization")
|
||||
void SetSelected(bool bInSelected);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Customization")
|
||||
int32 GetOptionIndex() const { return OptionIndex; }
|
||||
|
||||
protected:
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> OptionButton;
|
||||
|
||||
/** Porte la vignette en mode piece, la couleur en mode couleur. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UImage> OptionImage;
|
||||
|
||||
/**
|
||||
* Le numero de repli, montre uniquement quand une piece n'a pas de
|
||||
* vignette. BindWidgetOptional : un WBP qui s'en passe reste valide, il
|
||||
* affichera simplement des cases vides.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UTextBlock> IndexText;
|
||||
|
||||
/** Le cadre de selection. Optionnel : on peut le rendre autrement. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UBorder> SelectionBorder;
|
||||
|
||||
/** Couleur du cadre quand la case porte le choix courant. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Customization")
|
||||
FLinearColor SelectedBorderColor = FLinearColor(1.f, 0.78f, 0.35f, 1.f);
|
||||
|
||||
/** Couleur du cadre au repos. Transparente par defaut : il disparait. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Customization")
|
||||
FLinearColor UnselectedBorderColor = FLinearColor(1.f, 1.f, 1.f, 0.f);
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void HandleButtonClicked();
|
||||
|
||||
int32 OptionIndex = 0;
|
||||
};
|
||||
@@ -0,0 +1,362 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "Engine/EngineTypes.h"
|
||||
#include "FootstepSoundsDataAsset.h"
|
||||
#include "FootstepComponent.generated.h"
|
||||
|
||||
class ACharacter;
|
||||
class UCharacterMovementComponent;
|
||||
class USoundAttenuation;
|
||||
class USoundBase;
|
||||
class USoundConcurrency;
|
||||
|
||||
/**
|
||||
* Les reglages d'une allure : sa foulee et son volume.
|
||||
*
|
||||
* Groupes dans une struct pour la meme raison que FHeadBobProfile : trois
|
||||
* allures a plat feraient neuf lignes melangees dans le Details panel, alors
|
||||
* que la struct donne un repli par allure.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FFootstepGaitSettings
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
FFootstepGaitSettings() = default;
|
||||
|
||||
FFootstepGaitSettings(float InStrideLength, float InVolume)
|
||||
: StrideLength(InStrideLength)
|
||||
, Volume(InVolume)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance parcourue entre deux pas, en cm.
|
||||
*
|
||||
* C'est le reglage central du systeme, et il se calcule plutot qu'il ne se
|
||||
* devine : pour que les pas tombent en rythme avec le balancement de tete,
|
||||
* il faut StrideLength = Vitesse / (2 x StrideFrequency du profil de
|
||||
* UHeadBobComponent) -- le "2" parce qu'une foulee compte deux pas. Les
|
||||
* valeurs par defaut sortent de ce calcul avec les vitesses du pawn.
|
||||
*
|
||||
* Corollaire a retenir : changer WalkSpeed ou SprintSpeed sans retoucher ici
|
||||
* desynchronise le son de l'image, et ca s'entend avant de se voir.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps", meta = (ClampMin = "10", UIMax = "300"))
|
||||
float StrideLength = 180.f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps", meta = (ClampMin = "0", UIMax = "2"))
|
||||
float Volume = 1.f;
|
||||
|
||||
/**
|
||||
* Variation aleatoire de hauteur, en plus ou en moins autour de 1.
|
||||
*
|
||||
* Le pack ne livre qu'un ou deux echantillons par surface et par allure :
|
||||
* sans ce grain, une course en ligne droite repete exactement le meme son
|
||||
* trois fois par seconde et l'oreille l'entend comme une boucle. Quelques
|
||||
* pourcents suffisent, au-dela le personnage change de poids a chaque pas.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps", meta = (ClampMin = "0", UIMax = "0.4"))
|
||||
float PitchVariation = 0.06f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bruits de pas : mesure la distance parcourue, cherche la matiere sous les
|
||||
* pieds, joue le son qui va avec.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* Pourquoi la distance et pas un AnimNotify
|
||||
* ---------------------------------------------------------------------------
|
||||
* C'est le choix structurant. Trois raisons de ne pas accrocher le son aux
|
||||
* animations :
|
||||
*
|
||||
* - Le corps 3e personne du joueur local est en bOwnerNoSee. Faire dependre
|
||||
* son propre son d'un mesh qu'il ne voit pas est fragile, et le devient
|
||||
* d'autant plus le jour ou l'on optimisera son tick d'animation.
|
||||
* - Les animations du pack n'ont aucun notify, et il faudrait en poser dans le
|
||||
* BlendSpace, ou ils se declenchent selon l'echantillon dominant : le
|
||||
* passage marche -> course y saute ou double un pas.
|
||||
* - Les bras FPS sont sur BasePose_Skeleton, qui n'a rien a voir avec
|
||||
* SK_BodyA. Aucun notify ne peut servir aux deux.
|
||||
*
|
||||
* La distance, elle, donne un espacement regulier, se regle en PIE sans
|
||||
* recompiler, et se comporte identiquement sur le pawn local et sur ceux des
|
||||
* amis.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* Reseau : rien n'est replique, et rien n'a a l'etre
|
||||
* ---------------------------------------------------------------------------
|
||||
* Exactement le raisonnement de UPlayerBodyAnimInstance : tout ce que ce
|
||||
* composant lit DERIVE d'un etat deja synchronise -- la vitesse par le
|
||||
* CharacterMovement, l'accroupissement par ACharacter, le mode de deplacement
|
||||
* par la replication du mouvement. Chaque machine calcule donc les pas de tous
|
||||
* les pawns qu'elle connait. Un Multicast par pas, a trois pas par seconde et
|
||||
* par joueur, serait du trafic pur pour un resultat identique.
|
||||
*
|
||||
* Le son du joueur local part en 2D, celui des autres en 3D a leurs pieds :
|
||||
* un pas local joue en 3D depuis les chevilles est attenue et sonne creux.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
|
||||
class EMBERWILD_API UFootstepComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UFootstepComponent();
|
||||
|
||||
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
|
||||
|
||||
/**
|
||||
* Coupe ou rallume les pas. Pour la mort, une cinematique, un mode fantome.
|
||||
* Coupe, le compteur de foulee est remis a zero : rallumer ne doit pas
|
||||
* declencher le pas reste en attente depuis l'autre bout de la carte.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Footsteps")
|
||||
void SetFootstepsEnabled(bool bEnabled);
|
||||
|
||||
/** Remet le cycle de foulee a zero. Pour une teleportation ou un respawn. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Footsteps")
|
||||
void ResetStride();
|
||||
|
||||
/**
|
||||
* Joue un pas immediatement, a l'allure courante. Sert aux montages et aux
|
||||
* AnimNotify si l'on veut un jour caler un geste precis sur un son.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Footsteps")
|
||||
void PlayFootstepNow();
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
|
||||
|
||||
/**
|
||||
* Saut et reception, sur TOUTES les machines.
|
||||
*
|
||||
* Et surtout pas ACharacter::Landed / OnJumped, qui semblent pourtant faits
|
||||
* pour ca : Landed vient de ProcessLanded, qui n'est appele que la ou le
|
||||
* mouvement est reellement simule, et OnJumped que la ou l'input est lu. Ni
|
||||
* l'un ni l'autre n'existe sur le pawn d'un ami vu depuis notre machine, ou
|
||||
* le mouvement arrive deja fait. Le mode de deplacement, lui, EST replique,
|
||||
* donc ce delegue est diffusé partout -- c'est le seul signal qui couvre les
|
||||
* quatre joueurs.
|
||||
*/
|
||||
UFUNCTION()
|
||||
void HandleMovementModeChanged(ACharacter* Character, EMovementMode PrevMovementMode, uint8 PreviousCustomMode);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Assets
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** La table surface -> sons. Sans elle le composant ne fait rien. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps")
|
||||
TObjectPtr<UFootstepSoundsDataAsset> SoundBank;
|
||||
|
||||
/**
|
||||
* Attenuation appliquee aux pas des AUTRES joueurs (le pas local est en 2D).
|
||||
*
|
||||
* A remplir : le pack ne fournit aucune attenuation, donc sans cet asset les
|
||||
* cues sont jouees en 3D sans courbe de distance et s'entendent d'un bout a
|
||||
* l'autre de la carte au meme volume.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps")
|
||||
TObjectPtr<USoundAttenuation> Attenuation;
|
||||
|
||||
/**
|
||||
* Concurrence, facultative. A quatre joueurs qui courent, huit a douze pas
|
||||
* par seconde peuvent se superposer ; une regle a 4 ou 5 voix evite que les
|
||||
* pas mangent le reste du mixage.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps")
|
||||
TObjectPtr<USoundConcurrency> Concurrency;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Reglages generaux
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Coupe entierement l'effet sans avoir a vider la banque de sons. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps")
|
||||
bool bEnableFootsteps = true;
|
||||
|
||||
/**
|
||||
* Joue le pas du joueur local en 2D plutot qu'a la position de ses pieds.
|
||||
* Decoche pour l'entendre spatialise comme celui des autres -- utile pour
|
||||
* regler l'attenuation sans avoir besoin d'un second joueur.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps")
|
||||
bool bPlayLocalStepsIn2D = true;
|
||||
|
||||
/**
|
||||
* Multiplicateur applique au seul pas local. Sous 1 par defaut : un son 2D
|
||||
* arrive a pleine puissance dans les deux oreilles, la ou celui des autres
|
||||
* passe par l'attenuation.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps", meta = (ClampMin = "0", UIMax = "2"))
|
||||
float LocalVolumeScale = 0.7f;
|
||||
|
||||
/** Joue le pas final quand le personnage s'immobilise. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps")
|
||||
bool bPlayStopSounds = true;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Allures
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Accroupi. Valeurs par defaut dans le constructeur. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Allures")
|
||||
FFootstepGaitSettings CrouchGait;
|
||||
|
||||
/** Marche normale. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Allures")
|
||||
FFootstepGaitSettings WalkGait;
|
||||
|
||||
/** Sprint. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Allures")
|
||||
FFootstepGaitSettings SprintGait;
|
||||
|
||||
/**
|
||||
* En dessous de cette vitesse horizontale (cm/s), on considere le
|
||||
* personnage immobile. Meme seuil et meme raison que UHeadBobComponent :
|
||||
* marcher dans un mur laisse l'input actif alors que le corps n'avance plus.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Allures", meta = (ClampMin = "0"))
|
||||
float IdleSpeedThreshold = 10.f;
|
||||
|
||||
/**
|
||||
* Au-dessus de cette vitesse horizontale (cm/s), on passe sur l'allure de
|
||||
* sprint. A caler entre WalkSpeed et SprintSpeed du pawn, comme le seuil
|
||||
* homonyme du balancement de tete -- les deux doivent basculer ensemble,
|
||||
* sinon la tete accelere avant les pieds.
|
||||
*
|
||||
* La vitesse REELLE et pas AFpsPlayer::IsSprinting() : le composant reste
|
||||
* ainsi utilisable par un PNJ ou un animal, qui n'ont pas de touche Maj.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Allures", meta = (ClampMin = "0"))
|
||||
float SprintSpeedThreshold = 520.f;
|
||||
|
||||
/**
|
||||
* Fraction de foulee deja "parcourue" au demarrage du mouvement.
|
||||
*
|
||||
* A 0, le premier pas ne tombe qu'apres une foulee entiere : le personnage
|
||||
* part en silence sur presque deux metres, ce qui se lit comme un temps de
|
||||
* reaction du jeu. A 1 il claque avant que le corps ait bouge. La moitie
|
||||
* place le premier pas juste apres l'entree en mouvement.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Allures", meta = (ClampMin = "0", ClampMax = "1"))
|
||||
float FirstStepFraction = 0.5f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Saut et reception
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vitesse de chute (cm/s) en dessous de laquelle la reception est muette.
|
||||
* Descendre une marche d'escalier passe techniquement par un instant de
|
||||
* chute ; sans ce seuil, la moindre bosse du terrain claque.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Air", meta = (ClampMin = "0"))
|
||||
float LandMinFallSpeed = 150.f;
|
||||
|
||||
/** Volume du saut et de la reception. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Air", meta = (ClampMin = "0", UIMax = "2"))
|
||||
float AirVolume = 1.f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Trace de sol
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Profondeur sondee sous les pieds, en cm. Quelques dizaines suffisent : le
|
||||
* trace part du centre de la capsule et on ne cherche le sol que lorsqu'on
|
||||
* marche dessus.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Trace", meta = (ClampMin = "1"))
|
||||
float TraceDepth = 60.f;
|
||||
|
||||
/**
|
||||
* Trace en collision COMPLEXE, et ce n'est pas negociable pour ce systeme.
|
||||
*
|
||||
* Un PhysicalMaterial pose sur un MATERIAU n'est rendu que par un trace
|
||||
* per-poly : le trace simple remonte celui du BodySetup du mesh, qui est
|
||||
* presque toujours vide. Le symptome d'un decochage est immediat et trompeur
|
||||
* -- tout le monde marche sur DefaultSurface, comme si la table n'etait pas
|
||||
* remplie.
|
||||
*
|
||||
* Le cout est nul a l'echelle du jeu : deux a trois traces par seconde et
|
||||
* par personnage, jamais un par frame.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Trace")
|
||||
bool bTraceComplex = true;
|
||||
|
||||
/** Canal du trace de sol. Visibility couvre tout le decor du projet. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Footsteps|Trace")
|
||||
TEnumAsByte<ECollisionChannel> TraceChannel = ECC_Visibility;
|
||||
|
||||
private:
|
||||
/** L'allure correspondant a l'etat reel du CharacterMovement. */
|
||||
EFootstepGait SelectGait(float HorizontalSpeed) const;
|
||||
|
||||
/** Les reglages de l'allure courante. */
|
||||
const FFootstepGaitSettings& GetGaitSettings(EFootstepGait Gait) const;
|
||||
|
||||
/**
|
||||
* Cherche la matiere sous les pieds et rend les sons correspondants.
|
||||
* @param OutLocation ou jouer le son : le point d'impact, ou les pieds si
|
||||
* le trace n'a rien touche (le sol peut etre hors du canal trace).
|
||||
*/
|
||||
const FFootstepSurfaceSounds* QueryGroundSounds(FVector& OutLocation) const;
|
||||
|
||||
/** Joue une cue, en 2D chez le proprietaire et en 3D partout ailleurs. */
|
||||
void PlayCue(USoundBase* Sound, float Volume, float PitchVariation, const FVector& Location) const;
|
||||
|
||||
/** Vrai quand le son doit sortir des haut-parleurs de CETTE machine. */
|
||||
bool ShouldPlaySounds() const;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Etat
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Distance horizontale accumulee depuis le dernier pas, en cm. */
|
||||
float DistanceSinceLastStep = 0.f;
|
||||
|
||||
/** Position de l'acteur au tick precedent, pour mesurer le deplacement reel. */
|
||||
FVector LastLocation = FVector::ZeroVector;
|
||||
|
||||
/**
|
||||
* Vrai des le premier tick valide. Sans lui, le premier deplacement mesure
|
||||
* partirait de l'origine du monde et declencherait une salve de pas.
|
||||
*/
|
||||
bool bHasLastLocation = false;
|
||||
|
||||
/** Etat du tick precedent, pour detecter le demarrage et l'arret. */
|
||||
bool bWasMoving = false;
|
||||
|
||||
/** Allure du dernier pas joue : c'est elle qui choisit le son d'arret. */
|
||||
EFootstepGait LastGait = EFootstepGait::Jog;
|
||||
|
||||
/**
|
||||
* Nombre de pas depuis l'entree en mouvement. Le son d'arret n'est joue
|
||||
* qu'a partir de un : un a-coup de collision suffit sinon a faire claquer
|
||||
* un pas final alors que le personnage n'a pas bouge.
|
||||
*/
|
||||
int32 StepsSinceMoveStart = 0;
|
||||
|
||||
/**
|
||||
* Plus grande vitesse de chute observee, en cm/s et positive.
|
||||
*
|
||||
* Mesuree pendant la chute plutot que lue a l'impact : au moment ou le mode
|
||||
* repasse a Walking, le moteur a deja remis Velocity.Z a zero. Un tick a
|
||||
* 20 Hz l'echantillonne largement assez -- et le cas ou il n'a pas eu le
|
||||
* temps de tourner du tout est precisement celui d'une chute negligeable,
|
||||
* qu'on veut de toute facon silencieuse.
|
||||
*/
|
||||
float MaxFallSpeed = 0.f;
|
||||
|
||||
/** Caches : lus a chaque tick, pas question d'y caster. */
|
||||
TObjectPtr<ACharacter> OwnerCharacter;
|
||||
|
||||
TObjectPtr<UCharacterMovementComponent> OwnerMovement;
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Chaos/ChaosEngineInterface.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "FootstepSoundsDataAsset.generated.h"
|
||||
|
||||
class USoundBase;
|
||||
|
||||
/**
|
||||
* Les trois allures pour lesquelles le pack livre un son.
|
||||
*
|
||||
* Les noms sont ceux des fichiers du pack et PAS ceux des etats du jeu, alors
|
||||
* que la tentation serait grande de les appeler Crouch / Walk / Sprint. La
|
||||
* correspondance est etablie une seule fois, dans UFootstepComponent, et elle
|
||||
* n'est pas l'identite : le personnage marche a 400 cm/s, ce qui est deja un
|
||||
* trot -- d'ou accroupi = Walk, marche = Jog, sprint = Run. Nommer l'enum
|
||||
* d'apres les etats obligerait a ecrire "le son Walk est dans le champ Crouch"
|
||||
* dans le Details panel, ce qui est exactement le genre de traduction qu'on
|
||||
* finit par se tromper en relisant.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class EFootstepGait : uint8
|
||||
{
|
||||
/** Le pas le plus discret du pack. Utilise quand le personnage est accroupi. */
|
||||
Walk,
|
||||
|
||||
/** Allure de marche normale du jeu (WalkSpeed). */
|
||||
Jog,
|
||||
|
||||
/** Sprint. */
|
||||
Run
|
||||
};
|
||||
|
||||
/**
|
||||
* La banque de sons d'UNE surface : les huit cues que le pack fournit pour
|
||||
* chacune de ses matieres.
|
||||
*
|
||||
* Tous les champs sont facultatifs. Une case vide ne produit aucun son et
|
||||
* n'est pas une erreur -- c'est ce qui permet de commencer avec trois surfaces
|
||||
* remplies et d'ajouter les autres au fil du temps, ou de renoncer aux sons
|
||||
* d'arret sans toucher au code.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FFootstepSurfaceSounds
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Les pas eux-memes, un par allure.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Steps")
|
||||
TObjectPtr<USoundBase> WalkStep;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Steps")
|
||||
TObjectPtr<USoundBase> JogStep;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Steps")
|
||||
TObjectPtr<USoundBase> RunStep;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Le pas final, joue quand le personnage s'immobilise.
|
||||
//
|
||||
// Ce n'est pas un ornement : sans lui, une course qui s'arrete coupe la
|
||||
// serie de pas net, comme si le personnage s'etait teleporte. Le pack les
|
||||
// livre exprès, avec un son plus traine que le pas courant.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stop")
|
||||
TObjectPtr<USoundBase> WalkStop;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stop")
|
||||
TObjectPtr<USoundBase> JogStop;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stop")
|
||||
TObjectPtr<USoundBase> RunStop;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Saut et reception. Une seule variante chacun, quelle que soit l'allure.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Air")
|
||||
TObjectPtr<USoundBase> Jump;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Air")
|
||||
TObjectPtr<USoundBase> Land;
|
||||
|
||||
/** Le pas courant d'une allure, ou nullptr si la case est vide. */
|
||||
USoundBase* GetStepSound(EFootstepGait Gait) const;
|
||||
|
||||
/** Le pas final d'une allure, ou nullptr si la case est vide. */
|
||||
USoundBase* GetStopSound(EFootstepGait Gait) const;
|
||||
};
|
||||
|
||||
/**
|
||||
* La table de correspondance surface physique -> banque de sons.
|
||||
*
|
||||
* Un seul asset a remplir pour tout le jeu, et le SEUL endroit a toucher pour
|
||||
* ajouter une matiere. Le composant n'y range aucun etat : il le lit, c'est
|
||||
* tout, ce qui permet de le partager entre le joueur, les futurs PNJ et les
|
||||
* animaux sans se poser de question.
|
||||
*
|
||||
* La cle est le SurfaceType declare dans Project Settings > Physics, pas un
|
||||
* nom ni un chemin d'asset : c'est le seul identifiant que le moteur nous
|
||||
* rende depuis un trace, et il tient dans un octet.
|
||||
*/
|
||||
UCLASS(BlueprintType)
|
||||
class EMBERWILD_API UFootstepSoundsDataAsset : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Une entree par matiere du jeu. Les surfaces absentes de cette table
|
||||
* tombent sur DefaultSurface.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Footsteps")
|
||||
TMap<TEnumAsByte<EPhysicalSurface>, FFootstepSurfaceSounds> Surfaces;
|
||||
|
||||
/**
|
||||
* Le repli, et il n'est pas facultatif en pratique.
|
||||
*
|
||||
* Un decor n'est jamais tague a 100 % : il restera toujours un rocher de
|
||||
* demo, un mesh importe a la va-vite ou un acteur de test sans materiau. Le
|
||||
* symptome d'un repli vide est un silence total sur ces surfaces, qu'on
|
||||
* confond immediatement avec "le systeme ne marche pas". Mettre ici les
|
||||
* cues de terre est le choix sur.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Footsteps")
|
||||
FFootstepSurfaceSounds DefaultSurface;
|
||||
|
||||
/**
|
||||
* Les sons d'une surface, ou DefaultSurface si elle n'est pas dans la table.
|
||||
* Ne rend jamais nullptr : l'appelant n'a qu'a tester les cues elles-memes.
|
||||
*/
|
||||
const FFootstepSurfaceSounds& GetSurfaceSounds(EPhysicalSurface Surface) const;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Animation/AnimInstance.h"
|
||||
#include "FpsPlayer.h"
|
||||
#include "FpsArmsAnimInstance.generated.h"
|
||||
|
||||
/**
|
||||
* AnimInstance des bras en vue premiere personne.
|
||||
*
|
||||
* Elle ne DECIDE de rien : elle lit l'etat du pawn une fois par frame et le
|
||||
* republie sous forme de variables. C'est la machine a etats du Blueprint enfant
|
||||
* (ABP_FpsArms) qui choisit l'animation. La separation est volontaire -- une
|
||||
* transition d'animation se regle a l'oeil, avec des durees de fondu, et ca n'a
|
||||
* rien a faire dans du C++ qu'il faut recompiler.
|
||||
*
|
||||
* Corollaire : ces variables ne sont JAMAIS repliquees. Les bras ne sont vus que
|
||||
* par leur proprietaire, donc chaque machine calcule les siens a partir d'un etat
|
||||
* de pawn que la replication a deja synchronise (vitesse, sprint, accroupi).
|
||||
*/
|
||||
UCLASS()
|
||||
class EMBERWILD_API UFpsArmsAnimInstance : public UAnimInstance
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void NativeInitializeAnimation() override;
|
||||
virtual void NativeUpdateAnimation(float DeltaSeconds) override;
|
||||
|
||||
protected:
|
||||
// ------------------------------------------------------------------
|
||||
// Etat publie vers le Blueprint d'animation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Vitesse horizontale en cm/s. Sert aussi a moduler le PlayRate de la course. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
float GroundSpeed = 0.f;
|
||||
|
||||
/** Vrai quand le joueur se deplace REELLEMENT, freinage exclu. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
bool bIsMoving = false;
|
||||
|
||||
/**
|
||||
* Intention brute de sprint : la touche est enfoncee, rien de plus.
|
||||
*
|
||||
* NE PAS la brancher sur une transition de la machine a etats -- c'est
|
||||
* exactement ce qui faisait courir les bras d'un joueur immobile qui appuyait
|
||||
* sur Maj. Elle reste publiee parce qu'elle est la seule a dire ce que le
|
||||
* joueur VEUT (un effet de FOV, un cout de stamina) ; pour l'animation, c'est
|
||||
* bIsRunning qu'on lit.
|
||||
*/
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
bool bIsSprinting = false;
|
||||
|
||||
/**
|
||||
* Sprint EFFECTIF : au sol, debout, et le joueur avance pour de bon.
|
||||
*
|
||||
* Toute la composition est faite ici, en un seul endroit, plutot que
|
||||
* reconstruite dans chaque transition du Blueprint. Une machine a etats a
|
||||
* autant d'entrees vers Run que d'etats voisins ; recomposer la condition a
|
||||
* la main dans chacune, c'est se garantir d'en oublier une.
|
||||
*/
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
bool bIsRunning = false;
|
||||
|
||||
/** Deplacement au sol, debout, sans sprint. Complementaire exact de bIsRunning. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
bool bIsWalking = false;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
bool bIsFalling = false;
|
||||
|
||||
/**
|
||||
* La reception qui vient d'avoir lieu, ou None tant qu'on est en l'air.
|
||||
*
|
||||
* C'est elle qui pilote la SORTIE de l'etat de chute, et il n'y a rien a
|
||||
* composer autour : les trois valeurs menent aux trois etats de sortie
|
||||
* (reception normale, reception encaissee, retour direct a la locomotion) et
|
||||
* s'excluent par construction. Le seuil sous lequel une chute ne merite
|
||||
* aucune animation vit sur le pawn -- c'est le meme que celui de la secousse
|
||||
* de camera, pour que les deux effets ne se contredisent jamais.
|
||||
*/
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
ELandingImpact LandingImpact = ELandingImpact::None;
|
||||
|
||||
/**
|
||||
* Vitesse verticale signee (cm/s), positive vers le haut.
|
||||
*
|
||||
* C'est elle, et pas bIsFalling, qui distingue un saut d'une chute : le
|
||||
* moteur appelle « falling » tout ce qui n'est pas au sol, donc sortir d'un
|
||||
* rebord jouerait l'animation de detente si on se fiait au seul booleen.
|
||||
*/
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
float VerticalSpeed = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
bool bIsCrouching = false;
|
||||
|
||||
/**
|
||||
* Le joueur tient un objet VISIBLE dans la main.
|
||||
*
|
||||
* A brancher sur un « Blend Poses by bool » A L'INTERIEUR de l'etat de
|
||||
* deplacement, jamais sur une transition vers un etat jumeau. Deux etats
|
||||
* « marche avec » et « marche sans » doubleraient la machine et, surtout,
|
||||
* equiper un outil en pleine course claquerait d'un cycle a l'autre. Un blend
|
||||
* fond les deux poses, l'outil apparaissant pendant que la foulee continue.
|
||||
*
|
||||
* C'est le nombre de MESHES montes qu'elle reflete, pas le contenu de la case
|
||||
* active : la plupart des ressources n'ont pas bShowInHand, et les tenir ne
|
||||
* doit pas changer la posture des bras.
|
||||
*/
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Held Item")
|
||||
bool bHasItemInHand = false;
|
||||
|
||||
|
||||
/**
|
||||
* Vitesse de lecture du cycle de deplacement, a brancher sur la broche
|
||||
* Play Rate du noeud d'animation.
|
||||
*
|
||||
* Le projet a trois vitesses de jeu (accroupi 200, marche 400, sprint 650)
|
||||
* pour un seul cycle d'animation. Plutot qu'un etat par vitesse -- trois
|
||||
* seuils, donc trois occasions de claquer quand on les franchit -- on etire
|
||||
* le meme cycle. La cadence des bras suit alors la vitesse en continu, y
|
||||
* compris pendant l'acceleration.
|
||||
*
|
||||
* Un vrai etat Sprint n'aura de sens que le jour ou l'animateur livrera une
|
||||
* animation de sprint a la POSTURE differente. Le meme cycle joue plus vite
|
||||
* n'est pas une animation de sprint.
|
||||
*/
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Movement")
|
||||
float LocomotionPlayRate = 1.f;
|
||||
|
||||
/**
|
||||
* En dessous de cette vitesse (cm/s) on considere le joueur immobile.
|
||||
*
|
||||
* Un seuil strictement a zero ferait clignoter la transition course/repos
|
||||
* pendant le freinage, ou la vitesse decroit en passant par des valeurs
|
||||
* minuscules mais non nulles.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Movement", meta = (ClampMin = "0"))
|
||||
float MovingSpeedThreshold = 10.f;
|
||||
|
||||
/**
|
||||
* Vitesse (cm/s) a laquelle le cycle tourne a sa cadence d'origine.
|
||||
*
|
||||
* A regler a l'oeil dans ABP_FpsArms : c'est la vitesse ou les bras ont l'air
|
||||
* naturels sans etirement. Par defaut la vitesse de marche du pawn, donc le
|
||||
* sprint accelere d'environ 60 %.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Movement", meta = (ClampMin = "1"))
|
||||
float ReferenceLocomotionSpeed = 400.f;
|
||||
|
||||
/**
|
||||
* Bornes du Play Rate. Sans le plancher, un joueur qui rampe a 20 cm/s
|
||||
* regarderait des bras quasi immobiles ; sans le plafond, une future vitesse
|
||||
* bonus donnerait des bras en accelere qui sortent du registre credible.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Movement", meta = (ClampMin = "0.1"))
|
||||
float MinLocomotionPlayRate = 0.6f;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Movement", meta = (ClampMin = "0.1"))
|
||||
float MaxLocomotionPlayRate = 1.8f;
|
||||
|
||||
private:
|
||||
/**
|
||||
* Cache du pawn. TryGetPawnOwner() fait un Cast a chaque appel, et cette
|
||||
* fonction tourne a chaque frame sur un mesh anime.
|
||||
*
|
||||
* TWeakObjectPtr et pas un pointeur nu : apres un respawn le pawn est detruit,
|
||||
* et cet objet peut lui survivre d'une frame.
|
||||
*/
|
||||
TWeakObjectPtr<AFpsPlayer> OwningPlayer;
|
||||
};
|
||||
@@ -0,0 +1,897 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Character.h"
|
||||
#include "FpsPlayer.generated.h"
|
||||
|
||||
class APickupItem;
|
||||
class UAnimMontage;
|
||||
class UCameraComponent;
|
||||
class UCharacterAppearanceComponent;
|
||||
class UCraftingComponent;
|
||||
class UFootstepComponent;
|
||||
class UHeadBobComponent;
|
||||
class UHeldItemComponent;
|
||||
class UInputAction;
|
||||
class UInputMappingContext;
|
||||
class UInteractionComponent;
|
||||
class UInventoryComponent;
|
||||
class UItemDataAsset;
|
||||
class USurvivalStatsComponent;
|
||||
struct FInputActionValue;
|
||||
|
||||
/**
|
||||
* Ce que vaut la réception qui vient d'avoir lieu.
|
||||
*
|
||||
* Produit par le PAWN, qui est le seul à connaître les deux seuils, et republié
|
||||
* tel quel par UFpsArmsAnimInstance. Un enum plutôt que deux booléens parce que
|
||||
* les trois cas s'excluent par construction : la machine à états compare, elle
|
||||
* n'a aucune condition à recomposer, et il n'existe aucune combinaison où deux
|
||||
* transitions seraient valides en même temps — c'est-à-dire prises au hasard.
|
||||
*
|
||||
* Remis à None dès l'entrée en chute (voir Tick), donc il vaut None pendant tout
|
||||
* le vol : une transition n'a jamais à vérifier bIsFalling en plus.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class ELandingImpact : uint8
|
||||
{
|
||||
/** En l'air, ou chute trop faible pour mériter une animation. */
|
||||
None,
|
||||
|
||||
/** Réception normale (JumpLanding). */
|
||||
Soft,
|
||||
|
||||
/**
|
||||
* Réception encaissée (HardLanding) — c'est-à-dire, très exactement, une
|
||||
* chute qui a coûté de la vie. Le seuil n'est pas un réglage d'animation,
|
||||
* c'est `FallDamageMinSpeed`.
|
||||
*/
|
||||
Hard
|
||||
};
|
||||
|
||||
/**
|
||||
* Ressort amorti à une dimension, partagé par les effets de caméra.
|
||||
*
|
||||
* Pourquoi un ressort plutôt qu'une courbe paramétrée par le temps : un ressort
|
||||
* n'a pas d'animation « en cours » qu'il faudrait annuler pour en jouer une
|
||||
* autre. Une nouvelle impulsion s'ajoute simplement à l'état courant, position
|
||||
* et vitesse restent continues, donc le mouvement est interruptible à n'importe
|
||||
* quel instant sans jamais produire de saut. C'est exactement ce que le crouch
|
||||
* demande quand le joueur martèle la touche.
|
||||
*/
|
||||
struct FCameraSpring
|
||||
{
|
||||
/** Déplacement courant, dans l'unité de ce que le ressort pilote. */
|
||||
float Offset = 0.f;
|
||||
|
||||
/** Vitesse courante, par seconde. */
|
||||
float Velocity = 0.f;
|
||||
|
||||
/** Impulsion pas encore versée, étalée sur RampTime pour éviter l'à-coup. */
|
||||
float PendingImpulse = 0.f;
|
||||
|
||||
void AddImpulse(float Strength) { PendingImpulse += Strength; }
|
||||
|
||||
/** Remet le ressort au repos, sans transition (respawn, téléportation). */
|
||||
void Reset()
|
||||
{
|
||||
Offset = 0.f;
|
||||
Velocity = 0.f;
|
||||
PendingImpulse = 0.f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intègre le ressort d'un pas de temps et renvoie le nouvel Offset.
|
||||
*
|
||||
* @param Frequency fréquence propre en Hz : vitesse de retour au repos.
|
||||
* @param Damping 1 = retour net sans rebond, en dessous ça oscille.
|
||||
* @param RampTime durée sur laquelle une impulsion est versée (0 = d'un coup).
|
||||
* @param MaxOffset garde-fou d'amplitude.
|
||||
*/
|
||||
float Update(float Step, float Frequency, float Damping, float RampTime, float MaxOffset);
|
||||
};
|
||||
|
||||
/**
|
||||
* Personnage joueur en vue première personne.
|
||||
* Les assets d'input (Input Mapping Context + Input Actions) se branchent
|
||||
* dans le Blueprint enfant (BP_FpsPlayer), catégorie "Input".
|
||||
*/
|
||||
UCLASS()
|
||||
class EMBERWILD_API AFpsPlayer : public ACharacter
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
// Sets default values for this character's properties
|
||||
AFpsPlayer();
|
||||
|
||||
// Called every frame
|
||||
virtual void Tick(float DeltaTime) override;
|
||||
|
||||
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||
|
||||
// Called to bind functionality to input
|
||||
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
|
||||
|
||||
/** Vrai tant que le joueur sprinte (utile pour l'anim ou la stamina plus tard). */
|
||||
UFUNCTION(BlueprintPure, Category = "Mouvement")
|
||||
bool IsSprinting() const { return bIsSprinting; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Camera")
|
||||
UCameraComponent* GetFirstPersonCamera() const { return FirstPersonCamera; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Camera")
|
||||
UHeadBobComponent* GetHeadBob() const { return HeadBob; }
|
||||
|
||||
/** Les bras vus par le joueur local. C'est ici que viendra l'objet en main. */
|
||||
UFUNCTION(BlueprintPure, Category = "Animation")
|
||||
USkeletalMeshComponent* GetFirstPersonArms() const { return FirstPersonArms; }
|
||||
|
||||
/** La réception en cours, ou None en l'air. Lue par les AnimInstance. */
|
||||
UFUNCTION(BlueprintPure, Category = "Animation")
|
||||
ELandingImpact GetLandingImpact() const { return LandingImpact; }
|
||||
|
||||
/**
|
||||
* Joue un montage sur les bras du joueur LOCAL, et seulement lui.
|
||||
*
|
||||
* Rien n'est replique et c'est voulu : les bras sont en OnlyOwnerSee, donc
|
||||
* les animer chez le serveur ou chez un ami ferait tourner un squelette que
|
||||
* personne ne rend. Le geste vu par les autres joueurs viendra du mesh 3e
|
||||
* personne, avec ses propres animations, le jour ou l'animateur les livrera.
|
||||
*
|
||||
* @return la duree du montage en secondes, 0 s'il n'a pas pu etre joue.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Animation")
|
||||
float PlayArmsMontage(UAnimMontage* Montage, float PlayRate = 1.f);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Interaction")
|
||||
UInteractionComponent* GetInteractionComponent() const { return InteractionComponent; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
UInventoryComponent* GetInventoryComponent() const { return InventoryComponent; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Crafting")
|
||||
UCraftingComponent* GetCraftingComponent() const { return CraftingComponent; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Survie")
|
||||
USurvivalStatsComponent* GetSurvivalStats() const { return SurvivalStats; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Apparence")
|
||||
UCharacterAppearanceComponent* GetAppearanceComponent() const { return AppearanceComponent; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
UHeldItemComponent* GetHeldItemComponent() const { return HeldItemComponent; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Audio")
|
||||
UFootstepComponent* GetFootstepComponent() const { return FootstepComponent; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Commandes console d'apparence
|
||||
//
|
||||
// Elles vivent ICI et non sur UCharacterAppearanceComponent, ou leur place
|
||||
// serait pourtant plus naturelle : la chaine de routage des commandes
|
||||
// (UPlayer::Exec, dans Player.cpp) interroge le monde, le PlayerInput, le
|
||||
// controller, le PAWN, le HUD, le GameMode, le CheatManager, le GameState
|
||||
// et le camera manager -- jamais les composants, AActor ne surchargeant pas
|
||||
// ProcessConsoleExec pour les parcourir. Une UFUNCTION(Exec) posee sur un
|
||||
// composant est silencieusement ignoree, sans le moindre message.
|
||||
//
|
||||
// Le pawn est le maillon le plus proche du composant dans cette chaine,
|
||||
// d'ou ces trois delegations d'une ligne. Elles disparaitront le jour ou
|
||||
// l'ecran de customisation existera.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** `EmberPart hair 3` -- body, head, hair, eyebrows, beard, mustache, eyes. */
|
||||
UFUNCTION(Exec, Category = "Apparence")
|
||||
void EmberPart(const FString& Part, int32 Index);
|
||||
|
||||
/** `EmberColor skin 2` -- skin, hair, eye, underwear. Index dans la palette. */
|
||||
UFUNCTION(Exec, Category = "Apparence")
|
||||
void EmberColor(const FString& Part, int32 ColorIndex);
|
||||
|
||||
/** Ecrit l'apparence courante et le nombre d'options de chaque categorie. */
|
||||
UFUNCTION(Exec, Category = "Apparence")
|
||||
void EmberAppearanceDump();
|
||||
|
||||
private:
|
||||
/**
|
||||
* Persiste l'apparence du composant et l'annonce au serveur, via le
|
||||
* controller. Appelee apres chaque commande console, pour que le
|
||||
* changement se voie chez les autres et survive au relancement.
|
||||
*/
|
||||
void SubmitCurrentAppearance();
|
||||
|
||||
public:
|
||||
|
||||
/** Sert a l'UI pour afficher la vraie touche mappee, plutot qu'un "E" ecrit en dur. */
|
||||
UFUNCTION(BlueprintPure, Category = "Input")
|
||||
const UInputAction* GetInteractAction() const { return InteractAction; }
|
||||
|
||||
/**
|
||||
* Coupe les etats maintenus avant de desactiver les inputs du pawn.
|
||||
*
|
||||
* Sprint et saut sont declenches par Started et arretes par Completed.
|
||||
* Si on desactive l'input pendant que la touche est enfoncee, le Completed
|
||||
* n'a plus personne pour l'ecouter et l'etat reste bloque : on rouvrirait
|
||||
* le jeu en sprintant sans toucher a Maj.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Input")
|
||||
void ClearTransientInputStates();
|
||||
|
||||
/**
|
||||
* Retire des objets d'un slot et les fait apparaitre au sol devant le joueur.
|
||||
* @param Quantity nombre d'exemplaires ; passe un grand nombre pour tout jeter.
|
||||
* @return true si quelque chose est reellement sorti de l'inventaire.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
bool DropSlot(int32 SlotIndex, int32 Quantity);
|
||||
|
||||
/**
|
||||
* Meme chose, depuis un inventaire quelconque : celui d'un coffre ouvert,
|
||||
* typiquement.
|
||||
*
|
||||
* C'est le PAWN qui pose au sol et pas le composant, pour la meme raison
|
||||
* que le surplus de fabrication : un UInventoryComponent ne sait pas
|
||||
* engendrer un acteur, et un coffre qui deposerait lui-meme laisserait le
|
||||
* butin dans sa propre caisse. L'objet part donc toujours devant les yeux
|
||||
* du joueur, quel que soit le contenant d'ou il sort.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
bool DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity);
|
||||
|
||||
/** Vide l'inventaire entier en un tas autour du personnage. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
void DropAllItems();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Survie")
|
||||
bool IsDead() const { return bIsDead; }
|
||||
|
||||
/**
|
||||
* Secoue la caméra verticalement. Strength est la vitesse initiale en cm/s
|
||||
* donnée à la caméra ; positif = vers le haut, négatif = vers le bas.
|
||||
* Réutilisable pour une explosion, un recul d'arme, un coup reçu.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Camera")
|
||||
void AddCameraPunch(float Strength);
|
||||
|
||||
/**
|
||||
* Recopie les reglages du joueur dans le pawn.
|
||||
*
|
||||
* Appelee au BeginPlay ET a chaque fois que USurvivalUserSettings diffuse :
|
||||
* apres un respawn le pawn est un NOUVEL acteur, une valeur poussee une
|
||||
* seule fois serait perdue avec le cadavre. UFUNCTION obligatoire, c'est ce
|
||||
* qui permet le AddDynamic sur le delegue.
|
||||
*/
|
||||
UFUNCTION()
|
||||
void ApplyUserSettings();
|
||||
|
||||
/**
|
||||
* Pose la transform de la camera juste avant que la vue soit lue.
|
||||
*
|
||||
* Le gestionnaire de camera passe par ici pour construire le point de vue :
|
||||
* c'est le seul endroit ou l'on est certain de lire la position de l'acteur
|
||||
* et la demi-hauteur de la capsule au MEME instant. Le Tick du pawn, lui,
|
||||
* n'a aucune dependance de tick vis-a-vis du CharacterMovement (verifie dans
|
||||
* Character.cpp : seul le Mesh en recoit une), donc la frame ou la capsule
|
||||
* change de taille pouvait etre calculee avec l'ancienne demi-hauteur --
|
||||
* soit un decalage de 28 cm sur une frame, a chaque accroupissement.
|
||||
* Bonus : le regard n'a plus une frame de retard sur la souris.
|
||||
*/
|
||||
virtual void CalcCamera(float DeltaTime, FMinimalViewInfo& OutResult) override;
|
||||
|
||||
/**
|
||||
* L'autre cote de la course avec le PlayerState.
|
||||
*
|
||||
* Chez un client, le pawn et le PlayerState arrivent par deux chemins de
|
||||
* replication independants : tantot l'apparence est la avant le corps --
|
||||
* ASurvivalPlayerState::OnRep_CharacterAppearance la pose alors lui-meme --
|
||||
* tantot c'est l'inverse, et c'est ici qu'on va la chercher. Ni l'un ni
|
||||
* l'autre ne peut supposer avoir gagne, donc les deux appellent la meme
|
||||
* fonction idempotente.
|
||||
*/
|
||||
virtual void OnRep_PlayerState() override;
|
||||
|
||||
/**
|
||||
* Les deux moities de « le controller est arrive ».
|
||||
*
|
||||
* UHeldItemComponent ne peut lire la case active QUE chez le joueur local,
|
||||
* et son BeginPlay peut tomber avant que le pawn soit possede -- chez un
|
||||
* client, le pawn et son controller arrivent par deux chemins independants,
|
||||
* exactement comme le PlayerState. Un joueur qui reapparait avec une hache
|
||||
* deja en case 1 aurait alors la main vide jusqu'a ce qu'il touche a son
|
||||
* inventaire. PossessedBy couvre le serveur, OnRep_Controller le client, et
|
||||
* RefreshFromInventory() est idempotente.
|
||||
*/
|
||||
virtual void PossessedBy(AController* NewController) override;
|
||||
virtual void OnRep_Controller() override;
|
||||
|
||||
protected:
|
||||
// Called when the game starts or when spawned
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
/** Lit l'apparence sur le PlayerState et la monte, s'il est deja arrive. */
|
||||
void RefreshAppearanceFromPlayerState();
|
||||
|
||||
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
|
||||
|
||||
// Appelés par le CharacterMovement quand la capsule change de taille (accroupi)
|
||||
virtual void OnStartCrouch(float HalfHeightAdjust, float ScaledHalfHeightAdjust) override;
|
||||
virtual void OnEndCrouch(float HalfHeightAdjust, float ScaledHalfHeightAdjust) override;
|
||||
|
||||
// Réception au sol et décollage
|
||||
virtual void Landed(const FHitResult& Hit) override;
|
||||
virtual void OnJumped_Implementation() override;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Composants
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Caméra placée à hauteur des yeux, suit la rotation du controller. */
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
|
||||
TObjectPtr<UCameraComponent> FirstPersonCamera;
|
||||
|
||||
/**
|
||||
* Bras vue première personne, visibles du seul propriétaire.
|
||||
*
|
||||
* Attachés à la CAMÉRA et non à la capsule : ils doivent suivre le tangage du
|
||||
* regard, sinon lever les yeux les laisserait à l'horizontale. Bénéfice
|
||||
* gratuit au passage — ils héritent du ressort d'impact et de la bascule
|
||||
* d'accroupissement, donc les bras encaissent l'atterrissage avec la caméra
|
||||
* sans une ligne de code de plus.
|
||||
*
|
||||
* Sa transform relative se règle À L'ŒIL dans BP_FpsPlayer : les valeurs du
|
||||
* constructeur ne sont qu'un point de départ, elles dépendent entièrement de
|
||||
* l'origine du FBX livré par l'animateur.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Animation")
|
||||
TObjectPtr<USkeletalMeshComponent> FirstPersonArms;
|
||||
|
||||
/**
|
||||
* Montage joué sur les bras au ramassage d'un objet (MTG_GrabItem).
|
||||
* Vide = aucun geste, le ramassage fonctionne quand même.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Animation")
|
||||
TObjectPtr<UAnimMontage> GrabMontage;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Dégâts de chute
|
||||
//
|
||||
// C'est ce bloc qui définit la réception encaissée, et pas l'inverse :
|
||||
// `ELandingImpact::Hard` signifie exactement « cet atterrissage a coûté de la
|
||||
// vie ». Un seuil d'animation réglé de son côté finirait forcément par
|
||||
// diverger de celui des dégâts, et le joueur verrait son personnage encaisser
|
||||
// sans rien perdre — ou pire, perdre de la vie en se réceptionnant
|
||||
// souplement. Une animation qui ment sur l'état du jeu est pire que pas
|
||||
// d'animation du tout.
|
||||
//
|
||||
// L'atterrissage est un ÉTAT de la machine et non un montage : les trois
|
||||
// animations livrées (JumpStart, JumpFallLoop, JumpLanding) forment une
|
||||
// chaîne dont le milieu est une BOUCLE, et une boucle ne se joue pas en
|
||||
// montage.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vitesse de chute (cm/s) à partir de laquelle on prend des dégâts.
|
||||
*
|
||||
* Doit rester nettement au-dessus de la retombée d'un saut sur place
|
||||
* (~JumpZVelocity, 450 par défaut), sinon sauter coûte de la vie et le
|
||||
* déplacement devient une punition.
|
||||
*
|
||||
* À 800 cm/s, c'est une chute d'environ 3,2 m (v = racine(2·g·h), g = 980).
|
||||
* Ne pas raisonner en hauteur pour régler cette valeur : la hauteur ne se
|
||||
* mesure nulle part dans le moteur, alors que la vitesse d'impact est
|
||||
* exactement ce que le CharacterMovement a sous la main — et elle tient
|
||||
* compte, gratuitement, d'un saut fait en tombant.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Fall Damage", meta = (ClampMin = "0"))
|
||||
float FallDamageMinSpeed = 800.f;
|
||||
|
||||
/** Vitesse (cm/s) au-delà de laquelle les dégâts plafonnent (~20 m de chute). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Fall Damage", meta = (ClampMin = "1"))
|
||||
float FallDamageMaxSpeed = 2000.f;
|
||||
|
||||
/**
|
||||
* Dégâts pris pile au seuil. Volontairement non nul : franchir la limite
|
||||
* doit se sentir, sinon le seuil n'existe que dans le code.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Fall Damage", meta = (ClampMin = "0"))
|
||||
float FallDamageMin = 15.f;
|
||||
|
||||
/** Dégâts pris à FallDamageMaxSpeed et au-delà. À 100, la chute est mortelle. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Fall Damage", meta = (ClampMin = "0"))
|
||||
float FallDamageMax = 100.f;
|
||||
|
||||
/** Les dégâts de la chute qui vient d'avoir lieu, 0 sous le seuil. */
|
||||
UFUNCTION(BlueprintPure, Category = "Fall Damage")
|
||||
float ComputeFallDamage(float FallSpeed) const;
|
||||
|
||||
/**
|
||||
* Balancement de tête procédural : respiration à l'arrêt, pas à la marche,
|
||||
* foulée au sprint. Tous ses réglages sont dans le Details panel du composant,
|
||||
* pas ici : cinq profils de cinq valeurs noieraient la catégorie "Camera".
|
||||
*
|
||||
* Il ne touche pas la caméra lui-même, il expose deux décalages que
|
||||
* ApplyCameraTransform additionne — cette fonction reste le seul écrivain.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
|
||||
TObjectPtr<UHeadBobComponent> HeadBob;
|
||||
|
||||
/** Détecte et déclenche les interactions avec le monde. */
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<UInteractionComponent> InteractionComponent;
|
||||
|
||||
/**
|
||||
* Inventaire du joueur. Sur le pawn et non sur le controller :
|
||||
* un cadavre qui garde son butin fait partie du gameplay de survie.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Inventaire")
|
||||
TObjectPtr<UInventoryComponent> InventoryComponent;
|
||||
|
||||
/**
|
||||
* Fabrication. Sur le pawn comme l'inventaire : c'est ce corps qui fabrique,
|
||||
* et il lit l'inventaire du meme acteur.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Crafting")
|
||||
TObjectPtr<UCraftingComponent> CraftingComponent;
|
||||
|
||||
/** Vie, faim, soif. Sur le pawn : c'est ce corps qui a faim. */
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Survie")
|
||||
TObjectPtr<USurvivalStatsComponent> SurvivalStats;
|
||||
|
||||
/**
|
||||
* Monte la tete, les cheveux, la barbe et les couleurs sur le mesh 3e
|
||||
* personne.
|
||||
*
|
||||
* Sur le pawn et non sur le controller, alors que l'apparence, elle, vivra
|
||||
* sur le PlayerState : ce composant ne STOCKE rien, il assemble un corps.
|
||||
* Or c'est le corps qui meurt et reapparait, et un nouveau pawn doit se
|
||||
* remonter tout seul.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Apparence")
|
||||
TObjectPtr<UCharacterAppearanceComponent> AppearanceComponent;
|
||||
|
||||
/**
|
||||
* Montre dans la main l'objet de la case active de la barre rapide.
|
||||
*
|
||||
* Sur le pawn : ce sont ces bras-la et ce corps-la qui tiennent l'objet, et
|
||||
* un cadavre ne tient plus rien. Il lit l'inventaire du meme acteur.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Inventaire")
|
||||
TObjectPtr<UHeldItemComponent> HeldItemComponent;
|
||||
|
||||
/**
|
||||
* Bruits de pas.
|
||||
*
|
||||
* Sur le pawn et sur TOUS les pawns, pas seulement celui du joueur local :
|
||||
* le composant ne replique rien, chaque machine calcule les pas de tous les
|
||||
* corps qu'elle connait a partir de leur vitesse deja synchronisee. Le
|
||||
* mettre sur le controller n'aurait donc donne les pas que d'un joueur.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Audio")
|
||||
TObjectPtr<UFootstepComponent> FootstepComponent;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Enhanced Input : assets à assigner dans le Blueprint
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Contexte ajouté au joueur au BeginPlay (IMC_Default). */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputMappingContext> DefaultMappingContext;
|
||||
|
||||
/** Déplacement, valeur Axis2D (X = droite/gauche, Y = avant/arrière). */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> MoveAction;
|
||||
|
||||
/** Regard souris / stick droit, valeur Axis2D. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> LookAction;
|
||||
|
||||
/** Saut, valeur Digital (bool). */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> JumpAction;
|
||||
|
||||
/** Sprint, valeur Digital (bool) — maintenu. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> SprintAction;
|
||||
|
||||
/** Accroupi, valeur Digital (bool) — maintenu. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> CrouchAction;
|
||||
|
||||
/** Interaction, valeur Digital (bool). */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> InteractAction;
|
||||
|
||||
/**
|
||||
* Utilise l'objet actif de la barre rapide, valeur Digital (bool).
|
||||
* A mapper sur le clic gauche.
|
||||
*
|
||||
* Bindee sur le PAWN volontairement : quand l'inventaire est ouvert, les
|
||||
* inputs du pawn sont coupes, donc le clic gauche sert au glisser-deposer
|
||||
* de l'UI sans qu'on ait le moindre test a ecrire.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> UseItemAction;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Réglages : mouvement
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Vitesse de marche normale (cm/s). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mouvement", meta = (ClampMin = "0"))
|
||||
float WalkSpeed = 400.f;
|
||||
|
||||
/** Vitesse en sprint (cm/s). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mouvement", meta = (ClampMin = "0"))
|
||||
float SprintSpeed = 650.f;
|
||||
|
||||
/** Vitesse accroupi (cm/s). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mouvement", meta = (ClampMin = "0"))
|
||||
float CrouchSpeed = 200.f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Réglages : objets jetés au sol
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Classe engendrée quand on jette un objet. Assigne ton BP_Pickup_Test :
|
||||
* son ItemData est écrasé au spawn, un seul Blueprint suffit pour tout.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Inventaire|Drop")
|
||||
TSubclassOf<APickupItem> DroppedPickupClass;
|
||||
|
||||
/** Distance devant les yeux où l'objet est déposé (cm). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventaire|Drop", meta = (ClampMin = "10"))
|
||||
float DropDistance = 120.f;
|
||||
|
||||
/** Hauteur au-dessus du sol trouvé, pour éviter que l'objet s'y encastre. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventaire|Drop")
|
||||
float DropGroundOffset = 5.f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Réglages : regard
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sensibilité horizontale : degrés de rotation par unité de souris.
|
||||
* 1.0 = brut (beaucoup trop rapide), ~0.4 est un bon point de départ.
|
||||
*
|
||||
* ATTENTION : ces trois valeurs, comme BaseFOV, sont ECRASEES au BeginPlay
|
||||
* par USurvivalUserSettings. Elles ne servent plus que de repli si l'objet
|
||||
* de réglages est absent ; pour changer la sensibilité, passe par le menu.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (ClampMin = "0.01"))
|
||||
float LookSensitivityX = 0.4f;
|
||||
|
||||
/** Sensibilité verticale. Souvent réglée un peu plus basse que l'horizontale. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (ClampMin = "0.01"))
|
||||
float LookSensitivityY = 0.4f;
|
||||
|
||||
/** Inverse l'axe vertical de la souris. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
|
||||
bool bInvertLookY = false;
|
||||
|
||||
/**
|
||||
* Hauteur de l'oeil AU-DESSUS DES PIEDS, debout (cm).
|
||||
* 160 cm correspond a un personnage d'environ 1m76.
|
||||
* Mesure depuis le sol et non depuis la capsule : c'est ce qui permet
|
||||
* d'annuler le saut instantane de la capsule quand elle retrecit.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
|
||||
float EyeHeightStanding = 160.f;
|
||||
|
||||
/** Hauteur de l'oeil au-dessus des pieds, accroupi (cm). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
|
||||
float EyeHeightCrouched = 104.f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Réglages : animation d'accroupissement
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Durée en secondes de la transition debout <-> accroupi. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Accroupi", meta = (ClampMin = "0.01"))
|
||||
float CrouchTransitionDuration = 0.4f;
|
||||
|
||||
/**
|
||||
* Tangage (degrés) au pic de la bascule. Positif = le regard plonge.
|
||||
*
|
||||
* Le tilt n'est plus une courbe indexée sur l'avancement de la transition
|
||||
* mais un ressort relancé à chaque changement d'état : c'est ce qui permet
|
||||
* de s'accroupir et de se relever coup sur coup sans que la caméra saute.
|
||||
* L'impulsion est normalisée pour que le pic tombe bien sur cette valeur.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Accroupi")
|
||||
float CrouchTiltPitch = 3.f;
|
||||
|
||||
/**
|
||||
* Roulis (degrés) au pic de la bascule : bascule sur le côté.
|
||||
* A 0 par defaut, le mouvement reste purement vertical.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Accroupi")
|
||||
float CrouchTiltRoll = 0.f;
|
||||
|
||||
/**
|
||||
* Fréquence du ressort de bascule (Hz). Le pic est atteint après environ
|
||||
* 1/(2*PI*f) secondes : 0.9 Hz place le sommet vers 0.18 s, soit à peu près
|
||||
* au milieu d'une transition de 0.4 s. Plus haut = plus sec et plus court.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Accroupi", meta = (ClampMin = "0.1"))
|
||||
float CrouchTiltFrequency = 0.9f;
|
||||
|
||||
/** Amortissement de la bascule. 1 = aucun rebond ; en dessous, la tête oscille. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Accroupi", meta = (ClampMin = "0.05", ClampMax = "2.0"))
|
||||
float CrouchTiltDamping = 1.f;
|
||||
|
||||
/**
|
||||
* Durée (s) sur laquelle l'impulsion de bascule est versée dans le ressort.
|
||||
* A 0 la rotation démarre à pleine vitesse angulaire en une frame, ce qui
|
||||
* se lit comme un claquement ; quelques centièmes suffisent à l'adoucir.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Accroupi", meta = (ClampMin = "0"))
|
||||
float CrouchTiltRampTime = 0.05f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Réglages : impacts caméra (saut / atterrissage)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fréquence du ressort de caméra en Hz : à quelle vitesse elle revient
|
||||
* en place après un choc. Plus haut = plus sec.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact", meta = (ClampMin = "0.1"))
|
||||
float PunchFrequency = 3.f;
|
||||
|
||||
/**
|
||||
* Amortissement du ressort. 1.0 = retour net sans rebond,
|
||||
* 0.5 = un rebond visible, en dessous ça oscille longtemps.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact", meta = (ClampMin = "0.05", ClampMax = "2.0"))
|
||||
float PunchDamping = 0.85f;
|
||||
|
||||
/**
|
||||
* Durée (s) sur laquelle une impulsion est injectée dans le ressort.
|
||||
* A 0 elle est appliquée d'un coup : la caméra part à pleine vitesse en
|
||||
* une frame, ce qui se lit comme un a-coup. Quelques centiemes suffisent
|
||||
* a rendre l'entree en mouvement progressive.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact", meta = (ClampMin = "0"))
|
||||
float PunchImpulseRampTime = 0.06f;
|
||||
|
||||
/** Degrés de tangage ajoutés par cm de déplacement vertical de la caméra. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact")
|
||||
float PunchPitchPerCm = 0.35f;
|
||||
|
||||
/** Amplitude maximale du déplacement vertical (cm), garde-fou. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact", meta = (ClampMin = "0"))
|
||||
float PunchMaxOffset = 50.f;
|
||||
|
||||
/**
|
||||
* Impulsion (cm/s) à l'atterrissage, quand l'impact est au maximum.
|
||||
* Ordre de grandeur : le deplacement en cm de la camera vaut environ
|
||||
* cette valeur x 0.018 (avec les reglages de ressort par defaut).
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact", meta = (ClampMin = "0"))
|
||||
float LandPunchStrength = 2000.f;
|
||||
|
||||
/** En dessous de cette vitesse de chute (cm/s), aucun effet à l'atterrissage. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact", meta = (ClampMin = "0"))
|
||||
float LandPunchMinFallSpeed = 150.f;
|
||||
|
||||
/**
|
||||
* Vitesse de chute (cm/s) à partir de laquelle l'effet est au maximum.
|
||||
* Un saut sur place retombe a peu pres a JumpZVelocity (450), donc cette
|
||||
* valeur doit rester proche pour qu'un saut normal se sente deja bien.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact", meta = (ClampMin = "1"))
|
||||
float LandPunchMaxFallSpeed = 900.f;
|
||||
|
||||
/**
|
||||
* Impulsion (cm/s) au décollage du saut. Positif = la caméra s'enfonce
|
||||
* un instant (compression des jambes) ; mets une valeur négative pour
|
||||
* qu'elle parte vers le haut à la place.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Impact")
|
||||
float JumpPunchStrength = 550.f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Réglages : champ de vision
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** FOV au repos, en degrés. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|FOV", meta = (ClampMin = "30", ClampMax = "150"))
|
||||
float BaseFOV = 90.f;
|
||||
|
||||
/** Degrés de FOV ajoutés en sprint. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|FOV")
|
||||
float SprintFOVOffset = 8.f;
|
||||
|
||||
/** Vitesse de transition du FOV. Plus haut = plus brusque. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|FOV", meta = (ClampMin = "0.1"))
|
||||
float FOVInterpSpeed = 8.f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Handlers d'input
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
void Move(const FInputActionValue& Value);
|
||||
void Look(const FInputActionValue& Value);
|
||||
void StartSprint();
|
||||
void StopSprint();
|
||||
void StartCrouch();
|
||||
void StopCrouch();
|
||||
void Interact();
|
||||
|
||||
/**
|
||||
* Relachement de la touche de sprint / d'accroupissement.
|
||||
*
|
||||
* Separes de StopSprint et StopCrouch, qui restent des ordres inconditionnels :
|
||||
* en mode bascule le relachement ne doit rien faire, alors que la mort ou
|
||||
* l'ouverture de l'inventaire doivent bel et bien couper le sprint.
|
||||
*/
|
||||
void HandleSprintReleased();
|
||||
void HandleCrouchReleased();
|
||||
|
||||
/**
|
||||
* Clic gauche : lance le GESTE de l'objet actif de la barre rapide.
|
||||
*
|
||||
* Elle ne produit plus l'effet elle-même — c'est le marqueur
|
||||
* `UAnimNotify_ItemAction` posé dans le montage qui le fait, au moment où le
|
||||
* geste frappe. Sans montage à jouer, elle retombe sur l'effet immédiat :
|
||||
* un objet qui n'a pas d'animation n'aura jamais de marqueur, et le priver
|
||||
* d'effet serait un objet cassé plutôt qu'un objet sans animation.
|
||||
*/
|
||||
void UseSelectedItem();
|
||||
|
||||
public:
|
||||
/**
|
||||
* L'objet en main agit MAINTENANT. Appelée par l'AnimNotify du montage.
|
||||
*
|
||||
* Publique et `BlueprintCallable` pour cette seule raison : c'est le point
|
||||
* d'entrée de l'animation vers le gameplay, y compris depuis un notify
|
||||
* Blueprint. Elle ne fait rien d'autre que ce que faisait la seconde moitié
|
||||
* de `UseSelectedItem` : router vers l'autorité.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Items")
|
||||
void TriggerHeldItemAction();
|
||||
|
||||
private:
|
||||
/**
|
||||
* La case visée par le clic, retenue jusqu'à l'impact.
|
||||
*
|
||||
* Relire la case active au moment du marqueur serait faux : trois dixièmes
|
||||
* de seconde séparent l'appui de l'impact, et la molette peut passer sur un
|
||||
* autre objet entre-temps — le coup de hache partirait alors avec la pomme
|
||||
* qu'on vient de sélectionner. Changer d'objet coupe bien le montage en
|
||||
* cours (`PlayEquipMontage` ne saute pas), mais seulement quand le nouvel
|
||||
* objet a un montage d'équipement : ce n'est pas une garantie sur laquelle
|
||||
* s'appuyer.
|
||||
*/
|
||||
int32 PendingUseSlotIndex = INDEX_NONE;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Réseau
|
||||
//
|
||||
// Jeter et utiliser engendrent ou détruisent des choses dans le monde :
|
||||
// ce sont des actions d'autorité. Le routage vit dans les fonctions
|
||||
// publiques elles-mêmes, pour que ni l'UI ni l'input n'aient à savoir
|
||||
// s'ils tournent chez l'hôte ou chez un client.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity);
|
||||
|
||||
/**
|
||||
* Le slot est passé explicitement : SelectedHotbarIndex n'est PAS répliqué,
|
||||
* c'est une intention purement locale. Le serveur ne peut donc pas deviner
|
||||
* ce que le joueur avait en main.
|
||||
*/
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_UseItem(int32 SlotIndex);
|
||||
|
||||
/** L'utilisation réelle, côté autorité. */
|
||||
void PerformUseItem(int32 SlotIndex);
|
||||
|
||||
/**
|
||||
* Point d'entrée unique du sprint : applique en local ET prévient le serveur.
|
||||
*
|
||||
* Sans le second, le serveur croirait le client à `WalkSpeed` et corrigerait
|
||||
* sa position à chaque pas — c'est le rubber-banding classique, et il ne se
|
||||
* voit que sur une machine distante, jamais chez l'hôte.
|
||||
*/
|
||||
void SetSprinting(bool bNewSprinting);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_SetSprinting(bool bNewSprinting);
|
||||
|
||||
/** La vitesse suit l'état, y compris sur les pawns des autres joueurs. */
|
||||
UFUNCTION()
|
||||
void OnRep_IsSprinting();
|
||||
|
||||
void ApplySprintSpeed();
|
||||
|
||||
/** Fait avancer toutes les animations de caméra (intégration uniquement). */
|
||||
void UpdateCameraEffects(float DeltaTime);
|
||||
|
||||
/**
|
||||
* Recopie l'état des animations dans la transform de la caméra.
|
||||
* Séparée de l'intégration : appelée à la fois en fin de Tick et depuis
|
||||
* CalcCamera, elle doit rester idempotente et sans notion de temps.
|
||||
*/
|
||||
void ApplyCameraTransform();
|
||||
|
||||
/** Relance la bascule de tête. Direction : +1 on s'accroupit, -1 on se relève. */
|
||||
void AddCrouchTilt(float Direction);
|
||||
|
||||
/** Branche sur OnDied du composant de survie. */
|
||||
UFUNCTION()
|
||||
void HandleDeath();
|
||||
|
||||
/**
|
||||
* Le resultat d'une fabrication n'est pas entre dans l'inventaire : on le
|
||||
* pose aux pieds du joueur plutot que de le laisser disparaitre.
|
||||
*
|
||||
* C'est le pawn qui fait ca et non UCraftingComponent : le composant n'a pas
|
||||
* a savoir comment on engendre un objet dans le monde, et un etabli qui
|
||||
* porterait le meme composant deposerait son surplus sur sa propre table.
|
||||
*/
|
||||
UFUNCTION()
|
||||
void HandleCraftOverflow(UItemDataAsset* Item, int32 Quantity, int32 RemainingUses);
|
||||
|
||||
/** Fait apparaitre un pickup deja configure a un endroit donne. */
|
||||
APickupItem* SpawnPickup(const struct FInventorySlot& Slot, const FVector& Location);
|
||||
|
||||
/** Descend une position jusqu'au sol, pour ne pas laisser d'objet en l'air. */
|
||||
FVector PlaceOnGround(const FVector& Start) const;
|
||||
|
||||
bool bIsDead = false;
|
||||
|
||||
/** Progression de l'animation d'accroupissement : 0 = debout, 1 = accroupi. */
|
||||
float CrouchAlpha = 0.f;
|
||||
|
||||
/** Valeur visée par CrouchAlpha. */
|
||||
float CrouchTargetAlpha = 0.f;
|
||||
|
||||
/** Déplacement vertical de la caméra (cm) : sauts, atterrissages, explosions. */
|
||||
FCameraSpring PunchSpring;
|
||||
|
||||
/**
|
||||
* Bascule de tête pendant l'accroupissement, en unités normalisées :
|
||||
* son pic vaut 1, ce sont CrouchTiltPitch et CrouchTiltRoll qui le
|
||||
* convertissent en degrés.
|
||||
*/
|
||||
FCameraSpring CrouchTiltSpring;
|
||||
|
||||
/** Vitesse verticale mémorisée pendant la chute, pour calibrer l'atterrissage. */
|
||||
float LastFallSpeed = 0.f;
|
||||
|
||||
/**
|
||||
* La réception en cours, lue par les AnimInstance.
|
||||
*
|
||||
* Écrite à deux endroits et deux seulement : remise à None dans Tick tant
|
||||
* qu'on est en l'air, posée à sa vraie valeur dans Landed(). C'est ce qui
|
||||
* garantit qu'elle vaut None pendant tout le vol — sans quoi la machine à
|
||||
* états prendrait sa transition de réception avec la valeur du saut
|
||||
* précédent, dès la première image de chute.
|
||||
*/
|
||||
ELandingImpact LandingImpact = ELandingImpact::None;
|
||||
|
||||
/** Répliqué pour que les autres joueurs voient la bonne vitesse et la bonne anim. */
|
||||
UPROPERTY(ReplicatedUsing = OnRep_IsSprinting)
|
||||
bool bIsSprinting = false;
|
||||
|
||||
/** Évite de spammer le log si IA_Look est mal configurée. */
|
||||
bool bLoggedLookTypeWarning = false;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Copies locales des réglages joueur, rafraîchies par ApplyUserSettings
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 0 = caméra parfaitement fixe, 1 = amplitude d'origine. */
|
||||
float CameraShakeMultiplier = 1.f;
|
||||
|
||||
bool bSprintToggleMode = false;
|
||||
|
||||
bool bCrouchToggleMode = false;
|
||||
};
|
||||
@@ -0,0 +1,663 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
// ESlateVisibility n'est que declare en avant par le moteur : il faut le type
|
||||
// complet pour en stocker une valeur par defaut. Meme raison pour
|
||||
// ECraftingStation, stocke en membre.
|
||||
#include "CharacterAppearanceTypes.h"
|
||||
#include "Components/SlateWrapperTypes.h"
|
||||
#include "CraftingRecipeDataAsset.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "FpsPlayerController.generated.h"
|
||||
|
||||
class AStorageContainer;
|
||||
class UConfirmDialogWidget;
|
||||
class UCraftingComponent;
|
||||
class UHotbarWidget;
|
||||
class UInputAction;
|
||||
class UInventoryComponent;
|
||||
class UInteractionPromptWidget;
|
||||
class UInventoryScreenWidget;
|
||||
class UPauseMenuWidget;
|
||||
class UScreenFadeComponent;
|
||||
class USettingsMenuWidget;
|
||||
class USurvivalStatsWidget;
|
||||
class UUserWidget;
|
||||
struct FInputActionValue;
|
||||
|
||||
/**
|
||||
* Controller du joueur. C'est ici que vit l'UI, et pas dans le personnage :
|
||||
* le controller survit a la mort du pawn, alors que le personnage non.
|
||||
* Si tu creais le HUD dans AFpsPlayer, il disparaitrait a chaque respawn.
|
||||
*/
|
||||
UCLASS()
|
||||
class EMBERWILD_API AFpsPlayerController : public APlayerController
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
AFpsPlayerController();
|
||||
|
||||
/** Ouvre ou ferme l'ecran, toujours sur l'onglet Inventaire. */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI")
|
||||
void ToggleInventory();
|
||||
|
||||
/**
|
||||
* Ouvre l'ecran directement sur l'onglet Fabrication.
|
||||
* Depuis l'onglet Inventaire elle bascule, depuis l'onglet Fabrication elle referme.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "UI")
|
||||
void ToggleCrafting();
|
||||
|
||||
/**
|
||||
* Ouvre l'ecran de fabrication depuis un poste de travail.
|
||||
*
|
||||
* L'acces au poste ne dure QUE le temps de l'ecran : il est accorde ici et
|
||||
* repris a la fermeture. C'est le contrat de l'interaction -- on fabrique a
|
||||
* l'etabli parce qu'on est en train de s'en servir, pas parce qu'on en a
|
||||
* croise un un jour.
|
||||
*
|
||||
* Le contrôleur s'en charge et non l'etabli : c'est lui qui sait quand
|
||||
* l'ecran se referme, et lui seul peut donc rendre le poste au bon moment.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "UI")
|
||||
void OpenCraftingAtStation(ECraftingStation Station);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "UI")
|
||||
bool IsInventoryOpen() const { return bInventoryOpen; }
|
||||
|
||||
/**
|
||||
* Ouvre l'ecran d'inventaire avec le panneau du coffre a cote : le sac a
|
||||
* gauche, son contenu a droite.
|
||||
*
|
||||
* Ce n'est PAS un ecran a part. Le sac du joueur reste exactement au meme
|
||||
* endroit qu'à l'ouverture par Tab -- c'est tout l'interet, un ecran
|
||||
* distinct le ferait sauter a chaque fois qu'on ouvre un coffre.
|
||||
*
|
||||
* Comme pour l'etabli, c'est le controller qui pilote et non le coffre : lui
|
||||
* seul sait quand l'ecran se referme, et donc quand refermer le couvercle.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "UI|Storage")
|
||||
void OpenStorage(AStorageContainer* Container);
|
||||
|
||||
/** Referme l'ecran de coffre. Sans effet s'il n'y en a pas d'ouvert. */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI|Storage")
|
||||
void CloseStorage();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "UI|Storage")
|
||||
bool IsStorageOpen() const { return bStorageOpen; }
|
||||
|
||||
/**
|
||||
* Echap, quel que soit le chemin par lequel il arrive.
|
||||
*
|
||||
* Une seule touche pour cinq usages, d'ou une priorite explicite : boite de
|
||||
* confirmation, reglages, coffre, inventaire, puis pause. Sans cet ordre on
|
||||
* se retrouve avec deux ecrans ouverts en meme temps.
|
||||
*
|
||||
* Publique parce qu'elle a DEUX appelants : l'action Enhanced Input quand le
|
||||
* viewport tient le focus clavier, et le NativeOnKeyDown d'un widget quand
|
||||
* c'est lui qui l'a -- les touches n'atteignent alors plus l'input du jeu.
|
||||
*/
|
||||
void HandleEscapeInput();
|
||||
|
||||
/** Ferme le menu de pause et relance le monde. */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI|Pause")
|
||||
void ResumeGame();
|
||||
|
||||
/** Ouvre la demande de confirmation. Ne quitte rien par elle-meme. */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI|Pause")
|
||||
void RequestQuitToMainMenu();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "UI|Pause")
|
||||
bool IsPauseMenuOpen() const { return bPauseMenuOpen; }
|
||||
|
||||
/** Ouvre l'ecran de reglages par-dessus le menu de pause. */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI|Reglages")
|
||||
void OpenSettings();
|
||||
|
||||
/**
|
||||
* Ouvre le panneau d'invitation de l'overlay Steam.
|
||||
*
|
||||
* Passe par le controller et non directement du widget au sous-systeme :
|
||||
* inviter est une action du JOUEUR LOCAL, et le controller est justement
|
||||
* l'objet qui le represente. Un widget qui irait chercher un
|
||||
* GameInstanceSubsystem tout seul se brancherait sur le mauvais joueur le
|
||||
* jour ou l'ecran partage existera.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Coop")
|
||||
void ShowInviteFriends();
|
||||
|
||||
/**
|
||||
* Le point d'entree unique d'un changement d'apparence : persiste dans les
|
||||
* reglages du joueur ET l'annonce au serveur.
|
||||
*
|
||||
* Les deux ensemble et jamais l'un sans l'autre -- persister sans annoncer
|
||||
* laisserait le joueur seul a se voir change, annoncer sans persister le
|
||||
* ferait repartir au visage par defaut au prochain lancement. C'est par ici
|
||||
* que passent les commandes console, et par ici que passera l'ecran de
|
||||
* customisation.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Apparence")
|
||||
void SubmitCharacterAppearance(const FCharacterAppearance& InAppearance);
|
||||
|
||||
/** Faux quand Steam est absent ou qu'aucune session n'a pu etre creee. */
|
||||
UFUNCTION(BlueprintPure, Category = "Coop")
|
||||
bool CanInviteFriends() const;
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
/** Serveur uniquement. Chez un client, c'est AcknowledgePossession qui arrive. */
|
||||
virtual void OnPossess(APawn* InPawn) override;
|
||||
|
||||
/**
|
||||
* Le pendant CLIENT de OnPossess.
|
||||
*
|
||||
* OnPossess n'existe QUE sur le serveur : chez un client le pawn arrive par
|
||||
* replication et cette fonction n'est jamais appelee. Comme c'est elle qui
|
||||
* construisait le HUD, un joueur qui se connectait n'avait aucune interface --
|
||||
* ni viseur, ni barre rapide, ni jauges. Le piege est d'autant plus fourbe
|
||||
* que l'hote, lui, est bien le serveur et voit son HUD parfaitement.
|
||||
*/
|
||||
virtual void AcknowledgePossession(APawn* P) override;
|
||||
|
||||
virtual void SetupInputComponent() override;
|
||||
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Fondus
|
||||
//
|
||||
// La mécanique elle-même vit dans UScreenFadeComponent, partagé avec le
|
||||
// menu principal. Ici on ne garde que les durées propres au jeu.
|
||||
// La couleur et le WBP_Fade se règlent sur le composant.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Le voile. Assigne-lui ton WBP_Fade dans BP_FpsPlayerController. */
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "UI|Fondu")
|
||||
TObjectPtr<UScreenFadeComponent> ScreenFade;
|
||||
|
||||
/** Décoche pour démarrer sans fondu, pratique pendant les tests. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu")
|
||||
bool bFadeInOnStart = true;
|
||||
|
||||
/**
|
||||
* Délai maximal d'attente du pawn avant d'éclaircir quand même.
|
||||
*
|
||||
* Filet de sécurité pur : si la possession n'arrivait jamais, l'écran
|
||||
* resterait noir pour toujours et le joueur croirait le jeu planté. Mieux
|
||||
* vaut une vue bancale qu'un écran mort.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu", meta = (ClampMin = "1", Units = "s"))
|
||||
float StartupFadeTimeout = 10.f;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu", meta = (ClampMin = "0.1"))
|
||||
float StartupFadeDuration = 1.5f;
|
||||
|
||||
/** Durée du fondu au noir quand le joueur meurt. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu", meta = (ClampMin = "0.1"))
|
||||
float DeathFadeDuration = 1.f;
|
||||
|
||||
/**
|
||||
* Temps total entre la mort et la réapparition.
|
||||
* Doit rester supérieur à DeathFadeDuration, sinon le joueur réapparaît
|
||||
* avant que l'écran soit entièrement noir.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu", meta = (ClampMin = "0.1"))
|
||||
float RespawnDelay = 3.f;
|
||||
|
||||
/**
|
||||
* Ouverture de l'inventaire, valeur Digital (bool).
|
||||
* L'input d'interface vit sur le controller et non sur le pawn : ouvrir
|
||||
* son sac doit rester possible meme sans corps a piloter.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> ToggleInventoryAction;
|
||||
|
||||
/**
|
||||
* Ouverture directe sur l'onglet Fabrication, valeur Digital (bool).
|
||||
* Sur le controller comme ToggleInventoryAction : elle doit repondre alors
|
||||
* meme que l'input du pawn est coupe, ecran ouvert.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> ToggleCraftingAction;
|
||||
|
||||
/**
|
||||
* Jette l'objet survole, valeur Digital (bool). Ctrl enfonce = un seul
|
||||
* exemplaire au lieu de la pile entiere.
|
||||
* Sur le controller aussi : le pawn a ses inputs coupes quand l'inventaire
|
||||
* est ouvert, donc un binding sur le pawn ne repondrait jamais.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> DropItemAction;
|
||||
|
||||
/**
|
||||
* Selection directe d'un slot de la barre rapide, valeur Axis1D.
|
||||
*
|
||||
* Une seule action pour toutes les touches : dans IMC_Default, mappe les
|
||||
* touches 1 a 6 sur cette action et donne a chacune un modificateur
|
||||
* Scalar different (1, 2, 3...). Le handler lit la valeur et en deduit
|
||||
* le slot. Six actions separees seraient six assets a maintenir.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> HotbarSelectAction;
|
||||
|
||||
/** Molette de souris pour defiler dans la barre, valeur Axis1D. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> HotbarScrollAction;
|
||||
|
||||
/**
|
||||
* Echap : ferme l'inventaire s'il est ouvert, sinon bascule la pause.
|
||||
*
|
||||
* ATTENTION : coche "Trigger When Paused" sur l'asset IA_Pause. Un jeu en
|
||||
* pause ne fait plus qu'un tick reduit ou seules les actions portant ce
|
||||
* drapeau sont evaluees -- sans lui, on peut mettre en pause mais plus
|
||||
* jamais en sortir au clavier.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
|
||||
TObjectPtr<UInputAction> PauseAction;
|
||||
|
||||
/**
|
||||
* Viseur, affiche en permanence. Widget separe du prompt : ce dernier se
|
||||
* met en Collapsed des qu'on ne vise plus rien, et emporterait le viseur
|
||||
* avec lui s'ils partageaient le meme widget.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
|
||||
TSubclassOf<UUserWidget> CrosshairClass;
|
||||
|
||||
/** A assigner dans BP_FpsPlayerController : ton WBP_InteractionPrompt. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
|
||||
TSubclassOf<UInteractionPromptWidget> InteractionPromptClass;
|
||||
|
||||
/**
|
||||
* Ton WBP_InventoryScreen : l'ecran a onglets qui contient la grille
|
||||
* d'inventaire ET la page de fabrication. Ce n'est plus WBP_Inventory
|
||||
* directement, celui-ci est devenu une page a l'interieur.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
|
||||
TSubclassOf<UInventoryScreenWidget> InventoryScreenClass;
|
||||
|
||||
/**
|
||||
* Distance au-dela de laquelle l'ecran de coffre se referme tout seul.
|
||||
*
|
||||
* Sans elle, on viderait un coffre depuis l'autre bout de la carte : rien
|
||||
* n'oblige le joueur a rester devant une fois l'ecran ouvert.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Storage", meta = (ClampMin = "50", Units = "cm"))
|
||||
float MaxStorageDistance = 400.f;
|
||||
|
||||
/** Ton WBP_Hotbar, affiche en permanence. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
|
||||
TSubclassOf<UHotbarWidget> HotbarClass;
|
||||
|
||||
/** Ton WBP_SurvivalStats : les trois jauges. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
|
||||
TSubclassOf<USurvivalStatsWidget> SurvivalStatsClass;
|
||||
|
||||
/** Ton WBP_PauseMenu. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Pause")
|
||||
TSubclassOf<UPauseMenuWidget> PauseMenuClass;
|
||||
|
||||
/**
|
||||
* Ton WBP_ConfirmDialog, le meme que celui du menu principal. Laisse-le
|
||||
* assigne : sans lui on retourne au menu sans rien demander, et la partie
|
||||
* est perdue sur un clic.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Pause")
|
||||
TSubclassOf<UConfirmDialogWidget> QuitToMenuDialogClass;
|
||||
|
||||
/** La question posee avant d'abandonner la partie. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Pause")
|
||||
FText QuitToMenuMessage;
|
||||
|
||||
/**
|
||||
* Ton WBP_Settings, le meme que celui du menu principal. C'est tout
|
||||
* l'interet de son delegue OnCloseRequested : l'ecran ignore qui l'ouvre.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Reglages")
|
||||
TSubclassOf<USettingsMenuWidget> SettingsMenuClass;
|
||||
|
||||
/**
|
||||
* Ta map de menu principal.
|
||||
*
|
||||
* TSoftObjectPtr comme dans AMainMenuPlayerController : une reference dure
|
||||
* ferait charger la map du menu en meme temps que celle du jeu.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Pause")
|
||||
TSoftObjectPtr<UWorld> MainMenuLevel;
|
||||
|
||||
/** Fondu au noir avant de rendre la main au menu principal. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Fondu", meta = (ClampMin = "0.1"))
|
||||
float QuitToMenuFadeDuration = 1.f;
|
||||
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "UI")
|
||||
TObjectPtr<UUserWidget> Crosshair;
|
||||
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "UI")
|
||||
TObjectPtr<UInteractionPromptWidget> InteractionPrompt;
|
||||
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "UI")
|
||||
TObjectPtr<UInventoryScreenWidget> InventoryScreen;
|
||||
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "UI")
|
||||
TObjectPtr<UHotbarWidget> Hotbar;
|
||||
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "UI")
|
||||
TObjectPtr<USurvivalStatsWidget> SurvivalStatsWidget;
|
||||
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "UI|Pause")
|
||||
TObjectPtr<UPauseMenuWidget> PauseMenu;
|
||||
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "UI|Pause")
|
||||
TObjectPtr<UConfirmDialogWidget> QuitToMenuDialog;
|
||||
|
||||
UPROPERTY(Transient, BlueprintReadOnly, Category = "UI|Reglages")
|
||||
TObjectPtr<USettingsMenuWidget> SettingsMenu;
|
||||
|
||||
private:
|
||||
void CreateHudWidgets();
|
||||
|
||||
/**
|
||||
* Tout ce qu'il faut faire quand ce controller prend un pawn, quel que soit
|
||||
* le cote du reseau : creer le HUD s'il manque, rebrancher les widgets sur
|
||||
* le nouveau corps, ecouter sa mort.
|
||||
*
|
||||
* Appelee par OnPossess (serveur) ET AcknowledgePossession (client), donc
|
||||
* potentiellement deux fois chez l'hote qui est les deux a la fois. Elle
|
||||
* doit rester idempotente -- d'ou AddUniqueDynamic sur OnDied, et
|
||||
* CreateHudWidgets qui ne cree que ce qui n'existe pas encore.
|
||||
*/
|
||||
void SetupForPossessedPawn(APawn* InPawn);
|
||||
|
||||
/**
|
||||
* Éclaircit l'écran d'arrivée, une seule fois, et seulement quand le joueur
|
||||
* a réellement un corps.
|
||||
*
|
||||
* La condition n'est PAS « ai-je un pawn » mais « la caméra est-elle sur mon
|
||||
* pawn ». Chez un client, le contrôleur commence sur un `SpectatorPawn` né
|
||||
* au centre du monde -- sous le terrain -- et y reste encore un instant
|
||||
* APRÈS `AcknowledgePossession`. Mesuré sur une vraie connexion : au moment
|
||||
* où la possession est acquittée, `GetViewTarget()` vaut toujours
|
||||
* `SpectatorPawn_0`. Éclaircir là montre le dessous de la carte.
|
||||
*
|
||||
* Tant que la caméra n'y est pas, on repasse toutes les `StartupFadeRetry`
|
||||
* secondes. L'écran reste noir pendant ce temps -- le rendu du monde est
|
||||
* suspendu par le GameInstance depuis le chargement de la map.
|
||||
*/
|
||||
void TryStartupFadeIn();
|
||||
|
||||
/**
|
||||
* Éclaircit pour de bon, sans vérifier la caméra.
|
||||
* Appelée par le filet de sécurité : mieux vaut une vue bancale qu'un écran
|
||||
* noir définitif, qui se lit comme un plantage.
|
||||
*/
|
||||
void CompleteStartupFadeIn();
|
||||
|
||||
/** L'éclaircissement lui-même, après le temps de chauffe du monde. */
|
||||
void StartStartupFade();
|
||||
|
||||
void OpenPauseMenu();
|
||||
|
||||
/** Oui : fondu au noir, puis retour au menu principal. */
|
||||
UFUNCTION()
|
||||
void HandleQuitToMenuConfirmed();
|
||||
|
||||
/**
|
||||
* L'hote s'en va : ce client doit partir aussi.
|
||||
*
|
||||
* Une RPC explicite et non un simple abandon de connexion. Quand l'hote
|
||||
* ferme sa partie, le moteur ne previent personne : les clients restent
|
||||
* dans un monde mort, avec le pawn fantome de l'hote encore plante devant
|
||||
* eux, jusqu'a un timeout qui peut ne jamais venir. On leur dit donc de
|
||||
* partir, et ils partent avec leur propre fondu.
|
||||
*/
|
||||
UFUNCTION(Client, Reliable)
|
||||
void Client_ReturnToMainMenu();
|
||||
|
||||
/**
|
||||
* Lance le depart : ferme les ecrans, previent le GameInstance, fond au noir.
|
||||
*
|
||||
* Idempotente -- un client peut recevoir l'ordre de l'hote alors qu'il avait
|
||||
* deja clique Quitter de son cote, et deux fondus simultanes se
|
||||
* marcheraient dessus.
|
||||
*/
|
||||
void BeginQuitToMainMenu();
|
||||
|
||||
/**
|
||||
* Envoie l'ordre de depart a tous les AUTRES joueurs. Serveur uniquement.
|
||||
*
|
||||
* Appele AVANT le fondu de l'hote et non apres : les clients ont ainsi
|
||||
* toute la duree de ce fondu pour recevoir le message et lancer le leur.
|
||||
* Prevenir a la fin donnerait un client qui decouvre la coupure sans
|
||||
* explication, ecran de jeu fige.
|
||||
*/
|
||||
void KickRemoteClientsToMainMenu();
|
||||
|
||||
/** Non : on referme la boite et on rend la main au menu de pause. */
|
||||
UFUNCTION()
|
||||
void HandleQuitToMenuCancelled();
|
||||
|
||||
/** Le fondu est termine : on charge la map du menu, si c'est ce qu'on attendait. */
|
||||
UFUNCTION()
|
||||
void HandleFadeFinished();
|
||||
|
||||
/**
|
||||
* Le joueur a accepte une invitation EN PLEINE PARTIE : on fond au noir
|
||||
* avant que le voyage vers l'autre hote parte.
|
||||
*
|
||||
* bQuittingToMainMenu reste faux : ce n'est pas nous qui chargeons la map
|
||||
* suivante, c'est USessionSubsystem. HandleFadeFinished doit donc laisser
|
||||
* passer ce fondu-la sans rien faire.
|
||||
*/
|
||||
UFUNCTION()
|
||||
void HandleSessionTravelPending();
|
||||
|
||||
/** L'ecran de reglages demande a se fermer : on rend la main au menu de pause. */
|
||||
UFUNCTION()
|
||||
void HandleSettingsCloseRequested();
|
||||
|
||||
/**
|
||||
* Point d'entree unique de l'ouverture et de la fermeture de l'ecran.
|
||||
*
|
||||
* Tab et la touche de fabrication passent tous les deux par ici : le mode
|
||||
* d'input, la souris et la barre rapide sont ainsi decides a un seul endroit.
|
||||
* Deux chemins separes finiraient par diverger.
|
||||
*/
|
||||
void SetInventoryScreenOpen(bool bOpen, bool bShowCraftingTab);
|
||||
|
||||
/** Rend le poste accorde par le dernier OpenCraftingAtStation. Sans effet sinon. */
|
||||
void ReleaseActiveStation();
|
||||
|
||||
/**
|
||||
* Rend le coffre ouvert : couvercle referme, panneau masque, surveillance
|
||||
* arretee. Sans effet s'il n'y en a pas.
|
||||
*
|
||||
* Separee de CloseStorage() pour la meme raison que ReleaseActiveStation :
|
||||
* c'est la fermeture de l'ECRAN qui l'appelle, quelle qu'en soit la cause --
|
||||
* Tab, Echap, la mort, un autre coffre. Un seul point de sortie, donc aucun
|
||||
* chemin qui laisse un couvercle leve derriere soi.
|
||||
*/
|
||||
void ReleaseActiveStorage();
|
||||
|
||||
/**
|
||||
* Mode d'input et curseur pour un ecran plein cadre.
|
||||
*
|
||||
* Partage par l'inventaire et le coffre : deux copies de ces huit lignes
|
||||
* finiraient par diverger, et le symptome -- une camera qui ne repond plus,
|
||||
* un sprint bloque -- se cherche tres longtemps.
|
||||
*/
|
||||
void SetUiInputMode(bool bUiOpen);
|
||||
|
||||
/** Le joueur s'est-il trop eloigne du coffre ouvert. Appele par un timer. */
|
||||
void CheckStorageDistance();
|
||||
|
||||
/** Raccourci vers le composant de fabrication du pawn possede. */
|
||||
UCraftingComponent* GetPawnCrafting() const;
|
||||
|
||||
/** L'ecran a change d'onglet : la barre rapide n'a rien a faire sous la page de craft. */
|
||||
UFUNCTION()
|
||||
void HandleInventoryTabChanged(bool bInventoryTabActive);
|
||||
|
||||
/**
|
||||
* Etat unique de la barre rapide : visible partout, sauf par-dessus l'onglet
|
||||
* Fabrication. Passer par une seule fonction evite les cas ou la fermeture
|
||||
* de l'ecran laisserait la barre cachee.
|
||||
*/
|
||||
void UpdateHotbarVisibility();
|
||||
|
||||
/**
|
||||
* Relaie le survol d'une case de la barre rapide vers le panneau de detail
|
||||
* de l'ecran d'inventaire.
|
||||
*
|
||||
* Ignore ecran ferme : sans curseur il n'y a pas de survol, mais un widget
|
||||
* masque sous la souris peut encore emettre sa sortie -- et decrire un objet
|
||||
* dans un panneau invisible n'aurait aucun sens.
|
||||
*/
|
||||
UFUNCTION()
|
||||
void HandleHotbarSlotHovered(UInventoryComponent* Inventory, int32 SlotIndex, bool bHovered);
|
||||
|
||||
/** Jette la pile survolee dans la grille, si l'inventaire est ouvert. */
|
||||
void DropHoveredItem();
|
||||
|
||||
void HandleHotbarSelect(const FInputActionValue& Value);
|
||||
void HandleHotbarScroll(const FInputActionValue& Value);
|
||||
|
||||
/** Raccourci vers l'inventaire du pawn possede. */
|
||||
UInventoryComponent* GetPawnInventory() const;
|
||||
|
||||
/** Branche sur OnDied du pawn possede. */
|
||||
UFUNCTION()
|
||||
void HandlePawnDied();
|
||||
|
||||
/**
|
||||
* Detruit le cadavre et demande un nouveau pawn au GameMode.
|
||||
*
|
||||
* Le GameMode n'existe QUE sur le serveur : chez un client
|
||||
* GetAuthGameMode() rend nullptr, toujours. Un client route donc par
|
||||
* Server_RequestRespawn, et ne garde en local que son fondu de retour --
|
||||
* c'est de l'affichage, ca lui appartient.
|
||||
*/
|
||||
void RespawnPlayer();
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_RequestRespawn();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Ouverture d'ecran demandee par un acteur du monde
|
||||
//
|
||||
// Interact_Implementation s'execute sur le SERVEUR depuis le passage en
|
||||
// coop. Or ces acteurs ouvrent une interface, qui n'existe que sur la
|
||||
// machine du joueur : l'appel doit donc repartir vers le client par une
|
||||
// RPC Client_. Sans elles, le serveur cree des widgets dans le vide et
|
||||
// l'ecran du joueur ne s'ouvre jamais.
|
||||
//
|
||||
// L'etat "devant quel poste je suis" est tenu des DEUX cotes : le client en
|
||||
// a besoin pour filtrer sa liste de recettes, le serveur pour valider le
|
||||
// craft. Ne le tenir que d'un cote laisserait soit une liste vide, soit un
|
||||
// etabli utilisable depuis la foret.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
UFUNCTION(Client, Reliable)
|
||||
void Client_OpenCraftingAtStation(ECraftingStation Station);
|
||||
|
||||
UFUNCTION(Client, Reliable)
|
||||
void Client_OpenStorage(AStorageContainer* Container);
|
||||
|
||||
/** Le joueur a referme son ecran : le serveur reprend le poste accorde. */
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_ReleaseStation(ECraftingStation Station);
|
||||
|
||||
/** Le joueur a referme le coffre : le serveur rabat le couvercle. */
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_ReleaseStorage(AStorageContainer* Container);
|
||||
|
||||
/** Accorde le poste sur le composant du pawn et note lequel. */
|
||||
void GrantStation(ECraftingStation Station);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pseudo
|
||||
//
|
||||
// Le serveur ne peut PAS deviner le pseudo Steam d'un client : l'API
|
||||
// d'identite ne connait que l'utilisateur local de chaque machine. C'est
|
||||
// donc au client de l'annoncer. Sans ca, le moteur laisse le nom par
|
||||
// defaut du fichier de configuration -- on lit "Player" au-dessus des
|
||||
// quatre joueurs, ce qui se remarque tout de suite.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Le pseudo de l'utilisateur Steam local, ou une chaine vide si indisponible. */
|
||||
FString ResolveLocalPlayerNickname() const;
|
||||
|
||||
/** Ecrit le pseudo sur le PlayerState, qui le replique a tout le monde. */
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_SetPlayerNickname(const FString& Nickname);
|
||||
|
||||
/**
|
||||
* Meme chemin que le pseudo, et pour la meme raison : le serveur ne peut
|
||||
* pas deviner l'apparence choisie par un client, elle vit dans le fichier
|
||||
* de reglages de SA machine. C'est donc lui qui l'annonce.
|
||||
*
|
||||
* Treize octets envoyes une fois a la connexion -- l'apparence ne change
|
||||
* pas en cours de partie, l'ecran de customisation vit dans le menu.
|
||||
*/
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_SetCharacterAppearance(const FCharacterAppearance& InAppearance);
|
||||
|
||||
/** Lit l'apparence persistee et l'envoie au serveur. Appelee au BeginPlay. */
|
||||
void AnnounceCharacterAppearance();
|
||||
|
||||
FTimerHandle RespawnTimerHandle;
|
||||
|
||||
/** Le fondu d'arrivée attend le pawn. */
|
||||
bool bStartupFadePending = false;
|
||||
|
||||
FTimerHandle StartupFadeTimeoutHandle;
|
||||
|
||||
/** Réessai tant que la caméra n'est pas sur le pawn. */
|
||||
FTimerHandle StartupFadeRetryHandle;
|
||||
|
||||
|
||||
/** Période de réessai, en secondes. Assez court pour ne pas se voir. */
|
||||
static constexpr float StartupFadeRetry = 0.05f;
|
||||
|
||||
/** Surveillance de l'eloignement, actif seulement pendant qu'un coffre est ouvert. */
|
||||
FTimerHandle StorageDistanceTimerHandle;
|
||||
|
||||
/** Le coffre actuellement ouvert, pour lui refermer son couvercle. */
|
||||
TWeakObjectPtr<AStorageContainer> ActiveStorage;
|
||||
|
||||
bool bStorageOpen = false;
|
||||
|
||||
bool bInventoryOpen = false;
|
||||
|
||||
/** Onglet Fabrication affiche. N'a de sens que si bInventoryOpen est vrai. */
|
||||
bool bCraftingTabActive = false;
|
||||
|
||||
/**
|
||||
* Poste accorde par l'ecran actuellement ouvert, a rendre a la fermeture.
|
||||
* Count = aucun, l'ecran a ete ouvert au clavier et pas depuis un etabli.
|
||||
*/
|
||||
ECraftingStation ActiveStation = ECraftingStation::Count;
|
||||
|
||||
/**
|
||||
* Visibilite d'origine de la barre rapide, relevee a sa creation.
|
||||
* On la restaure telle quelle plutot que de forcer Visible : le Blueprint a
|
||||
* peut-etre choisi SelfHitTestInvisible, et l'ecraser changerait
|
||||
* silencieusement son comportement au clic.
|
||||
*/
|
||||
ESlateVisibility HotbarDefaultVisibility = ESlateVisibility::Visible;
|
||||
|
||||
bool bPauseMenuOpen = false;
|
||||
|
||||
/**
|
||||
* La boite de confirmation avale Echap elle-meme quand elle a le focus,
|
||||
* mais un clic ailleurs le lui retire : sans ce booleen, Echap refermerait
|
||||
* alors le menu de pause en laissant la question affichee dans le vide.
|
||||
*/
|
||||
bool bQuitToMenuDialogOpen = false;
|
||||
|
||||
bool bSettingsOpen = false;
|
||||
|
||||
/** Distingue le fondu de sortie de tous les autres dans HandleFadeFinished. */
|
||||
bool bQuittingToMainMenu = false;
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "HeadBobComponent.generated.h"
|
||||
|
||||
class ACharacter;
|
||||
class UCharacterMovementComponent;
|
||||
|
||||
/**
|
||||
* Les reglages d'un etat de deplacement : immobile, marche, sprint, accroupi, en l'air.
|
||||
*
|
||||
* Regroupes dans une struct plutot qu'a plat sur le composant pour deux raisons.
|
||||
* D'abord le Details panel : cinq etats x cinq valeurs a plat feraient vingt-cinq
|
||||
* lignes melangees, alors que la struct donne un repli par etat. Ensuite le code :
|
||||
* passer d'un etat a l'autre est une interpolation champ a champ, et une struct
|
||||
* permet de l'ecrire une seule fois au lieu d'une par valeur.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FHeadBobProfile
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
FHeadBobProfile() = default;
|
||||
|
||||
FHeadBobProfile(float InStrideFrequency, float InVerticalAmount, float InLateralAmount, float InRollAmount, float InPitchAmount)
|
||||
: StrideFrequency(InStrideFrequency)
|
||||
, VerticalAmount(InVerticalAmount)
|
||||
, LateralAmount(InLateralAmount)
|
||||
, RollAmount(InRollAmount)
|
||||
, PitchAmount(InPitchAmount)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Foulees par seconde. Une foulee compte DEUX pas : l'oscillation verticale
|
||||
* bat donc au double de cette valeur, une bosse par pied qui touche le sol.
|
||||
* A 1.1, les pas tombent a 2.2 Hz, ce qui correspond a une marche normale.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0", UIMax = "4"))
|
||||
float StrideFrequency = 1.1f;
|
||||
|
||||
/** Amplitude verticale en cm, mesuree du repos au sommet (le debattement total vaut le double). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0", UIMax = "10"))
|
||||
float VerticalAmount = 1.6f;
|
||||
|
||||
/** Amplitude laterale en cm : le balancement gauche-droite du buste. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0", UIMax = "10"))
|
||||
float LateralAmount = 1.2f;
|
||||
|
||||
/** Roulis en degres au pic du balancement lateral. La tete penche dans le sens du pas. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (UIMin = "-3", UIMax = "3"))
|
||||
float RollAmount = 0.4f;
|
||||
|
||||
/**
|
||||
* Tangage en degres au pic vertical. A 0 par defaut, et ce n'est pas un oubli :
|
||||
* le tangage deplace le reticule, donc il degrade la visee. A n'ouvrir que si
|
||||
* l'on assume un jeu ou l'on ne vise pas au pixel.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (UIMin = "-3", UIMax = "3"))
|
||||
float PitchAmount = 0.f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Balancement de tete procedural, sans la moindre animation authoree.
|
||||
*
|
||||
* Le principe tient en une phase unique, avancee chaque frame par la frequence de
|
||||
* l'etat courant, et une amplitude qui glisse d'un etat a l'autre. C'est ce qui
|
||||
* rend le passage marche -> sprint continu : ce n'est pas une animation qu'on
|
||||
* coupe pour en lancer une autre, ce sont deux jeux de coefficients entre lesquels
|
||||
* on interpole pendant que la phase, elle, ne s'interrompt jamais. Remettre la
|
||||
* phase a zero a chaque changement d'etat produirait exactement le "clac" qu'on
|
||||
* cherche a eviter.
|
||||
*
|
||||
* Le composant ne touche PAS a la camera. Il expose un decalage de position et un
|
||||
* decalage de rotation, que AFpsPlayer::ApplyCameraTransform additionne aux siens.
|
||||
* C'est la condition pour que cette fonction reste le seul endroit du projet qui
|
||||
* ecrit la transform de la camera -- elle est rappelee depuis CalcCamera, et deux
|
||||
* ecrivains se marcheraient dessus une frame sur deux.
|
||||
*
|
||||
* Rien n'est replique et rien n'a a l'etre : le balancement n'existe que dans les
|
||||
* yeux du joueur local. Les bras suivent gratuitement, puisqu'ils sont attaches a
|
||||
* la camera.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
|
||||
class EMBERWILD_API UHeadBobComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UHeadBobComponent();
|
||||
|
||||
/**
|
||||
* Fait avancer la phase et recalcule les decalages.
|
||||
*
|
||||
* Appelee par le pawn depuis son Tick, et surtout PAS depuis un TickComponent a
|
||||
* lui : l'ordre de tick entre un acteur et ses composants n'est pas garanti sans
|
||||
* dependance explicite, et le resultat serait un decalage lu une frame apres
|
||||
* avoir ete calcule -- invisible a 120 fps, franchement mou a 30.
|
||||
*/
|
||||
void UpdateBob(float DeltaTime);
|
||||
|
||||
/** Decalage a ajouter a la position RELATIVE de la camera (X avant, Y droite, Z haut), en cm. */
|
||||
UFUNCTION(BlueprintPure, Category = "Head Bob")
|
||||
FVector GetLocationOffset() const { return LocationOffset; }
|
||||
|
||||
/** Decalage a ajouter a la rotation de la camera, en degres. */
|
||||
UFUNCTION(BlueprintPure, Category = "Head Bob")
|
||||
FRotator GetRotationOffset() const { return RotationOffset; }
|
||||
|
||||
/**
|
||||
* Remet le balancement au repos sans transition.
|
||||
* Pour une teleportation ou une prise de controle : on ne veut pas que la tete
|
||||
* finisse le pas d'avant a l'autre bout de la carte.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Head Bob")
|
||||
void ResetBob();
|
||||
|
||||
/**
|
||||
* Facteur global pousse par les reglages joueur (curseur "secousses de camera").
|
||||
* A 0 la tete ne bouge plus du tout : c'est un confort d'accessibilite, pas une
|
||||
* option cosmetique, et il doit valoir pour le balancement comme pour les chocs.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Head Bob")
|
||||
void SetIntensityScale(float NewScale);
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Reglages generaux
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Coupe entierement l'effet, sans avoir a remettre les cinq profils a zero. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob")
|
||||
bool bEnableHeadBob = true;
|
||||
|
||||
/**
|
||||
* Multiplicateur d'amplitude commun aux cinq profils.
|
||||
* C'est le curseur a bouger pour un reglage d'ensemble ("un peu moins de tout")
|
||||
* sans avoir a retoucher vingt valeurs une par une.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0", UIMax = "3"))
|
||||
float GlobalIntensity = 1.f;
|
||||
|
||||
/**
|
||||
* Vitesse de fondu d'un profil vers un autre (facteur d'interpolation
|
||||
* exponentielle, par seconde). Plus haut = le sprint prend son amplitude plus
|
||||
* vite. Trop haut, le changement d'etat redevient une cassure.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob", meta = (ClampMin = "0.1", UIMax = "20"))
|
||||
float ProfileBlendSpeed = 6.f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Seuils de choix d'etat
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* En dessous de cette vitesse horizontale (cm/s), on est considere immobile.
|
||||
* Le seuil se lit sur la vitesse REELLE et non sur la touche enfoncee : marcher
|
||||
* dans un mur laisse l'input actif alors que le corps ne bouge plus, et sans ce
|
||||
* detail la tete continuerait de gigoter face a la cloison.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Seuils", meta = (ClampMin = "0"))
|
||||
float IdleSpeedThreshold = 10.f;
|
||||
|
||||
/**
|
||||
* Au-dessus de cette vitesse horizontale (cm/s), on bascule sur le profil de
|
||||
* sprint. A regler entre WalkSpeed et SprintSpeed du pawn -- 400 et 650 par
|
||||
* defaut, d'ou ces 520 : assez haut pour que la phase d'acceleration ne compte
|
||||
* pas comme du sprint, assez bas pour que le sprint reel soit toujours detecte.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Seuils", meta = (ClampMin = "0"))
|
||||
float SprintSpeedThreshold = 520.f;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Profils, un par etat. Valeurs par defaut dans le constructeur.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Immobile : une respiration lente, quasi purement verticale. C'est ce qui evite la statue. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
|
||||
FHeadBobProfile IdleProfile;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
|
||||
FHeadBobProfile WalkProfile;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
|
||||
FHeadBobProfile SprintProfile;
|
||||
|
||||
/** Accroupi : plus lent et plus discret que la marche, le corps est ramasse. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
|
||||
FHeadBobProfile CrouchProfile;
|
||||
|
||||
/**
|
||||
* En l'air : tout a zero, y compris la frequence -- la phase se fige donc en vol
|
||||
* et repart d'ou elle en etait a la reception, ce qui evite que le premier pas
|
||||
* apres un saut tombe sur un pied arbitraire. Le profil reste editable malgre
|
||||
* tout, au cas ou l'on veuille un flottement pendant la chute.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Head Bob|Profils")
|
||||
FHeadBobProfile AirProfile;
|
||||
|
||||
private:
|
||||
/** Choisit le profil vise a partir de l'etat reel du CharacterMovement. */
|
||||
const FHeadBobProfile& SelectTargetProfile() const;
|
||||
|
||||
/** Rapproche BlendedProfile de Target, champ par champ. */
|
||||
void BlendTowards(const FHeadBobProfile& Target, float Step);
|
||||
|
||||
/**
|
||||
* Position dans le cycle de foulee, en radians, ramenee dans [0, 2*PI[.
|
||||
* Le repli n'est pas cosmetique : sans lui la valeur grossit indefiniment et la
|
||||
* precision du float se degrade, jusqu'a un balancement qui devient saccade au
|
||||
* bout d'une longue partie.
|
||||
*/
|
||||
float StridePhase = 0.f;
|
||||
|
||||
/** Profil courant, resultat du fondu entre les profils d'etat. */
|
||||
FHeadBobProfile BlendedProfile;
|
||||
|
||||
FVector LocationOffset = FVector::ZeroVector;
|
||||
|
||||
FRotator RotationOffset = FRotator::ZeroRotator;
|
||||
|
||||
/** Pousse par les reglages joueur, se multiplie a GlobalIntensity. */
|
||||
float IntensityScale = 1.f;
|
||||
|
||||
/** Caches : ces deux pointeurs sont lus a chaque frame, pas question d'y caster. */
|
||||
TObjectPtr<ACharacter> OwnerCharacter;
|
||||
|
||||
TObjectPtr<UCharacterMovementComponent> OwnerMovement;
|
||||
};
|
||||
@@ -0,0 +1,339 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "ItemDataAsset.h"
|
||||
#include "HeldItemComponent.generated.h"
|
||||
|
||||
class AFpsPlayer;
|
||||
class UAnimInstance;
|
||||
class UItemDataAsset;
|
||||
class UStaticMeshComponent;
|
||||
|
||||
/** Diffuse a chaque changement d'objet en main, nul compris. L'UI et l'anim s'y abonnent. */
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHeldItemChanged, UItemDataAsset*, NewItem);
|
||||
|
||||
/**
|
||||
* Ce qu'a fait PlayUseMontage, du point de vue de l'appelant.
|
||||
*
|
||||
* Trois etats et pas un booleen, parce que l'appelant en tire trois conduites
|
||||
* differentes : un geste qui demarre porte son propre marqueur d'impact et il
|
||||
* n'y a plus qu'a attendre ; un geste deja en cours en enverra un lui aussi ;
|
||||
* une absence de montage, elle, ne produira JAMAIS de marqueur et doit retomber
|
||||
* sur l'effet immediat. Les confondre donne soit un objet sans montage qui ne
|
||||
* fait plus rien du tout, soit un clic martele qui declenche l'effet a chaque
|
||||
* image pendant que le bras, lui, ne rejoue pas.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class EUseMontageResult : uint8
|
||||
{
|
||||
/** Le geste vient de demarrer : son AnimNotify declenchera l'effet. */
|
||||
Started,
|
||||
|
||||
/** Le meme geste tournait deja ; on ne l'a pas relance. */
|
||||
AlreadyPlaying,
|
||||
|
||||
/** Rien a jouer -- l'objet n'a pas de UseMontage, ou les bras sont distants. */
|
||||
NoMontage
|
||||
};
|
||||
|
||||
/**
|
||||
* Montre dans la main l'objet occupant la case active de la barre rapide.
|
||||
*
|
||||
* Un systeme = un composant, comme le reste du projet. Celui-ci ne decide de
|
||||
* rien et ne consomme rien : il regarde l'inventaire et pose deux meshes. Ce que
|
||||
* l'objet FAIT quand on clique reste dans AFpsPlayer::PerformUseItem.
|
||||
*
|
||||
* Deux meshes et pas un, parce qu'il y a deux corps a habiller :
|
||||
*
|
||||
* - les BRAS (FirstPersonArms), visibles du seul proprietaire, attaches a la
|
||||
* camera. C'est la hache qu'on voit soi-meme.
|
||||
* - le CORPS (le Mesh du Character), invisible pour son proprietaire mais vu
|
||||
* par les trois autres joueurs -- et dont on percoit sa propre ombre.
|
||||
*
|
||||
* Les bras recopient du porteur leur « visible pour moi seul ». Le mesh du corps,
|
||||
* lui, se cache par la VISIBILITE chez le seul joueur qui controle ce pawn --
|
||||
* bOwnerNoSee y couperait son ombre, voir UpdateLocalVisibility().
|
||||
*
|
||||
* ------------------------------------------------------------------
|
||||
* Reseau : c'est le CLIENT qui pilote, toujours
|
||||
* ------------------------------------------------------------------
|
||||
*
|
||||
* UInventoryComponent::SelectedHotbarIndex n'est PAS replique -- c'est une
|
||||
* intention purement locale, et il n'y a aucune raison de la mettre sur le
|
||||
* reseau. Le serveur ne peut donc pas deviner ce que le joueur a en main : lire
|
||||
* l'inventaire d'un pawn distant y trouverait toujours la case 0.
|
||||
*
|
||||
* Ce qui se replique est donc l'OBJET lui-meme, pas l'index. Le proprietaire
|
||||
* l'applique en local et l'annonce ; le serveur l'enregistre et le rediffuse aux
|
||||
* autres. Exactement le schema de bIsSprinting, COND_SkipOwner compris : renvoyer
|
||||
* a l'emetteur une valeur qui a un aller-retour de retard ne peut que faire
|
||||
* clignoter ce qu'il tient deja.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
|
||||
class EMBERWILD_API UHeldItemComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UHeldItemComponent();
|
||||
|
||||
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||
|
||||
/** L'objet actuellement montre dans la main. Nul = les mains sont vides. */
|
||||
UFUNCTION(BlueprintPure, Category = "Held Item")
|
||||
UItemDataAsset* GetHeldItem() const { return HeldItem; }
|
||||
|
||||
/**
|
||||
* Vrai quand un mesh est effectivement monte dans la main.
|
||||
*
|
||||
* Different de `GetHeldItem() != nullptr`, et la nuance compte pour
|
||||
* l'animation : la plupart des ressources du jeu n'ont pas bShowInHand, donc
|
||||
* les tenir ne met rien dans la paume. Se fier a l'objet ferait jouer la
|
||||
* posture « objet en main » sur des mains vides, ce qui se voit tout de suite.
|
||||
*/
|
||||
UFUNCTION(BlueprintPure, Category = "Held Item")
|
||||
bool HasItemInHand() const { return bItemVisibleInHand; }
|
||||
|
||||
|
||||
/**
|
||||
* Relit la case active et met la main a jour.
|
||||
*
|
||||
* Sans effet ailleurs que chez le joueur local : ce composant est le seul du
|
||||
* projet dont la SOURCE de verite vive uniquement sur la machine du
|
||||
* proprietaire. Idempotente, donc appelable a la volee.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Held Item")
|
||||
void RefreshFromInventory();
|
||||
|
||||
/**
|
||||
* Joue le geste du clic gauche avec l'objet en main.
|
||||
*
|
||||
* Appelee par le PAWN au moment de l'input, donc chez le joueur local et
|
||||
* AVANT tout aller-retour reseau : un coup de pioche qui attendrait la
|
||||
* reponse du serveur partirait avec un temps de retard sur le clic, et c'est
|
||||
* la premiere chose qu'on sent dans un jeu.
|
||||
*
|
||||
* Il ne fait QUE l'animation : l'effet de l'objet part du marqueur
|
||||
* UAnimNotify_ItemAction pose dans le montage, pour tomber au moment de
|
||||
* l'impact et pas a l'appui. D'ou le retour a trois etats, que l'appelant
|
||||
* doit lire -- voir EUseMontageResult.
|
||||
*
|
||||
* @return ce qu'il est advenu du geste.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Held Item")
|
||||
EUseMontageResult PlayUseMontage();
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Held Item")
|
||||
FOnHeldItemChanged OnHeldItemChanged;
|
||||
|
||||
/**
|
||||
* Os ou socket des BRAS auquel l'objet s'accroche.
|
||||
*
|
||||
* C'est un OS du rig et pas un socket taille sur mesure, d'ou les offsets
|
||||
* par objet dans UItemDataAsset : creer un socket par outil demanderait de
|
||||
* rouvrir le squelette a chaque nouvel objet, alors que l'offset se regle
|
||||
* dans le DataAsset qu'on est deja en train d'ecrire.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Held Item")
|
||||
FName FirstPersonSocket = TEXT("RightHand_Holder_JTN");
|
||||
|
||||
/** Os de la main droite du mannequin UE5, porteur du mesh 3e personne. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Held Item")
|
||||
FName ThirdPersonSocket = TEXT("hand_r");
|
||||
|
||||
/**
|
||||
* Geste du clic gauche quand les mains sont VIDES (MTG_Punch).
|
||||
*
|
||||
* Ici et pas sur un DataAsset, pour la raison evidente qu'il n'y a pas
|
||||
* d'objet a qui le demander. Ici et pas sur AFpsPlayer a cote de GrabMontage,
|
||||
* parce que c'est ce composant qui choisit quel geste part au clic : deux
|
||||
* moities de la meme decision dans deux classes finiraient par diverger.
|
||||
*
|
||||
* Il ne sert QUE quand aucun objet n'occupe la case active. Un objet en main
|
||||
* sans UseMontage ne joue rien -- retomber sur le poing ferait boxer le
|
||||
* joueur en mangeant une pomme.
|
||||
*/
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Held Item")
|
||||
TObjectPtr<class UAnimMontage> UnarmedUseMontage;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Accroupissement
|
||||
//
|
||||
// Des MONTAGES et pas des etats de la machine, alors que CrouchIdle et
|
||||
// CrouchWalk, eux, sont bien des etats. La frontiere est la meme que partout
|
||||
// ailleurs : ce qui DURE est un etat, ce qui ARRIVE est un montage. Plier les
|
||||
// genoux prend trois dixiemes de seconde et se superpose au cycle en cours.
|
||||
//
|
||||
// Raison technique par-dessus, et elle est decisive : un etat dont la sortie
|
||||
// depend de la fin de l'animation utilise « Automatic Rule Based on Sequence
|
||||
// Player in State », qui a besoin d'un Sequence Player pour lire sa duree. Un
|
||||
// etat qui contiendrait un choix entre deux animations -- avec et sans objet
|
||||
// -- lui retire cette certitude, et il faudrait ecrire des durees en dur dans
|
||||
// les transitions, a re-regler a chaque animation livree.
|
||||
//
|
||||
// Ici et pas sur UItemDataAsset : le geste est GENERIQUE, deux variantes en
|
||||
// tout. Le jour ou un outil voudra la sienne, un champ optionnel sur son
|
||||
// DataAsset primera sur ceux-ci, sans rien casser.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Geste d'accroupissement, mains vides (MTG_EnterCrouch). */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Held Item|Crouch")
|
||||
TObjectPtr<class UAnimMontage> UnarmedEnterCrouchMontage;
|
||||
|
||||
/** Meme geste, un objet en main. Vide = on rejoue la version mains vides. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Held Item|Crouch")
|
||||
TObjectPtr<class UAnimMontage> HeldEnterCrouchMontage;
|
||||
|
||||
/** Geste de relevee, mains vides (MTG_ExitCrouch). */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Held Item|Crouch")
|
||||
TObjectPtr<class UAnimMontage> UnarmedExitCrouchMontage;
|
||||
|
||||
/** Meme geste, un objet en main. Vide = on rejoue la version mains vides. */
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Held Item|Crouch")
|
||||
TObjectPtr<class UAnimMontage> HeldExitCrouchMontage;
|
||||
|
||||
/**
|
||||
* Joue le geste d'accroupissement ou de relevee, selon ce qu'on tient.
|
||||
*
|
||||
* Appelee par AFpsPlayer depuis OnStartCrouch / OnEndCrouch, qui sont deja
|
||||
* surchargees : le pawn sait exactement quand la capsule change de taille,
|
||||
* personne n'a a le deviner.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Held Item|Crouch")
|
||||
bool PlayCrouchMontage(bool bEntering);
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
virtual void OnUnregister() override;
|
||||
|
||||
private:
|
||||
/**
|
||||
* L'objet en main. Un pointeur d'asset se replique comme n'importe quelle
|
||||
* reference d'objet -- c'est deja ce que fait FInventorySlot::Item, et c'est
|
||||
* pour ca qu'on peut se permettre d'envoyer l'objet plutot qu'un index.
|
||||
*
|
||||
* COND_SkipOwner : le proprietaire vient de l'appliquer en local, le lui
|
||||
* renvoyer ecraserait son etat courant par une valeur en retard.
|
||||
*/
|
||||
UPROPERTY(ReplicatedUsing = OnRep_HeldItem)
|
||||
TObjectPtr<UItemDataAsset> HeldItem;
|
||||
|
||||
UFUNCTION()
|
||||
void OnRep_HeldItem();
|
||||
|
||||
/** Point d'entree unique : applique en local, puis annonce au serveur. */
|
||||
void SetHeldItem(UItemDataAsset* NewItem);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_SetHeldItem(UItemDataAsset* NewItem);
|
||||
|
||||
/** Monte ou demonte les deux meshes selon HeldItem. Tourne sur TOUTES les machines. */
|
||||
void ApplyHeldVisuals();
|
||||
|
||||
/**
|
||||
* Joue le geste de sortie d'outil sur les bras du joueur LOCAL.
|
||||
*
|
||||
* Appelee depuis SetHeldItem et pas depuis ApplyHeldVisuals, alors que les
|
||||
* deux disent « l'objet en main a change » : ApplyHeldVisuals tourne sur
|
||||
* toutes les machines (OnRep, serveur), et un geste de bras n'a de sens que
|
||||
* chez celui qui les voit.
|
||||
*/
|
||||
void PlayEquipMontage();
|
||||
|
||||
/**
|
||||
* Joue un montage sur les bras du joueur LOCAL, et lui seul.
|
||||
*
|
||||
* @param bSkipIfAlreadyPlaying vrai pour le clic gauche : marteler le bouton
|
||||
* relancerait le geste depuis le debut a chaque image, ce qui donne un
|
||||
* bras qui tremble au lieu de frapper. Faux pour l'equipement, ou
|
||||
* changer d'outil doit couper net le geste precedent.
|
||||
* @return true si un montage a demarre.
|
||||
*/
|
||||
bool PlayLocalArmsMontage(class UAnimMontage* Montage, bool bSkipIfAlreadyPlaying);
|
||||
|
||||
/**
|
||||
* Branche ou debranche la couche d'animation de l'objet sur les bras.
|
||||
*
|
||||
* Locale elle aussi, et pour la meme raison que le montage : les bras sont en
|
||||
* OnlyOwnerSee. Chez l'hote, lier une couche sur le squelette de bras d'un ami
|
||||
* ferait evaluer un graphe que personne ne rend.
|
||||
*
|
||||
* Idempotente : elle compare a la couche deja liee et ne fait rien si elle est
|
||||
* la bonne. C'est ce qui permet de l'appeler a chaque rafraichissement
|
||||
* d'inventaire sans relancer le graphe.
|
||||
*/
|
||||
void UpdateArmsAnimLayer();
|
||||
|
||||
/**
|
||||
* Cache le mesh du CORPS chez le joueur qui controle ce pawn, et lui seul.
|
||||
*
|
||||
* Par la VISIBILITE et non par bOwnerNoSee : sur un UStaticMeshComponent,
|
||||
* bCastHiddenShadow n'honore que la premiere -- cache par le proprietaire,
|
||||
* l'objet perd son ombre en meme temps que son mesh. Detail dans le .cpp.
|
||||
*/
|
||||
void UpdateLocalVisibility();
|
||||
|
||||
/**
|
||||
* Cree les deux composants de mesh s'ils manquent.
|
||||
*
|
||||
* A la demande et pas dans le constructeur : le composant n'a alors aucune
|
||||
* hypothese sur l'ordre de creation des sous-objets d'AFpsPlayer, et un pawn
|
||||
* qui ne tient jamais rien ne paie pas deux composants pour rien.
|
||||
*/
|
||||
void EnsureHeldMeshComponents();
|
||||
|
||||
/**
|
||||
* Cree un mesh d'objet en main attache a Parent, ou nullptr si impossible.
|
||||
*
|
||||
* @param bCastWorldShadow l'objet projette-t-il une ombre dans le monde.
|
||||
* Vrai pour la main du corps, faux pour les bras -- un mesh colle a
|
||||
* l'objectif projetterait une ombre enorme en travers du decor.
|
||||
*/
|
||||
UStaticMeshComponent* CreateHeldMeshComponent(FName ComponentName, class USkeletalMeshComponent* Parent,
|
||||
FName SocketName, bool bCastWorldShadow);
|
||||
|
||||
/** Les deux delegues d'inventaire menent ici. */
|
||||
UFUNCTION()
|
||||
void HandleInventoryChanged();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleSelectedHotbarSlotChanged(int32 NewIndex);
|
||||
|
||||
/** Le pawn porteur, ou nullptr si le composant vit ailleurs. */
|
||||
AFpsPlayer* GetOwningPlayer() const;
|
||||
|
||||
/** Mesh vu par le seul proprietaire, accroche aux bras. */
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<UStaticMeshComponent> FirstPersonHeldMesh;
|
||||
|
||||
/** Mesh vu par les autres joueurs, accroche a la main du corps. */
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<UStaticMeshComponent> ThirdPersonHeldMesh;
|
||||
|
||||
/**
|
||||
* Un mesh est-il reellement monte en main, en cache.
|
||||
*
|
||||
* Recopie de la decision prise par ApplyHeldVisuals plutot que recalculee :
|
||||
* l'AnimInstance la lit a chaque frame, et surtout deux facons de repondre a
|
||||
* la meme question finiraient par diverger.
|
||||
*/
|
||||
bool bItemVisibleInHand = false;
|
||||
|
||||
/**
|
||||
* La couche actuellement liee aux bras, ou nulle.
|
||||
*
|
||||
* Indispensable, et pas un simple cache : LinkAnimClassLayers AJOUTE une
|
||||
* couche, elle ne remplace pas la precedente. Sans garder ce qui est en
|
||||
* place pour le delier, passer de la hache a la pioche laisserait les deux
|
||||
* branchees -- et c'est la premiere qui gagnerait sur les fonctions que la
|
||||
* seconde n'implemente pas.
|
||||
*/
|
||||
UPROPERTY(Transient)
|
||||
TSubclassOf<UAnimInstance> LinkedArmsLayer;
|
||||
|
||||
/** Evite de repeter l'avertissement de socket introuvable a chaque montage. */
|
||||
bool bLoggedMissingSocket = false;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "HotbarWidget.generated.h"
|
||||
|
||||
class UHorizontalBox;
|
||||
class UInventoryComponent;
|
||||
class UInventorySlotWidget;
|
||||
|
||||
/**
|
||||
* Le curseur entre ou sort d'une case de la barre rapide.
|
||||
*
|
||||
* La barre n'affiche rien elle-meme : le panneau de detail vit dans l'ecran
|
||||
* d'inventaire, qu'elle ne connait pas. C'est le PlayerController qui tient les
|
||||
* deux widgets et qui fait le pont -- il est deja le proprietaire du HUD.
|
||||
*/
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnHotbarSlotHovered, UInventoryComponent*, Inventory, int32, SlotIndex, bool, bHovered);
|
||||
|
||||
/**
|
||||
* Barre rapide en bas de l'ecran, affichee en permanence.
|
||||
*
|
||||
* Elle ne possede aucune donnee : elle montre les N premiers slots de
|
||||
* l'inventaire. Deplacer un objet de la grille vers la barre, c'est
|
||||
* simplement le deplacer vers un slot d'index bas -- le glisser-deposer
|
||||
* existant s'en occupe deja sans aucun code supplementaire.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UHotbarWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
/** Remet a jour contenu et selection. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
void Refresh();
|
||||
|
||||
/** (Re)branche sur l'inventaire du pawn possede. Voir UInventoryWidget. */
|
||||
void BindToOwningPawn();
|
||||
|
||||
/**
|
||||
* Destination du clic droit pour les cases de la barre.
|
||||
*
|
||||
* La barre reste affichee et cliquable par-dessus l'ecran de coffre : sans
|
||||
* ca, le clic droit marcherait dans la grille et pas dans la barre, ce qui
|
||||
* passerait pour un bug. Le controller lui pousse le coffre a l'ouverture
|
||||
* et nullptr a la fermeture.
|
||||
*/
|
||||
void SetQuickTransferTarget(UInventoryComponent* InTarget);
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Inventory")
|
||||
FOnHotbarSlotHovered OnSlotHovered;
|
||||
|
||||
protected:
|
||||
/** A nommer exactement "SlotContainer" dans le Widget Blueprint. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UHorizontalBox> SlotContainer;
|
||||
|
||||
/** Le meme WBP_InventorySlot que la grille : une seule apparence a maintenir. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire")
|
||||
TSubclassOf<UInventorySlotWidget> SlotWidgetClass;
|
||||
|
||||
/** Espacement entre les cases. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire", meta = (ClampMin = "0"))
|
||||
float SlotPadding = 4.f;
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void HandleInventoryChanged();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleSelectionChanged(int32 NewIndex);
|
||||
|
||||
/** Abonne aux cases : relaie le survol vers l'exterieur. */
|
||||
UFUNCTION()
|
||||
void HandleSlotHovered(int32 SlotIndex, bool bHovered);
|
||||
|
||||
void RebuildSlots(int32 DesiredCount);
|
||||
|
||||
UInventoryComponent* ResolveInventory() const;
|
||||
|
||||
TWeakObjectPtr<UInventoryComponent> BoundInventory;
|
||||
|
||||
/** Destination du clic droit, recopiee sur chaque case creee. */
|
||||
TWeakObjectPtr<UInventoryComponent> QuickTransferTarget;
|
||||
|
||||
/** UPROPERTY obligatoire : sans ca le GC collecterait les cases. */
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<UInventorySlotWidget>> SlotWidgets;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "UObject/Interface.h"
|
||||
#include "Interactable.generated.h"
|
||||
|
||||
/**
|
||||
* Classe technique requise par Unreal. On ne la manipule jamais directement,
|
||||
* elle sert uniquement au systeme de reflection : c'est elle qu'on passe a
|
||||
* Implements<UInteractable>() et a Execute_XXX().
|
||||
*/
|
||||
UINTERFACE(MinimalAPI, BlueprintType)
|
||||
class UInteractable : public UInterface
|
||||
{
|
||||
GENERATED_BODY()
|
||||
};
|
||||
|
||||
/**
|
||||
* A implementer par tout ce avec quoi le joueur peut interagir : objets a
|
||||
* ramasser, portes, coffres, etablis, plantes a recolter.
|
||||
*
|
||||
* Les fonctions sont des BlueprintNativeEvent : une classe C++ surcharge
|
||||
* XXX_Implementation(), un Blueprint surcharge l'evenement du meme nom.
|
||||
* Les deux fonctionnent, et l'appelant n'a pas a savoir lequel des deux
|
||||
* il a en face -- c'est tout l'interet de passer par une interface.
|
||||
*
|
||||
* IMPORTANT : pour appeler ces fonctions depuis l'exterieur, utilise
|
||||
* IInteractable::Execute_Interact(Actor, Interactor) et jamais
|
||||
* Actor->Interact() directement. Seul le wrapper Execute_ sait router
|
||||
* l'appel vers une implementation Blueprint.
|
||||
*/
|
||||
class EMBERWILD_API IInteractable
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Texte affiche au joueur quand il vise l'objet ("Ramasser", "Ouvrir"...).
|
||||
* Renvoie du FText et non du FString : c'est le type localisable d'Unreal,
|
||||
* indispensable si tu traduis le jeu un jour.
|
||||
*
|
||||
* Interactor est fourni parce que le texte depend souvent de CELUI qui vise
|
||||
* et d'ou il vise : un feu de camp nomme l'emplacement precis qu'on regarde,
|
||||
* un coffre verrouille annonce quelle cle il attend. Sans lui, l'objet
|
||||
* devrait aller chercher le joueur local par un chemin detourne -- ce qui
|
||||
* marche exactement jusqu'au jour ou il y a deux joueurs.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction")
|
||||
FText GetInteractionPrompt(AActor* Interactor) const;
|
||||
virtual FText GetInteractionPrompt_Implementation(AActor* Interactor) const { return FText::GetEmpty(); }
|
||||
|
||||
/**
|
||||
* Permet de refuser l'interaction selon le contexte : porte verrouillee,
|
||||
* inventaire plein, competence manquante. Renvoyer false empeche l'objet
|
||||
* d'etre meme selectionne par le composant d'interaction.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction")
|
||||
bool CanInteract(AActor* Interactor) const;
|
||||
virtual bool CanInteract_Implementation(AActor* Interactor) const { return true; }
|
||||
|
||||
/** L'interaction elle-meme. Interactor est le pawn du joueur. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction")
|
||||
void Interact(AActor* Interactor);
|
||||
virtual void Interact_Implementation(AActor* Interactor) {}
|
||||
|
||||
/** Le joueur commence a viser l'objet : surbrillance, son, animation. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction")
|
||||
void OnBeginFocus();
|
||||
virtual void OnBeginFocus_Implementation() {}
|
||||
|
||||
/** Le joueur ne vise plus l'objet. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction")
|
||||
void OnEndFocus();
|
||||
virtual void OnEndFocus_Implementation() {}
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "Engine/EngineTypes.h"
|
||||
#include "InteractionComponent.generated.h"
|
||||
|
||||
/**
|
||||
* Diffuse a chaque changement de cible visee. NewFocus vaut nullptr quand le
|
||||
* joueur ne vise plus rien : c'est le signal pour cacher le prompt a l'ecran.
|
||||
*/
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInteractionFocusChanged, AActor*, NewFocus, AActor*, OldFocus);
|
||||
|
||||
/**
|
||||
* Diffuse quand le TEXTE du prompt change, ce qui n'est pas la meme chose qu'un
|
||||
* changement de cible : un feu de camp affiche un compte a rebours, et le fait
|
||||
* de balayer d'un emplacement a l'autre ne change pas l'acteur vise. S'abonner
|
||||
* uniquement a OnFocusChanged laisserait ces deux cas figes a l'ecran.
|
||||
*/
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnInteractionPromptChanged, const FText&, NewPrompt);
|
||||
|
||||
/**
|
||||
* A poser sur le pawn du joueur. Lance regulierement un trace depuis le point
|
||||
* de vue de la camera, garde en memoire l'objet IInteractable vise, et declenche
|
||||
* l'interaction a la demande.
|
||||
*
|
||||
* Le composant ne connait aucun type d'objet concret : il ne parle qu'a
|
||||
* l'interface. Tu peux ajouter des portes, des coffres ou des PNJ sans jamais
|
||||
* revenir toucher a ce fichier.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
|
||||
class EMBERWILD_API UInteractionComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UInteractionComponent();
|
||||
|
||||
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
|
||||
|
||||
/**
|
||||
* Declenche l'interaction sur l'objet vise, s'il y en a un.
|
||||
*
|
||||
* Le trace, lui, reste PUREMENT LOCAL : chacun vise chez soi, a sa propre
|
||||
* frequence, sans le moindre aller-retour reseau -- le prompt doit s'afficher
|
||||
* a l'instant ou l'on vise, pas 60 ms plus tard. Seule l'action part au
|
||||
* serveur, qui est le seul a pouvoir modifier le monde.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||
void TryInteract();
|
||||
|
||||
/** L'acteur actuellement vise, ou nullptr. */
|
||||
UFUNCTION(BlueprintPure, Category = "Interaction")
|
||||
AActor* GetFocusedActor() const { return FocusedActor.Get(); }
|
||||
|
||||
/**
|
||||
* Texte a afficher a l'ecran, vide si rien n'est vise.
|
||||
*
|
||||
* Renvoie une valeur DEJA calculee : le prompt est reconstruit une fois par
|
||||
* trace, pas une fois par lecture. Un widget peut donc l'interroger sans
|
||||
* jamais declencher le moindre travail.
|
||||
*/
|
||||
UFUNCTION(BlueprintPure, Category = "Interaction")
|
||||
const FText& GetFocusedPrompt() const { return CachedPrompt; }
|
||||
|
||||
/** Branche ton widget la-dessus pour afficher / cacher le prompt. */
|
||||
UPROPERTY(BlueprintAssignable, Category = "Interaction")
|
||||
FOnInteractionFocusChanged OnFocusChanged;
|
||||
|
||||
/** Le texte affiche a change. C'est a ca qu'un widget de prompt s'abonne. */
|
||||
UPROPERTY(BlueprintAssignable, Category = "Interaction")
|
||||
FOnInteractionPromptChanged OnPromptChanged;
|
||||
|
||||
protected:
|
||||
/** Portee du trace en cm. 250 = un peu plus d'un bras et demi. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction", meta = (ClampMin = "1"))
|
||||
float InteractionRange = 250.f;
|
||||
|
||||
/**
|
||||
* Rayon de la sphere balayee. Un trace en ligne pure oblige a viser au pixel
|
||||
* pres et rend le jeu penible ; quelques cm de tolerance changent tout.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction", meta = (ClampMin = "0"))
|
||||
float TraceRadius = 8.f;
|
||||
|
||||
/**
|
||||
* Intervalle entre deux traces, en secondes. Inutile de tracer 120 fois par
|
||||
* seconde pour un affichage que l'oeil lit a 20 Hz.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction", meta = (ClampMin = "0"))
|
||||
float TraceInterval = 0.05f;
|
||||
|
||||
/** Canal de collision teste. Visibility convient dans la majorite des cas. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
TEnumAsByte<ECollisionChannel> TraceChannel = ECC_Visibility;
|
||||
|
||||
/** Affiche le trace dans le monde, tres utile quand rien ne repond. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
bool bDrawDebug = false;
|
||||
|
||||
/**
|
||||
* Marge accordee par le serveur quand il verifie la distance annoncee par un
|
||||
* client. 1.0 refuserait des interactions parfaitement legitimes : entre le
|
||||
* moment ou le client vise et celui ou son message arrive, son personnage a
|
||||
* bouge, et la position que le serveur connait de lui a jusqu'a un aller-retour
|
||||
* de retard.
|
||||
*
|
||||
* Ce n'est PAS de l'anti-triche -- coop entre amis, on ne paie pas ce prix --
|
||||
* mais un garde-fou contre un client desynchronise qui ouvrirait un coffre a
|
||||
* l'autre bout de la carte.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Reseau", meta = (ClampMin = "1.0"))
|
||||
float ServerRangeTolerance = 2.f;
|
||||
|
||||
private:
|
||||
/**
|
||||
* L'interaction demandee par un client, executee par le serveur.
|
||||
*
|
||||
* Reliable : rater un ramassage parce qu'un paquet s'est perdu serait
|
||||
* incomprehensible pour le joueur. Le debit est nul de toute facon, une
|
||||
* interaction est un evenement rare a l'echelle du reseau.
|
||||
*/
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_Interact(AActor* Target);
|
||||
|
||||
/**
|
||||
* L'execution reelle, cote autorite. Revalide la cible : le client peut
|
||||
* l'avoir vue valide alors qu'un autre joueur venait de la ramasser.
|
||||
*/
|
||||
void PerformInteract(AActor* Target);
|
||||
/** Relance le trace et met a jour FocusedActor. */
|
||||
void UpdateFocus();
|
||||
|
||||
/** Vrai si l'acteur implemente l'interface et accepte l'interaction. */
|
||||
bool IsValidInteractable(AActor* Actor) const;
|
||||
|
||||
/** Change la cible en notifiant l'ancienne et la nouvelle. */
|
||||
void SetFocusedActor(AActor* NewFocus);
|
||||
|
||||
/** Point de vue reel du joueur, camera comprise. */
|
||||
bool GetViewPoint(FVector& OutLocation, FRotator& OutRotation) const;
|
||||
|
||||
/** Reconstruit CachedPrompt et diffuse uniquement si le texte a reellement change. */
|
||||
void RefreshPrompt();
|
||||
|
||||
/** Cible courante. TWeakObjectPtr : si l'objet est detruit, on ne garde pas un pointeur mort. */
|
||||
TWeakObjectPtr<AActor> FocusedActor;
|
||||
|
||||
/**
|
||||
* Vrai si on avait une cible au dernier changement diffuse.
|
||||
* Indispensable : quand la cible se detruit (pickup ramasse), FocusedActor
|
||||
* se vide tout seul, et une simple comparaison de pointeurs ne verrait
|
||||
* jamais la transition "j'avais une cible" -> "je n'en ai plus".
|
||||
*/
|
||||
bool bHasFocusedActor = false;
|
||||
|
||||
/**
|
||||
* Dernier texte diffuse. Le garder permet deux choses : ne reveiller l'UI
|
||||
* que sur un vrai changement, et servir GetFocusedPrompt() sans recalculer.
|
||||
*/
|
||||
FText CachedPrompt;
|
||||
|
||||
float TimeSinceLastTrace = 0.f;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "InteractionPromptWidget.generated.h"
|
||||
|
||||
class UInteractionComponent;
|
||||
class UTextBlock;
|
||||
|
||||
/**
|
||||
* Affiche "Appuyez sur E pour Ramasser Objet" quand le joueur vise quelque
|
||||
* chose d'interactif, et se cache sinon.
|
||||
*
|
||||
* La classe est Abstract : on ne l'instancie jamais directement, on cree un
|
||||
* Widget Blueprint (WBP_InteractionPrompt) qui en herite. Le C++ porte la
|
||||
* logique, le Blueprint porte la mise en page. C'est la separation standard
|
||||
* en UMG, l'equivalent d'un prefab d'UI avec son script en Unity.
|
||||
*
|
||||
* Le widget ne fait AUCUN travail par frame : il s'abonne au delegue
|
||||
* OnPromptChanged du composant d'interaction et ne se met a jour que lorsque
|
||||
* le texte change reellement.
|
||||
*
|
||||
* OnPromptChanged et pas OnFocusChanged : le texte peut bouger sans que la
|
||||
* cible change -- un feu de camp qui egrene son compte a rebours, ou le regard
|
||||
* qui glisse d'un emplacement du grill a l'autre sur le meme acteur.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UInteractionPromptWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
/** (Re)branche sur le composant d'interaction du pawn possede. */
|
||||
void BindToOwningPawn();
|
||||
|
||||
protected:
|
||||
/**
|
||||
* meta = (BindWidget) : Unreal relie automatiquement ce pointeur au widget
|
||||
* nomme exactement "PromptText" dans le Widget Blueprint enfant. Si tu
|
||||
* oublies de le creer ou que tu le renommes, le Blueprint refuse de
|
||||
* compiler avec un message clair -- pas de crash silencieux au runtime.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UTextBlock> PromptText;
|
||||
|
||||
/**
|
||||
* Texte affiche. {0} sera remplace par la touche, {1} par l'action.
|
||||
* Editable dans le Widget Blueprint pour ajuster la formulation.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
FText PromptFormat;
|
||||
|
||||
UInteractionPromptWidget(const FObjectInitializer& ObjectInitializer);
|
||||
|
||||
private:
|
||||
/** Appele par le composant d'interaction a chaque changement du texte affiche. */
|
||||
UFUNCTION()
|
||||
void HandlePromptChanged(const FText& NewPrompt);
|
||||
|
||||
/** Recalcule le texte et la visibilite. */
|
||||
void Refresh();
|
||||
|
||||
/** Nom lisible de la touche actuellement mappee sur l'action Interagir. */
|
||||
FText GetInteractKeyDisplayName() const;
|
||||
|
||||
/** Le composant auquel on est abonne, pour pouvoir se desabonner proprement. */
|
||||
TWeakObjectPtr<UInteractionComponent> BoundComponent;
|
||||
};
|
||||
@@ -0,0 +1,397 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "InventoryComponent.generated.h"
|
||||
|
||||
class UItemDataAsset;
|
||||
|
||||
/**
|
||||
* Contenu d'un emplacement. Item nul = slot vide.
|
||||
* Le tableau de slots a toujours exactement la taille de l'inventaire :
|
||||
* l'index dans le tableau EST la position dans la grille, ce qui rend
|
||||
* l'affichage et le futur drag & drop triviaux.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FInventorySlot
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventaire")
|
||||
TObjectPtr<UItemDataAsset> Item = nullptr;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventaire")
|
||||
int32 Quantity = 0;
|
||||
|
||||
/**
|
||||
* Charges restantes de l'exemplaire courant, pour les objets a usages
|
||||
* multiples. Etat par instance, d'ou sa place ici et pas sur le DataAsset :
|
||||
* une canette entamee ne doit pas entamer toutes les canettes du jeu.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventaire")
|
||||
int32 RemainingUses = 0;
|
||||
|
||||
bool IsEmpty() const { return Item == nullptr || Quantity <= 0; }
|
||||
|
||||
void Clear()
|
||||
{
|
||||
Item = nullptr;
|
||||
Quantity = 0;
|
||||
RemainingUses = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/** Diffuse a chaque modification du contenu ou de la taille. L'UI s'y abonne. */
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnInventoryChanged);
|
||||
|
||||
/** Diffuse quand le joueur change de slot actif dans la barre rapide. */
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSelectedHotbarSlotChanged, int32, NewIndex);
|
||||
|
||||
/**
|
||||
* Inventaire en grille a slots, facon Raft / Valheim.
|
||||
*
|
||||
* Le nombre de slots est dynamique : BaseSlotCount fixe le minimum, et
|
||||
* BonusSlotCount permet a un sac equipe de l'agrandir. Retirer le sac
|
||||
* refuse si les objets ne rentrent plus, plutot que de les faire
|
||||
* disparaitre en silence.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent))
|
||||
class EMBERWILD_API UInventoryComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UInventoryComponent();
|
||||
|
||||
virtual void InitializeComponent() override;
|
||||
|
||||
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||
|
||||
/**
|
||||
* Ajoute des objets, en remplissant d'abord les piles entamees.
|
||||
* @param RemainingUses charges de l'exemplaire ajoute ; -1 signifie "plein".
|
||||
* Sert a rendre son etat a un objet ramasse au sol.
|
||||
* @return le nombre d'exemplaires qui N'ONT PAS pu rentrer (0 = tout est passe).
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
int32 AddItem(UItemDataAsset* Item, int32 Quantity = 1, int32 RemainingUses = -1);
|
||||
|
||||
/**
|
||||
* Consomme une utilisation du slot.
|
||||
* Retire un exemplaire quand il ne reste plus de charge.
|
||||
* @return true si quelque chose a ete consomme.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
bool ConsumeUse(int32 SlotIndex);
|
||||
|
||||
/**
|
||||
* Retire des objets, en vidant d'abord les piles les plus petites.
|
||||
* @return le nombre d'exemplaires reellement retires.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
int32 RemoveItem(UItemDataAsset* Item, int32 Quantity = 1);
|
||||
|
||||
/** Combien d'exemplaires de cet objet, toutes piles confondues. */
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
int32 GetItemCount(const UItemDataAsset* Item) const;
|
||||
|
||||
/** Combien d'exemplaires pourraient encore rentrer, sans rien modifier. */
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
int32 GetRoomFor(const UItemDataAsset* Item) const;
|
||||
|
||||
/**
|
||||
* Un seul tableau, deux conteneurs distincts :
|
||||
* index 0 .. HotbarSlotCount-1 -> barre rapide
|
||||
* index HotbarSlotCount .. fin -> grille d'inventaire (+ bonus de sac)
|
||||
*
|
||||
* Un objet range dans la barre n'apparait PAS dans la grille, et
|
||||
* inversement. Ils partagent le meme tableau uniquement pour que le
|
||||
* glisser-deposer entre les deux ne demande aucune conversion d'index.
|
||||
*/
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
int32 GetTotalSlotCount() const { return FMath::Max(0, HotbarSlotCount + BackpackSlotCount + BonusSlotCount); }
|
||||
|
||||
/** Premier index affiche par la grille, juste apres la barre rapide. */
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
int32 GetBackpackStartIndex() const { return GetHotbarSlotCount(); }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
int32 GetUsedSlotCount() const;
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
const TArray<FInventorySlot>& GetSlots() const { return Slots; }
|
||||
|
||||
/**
|
||||
* Deplace la pile entiere d'un slot vers un autre.
|
||||
* - cible vide -> la pile entiere part
|
||||
* - meme objet -> fusion jusqu'a MaxStackSize, le reste demeure a la source
|
||||
* - objet different -> echange des deux slots
|
||||
* @return true si quelque chose a change.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
bool MoveItem(int32 FromIndex, int32 ToIndex);
|
||||
|
||||
/**
|
||||
* Deplace seulement une partie d'une pile.
|
||||
*
|
||||
* Un transfert partiel vers un slot occupe par un objet DIFFERENT est
|
||||
* refuse : il n'existe aucune facon sensee d'echanger la moitie d'une pile
|
||||
* contre autre chose. Seul un deplacement total peut provoquer un echange.
|
||||
*
|
||||
* @return true si quelque chose a change.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
bool MoveItemQuantity(int32 FromIndex, int32 ToIndex, int32 Quantity);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Transferts entre deux inventaires (coffres)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deplace le contenu d'un slot vers un autre, dans le MEME inventaire ou
|
||||
* dans un autre. C'est desormais l'unique implementation du deplacement :
|
||||
* MoveItem() et MoveItemQuantity() ne sont que des appels avec From == To.
|
||||
*
|
||||
* Statique parce qu'elle n'appartient ni a la source ni a la destination --
|
||||
* elle arbitre entre les deux, et doit diffuser OnInventoryChanged sur les
|
||||
* DEUX. Oublier la seconde diffusion laisse la grille d'en face afficher un
|
||||
* etat perime jusqu'au prochain evenement, ce qui se lit comme un objet
|
||||
* qui disparait.
|
||||
*
|
||||
* @param Quantity nombre d'exemplaires ; MAX_int32 pour la pile entiere.
|
||||
* @return true si quelque chose a change.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
static bool TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity);
|
||||
|
||||
/**
|
||||
* Envoie le contenu d'un slot vers un autre inventaire sans designer de
|
||||
* case d'arrivee : c'est le clic droit de l'ecran de coffre.
|
||||
*
|
||||
* Passe par AddItem(), donc exactement comme un ramassage -- piles entamees
|
||||
* d'abord, puis barre rapide, puis grille. Ce qui ne rentre pas reste sur
|
||||
* place : un transfert rapide ne perd jamais rien.
|
||||
*
|
||||
* @return le nombre d'exemplaires reellement partis.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
static int32 QuickTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To);
|
||||
|
||||
/**
|
||||
* Deverse tout ce qui peut entrer vers un autre inventaire : les boutons
|
||||
* "Tout ranger" et "Tout prendre".
|
||||
*
|
||||
* @param FirstIndex premier slot considere. Le joueur passe
|
||||
* GetBackpackStartIndex() pour que "Tout ranger" laisse sa barre
|
||||
* rapide tranquille -- ranger ses outils dans le coffre a chaque
|
||||
* retour d'expedition serait une punition, pas un confort.
|
||||
* @return le nombre d'exemplaires reellement partis.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
int32 TransferAllTo(UInventoryComponent* To, int32 FirstIndex = 0);
|
||||
|
||||
/**
|
||||
* Transforme ce composant en contenant du monde : NewSlotCount cases de
|
||||
* grille et AUCUNE barre rapide, pour que GetBackpackStartIndex() vaille 0
|
||||
* et que la grille affiche la totalite du coffre.
|
||||
*
|
||||
* A appeler avant que le composant contienne quoi que ce soit -- typiquement
|
||||
* depuis PreInitializeComponents() de l'acteur porteur. Reduire la taille
|
||||
* d'un conteneur deja rempli fait disparaitre ce qui depasse.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
void ConfigureAsContainer(int32 NewSlotCount);
|
||||
|
||||
/** Index du premier slot libre, ou INDEX_NONE si l'inventaire est plein. */
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire")
|
||||
int32 FindFirstEmptySlot() const;
|
||||
|
||||
/**
|
||||
* Retire jusqu'a Quantity exemplaires d'un slot precis et renvoie ce qui a
|
||||
* ete retire. Sert au drop au sol et, plus tard, au transfert vers un coffre.
|
||||
*
|
||||
* Different de RemoveItem() qui pioche par type sans se soucier du slot :
|
||||
* ici le joueur a designe une case, on ne touche qu'a celle-la.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
bool TakeFromSlot(int32 SlotIndex, int32 Quantity, FInventorySlot& OutTaken);
|
||||
|
||||
/**
|
||||
* Change le bonus de slots apporte par l'equipement (sac).
|
||||
* @return false si reduire la taille laisserait des objets dehors ;
|
||||
* dans ce cas rien n'est modifie et le sac ne doit pas etre retire.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
bool SetBonusSlotCount(int32 NewBonusSlots);
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Inventaire")
|
||||
FOnInventoryChanged OnInventoryChanged;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Barre rapide
|
||||
// ------------------------------------------------------------------
|
||||
//
|
||||
// La barre rapide n'est PAS un jeu de slots separe : ce sont les
|
||||
// HotbarSlotCount premiers slots de l'inventaire, simplement affiches
|
||||
// deux fois. Consequence directe : le glisser-deposer entre la barre et
|
||||
// la grille fonctionne sans une ligne de code de plus, puisque ce sont
|
||||
// les memes index dans le meme tableau.
|
||||
|
||||
/** Change le slot actif. Un index hors de la barre est ignore. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire|Barre rapide")
|
||||
void SelectHotbarSlot(int32 Index);
|
||||
|
||||
/** Avance ou recule dans la barre, en boucle. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire|Barre rapide")
|
||||
void CycleHotbarSelection(int32 Delta);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire|Barre rapide")
|
||||
int32 GetSelectedHotbarIndex() const { return SelectedHotbarIndex; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire|Barre rapide")
|
||||
int32 GetHotbarSlotCount() const { return FMath::Min(HotbarSlotCount, Slots.Num()); }
|
||||
|
||||
/** Contenu du slot actif. C'est ce que le joueur "a en main". */
|
||||
UFUNCTION(BlueprintPure, Category = "Inventaire|Barre rapide")
|
||||
FInventorySlot GetSelectedSlot() const;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Inventaire|Barre rapide")
|
||||
FOnSelectedHotbarSlotChanged OnSelectedHotbarSlotChanged;
|
||||
|
||||
protected:
|
||||
/** Cases affichees dans la grille d'inventaire. N'inclut PAS la barre rapide. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire", meta = (ClampMin = "0"))
|
||||
int32 BackpackSlotCount = 20;
|
||||
|
||||
/** Slots supplementaires apportes par l'equipement (sac). Passe par SetBonusSlotCount(). */
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Inventaire")
|
||||
int32 BonusSlotCount = 0;
|
||||
|
||||
/**
|
||||
* Cases de la barre rapide, independantes de celles de la grille.
|
||||
* Elles occupent les premiers index du tableau.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire|Barre rapide", meta = (ClampMin = "0"))
|
||||
int32 HotbarSlotCount = 10;
|
||||
|
||||
/**
|
||||
* Toujours dimensionne a GetTotalSlotCount().
|
||||
*
|
||||
* Replique a TOUT LE MONDE et non au seul proprietaire, pour deux raisons.
|
||||
*
|
||||
* Technique d'abord : les conditions de GetLifetimeReplicatedProps sont
|
||||
* mises en cache par CLASSE, pas par instance. Un COND_OwnerOnly viserait
|
||||
* donc aussi le composant d'un coffre -- or un coffre n'appartient a aucune
|
||||
* connexion, et sa grille n'arriverait chez personne. Le meme composant sert
|
||||
* le sac et le contenant, c'est une decision d'architecture qu'on ne veut pas
|
||||
* defaire pour un drapeau de replication.
|
||||
*
|
||||
* De fond ensuite : les coffres sont a acces simultane, donc leur contenu
|
||||
* DOIT arriver a tous ceux qui l'ouvrent. Et voir le sac d'un coequipier n'a
|
||||
* rien d'un probleme dans une coop entre amis sans anti-triche -- c'est meme
|
||||
* ce qui rendra un futur echange d'objets gratuit.
|
||||
*
|
||||
* Cout reel : 4 joueurs x 30 slots de trois champs, envoyes uniquement quand
|
||||
* ils changent, et delta-compresses. Negligeable.
|
||||
*/
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, ReplicatedUsing = OnRep_Slots, Category = "Inventaire")
|
||||
TArray<FInventorySlot> Slots;
|
||||
|
||||
/**
|
||||
* Le contenu vient d'arriver du serveur : on rediffuse le delegue habituel.
|
||||
*
|
||||
* C'est LA ligne qui recable toute l'interface d'un coup. Grille, barre
|
||||
* rapide, panneau de detail et coffres sont deja abonnes a
|
||||
* OnInventoryChanged et ne font aucun Tick -- ils n'ont donc pas une seule
|
||||
* ligne a changer pour devenir reseau.
|
||||
*/
|
||||
UFUNCTION()
|
||||
void OnRep_Slots();
|
||||
|
||||
private:
|
||||
// ------------------------------------------------------------------
|
||||
// Reseau
|
||||
//
|
||||
// Le routage vit DANS les fonctions publiques et non chez leurs appelants :
|
||||
// une case d'inventaire appelle MoveItem() sans savoir si elle tourne chez
|
||||
// l'hote ou chez un client, et c'est tres bien ainsi. Ajouter le test
|
||||
// d'autorite dans chaque widget serait dix occasions de l'oublier.
|
||||
//
|
||||
// Pas de prediction locale : le client envoie et attend OnRep_Slots. Sur un
|
||||
// relais Steam l'aller-retour est de quelques dizaines de millisecondes,
|
||||
// invisible pour un glisser-deposer -- alors qu'une prediction fausse
|
||||
// donnerait une pile qui saute, symptome bien plus desagreable.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Ce composant peut-il modifier son contenu ici et maintenant. */
|
||||
bool HasInventoryAuthority() const;
|
||||
|
||||
/**
|
||||
* Par quel composant faire transiter une RPC serveur.
|
||||
*
|
||||
* Renvoie celui des deux qui appartient au pawn du joueur LOCAL : un coffre
|
||||
* n'appartient a aucune connexion, et une RPC emise depuis lui serait
|
||||
* silencieusement jetee par le moteur. C'est le piege numero un des
|
||||
* transferts vers un conteneur.
|
||||
*/
|
||||
static UInventoryComponent* FindNetProxy(UInventoryComponent* A, UInventoryComponent* B);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_QuickTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To);
|
||||
|
||||
/**
|
||||
* From est passe explicitement et n'est PAS "this" cote serveur : la RPC
|
||||
* transite par l'inventaire du joueur local, qui peut tres bien etre la
|
||||
* DESTINATION -- c'est le cas du bouton "Tout prendre" d'un coffre. Deduire
|
||||
* la source du recepteur viderait le sac du joueur dans lui-meme.
|
||||
*/
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_TransferAllTo(UInventoryComponent* From, UInventoryComponent* To, int32 FirstIndex);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_ConsumeUse(int32 SlotIndex);
|
||||
|
||||
/** Les regles de deplacement, une fois l'autorite acquise. */
|
||||
static bool ApplyTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity);
|
||||
|
||||
/** Redimensionne le tableau si la taille cible a change. */
|
||||
void EnsureSlotCount();
|
||||
|
||||
/** Regroupe les slots occupes au debut du tableau. */
|
||||
void CompactSlots();
|
||||
|
||||
/**
|
||||
* Regles de deplacement entre deux cases, sans savoir a quel inventaire
|
||||
* chacune appartient. Ne diffuse rien : c'est l'appelant qui sait combien
|
||||
* de composants ont bouge.
|
||||
*/
|
||||
static bool ApplySlotMove(FInventorySlot& From, FInventorySlot& To, int32 Quantity);
|
||||
|
||||
/**
|
||||
* Verse un slot dans un autre inventaire via AddItem(). Ne diffuse que du
|
||||
* cote destination (AddItem s'en charge) : la source reste a la charge de
|
||||
* l'appelant, qui peut en enchainer vingt.
|
||||
* @return exemplaires reellement partis.
|
||||
*/
|
||||
int32 PushSlotInto(int32 SlotIndex, UInventoryComponent& To);
|
||||
|
||||
/**
|
||||
* Diffusion differee. Un "Tout ranger" deplace jusqu'a vingt piles : sans
|
||||
* regroupement, l'UI se reconstruirait vingt fois pour un seul clic.
|
||||
* Toutes les modifications passent par BroadcastChanged() plutot que par
|
||||
* OnInventoryChanged.Broadcast() directement, sinon le lot fuit.
|
||||
*/
|
||||
void BroadcastChanged();
|
||||
void BeginBatch();
|
||||
void EndBatch();
|
||||
|
||||
int32 SelectedHotbarIndex = 0;
|
||||
|
||||
int32 BatchDepth = 0;
|
||||
bool bBatchDirty = false;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/DragDropOperation.h"
|
||||
#include "InventoryDragDropOperation.generated.h"
|
||||
|
||||
class UInventoryComponent;
|
||||
|
||||
/**
|
||||
* Ce qui voyage entre la case qu'on saisit et celle ou on lache.
|
||||
*
|
||||
* On transporte un INDEX et non le contenu du slot : si l'inventaire change
|
||||
* pendant le glisser (ramassage automatique, script), la cible relira l'etat
|
||||
* reel au moment du drop au lieu d'appliquer une donnee perimee.
|
||||
*/
|
||||
UCLASS()
|
||||
class EMBERWILD_API UInventoryDragDropOperation : public UDragDropOperation
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Inventaire")
|
||||
int32 SourceSlotIndex = INDEX_NONE;
|
||||
|
||||
/**
|
||||
* Combien d'exemplaires on deplace. Egal a la pile entiere pour un
|
||||
* glisser normal, la moitie avec Maj, un seul avec Ctrl.
|
||||
*/
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Inventaire")
|
||||
int32 Quantity = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Inventaire")
|
||||
TWeakObjectPtr<UInventoryComponent> SourceInventory;
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "InventoryScreenWidget.generated.h"
|
||||
|
||||
class AStorageContainer;
|
||||
class UButton;
|
||||
class UCraftingWidget;
|
||||
class UInventoryComponent;
|
||||
class UInventoryWidget;
|
||||
class UTextBlock;
|
||||
class UWidgetSwitcher;
|
||||
|
||||
/**
|
||||
* L'onglet visible a change. true = Inventaire, false = Fabrication.
|
||||
*
|
||||
* L'ecran se contente d'annoncer. Ce que ca implique pour le reste du HUD --
|
||||
* masquer la barre rapide, par exemple -- ne le regarde pas : il serait alors
|
||||
* un widget reutilisable qui connait la pile de son hote, exactement ce qu'on
|
||||
* evite partout ailleurs.
|
||||
*/
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnInventoryScreenTabChanged, bool, bInventoryTabActive);
|
||||
|
||||
/**
|
||||
* L'ecran que Tab ouvre : une barre d'onglets et deux pages, Inventaire et
|
||||
* Fabrication.
|
||||
*
|
||||
* Il ne contient aucune logique de l'une ni de l'autre. Les pages restent des
|
||||
* widgets autonomes -- la grille d'inventaire n'a pas change d'une ligne en
|
||||
* devenant une page -- et l'ecran ne fait que les montrer, les rebrancher apres
|
||||
* un respawn, et relayer au controller ce dont il a besoin.
|
||||
*
|
||||
* Classe Abstract : on cree un WBP_InventoryScreen qui en herite.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UInventoryScreenWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void NativeConstruct() override;
|
||||
|
||||
/** Rebranche les deux pages sur le pawn possede. A appeler depuis OnPossess. */
|
||||
void BindToOwningPawn();
|
||||
|
||||
/**
|
||||
* Index de la case d'inventaire survolee, ou INDEX_NONE.
|
||||
*
|
||||
* Renvoie INDEX_NONE des que l'onglet Fabrication est affiche : sans ca, la
|
||||
* touche "jeter" agirait sur une case invisible, sous le panneau de craft.
|
||||
*/
|
||||
int32 GetHoveredSlotIndex() const;
|
||||
|
||||
/**
|
||||
* Meme chose, avec l'inventaire auquel la case appartient. Depuis les
|
||||
* coffres, l'index seul ne designe plus rien de facon unique.
|
||||
*/
|
||||
bool GetHoveredSlot(UInventoryComponent*& OutInventory, int32& OutIndex) const;
|
||||
|
||||
/**
|
||||
* Survol d'une case exterieure a l'ecran -- la barre rapide, qui reste
|
||||
* affichee par-dessus lui. Alimente le panneau de detail de la page
|
||||
* d'inventaire, seul panneau existant.
|
||||
*/
|
||||
void NotifyExternalSlotHovered(UInventoryComponent* Inventory, int32 SlotIndex, bool bHovered);
|
||||
|
||||
/** Revient sur l'onglet Inventaire. Appele a chaque ouverture par Tab. */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI")
|
||||
void ShowInventoryTab();
|
||||
|
||||
/** Bascule sur l'onglet Fabrication. Appele par la touche dediee. */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI")
|
||||
void ShowCraftingTab();
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "UI")
|
||||
FOnInventoryScreenTabChanged OnTabChanged;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Panneau de coffre
|
||||
//
|
||||
// Le contenu d'un coffre s'affiche A COTE de la grille du joueur, dans ce
|
||||
// meme ecran, et non dans un ecran a part. La raison est du ressenti pur :
|
||||
// le sac ne bouge pas d'un pixel entre "j'ouvre mon sac" et "j'ouvre un
|
||||
// coffre". Deux ecrans distincts le feraient sauter a chaque fois.
|
||||
//
|
||||
// Techniquement c'est une deuxieme instance du MEME WBP_Inventory que
|
||||
// InventoryPage. Elle ne peut pas vivre a l'interieur de WBP_Inventory :
|
||||
// un Widget Blueprint qui se contient lui-meme est une reference
|
||||
// circulaire, et UMG refuse de le compiler.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Branche et affiche le panneau de droite. Force l'onglet Inventaire. */
|
||||
void ShowStorage(AStorageContainer* Container);
|
||||
|
||||
/** Masque et debranche le panneau. Sans effet s'il est deja cache. */
|
||||
void HideStorage();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "UI|Storage")
|
||||
bool IsStorageVisible() const { return bStorageVisible; }
|
||||
|
||||
protected:
|
||||
/**
|
||||
* Absorbe ou traite les depots qui ne visent aucune page.
|
||||
* Meme regle que dans la grille : lache hors des cases, l'objet est jete.
|
||||
*/
|
||||
virtual bool NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override;
|
||||
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UWidgetSwitcher> TabSwitcher;
|
||||
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> InventoryTabButton;
|
||||
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UButton> CraftingTabButton;
|
||||
|
||||
/**
|
||||
* Ton WBP_Inventory, pose comme enfant du WidgetSwitcher et renomme
|
||||
* "InventoryPage". Les deux pages sont obligatoires : un ecran a onglets
|
||||
* auquel il manque une page n'a aucun sens, autant que le Blueprint refuse
|
||||
* de compiler.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UInventoryWidget> InventoryPage;
|
||||
|
||||
/** Ton WBP_Crafting, renomme "CraftingPage". */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UCraftingWidget> CraftingPage;
|
||||
|
||||
/**
|
||||
* Tout le bloc de droite -- cadre, titre, grille, boutons -- qu'on montre et
|
||||
* qu'on cache d'un bloc.
|
||||
*
|
||||
* BindWidgetOptional et non BindWidget, comme tous les elements de coffre
|
||||
* qui suivent : l'ecran doit rester valide dans un projet ou personne n'a
|
||||
* encore construit ce panneau. Sans ca, ajouter les coffres casserait la
|
||||
* compilation de WBP_InventoryScreen tant que la mise en page n'est pas
|
||||
* faite -- et donc l'inventaire entier.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UWidget> StoragePanel;
|
||||
|
||||
/** Un deuxieme WBP_Inventory, renomme "StorageGrid". DECOCHE-lui Auto Bind To Pawn. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UInventoryWidget> StorageGrid;
|
||||
|
||||
/** Nom du coffre ouvert. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UTextBlock> StorageTitle;
|
||||
|
||||
/** Deverse la grille du sac dans le coffre. La barre rapide n'y passe pas. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UButton> StoreAllButton;
|
||||
|
||||
/** Vide le coffre dans le sac, dans la limite de la place disponible. */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UButton> TakeAllButton;
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void HandleStoreAllClicked();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleTakeAllClicked();
|
||||
|
||||
/** Inventaire du pawn possede, ou nullptr. */
|
||||
UInventoryComponent* GetPlayerInventory() const;
|
||||
|
||||
UFUNCTION()
|
||||
void HandleInventoryTabClicked();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCraftingTabClicked();
|
||||
|
||||
/**
|
||||
* Affiche une page et grise son bouton d'onglet.
|
||||
*
|
||||
* On passe le widget et non un index en dur : l'ordre des enfants du
|
||||
* WidgetSwitcher se change d'un glisser dans la Designer, et un 0/1 code ici
|
||||
* inverserait silencieusement les onglets le jour ou ca arrive.
|
||||
*/
|
||||
void ShowPage(UWidget* Page);
|
||||
|
||||
bool IsInventoryTabActive() const;
|
||||
|
||||
/**
|
||||
* Le panneau de coffre est affiche.
|
||||
*
|
||||
* Tant qu'il l'est, l'onglet Fabrication est grise : fabriquer avec un
|
||||
* coffre ouvert a cote n'a pas de sens tant que ses ressources ne comptent
|
||||
* pas dans les recettes, et laisser le bouton actif inviterait a un clic
|
||||
* dont le resultat serait incomprehensible.
|
||||
*/
|
||||
bool bStorageVisible = false;
|
||||
|
||||
TWeakObjectPtr<UInventoryComponent> BoundStorage;
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
// Fill out your copyright notice in the Description page of Project Settings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "InventoryComponent.h"
|
||||
#include "InventorySlotWidget.generated.h"
|
||||
|
||||
class UImage;
|
||||
class UProgressBar;
|
||||
class UTextBlock;
|
||||
|
||||
/**
|
||||
* Le curseur est entre sur cette case, ou en est sorti.
|
||||
*
|
||||
* Un delegue et non un pointeur vers la grille : la barre rapide pose les
|
||||
* memes cases sans etre un UInventoryWidget, et elle a autant besoin du survol
|
||||
* depuis que l'inventaire affiche un panneau de detail.
|
||||
*/
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInventorySlotHoverChanged, int32, SlotIndex, bool, bHovered);
|
||||
|
||||
/**
|
||||
* Une case de la grille d'inventaire : une icone et une quantite.
|
||||
*
|
||||
* Volontairement passive pour l'affichage : elle ne va jamais chercher
|
||||
* l'information elle-meme, c'est la grille parente qui lui pousse son contenu
|
||||
* via SetSlot(). Elle est en revanche autonome pour le glisser-deposer, parce
|
||||
* que c'est elle qui recoit les evenements souris.
|
||||
*/
|
||||
UCLASS(Abstract)
|
||||
class EMBERWILD_API UInventorySlotWidget : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void NativeConstruct() override;
|
||||
|
||||
/** Appele une fois a la creation : dit a la case qui elle represente. */
|
||||
void InitSlot(UInventoryComponent* InInventory, int32 InSlotIndex);
|
||||
|
||||
/** Le conteneur qui a cree cette case s'y abonne pour suivre le survol. */
|
||||
UPROPERTY()
|
||||
FOnInventorySlotHoverChanged OnHoverChanged;
|
||||
|
||||
/** Affiche le contenu d'un slot, ou se vide si le slot est vide. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
void SetSlot(const FInventorySlot& InSlot);
|
||||
|
||||
/**
|
||||
* Transforme cette instance en fantome de glisser : le fond de case
|
||||
* disparait pour ne laisser flotter que l'objet lui-meme.
|
||||
*/
|
||||
void ConfigureAsDragVisual();
|
||||
|
||||
/** Affiche ou masque le cadre de selection (barre rapide). */
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventaire")
|
||||
void SetSelected(bool bSelected);
|
||||
|
||||
/**
|
||||
* Inventaire vers lequel le CLIC DROIT envoie le contenu de cette case.
|
||||
*
|
||||
* Nul en temps normal : hors de l'ecran de coffre il n'existe aucun "autre
|
||||
* cote" evident, et deviner une destination reviendrait a faire disparaitre
|
||||
* des objets sur un clic malheureux. C'est l'ecran qui sait, et qui pousse.
|
||||
*/
|
||||
void SetQuickTransferTarget(UInventoryComponent* InTarget);
|
||||
|
||||
protected:
|
||||
// --- Glisser-deposer ---
|
||||
virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
|
||||
virtual void NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation) override;
|
||||
virtual bool NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override;
|
||||
|
||||
// --- Survol, pour que la touche "jeter" sache sur quoi agir ---
|
||||
virtual void NativeOnMouseEnter(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
|
||||
virtual void NativeOnMouseLeave(const FPointerEvent& InMouseEvent) override;
|
||||
|
||||
/** A nommer exactement "ItemIcon" dans le Widget Blueprint. */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UImage> ItemIcon;
|
||||
|
||||
/** A nommer exactement "QuantityText". */
|
||||
UPROPERTY(meta = (BindWidget))
|
||||
TObjectPtr<UTextBlock> QuantityText;
|
||||
|
||||
/**
|
||||
* Fond de case. BindWidgetOptional et non BindWidget : le Blueprint peut
|
||||
* s'en passer sans casser la compilation, c'est purement decoratif.
|
||||
* Nomme-le "SlotBackground" pour qu'il soit masque pendant le glisser.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UWidget> SlotBackground;
|
||||
|
||||
/**
|
||||
* Cadre affiche quand la case est le slot actif de la barre rapide.
|
||||
* Optionnel : la grille d'inventaire ne s'en sert jamais.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UWidget> SelectionBorder;
|
||||
|
||||
/**
|
||||
* Petite barre d'usure en bas de la case, pour les objets a usages
|
||||
* multiples. Masquee automatiquement sur tout le reste.
|
||||
* A nommer "UsesBar" dans le Widget Blueprint.
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UProgressBar> UsesBar;
|
||||
|
||||
/** Opacite du fantome qui suit le curseur pendant le glisser. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventaire", meta = (ClampMin = "0.05", ClampMax = "1.0"))
|
||||
float DragVisualOpacity = 0.85f;
|
||||
|
||||
/** Opacite de la case d'origine tant que le glisser est en cours. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventaire", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||
float DraggedSlotOpacity = 0.3f;
|
||||
|
||||
private:
|
||||
/** Rebranche l'opacite normale, que le glisser ait abouti ou non. */
|
||||
UFUNCTION()
|
||||
void HandleDragFinished(UDragDropOperation* Operation);
|
||||
|
||||
TWeakObjectPtr<UInventoryComponent> OwningInventory;
|
||||
|
||||
/** Destination du clic droit. Voir SetQuickTransferTarget(). */
|
||||
TWeakObjectPtr<UInventoryComponent> QuickTransferTarget;
|
||||
|
||||
int32 SlotIndex = INDEX_NONE;
|
||||
|
||||
/** Dernier contenu recu, pour construire le fantome de glisser. */
|
||||
FInventorySlot CachedSlot;
|
||||
|
||||
/** Vrai si cette instance est un fantome et non une vraie case de la grille. */
|
||||
bool bIsDragVisual = false;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user