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>
18 KiB
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 OnCraftOverflow — aucune 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()renvoieINDEX_NONEdè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, un0/1en 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. EnSelf 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
Content/Game/Data/Crafting→ clic droit → Miscellaneous → Data Asset → classeCraftingRecipeDataAsset→ nommerDA_Craft_<Objet>.- Remplir :
RecipeId,Ingredients,ResultItem,ResultQuantity,RequiredStation. - Ouvrir
DA_RecipeBooket ajouter la recette dansRecipes, à sa place dans l'ordre d'affichage. - Vérifier que le
ResultItema bien une icône — sans elle la case sera vide et tu croiras à un bug de code. - Sélectionner les assets → clic droit → Asset Actions → Validate Assets → 0 erreur.
Aucune recompilation, aucun Blueprint à toucher.
8. Ajouter un poste de travail
- Ajouter la valeur dans
ECraftingStation, avantCount. - Créer un
BP_dérivé deCraftingStation, régler sonMeshet sonStationType. - 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 :
| Où | Champ | Valeur |
|---|---|---|
BP_FpsPlayer → CraftingComponent |
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_Inventorydevenu page d'un WidgetSwitcher. - Dans un Vertical/Horizontal Box, un enfant en
Size = Fillne 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
Filll'étire quand même. Il fautAuto+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 = Imagesans 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 fautLeftouCentersur son slot pour qu'il prenne sa taille désirée. L'espacement se règle avecSlot Padding. Scale Box+Stretch = Scale To Fitest le seul « keep aspect ratio » d'UMG. Un Size Box avecMin/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 surDesired Sizepour 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 poste —
SetStationFilter()existe,ECraftingStation::Countest la sentinelle « tout ». - Recettes réelles — les deux
DA_Craft_Test_*sont du jetable bâti surDA_BranchetDA_Rocher. - Recettes à découvrir —
RecipeIdest 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.