Files
Unreal_EmberWild/Docs/Crafting.md
T
Mathew 41d6e6a7f3 (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>
2026-08-01 22:51:24 +02:00

18 KiB
Raw Blame History

Système de fabrication

Documentation du système de craft : architecture, API, câblage éditeur, décisions de design et dépannage.


1. Vue d'ensemble

Quatre couches, chacune ignorante de celle du dessus.

DONNÉES        DA_Craft_*  ──►  DA_RecipeBook
                                     │
LOGIQUE                    UCraftingComponent  (sur le pawn)
                             │  lit UInventoryComponent du même acteur
                             │
INTERFACE      UCraftingWidget ──► UCraftingRecipeSlotWidget
                             └──► UCraftingIngredientRowWidget
                                     │
CONTRÔLE       AFpsPlayerController  (ouvre, ferme, décide du HUD)
                     ▲
MONDE          ACraftingStation      (l'établi, via l'interaction)

Règle qui gouverne tout le reste : le composant calcule, les widgets affichent. Aucun widget n'interroge l'inventaire directement.


2. Les données

UCraftingRecipeDataAsset

Un asset par recette (DA_Craft_*). Donnée pure, aucun état.

Champ Rôle
RecipeId identifiant stable pour la sauvegarde des recettes débloquées
Ingredients tableau de FCraftIngredient { Item, Quantity }
ResultItem / ResultQuantity ce qui est produit
RequiredStation Hands ou Workbench
DisplayNameOverride / IconOverride à laisser vides sauf cas particulier

GetDisplayName() et GetIcon() retombent sur le ResultItem quand les overrides sont vides. Passer toujours par ces deux fonctions, jamais par ResultItem directement.

IsDataValid() refuse à la sauvegarde une recette sans résultat, et signale un RecipeId vide ou un objet listé deux fois dans les ingrédients. Ces trois erreurs ne planteraient pas — elles produiraient un comportement faux et silencieux.

UCraftingRecipeBook

Le catalogue (DA_RecipeBook). Un simple TArray de recettes, dont l'ordre est l'ordre d'affichage de la grille.

Un catalogue plutôt qu'un scan de l'AssetManager : contenu déterministe, rien ne se charge par surprise, et l'ordre devient une donnée de design réglée à la souris. Un catalogue plutôt qu'une liste sur le composant, aussi : un établi pourra pointer son propre livre.

ECraftingStation

enum class ECraftingStation : uint8 { Hands, Workbench, Count /*sentinelle*/ };

Count n'est jamais assignée à une recette. Elle sert de valeur « tout » pour le filtre d'affichage et permet de parcourir l'enum pour construire des boutons.

Fourneau et feu de cuisson n'y figurent pas volontairement : ils ne passent par aucun menu. Ce seront des acteurs posés dans le monde où l'on dépose une ressource et où le mesh change d'état, façon Raft. Leur file d'attente leur appartiendra.


3. La logique — UCraftingComponent

Sur le pawn, à côté de UInventoryComponent, qu'il résout une fois à l'initialisation.

API

Appel Usage
GatherRecipes(Out, StationFilter) peupler la grille
CanCraft(Recipe, OutReason) activer ou griser un bouton
GatherIngredientStatus(Recipe, Out) les compteurs « 2/3 »
Craft(Recipe) le clic
HasStation(Station) test ponctuel
AddAvailableStation / RemoveAvailableStation par un poste de travail

Délégués : OnCraftSucceeded(Recipe), OnAvailableStationsChanged(), OnCraftOverflow(Item, Quantity, RemainingUses).

GatherRecipes n'a pas de valeur par défaut sur StationFilter : UHT refuse une entrée UMETA(Hidden) comme défaut, et dé-cacher Count la rendrait sélectionnable dans le champ Required Station d'une recette. L'appelant écrit ECraftingStation::Count pour « tout ».

Postes disponibles

TMap<ECraftingStation, int32> de compteurs, pas un TSet. Deux établis dont les portées se chevauchent s'ajoutent tous les deux ; sortir du premier ne doit pas retirer l'accès alors qu'on est encore devant le second. Hands est initialisée à 1 et ne peut pas être retirée.

La diffusion de OnAvailableStationsChanged n'a lieu que sur les transitions 0 → 1 et 1 → 0. Entrer dans un deuxième établi ne reconstruit pas la grille pour rien.

Disponibilité et filtre d'affichage sont deux notions distinctes. La disponibilité est une condition de fabrication, le filtre est visuel. Les mélanger finirait par rendre un craft impossible à cause d'un bouton d'interface, et ce genre de bug ne se comprend jamais sur le moment.

Craft() — l'ordre compte

1. CanCraft()                      → refus, rien n'est touché
2. RemoveItem() par ingrédient     → consommation
3. AddItem(résultat)               → ajout
4. reste > 0 ? OnCraftOverflow     → le pawn le pose au sol

On consomme avant d'ajouter. Refuser parce que l'inventaire est plein serait faux : consommer trois branches libère justement la case du résultat. Et si le résultat ne rentre malgré tout pas, il part par OnCraftOverflowaucune ressource ne disparaît, ni à l'entrée ni à la sortie.

Le composant ne fait pas apparaître le pickup lui-même : il ne sait pas engendrer un acteur, et un établi qui porterait le même composant déposerait son surplus sur sa propre table. C'est AFpsPlayer::HandleCraftOverflow() qui s'en charge, abonné dans son BeginPlay. Si personne n'écoute, un UE_LOG(Error) le signale au lieu de perdre l'objet en silence.

Le résultat passe par AddItem(), donc exactement comme un ramassage : piles entamées d'abord, puis barre rapide, puis grille.


4. L'interface

UInventoryScreenWidget — l'écran à onglets

Ce que Tab ouvre. Il ne contient aucune logique : les deux pages restent des widgets autonomes, et UInventoryWidget n'a pas changé d'une ligne en devenant une page.

BindWidget Type Obligatoire
TabSwitcher Widget Switcher oui
InventoryTabButton Button oui
CraftingTabButton Button oui
InventoryPage ton WBP_Inventory oui
CraftingPage ton WBP_Crafting oui
  • GetHoveredSlotIndex() renvoie INDEX_NONE dès que l'onglet Fabrication est affiché, sinon la touche « jeter » agirait sur une case cachée sous le panneau de craft.
  • ShowPage() prend le widget, jamais un index : l'ordre des enfants d'un WidgetSwitcher se change d'un glisser dans la Designer, un 0/1 en dur inverserait silencieusement les onglets.
  • Diffuse OnTabChanged(bool bInventoryTabActive) et rien d'autre. Ce que ça implique pour le HUD ne le regarde pas.

UCraftingWidget — la page de fabrication

BindWidget Type
RecipeGrid Uniform Grid Panel obligatoire
ResultIcon Image obligatoire
ResultNameText Text Block obligatoire
CraftButton Button obligatoire
DetailsPanel n'importe quel Widget optionnel
IngredientList n'importe quel Panel optionnel
ResultQuantityText Text Block optionnel
ResultDescriptionText Text Block optionnel
EmptyGridMessage n'importe quel Widget optionnel

Réglages : RecipeSlotClass, IngredientRowClass, ColumnCount.

IngredientList est déclaré en UPanelWidget et non en UVerticalBox : le code ne fait qu'y ajouter des enfants, donc passer à un Wrap Box ou un Grid Panel ne demande pas une ligne de C++.

DetailsPanel est déclaré en UWidget : le nom peut donc être porté par une Border, ce qui masque le fond en même temps que le contenu quand rien n'est sélectionné.

Rafraîchissement, sans jamais de Tick :

  • abonné à OnInventoryChanged → recalcule seulement les teintes et l'état du bouton
  • abonné à OnAvailableStationsChanged → recalcule la liste

RebuildGrid compare la nouvelle liste à l'ancienne et sort immédiatement si elle est identique. Les lignes d'ingrédient sont recyclées et non recréées, puisque RefreshDetails repasse à chaque objet ramassé.

HandleCraftClicked ne re-vérifie pas CanCraft : Craft() le refait de toute façon, et dupliquer la condition, c'est se garantir qu'elles divergeront.

UCraftingRecipeSlotWidget — une case

RecipeIcon obligatoire ; RecipeNameText et SelectionBorder optionnels. Réglages : CraftableTint, UncraftableTint.

Les recettes non réalisables restent visibles et cliquables, seulement assombries : le joueur doit pouvoir consulter ce qui lui manque, sinon il ne sait pas quoi aller chercher.

La racine du Widget Blueprint doit avoir Visibility = Visible. En Self Hit Test Invisible, la case ne reçoit jamais le clic.

UCraftingIngredientRowWidget — une ligne de coût

IngredientCountText obligatoire ; IngredientIcon et IngredientNameText optionnels. Réglages : EnoughColor, MissingColor, CountFormat ({0} possédé, {1} requis).

Elle n'interroge jamais l'inventaire : le composant lui remet un FCraftIngredientStatus déjà calculé. Une ligne qui compterait elle-même referait le travail autant de fois qu'il y a d'ingrédients, et pourrait afficher autre chose que ce sur quoi le bouton s'appuie.


5. Le contrôleur

Ouverture et fermeture

Tout passe par SetInventoryScreenOpen(bOpen, bShowCraftingTab) — point d'entrée unique. Le mode d'input, le curseur, la coupure d'input du pawn, la barre rapide et le poste actif sont décidés à un seul endroit. Deux chemins parallèles finiraient par diverger.

Appel Comportement
ToggleInventory() Tab — ouvre/ferme, toujours sur l'onglet Inventaire
ToggleCrafting() touche dédiée — ouvre sur Fabrication, bascule depuis Inventaire, referme depuis Fabrication
OpenCraftingAtStation(Station) depuis un établi — accorde le poste puis ouvre

Quand l'écran est déjà ouvert, on ne fait que changer d'onglet : repasser par le mode d'input serait au mieux inutile, au pire nuisible — SetIgnoreLookInput gère un compteur qu'un appel en trop déséquilibrerait durablement.

Le poste actif

ActiveStation retient le poste accordé par le dernier OpenCraftingAtStation. Il est rendu dans SetInventoryScreenOpen(false, …), donc quel que soit le chemin de sortie — Tab, Échap, la mort. On fabrique à l'établi parce qu'on est en train de s'en servir, pas parce qu'on en a croisé un un jour.

La barre rapide

Situation Barre rapide
écran fermé visible
onglet Inventaire visible
onglet Fabrication masquée

Une seule fonction décide : UpdateHotbarVisibility(). Sinon, fermer l'écran depuis l'onglet craft laisserait la barre cachée pour le reste de la partie.

Sa visibilité d'origine est relevée à la création et restaurée telle quelle, plutôt que forcée à Visible : le Blueprint a peut-être choisi SelfHitTestInvisible, et l'écraser changerait son comportement au clic sans qu'on l'ait demandé.

Remappage

L'action est exposée dans l'onglet Touches sous l'identifiant ToggleCrafting, qui doit correspondre exactement au champ Name des Player Mappable Key Settings du mapping dans IMC_Default. Une faute de frappe donne une ligne « Non assignée » sans le moindre message.


6. L'établi — ACraftingStation

Un acteur interactif, détecté par le trace de UInteractionComponent comme un objet au sol.

Il ne fabrique rien et ne contient aucune recette : il ouvre l'écran en annonçant son type de poste, et le composant du joueur filtre en conséquence.

Un seul BP_CraftingStation suffit pour tous les postes — c'est StationType qui change par instance, comme ItemData sur BP_Pickup.

Pourquoi pas de sphère d'interaction

APickupItem en a une parce que les meshes d'asset packs sont souvent livrés sans collision simple, et que le trace est lancé en bTraceComplex = false — il les traverserait.

Pour un meuble construit, c'est l'inverse : le trace s'arrête au premier bloqueur, donc une sphère englobant la structure serait touchée avant le mesh. Le prompt apparaîtrait en visant le vide autour, et la sphère masquerait un objet posé au pied du meuble.

Le Mesh est donc réglé explicitement sur le profil BlockAll : il empêche le joueur de traverser et bloque Visibility pour que le trace le trouve. Un profil laissant passer Visibility rendrait le poste inutilisable sans qu'aucune erreur ne le signale.

Prérequis : le Static Mesh doit avoir une collision simple. Vérifie avec Show → Simple Collision dans l'éditeur de mesh. Sinon, Collision → Add Box Simplified Collision, ou pose une Box Collision dédiée dans le Blueprint.


7. Ajouter une recette

  1. Content/Game/Data/Crafting → clic droit → Miscellaneous → Data Asset → classe CraftingRecipeDataAsset → nommer DA_Craft_<Objet>.
  2. Remplir : RecipeId, Ingredients, ResultItem, ResultQuantity, RequiredStation.
  3. Ouvrir DA_RecipeBook et ajouter la recette dans Recipes, à sa place dans l'ordre d'affichage.
  4. Vérifier que le ResultItem a bien une icône — sans elle la case sera vide et tu croiras à un bug de code.
  5. Sélectionner les assets → clic droit → Asset Actions → Validate Assets → 0 erreur.

Aucune recompilation, aucun Blueprint à toucher.

8. Ajouter un poste de travail

  1. Ajouter la valeur dans ECraftingStation, avant Count.
  2. Créer un BP_ dérivé de CraftingStation, régler son Mesh et son StationType.
  3. Poser l'acteur dans le niveau.

Rien d'autre : la grille, le filtrage et le bouton suivent.


9. Récapitulatif des noms à ne pas casser

Un BindWidget obligatoire mal orthographié empêche le Blueprint de compiler, avec le nom du coupable dans le Compiler Results. Un BindWidgetOptional mal orthographié échoue en silence.

Widget Blueprint Noms obligatoires Noms optionnels
WBP_InventoryScreen TabSwitcher, InventoryTabButton, CraftingTabButton, InventoryPage, CraftingPage
WBP_Crafting RecipeGrid, ResultIcon, ResultNameText, CraftButton DetailsPanel, IngredientList, ResultQuantityText, ResultDescriptionText, EmptyGridMessage
WBP_RecipeSlot RecipeIcon RecipeNameText, SelectionBorder
WBP_IngredientRow IngredientCountText IngredientIcon, IngredientNameText

Assignations à ne pas oublier :

Champ Valeur
BP_FpsPlayerCraftingComponent Recipe Book DA_RecipeBook
BP_FpsPlayerController Inventory Screen Class WBP_InventoryScreen
BP_FpsPlayerController Toggle Crafting Action IA_ToggleCrafting
WBP_Crafting Recipe Slot Class WBP_RecipeSlot
WBP_Crafting Ingredient Row Class WBP_IngredientRow

10. Pièges UMG rencontrés

Tous ont coûté du temps au moins une fois.

  • Un Canvas Panel a une taille désirée de zéro. Imbriqué dans un conteneur en Auto, il disparaît. C'est ce qui arrive à WBP_Inventory devenu page d'un WidgetSwitcher.
  • Dans un Vertical/Horizontal Box, un enfant en Size = Fill ne compte pas dans la taille désirée du parent. Tous les enfants en Fill → le parent mesure zéro, et la Border qui l'entoure se réduit à son padding.
  • Un Size Box impose une taille désirée, pas une taille allouée. Un parent qui l'aligne en Fill l'étire quand même. Il faut Auto + Center.
  • Pour un cadre de taille fixe, le Size Box va autour de la Border. Le widget le plus externe gagne toujours.
  • Une Border ne peint rien avec Draw As = Image sans texture. Pour un aplat, Rounded Box. Contour creux : Tint en alpha 0 + Outline Width.
  • « Wrap With… » sur l'unique enfant d'une Border le fait sortir de la Border. Vérifier l'indentation juste après.
  • Un Uniform Grid Panel répartit la largeur qu'on lui donne en parts égales. En Fill, les cases se retrouvent espacées ; il faut Left ou Center sur son slot pour qu'il prenne sa taille désirée. L'espacement se règle avec Slot Padding.
  • Scale Box + Stretch = Scale To Fit est le seul « keep aspect ratio » d'UMG. Un Size Box avec Min/Max Aspect Ratio à 1.0 suffit pour des icônes carrées.
  • Le texte placeholder d'un Text Block fausse la mise en page dans la Designer. Y mettre une valeur représentative — le code l'écrase à l'exécution.
  • L'aperçu Fill Screen étire la racine à 1920 × 1080. Basculer sur Desired Size pour voir la vraie taille.

11. Dépannage

Symptôme Cause probable
Grille vide, message « aucune recette » Recipe Book non assigné, ou toutes les recettes en Workbench
Grille vide, aucun message RecipeSlotClass non assignée — voir Output Log
Une case ne réagit pas au clic racine de WBP_RecipeSlot en Self Hit Test Invisible
Cases vides sans icône le ResultItem n'a pas d'icône
Panneau de détail toujours affiché DetailsPanel non nommé, donc non lié
Liste d'ingrédients vide IngredientRowClass non assignée
Le bouton reste grisé avec les ressources RequiredStation inaccessible — pas devant l'établi
Ligne « Non assignée » dans le menu Touches Name des Player Mappable Key Settings ≠ ToggleCrafting
Le prompt de l'établi n'apparaît jamais le mesh n'a pas de collision simple
Objets perdus après un craft OnCraftOverflow sans abonné — cherche l'Error dans l'Output Log

Les messages du système sont préfixés UCraftingComponent, UCraftingWidget ou UInventoryWidget dans l'Output Log.


12. Reste à faire

  • Boutons de filtre par posteSetStationFilter() existe, ECraftingStation::Count est la sentinelle « tout ».
  • Recettes réelles — les deux DA_Craft_Test_* sont du jetable bâti sur DA_Branch et DA_Rocher.
  • Recettes à découvrirRecipeId est prévu pour ça ; la liste des identifiants connus ira dans la sauvegarde, jamais dans le DataAsset.
  • Retour joueur — son de fabrication sur OnCraftSucceeded, message quand le résultat tombe au sol.
  • Postes qui travaillent sans le joueur — fourneau, séchoir. Un autre système : acteurs du monde avec file d'attente et changement de mesh, sans passer par ce menu.