Files
2026-08-11 15:23:10 +02:00

23 KiB

Coffres

Contenants posés dans le monde : on les ouvre, on y range, on y reprend. Le couvercle se lève pour tout le monde, le contenu est partagé et accessible à plusieurs en même temps.

Fichiers : StorageContainer.h/.cpp, plus le panneau de coffre d'InventoryScreenWidget, l'API de transfert d'InventoryComponent et le bloc UI|Storage de FpsPlayerController.


1. Le principe

Un coffre n'a aucun modèle de données à lui. Son contenu est un UInventoryComponent, exactement le même que celui du joueur, simplement configuré sans barre rapide. Tout le reste en découle :

  • le glisser-déposer entre le sac et le coffre ne convertit rien — ce sont les mêmes cases, le même FInventorySlot ;
  • la durabilité d'une hache traverse le coffre sans une ligne de code, puisque l'usure vit sur le slot ;
  • il n'y a pas de « format coffre » à faire évoluer le jour où l'inventaire change.

Le coffre ne décide de rien. Il se contente d'appeler OpenStorage(this) sur le controller. C'est ce dernier qui referme le couvercle — par Échap, par la mort du joueur, ou parce qu'il s'est éloigné. Un coffre qui déciderait seul finirait ouvert dans la moitié des cas, et il n'a de toute façon aucun moyen de savoir que l'écran s'est fermé.

Ce n'est pas un écran à part, c'est un panneau de plus sur WBP_InventoryScreen — voir §5.


2. L'acteur AStorageContainer

2.1 Composants

Composant Rôle
BaseMesh La caisse : SM_Chest_Bottom. Racine, purement visuelle, collision désactivée
InteractionBox L'unique collision du coffre. Fille de la caisse
LidMesh Le couvercle : SM_Chest_Top. Enfant direct de la caisse, collision désactivée
Storage Le contenu. Un UInventoryComponent ordinaire

2.2 Réglages exposés

Catégorie Storage

Propriété Défaut Effet
SlotCount 20 Nombre de cases. Réglable par instance : le même Blueprint donne un petit coffre de 12 et un grand de 30
ColumnCount 6 Cases par ligne dans l'écran. Purement visuel
DisplayName Chest Titre affiché au-dessus de la grille
InteractionPrompt Ouvrir le coffre Texte affiché quand le joueur vise
StartingItems Contenu versé une seule fois au BeginPlay

StartingItems est de la donnée de level design, pas du gameplay : un coffre de départ, une cache à trouver. Ce qui ne rentre pas part dans l'Output Log en warning plutôt que de disparaître en silence.

Catégorie Storage|Collision

Propriété Défaut Effet
bAutoFitInteractionBox La boîte épouse seule les deux meshes, couvercle fermé
InteractionBoxPadding 0 cm Marge additive autour du volume trouvé

Catégorie Storage|Lid

Propriété Défaut Effet
OpenRotation (-100, 0, 0) Rotation relative du couvercle ouvert. Le repos vaut toujours zéro
LidDuration 0.35 s Durée de l'ouverture

Catégorie Storage|SoundOpenSound, CloseSound. Facultatifs : un champ vide ne joue rien.

2.3 API publique

void  SetOpen(bool bInOpen);          // SERVEUR uniquement. Lève ou rabat le couvercle
bool  IsOpen() const;
UInventoryComponent* GetStorage() const;
FText GetDisplayName() const;
int32 GetColumnCount() const;

SetOpen est appelée par le controller, pas par le coffre : lui seul sait quand l'écran se referme.


3. Le couvercle

3.1 La rotation se fait sur le pivot du mesh

C'est LidMesh lui-même qu'on tourne, et un UStaticMeshComponent pivote toujours autour de l'origine de son mesh. La charnière est donc celle que l'artiste a donnée à SM_Chest_Top — rien à placer, rien à régler.

Un SceneComponent vide LidPivot a existé entre la caisse et le couvercle, pour découpler la charnière du pivot du pack. Il a été retiré : il fallait le placer à la main dans chaque Blueprint, et laissé à (0,0,0) il faisait tourner le couvercle autour du centre de la caisse — c'est-à-dire à travers elle. C'était le symptôme, pas une configuration exotique.

Corollaire assumé : si un pack livre un couvercle dont le pivot est au centre, on corrige l'asset, pas le code — Modeling Mode > XForm > Edit Pivot sur le Static Mesh, pivot posé sur l'arête arrière, Accept. C'est payé une fois par pack de coffre.

OpenRotation est un FRotator complet et pas un simple angle : selon l'orientation dans laquelle le mesh a été exporté, la charnière peut tomber sur le pitch, le yaw ou le roll. On règle les trois à l'œil dans le viewport plutôt que d'aller comprendre quel axe local du mesh pointe où.

3.2 L'animation

Un Tick, mais éteint au repos (bStartWithTickEnabled = false). ApplyOpenState() le rallume, et le tick s'éteint lui-même en fin de course.

LidAlpha = FMath::FInterpConstantTo(LidAlpha, Target, DeltaTime, 1.f / LidDuration);
const float Eased = FMath::SmoothStep(0.f, 1.f, LidAlpha);
LidMesh->SetRelativeRotation(OpenRotation * Eased);
  • SmoothStep : un couvercle à vitesse constante démarre et s'arrête d'un coup, ce qui se lit comme une saccade sur un objet lourd.
  • L'alpha repart de sa valeur courante, donc refermer un coffre à moitié ouvert ne saute pas.
  • La valeur exacte est forcée avant de se rendormir. Sans ça le couvercle resterait à un poil de sa position finale pour le reste de la partie.
  • BeginPlay repose l'alpha à zéro quelle que soit la rotation laissée dans le Blueprint : le repos est la référence de l'animation. Une rotation authorée sur LidMesh serait donc perdue au lancement.

4. La collision — une seule boîte

Les deux meshes n'en portent aucune. C'est la seule exception à la règle « le mesh EST la surface d'interaction » posée par ACraftingStation, et elle vient de ce qu'un coffre est en deux morceaux dont un tourne :

  • compter sur les collisions des meshes imposerait que le pack en fournisse deux correctes ;
  • et laisserait surtout un trou dans la surface visable dès que le couvercle se lève — viser le haut d'un coffre ouvert ne toucherait plus rien.

La boîte est fille de la caisse, jamais du couvercle : le coffre se vise exactement au même endroit ouvert et fermé. Sinon le prompt disparaîtrait au moment précis où l'on s'en sert.

Son profil est BlockAll, posé explicitement : elle a deux rôles indissociables, empêcher le joueur de traverser le coffre et bloquer le canal Visibility pour que le trace d'interaction la trouve. Un profil qui laisserait passer Visibility rendrait le coffre inutilisable sans qu'aucune erreur ne le signale.

FitInteractionBox() — deux pièges

Appelée depuis OnConstruction, donc elle tourne aussi dans l'éditeur : changer de variante de coffre recale la boîte à l'instant, sans lancer le jeu.

  1. Lire les bounds de l'ASSET (UStaticMesh::GetBoundingBox()), pas Mesh->Bounds — ces dernières sont déjà multipliées par l'échelle, que SetBoxExtent remultiplie.
  2. Composer avec la transform RELATIVE à la racine, pas la transform monde : la boîte doit rester juste où que l'acteur soit posé dans le niveau.

Même paire de pièges que dans APickupItem::FitInteractionBox.


5. L'écran

5.1 Un panneau, pas un écran

Le contenu du coffre s'affiche à côté de la grille du joueur, sur WBP_InventoryScreen, comme frère du WidgetSwitcher et Collapsed par défaut.

La raison est du ressenti pur : le sac du joueur ne bouge pas d'un pixel entre « j'ouvre mon sac » et « j'ouvre un coffre ». Un écran distinct le ferait sauter à chaque fois.

Ce n'est pas non plus un troisième onglet : les deux grilles doivent être visibles ensemble, ce qu'un WidgetSwitcher interdit par construction.

5.2 C'est une deuxième instance du même WBP_Inventory

Elle ne peut pas vivre dans WBP_Inventory — un Widget Blueprint qui se contient lui-même est une référence circulaire, et UMG refuse de le compiler. D'où deux conséquences directes :

  • bAutoBindToPawn doit être DÉCOCHÉ sur la grille de coffre. Sans ça elle s'accroche au pawn dans son NativeConstruct — qui s'exécute avant que l'écran ait eu le temps de lui donner le conteneur — et le sac du joueur s'affiche des deux côtés.
  • Le panneau de détail est coupé par le code, jamais par une case à cocher (SetItemDetailsEnabled). Un réglage par instance serait un bug qui attend qu'on oublie de le décocher sur l'une des deux.

5.3 Ce que l'écran fait à l'ouverture

StorageGrid->SetColumnCount La forme vient du coffre posé dans le niveau, pas du Widget Blueprint
StorageGrid->BindToInventory Branche la grille sur le contenu
Cibles de clic droit croisées Sac → coffre et coffre → sac (§6.2)
InventoryPage->SetItemDetailsEnabled(false) Le sac perd son panneau de détail : trois colonnes seraient illisibles
ShowInventoryTab() Un coffre ouvert sous l'onglet Fabrication n'aurait aucun sens

Et l'onglet Fabrication est grisé tant qu'un coffre est ouvert : le panneau étant frère du WidgetSwitcher, il resterait affiché à côté de la grille de recettes. La touche de craft, elle, rend le coffre plutôt que d'ignorer l'appui — la touche exprime une intention claire.

HideStorage() est appelée dès NativeConstruct : l'état par défaut est reposé à chaque construction plutôt qu'hérité de la Designer ou de la session précédente. Elle coupe aussi les transferts rapides avant de débrancher — une case qui garderait le coffre en mémoire y enverrait encore des objets au prochain clic droit, panneau fermé.

Tous les éléments de coffre sont en BindWidgetOptional : l'écran doit rester valide dans un projet où le panneau n'a pas encore été construit, sinon ajouter les coffres casserait la compilation de WBP_InventoryScreen — et donc l'inventaire entier.


6. Les transferts

6.1 Un seul déplacement pour tout

static bool TransferSlot(UInventoryComponent* From, int32 FromIndex,
                         UInventoryComponent* To,   int32 ToIndex, int32 Quantity);

Statique, parce qu'elle n'appartient ni à la source ni à la destination : elle arbitre entre les deux. MoveItem() et MoveItemQuantity() ne sont plus que des appels avec From == To.

Elle diffuse OnInventoryChanged des deux côtés. Oublier la seconde diffusion laisse la grille d'en face afficher un état périmé jusqu'au prochain événement, ce qui se lit comme un objet qui disparaît.

6.2 Clic droit = envoyer en face

QuickTransferSlot() passe par AddItem(), donc exactement comme un ramassage : piles entamées d'abord, puis barre rapide, puis grille. Ce qui ne rentre pas reste sur place.

La destination est poussée par l'écran (SetQuickTransferTarget), jamais devinée. Hors de l'écran de coffre il n'existe aucun « autre côté », et deviner ferait disparaître des objets. La barre rapide reçoit elle aussi le coffre, pour que le clic droit y marche par-dessus l'écran.

6.3 « Tout ranger » / « Tout prendre »

int32 TransferAllTo(UInventoryComponent* To, int32 FirstIndex = 0);
  • Tout ranger part de GetBackpackStartIndex() : la barre rapide porte les outils qu'on vient d'utiliser, les ranger d'office serait une punition déguisée en confort.
  • Tout prendre part de l'index 0 — un coffre n'a pas de barre rapide. Ce qui ne rentre pas dans le sac reste dans le coffre.

7. Le cycle d'ouverture

Le controller est le seul maître. ReleaseActiveStorage() est le point de sortie unique : toute fermeture d'écran y passe, quelle qu'en soit la cause.

Déclencheur Chemin
Interagir avec le coffre Interact_ImplementationOpenStorage()
Échap HandlePauseInput() — le coffre passe avant l'inventaire
Tab SetInventoryScreenOpen(false)
Touche de craft Rend le coffre, puis affiche l'onglet Fabrication
Mort du joueur Fermeture de l'écran
Éloignement CheckStorageDistance(), timer à 0,25 s
Coffre détruit Idem, la TWeakObjectPtr est nulle
Ouvrir un autre coffre OpenStorage() rend le précédent avant

MaxStorageDistance (400 cm) : sans elle on viderait un coffre depuis l'autre bout de la carte, rien n'obligeant le joueur à rester devant une fois l'écran ouvert. Surveillée par un timer, jamais un Tick, et seulement pendant que l'écran est ouvert — un timer permanent pour une condition vraie deux minutes par partie serait du gaspillage. DistSquared, pour ne pas prendre une racine carrée quatre fois par seconde.

Le timer est aussi nettoyé à la destruction du HUD : il rappellerait une méthode d'un controller en train de disparaître.


8. Réseau

Autorité serveur sur tout, comme pour le reste du monde.

Élément Réplication
bOpen ReplicatedUsing = OnRep_Open — voir le couvercle d'un coffre se lever est le seul signe qu'un coéquipier fouille dedans
Slots du conteneur Répliqué à tout le monde par UInventoryComponent
L'animation, le son Rien : dérivés de bOpen des deux côtés par ApplyOpenState()

Les quatre pièges

  1. Interact_Implementation s'exécute sur le SERVEUR. PlayerController->OpenStorage(...) y créerait un widget dans le vide. D'où Client_OpenStorage : le serveur lève le couvercle (état du monde) et renvoie l'ordre d'écran au client. C'est le piège le plus contre-intuitif du portage, parce que le code semble correct et marche parfaitement chez l'hôte.
  2. SetOpen ne fait rien sans autorité. Chez un client, refermer l'écran doit passer par Server_ReleaseStorage — sinon le coffre resterait ouvert pour les trois autres joueurs alors que l'écran est fermé ici.
  3. FindNetProxy est le passage obligé des transferts. Un UInventoryComponent de coffre n'appartient à aucune connexion : une RPC émise depuis lui est silencieusement jetée. On route donc toujours par l'inventaire du pawn local, et la source comme la destination sont passées en paramètres — jamais déduites du récepteur, sinon « Tout prendre » viderait le sac du joueur dans lui-même.
  4. Slots est répliqué à tout le monde, pas au seul propriétaire. Ce n'est pas un raccourci : les conditions de GetLifetimeReplicatedProps sont mises en cache par classe, pas par instance. Un COND_OwnerOnly frapperait donc aussi le composant du coffre — or un coffre n'appartient à personne, et sa grille n'arriverait chez aucun joueur.

Accès simultané, pas de verrou. Deux joueurs peuvent fouiller le même coffre : TransferSlot diffuse déjà OnInventoryChanged des deux côtés, et tous les widgets y sont abonnés. L'UI se recâble donc toute seule à chaque OnRep_Slots().

Pas de prédiction. Le client envoie sa RPC et attend la réplication. Quelques dizaines de millisecondes sur un relais Steam, invisibles pour un glisser-déposer, alors qu'une prédiction fausse donnerait une pile qui saute.


9. Guide éditeur

9.1 Le Blueprint du coffre

  1. Content Browser → Content/Game/Building/Storage/ → clic droit → Blueprint ClassAll ClassesStorageContainer → nomme-le BP_Chest_Wooden.
  2. Ouvre-le. Sélectionne BaseMesh → Details → Static Mesh = SM_Chest_Bottom.
  3. Sélectionne LidMeshStatic Mesh = SM_Chest_Top.
  4. Toujours sur LidMesh, Details → Transform :
    • Location : les deux meshes viennent du même export, donc 0, 0, 0 les repose l'un sur l'autre. Ajuste seulement s'il flotte ou s'enfonce.
    • Rotation : 0, 0, 0 obligatoirement. BeginPlay écrase cette valeur — une rotation laissée ici serait perdue au lancement.
  5. Sélectionne l'acteur racine (Self) → Details → Storage | LidOpen Rotation. Le défaut est Pitch = -100. Tape des valeurs et regarde le viewport : si le couvercle bascule sur le côté, essaie le Roll, puis le Yaw.
  6. Storage → règle Slot Count et Column Count. Mets un Display Name (« Coffre en bois ») et un Interaction Prompt.
  7. Storage | SoundOpen Sound / Close Sound si tu en as.
  8. Laisse Auto Fit Interaction Box coché : la boîte se cale seule sur les deux meshes. Vérifie-la avec Show > Collision dans le viewport.
  9. Compile et Save.

Pose-le dans le niveau. Slot Count, Column Count, Display Name et Starting Items se règlent par instance dans le World Outliner : un seul Blueprint sert tous les coffres.

9.2 Le panneau de coffre dans WBP_InventoryScreen

À ne faire qu'une fois. Tout est en BindWidgetOptional, donc l'écran compile même à mi-chemin.

  1. Ouvre WBP_InventoryScreen.
  2. Dans la Designer, à côté du TabSwitcherfrère, pas enfant — pose un Horizontal Box ou une Border, et renomme-le StoragePanel. Mets sa Visibility à Collapsed (le code la repose de toute façon).
  3. Dedans, un Vertical Box contenant :
    • un Text Block nommé StorageTitle ;
    • ton WBP_Inventory (Palette → User Created), renommé StorageGrid ;
    • un Horizontal Box avec deux Button nommés StoreAllButton et TakeAllButton, chacun avec son Text Block (« Tout ranger », « Tout prendre »).
  4. ⚠ L'étape qu'on rate : sélectionne StorageGrid → Details → InventaireDÉCOCHE Auto Bind To Pawn.
  5. Rappel : un Canvas Panel a une taille désirée de zéro. Si StoragePanel disparaît dans un conteneur en Auto, c'est ça — et un enfant en Size = Fill ne compte pas non plus dans la taille désirée de son parent.
  6. Compile et Save.

9.3 Le controller

Un seul réglage : BP_FpsPlayerController → Details → UI | StorageMax Storage Distance (400 cm par défaut). Rien d'autre à brancher, le coffre appelle OpenStorage lui-même.

9.4 Vérifier que ça marche

  1. Vise le coffre : le prompt affiche ton Interaction Prompt, et au même endroit que le coffre soit ouvert ou fermé.
  2. Interagis : le couvercle se lève en ~0,35 s, avec un départ et un arrêt adoucis, et l'écran d'inventaire s'ouvre avec le sac inchangé et le coffre à côté.
  3. L'onglet Fabrication est grisé, et le panneau de détail du sac a disparu.
  4. Glisse un objet du sac vers le coffre : Maj = la moitié, Ctrl = un seul.
  5. Clic droit sur une case : elle part en face. Fais-le depuis la barre rapide aussi.
  6. Tout ranger vide la grille mais laisse la barre rapide intacte.
  7. Recule de plus de 4 m : l'écran se ferme seul et le couvercle se rabat.
  8. Appuie sur la touche de craft coffre ouvert : le coffre est rendu et l'onglet Fabrication s'affiche.
  9. En coop (build Steam) : l'autre joueur voit le couvercle se lever, et un objet déposé apparaît chez lui sans qu'il ait à refermer et rouvrir.

10. Dépannage

Symptôme Cause
Le couvercle traverse la caisse / tourne de travers Mauvais axe d'Open Rotation, ou pivot de SM_Chest_Top posé au centre du mesh — corrige-le dans Modeling Mode > XForm > Edit Pivot
Le couvercle est décalé de la caisse Location de LidMesh (§9.1 étape 4)
Le couvercle saute au lancement Une Rotation a été laissée sur LidMesh : BeginPlay la remet à zéro
Le coffre n'est pas visable Auto Fit Interaction Box décoché avec une boîte restée minuscule, ou profil de collision changé
Le prompt disparaît une fois ouvert La boîte a été reparentée sous LidMesh — elle doit rester fille de BaseMesh
Le sac du joueur s'affiche des deux côtés Auto Bind To Pawn resté coché sur StorageGrid
Le panneau ne s'affiche pas du tout StoragePanel ou StorageGrid mal nommé — l'Output Log le dit
Le panneau est là mais vide et plat Canvas Panel dans un conteneur Auto, ou tous les enfants en Fill
Le coffre s'ouvre chez l'hôte, pas chez le client Client_OpenStorage court-circuité, ou Interact appelé hors de Execute_Interact
Le couvercle reste levé chez les autres après fermeture Chemin de sortie qui ne passe pas par ReleaseActiveStorage()
Un objet réapparaît après un transfert Transfert exécuté côté client sans RPC — c'est la réplication qui le remet
« Tout prendre » vide le sac dans lui-même Source déduite du récepteur au lieu d'être passée en paramètre
Les objets de départ manquent Slot Count trop petit : le débordement part en warning dans l'Output Log

11. Étendre

Gratuit, sans une ligne de C++ :

  • D'autres coffres — le Blueprint est générique, seuls les meshes changent. La boîte d'interaction se recale seule.
  • Des tailles différentesSlot Count par instance.
  • Un coffre-piège, une cacheStarting Items.

Ce qui demanderait du code :

  • La sauvegarde du contenu. C'est le chantier réel : Slots n'est pas sérialisé aujourd'hui. Le format est déjà le bon — le même FInventorySlot que le sac, donc une seule sérialisation servira les deux.
  • Les ressources du coffre comptant dans les recettes — c'est la raison pour laquelle l'onglet Fabrication est grisé plutôt que masqué : le jour où UCraftingComponent saura lire un inventaire voisin, il suffira de le dégriser.
  • Un cadenas, un propriétaire — il n'y a aujourd'hui aucun verrou, et c'est délibéré : coop entre amis.
  • Un coffre en un seul mesh (tonneau, sac) — la UBoxComponent reste valable, il suffit de laisser LidMesh vide et OpenRotation à zéro. FitInteractionBox ignore déjà un mesh absent.

12. Ce que ce système a changé ailleurs

  • UInventoryComponent::TransferSlot est devenue l'unique implémentation du déplacement. MoveItem / MoveItemQuantity ne sont plus que des appels avec From == To.
  • ConfigureAsContainer() : zéro barre rapide, donc GetBackpackStartIndex() vaut 0 et la grille affiche tout. Aucun nouveau modèle de données.
  • UInventoryWidget a perdu son lien vers sa grille parente : une case diffuse OnHoverChanged au lieu de connaître son propriétaire. Avec deux grilles à l'écran, l'index seul ne désignait plus rien de façon unique — d'où GetHoveredSlot(inventaire, index).
  • NativeOnMouseLeave n'arrive jamais quand un widget s'efface sous le curseur, ce qui est exactement ce qui se passe en fermant un coffre. D'où le passage obligé par SetHoveredSlot() dans ClearSlotWidgets() et BindToInventory() — cette dernière annonce la sortie avant de changer d'inventaire, sinon on ne sait plus quel affichage périmer.
  • SetItemDetailsEnabled sur la grille, piloté par l'écran et non par une case à cocher.