# 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` ```cpp 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` 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()` 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_`. 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 : | 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_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 poste** — `SetStationFilter()` 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écouvrir** — `RecipeId` 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.