(Feat) Add Storage Chests + Item Details Panel

Coffres : AStorageContainer, un UInventoryComponent configure en conteneur,
affiche comme panneau de WBP_InventoryScreen plutot que dans un ecran a lui.
Deplacement unifie par UInventoryComponent::TransferSlot / TransferAllTo.

Panneau de detail : UItemDetailsWidget, pose DANS WBP_Inventory a droite de la
grille. Icone, nom, separation, description, barre d'usure. Il recoit un couple
(inventaire, index) et s'abonne a OnInventoryChanged, donc une charge consommee
sous le curseur se voit.

Une case ne connait plus sa grille : elle diffuse OnHoverChanged. La barre
rapide s'y abonne aussi et relaie par le PlayerController, seul a posseder a la
fois le HUD et l'ecran. Le panneau efface la case qu'on QUITTE et non lui-meme,
sinon passer de la barre a la grille viderait ce qui vient d'etre affiche.

L'ecran allume et eteint le panneau (SetItemDetailsEnabled) : WBP_Inventory
etant instancie deux fois en mode coffre, une case a cocher par instance serait
un bug qui attend qu'on oublie de la decocher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:51:24 +02:00
parent d50ef564ba
commit 41d6e6a7f3
50 changed files with 2947 additions and 167 deletions
@@ -10,48 +10,58 @@
#include "InventoryComponent.h"
#include "InventoryDragDropOperation.h"
#include "InventorySlotWidget.h"
#include "ItemDetailsWidget.h"
void UInventoryWidget::NativeConstruct()
{
Super::NativeConstruct();
BindToOwningPawn();
// 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 (BoundInventory.Get() == NewInventory)
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 = NewInventory;
BoundInventory = InInventory;
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.AddDynamic(this, &UInventoryWidget::HandleInventoryChanged);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryWidget : aucun UInventoryComponent trouve sur le pawn possede."));
}
// Les cases gardent l'ancien composant en memoire : on force leur
// recreation en vidant la grille.
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->RemoveFromParent();
}
}
SlotWidgets.Reset();
ClearSlotWidgets();
Refresh();
}
@@ -79,20 +89,138 @@ 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)
{
HoveredSlotIndex = Index;
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".
HoveredSlotIndex = INDEX_NONE;
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);
@@ -114,11 +242,13 @@ bool UInventoryWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDrop
}
// 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->DropSlot(Payload->SourceSlotIndex, Payload->Quantity);
Player->DropFromInventory(Payload->SourceInventory.Get(), Payload->SourceSlotIndex, Payload->Quantity);
}
}
@@ -133,16 +263,21 @@ void UInventoryWidget::Refresh()
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())
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryWidget::Refresh : aucun inventaire lie."));
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.
// 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);
@@ -161,6 +296,25 @@ void UInventoryWidget::Refresh()
}
}
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)
@@ -185,6 +339,11 @@ void UInventoryWidget::RebuildGrid(int32 DesiredCount)
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)
@@ -205,7 +364,10 @@ void UInventoryWidget::RebuildGrid(int32 DesiredCount)
// 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, this);
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);
}