(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
+419
View File
@@ -0,0 +1,419 @@
# Système de cuisson
Postes de transformation posés dans le monde : feu de camp aujourd'hui, fourneau et
séchoir demain. Le joueur dépose un objet dessus, attend, et le récupère transformé.
**Fichiers** : `CookingStation.h/.cpp`, plus le bloc *Cooking* / *Fuel* de `ItemDataAsset.h`.
---
## 1. Le principe
Le modèle est celui de Raft, assumé jusqu'au bout : **aucune interface**. Pas de fenêtre,
pas de barre de progression, pas de compteur. Le joueur tient sa viande crue dans la barre
rapide, vise un emplacement du grill, appuie sur Interagir, et regarde le mesh changer.
Trois conséquences qui structurent tout le reste :
- **Le poste ne commente jamais son propre état.** Le prompt d'interaction n'affiche que des
actions réalisables, exactement comme un objet à ramasser. Ce qui cuit, ce qui manque, ce
qui brûle se lit sur les flammes, sur le nombre de bûches et sur le mesh de la nourriture.
- **Le feu s'allume et s'arrête tout seul.** Il n'y a pas de geste « allumer ». Le bois brûle
s'il y a du travail, et attend sinon.
- **Aucun `Tick`.** Toute la temporalité passe par des timers.
Ce n'est **pas** un système de recettes. La cuisson n'est pas un choix fait dans un menu,
c'est une propriété de l'objet — voir §2. Le `CLAUDE.md` l'avait tranché dès le craft :
*« Fourneau et feu de cuisson n'ont volontairement PAS leur entrée dans `ECraftingStation` »*.
---
## 2. Les données — `UItemDataAsset`
### 2.1 La chaîne de transformation
Cuire n'est pas une recette mais un **chaînage d'objets**. « Steak cru » pointe vers « Steak
cuit », qui pointe vers « Steak brûlé ». Chaque maillon est un DataAsset complet, donc le
changement de visuel sur le feu ne coûte pas une ligne de code : c'est le `WorldMesh` du
maillon suivant.
| Champ | Type | Rôle |
|---|---|---|
| `bCanBeCooked` | `bool` | L'objet se transforme sur un feu actif |
| `CookDuration` | `float` | Secondes de feu **actif** nécessaires |
| `CookedResult` | `UItemDataAsset*` | Ce qu'il devient |
| `bCanBurn` | `bool` | L'objet se dégrade s'il reste sur le feu |
| `BurnDuration` | `float` | Secondes avant dégradation |
| `BurntResult` | `UItemDataAsset*` | Ce qu'il devient |
| `bIsFuel` | `bool` | L'objet peut alimenter un feu |
| `FuelDuration` | `float` | Secondes de combustion par exemplaire |
`bCanBeCooked` et `bCanBurn` sont **volontairement indépendants**. Une seule chaîne uniforme
imposerait le même comportement à tout le monde, alors qu'on veut pouvoir décrire :
- un objet qui cuit puis brûle (viande crue → cuite → brûlée) ;
- un objet qui brûle sans jamais avoir été cuisinable (une planche jetée dans les flammes) ;
- un plat qui reste indéfiniment au chaud sans se gâcher (`bCanBurn` décoché).
Si les deux sont cochés sur le même asset, **la cuisson passe en premier**. C'est le maillon
*cuit* qui portera `bCanBurn` pour continuer vers le brûlé.
### 2.2 Ce qui n'est PAS dans le DataAsset
Le temps déjà passé sur le feu. C'est de l'état, il vit sur la station. Le mettre dans le
DataAsset ferait cuire tous les steaks du monde en même temps — c'est la règle générale du
projet : *le DataAsset décrit ce qu'un objet **est**, jamais où il en est*.
### 2.3 Le visuel : `WorldMeshMaterial`
`UItemDataAsset` porte, à côté de `WorldMesh`, un **`WorldMeshMaterial`** optionnel qui
n'écrase que le premier slot de matériau.
Il existe pour la cuisson : dès qu'un pack partage une géométrie entre plusieurs aliments —
c'est le cas de `StylizedFood_JC`, 73 meshes pour 4 instances de matériau — une chaîne à
trois étapes demanderait trois meshes distincts là où un seul suffit avec trois teintes.
La règle « mesh + matériau optionnel » est centralisée dans **`ApplyWorldVisualsTo()`**, sur
le DataAsset. Deux appelants aujourd'hui — l'objet au sol (`APickupItem`) et l'objet sur le
feu — et un troisième viendra avec l'objet en main. Dupliquer la règle garantirait qu'un des
trois oublie le matériau.
### 2.4 Validation
`UItemDataAsset::IsDataValid()` refuse à la sauvegarde :
- `bCanBeCooked` coché sans `CookedResult` (et l'équivalent pour `bCanBurn`) ;
- un résultat qui **pointe sur l'asset lui-même**.
Ce second cas est le piège vécu : la viande se transforme en elle-même toutes les 20 s, le
mesh ne change pas, elle reste `bCanBeCooked` donc non récupérable, et le feu ne s'arrête
jamais puisqu'il croit avoir du travail. En jeu, ça ressemble exactement à un timer cassé.
Le runtime s'en protège aussi : `GetNextStage()` ignore un résultat auto-référencé, un
avertissement part dans l'Output Log au moment du dépôt, et l'objet reste récupérable pour
ne pas être séquestré sur le grill.
---
## 3. Le carburant — une pile d'objets
Le foyer **n'est pas une jauge de secondes**, c'est un `TArray<FFuelSlotState>` : chaque
entrée mémorise *quel objet* a été donné et *combien de temps il lui reste*.
```
Fuel[0] ← celle qui brûle. Son compte à rebours est FuelTimer.
Fuel[1..n] ← attendent leur tour, à leur durée pleine.
```
Quand `Fuel[0]` est consommée, elle est retirée du tableau et tout avance d'un cran. Le foyer
se lit donc toujours « les N premières bûches sont pleines », ce qui rend l'affichage trivial
et sans état supplémentaire.
**Pourquoi ce modèle plutôt qu'un total en secondes** : une branche à 10 s et une planche à
3 min occupent la même place. Le joueur voit ce qu'il a donné au feu, pas un pourcentage
abstrait, et mélanger les combustibles reste lisible. Avec une jauge, il fallait dix branches
pour voir bouger deux bûches.
### 3.1 La capacité
`GetFuelCapacity()` renvoie **le nombre de bûches taguées** dans le Blueprint. La capacité
est une décision de mise en scène ; la régler à deux endroits garantirait qu'ils finissent
par se contredire. `FallbackFuelCapacity` ne sert qu'aux postes sans bûche visible — un four
fermé, par exemple.
### 3.2 Les bûches visibles
Les meshes portant le tag **`FuelLog`** (réglable via `FuelIndicatorTag`) sont recensés une
fois au `BeginPlay` par `CacheFuelIndicators()`, puis **triés par nom**.
- Un tag plutôt qu'une liste de composants : le nombre de bûches change sans recompiler, et
un renommage ne casse rien. Une `TArray<FComponentReference>` ferait de toute façon la même
recherche par nom en interne, pour un coût identique — la seule différence serait qu'elle
casserait en silence au moindre renommage.
- Le tri est indispensable : `GetComponents` ne promet aucun ordre, et sans lui la bûche qui
disparaît en premier changerait d'une compilation du Blueprint à l'autre.
- Tri **alphabétique**, donc `Log_10` passerait avant `Log_2`. Au-delà de neuf bûches,
nomme-les `Log_01`, `Log_02`
- **`Log_1` doit être au fond du tas, `Log_N` au sommet** : la dernière disparaît en premier.
`RefreshFuelIndicators()` n'a plus rien à calculer : une bûche visible **est** un objet
stocké. Il n'y a ni palier, ni timer d'affichage.
---
## 4. L'acteur `ACookingStation`
### 4.1 Composants
| Composant | Rôle |
|---|---|
| `BaseMesh` | Racine, le foyer. Collision `QueryAndPhysics` — on n'entre pas dans un feu |
| `InteractionSphere` | Ce que le trace d'interaction touche. Bloque uniquement `Visibility` |
| `CookSlot_0``CookSlot_3` | Un par emplacement de cuisson : porte la nourriture **et** sert à la visée |
| `FireEffect` | Niagara. `AutoActivate` désactivé — c'est le C++ qui décide |
| `FireLight` | Halo du foyer, allumé avec la flamme |
Les `CookSlot_` sont créés **dans le constructeur**, en nombre fixe (`MaxCookingSlots = 4`).
C'est le prix à payer pour pouvoir les positionner **à la souris** dans le viewport du
Blueprint : des composants engendrés à l'exécution ne seraient réglables qu'en tapant des
coordonnées à l'aveugle. `CookingSlotCount` limite ceux réellement utilisés.
**`FireLight` est `Movable`.** Le défaut d'un composant lumière est `Static`, c'est-à-dire
cuit dans le lightmap : l'allumer au runtime n'aurait aucun effet à l'écran. Ses ombres sont
désactivées par défaut — une lumière ponctuelle projette sur six faces de cubemap, chaque
frame, pour chaque objet à portée. C'est de loin le poste le plus cher du système.
### 4.2 Réglages exposés
**Catégorie `Cooking`**
| Propriété | Défaut | Effet |
|---|---|---|
| `CookingSlotCount` | 2 | Emplacements de cuisson utilisés (1-4) |
| `CookingMeshScale` | 1.0 | Échelle du mesh de nourriture posé |
| `SlotAimHalfAngle` | 25° | Cône de visée autour de chaque emplacement |
| `CookSpeedMultiplier` | 1.0 | Divise les durées. Un fourneau va plus vite |
| `bAllowTakingRawItems` | ☐ | Autorise à reprendre ce qui n'a pas fini de cuire |
| `bShowRemainingTimeInPrompt` | ☐ | Ajoute les secondes au prompt — **outil de réglage** |
| `bHighlightOnFocus` | ☑ | Custom Depth quand le poste est visé |
**Catégorie `Cooking\|Fuel`**
| Propriété | Défaut | Effet |
|---|---|---|
| `FuelIndicatorTag` | `FuelLog` | Tag des meshes de bûches |
| `FallbackFuelCapacity` | 5 | Capacité si aucune bûche n'est taguée |
| `bFuelIndicatorsUseItemMesh` | ☐ | Chaque bûche prend le mesh de son combustible |
| `StartingFuelItem` / `StartingFuelCount` | — | Bois déjà présent au démarrage |
**Catégorie `Cooking\|Sound`**`LightSound`, `ExtinguishSound`, `PlaceItemSound`. Tous
facultatifs, un champ vide ne joue rien.
### 4.3 API publique
```cpp
bool Light(); // Normalement jamais appelée : UpdateFireState décide
void Extinguish();
bool HasPendingWork() const; // Un emplacement a-t-il encore une étape à franchir
bool IsLit() const;
int32 GetFuelCapacity() const;
int32 GetFuelCount() const;
float GetFuelRemaining() const; // Somme sur tout le foyer
float GetSlotTimeRemaining(int32) const;
FOnCookingStationChanged OnStationChanged; // Dépôt, retrait, transformation, allumage
```
---
## 5. L'interaction
### 5.1 Viser un emplacement
`ResolveAimedSlot()` prend le point de vue réel de l'interactor via
`GetActorEyesViewPoint()` — sur un pawn possédé, c'est le point de vue du controller, donc
exactement ce que le joueur voit. La station ne dépend ainsi pas d'`AFpsPlayer`.
Le choix se fait par **produit scalaire**, pas par distance à l'écran : c'est l'écart
*angulaire* au centre du viseur qui compte, et il ne demande ni projection ni accès au
viewport. Sous le cône `SlotAimHalfAngle`, aucun emplacement n'est visé et le joueur
s'adresse au foyer lui-même — c'est ce qui lui permet d'ajouter du bois sans devoir éviter le
grill du regard.
Aucune collision par emplacement : la transform du `CookSlot_` suffit. Zéro requête physique
supplémentaire.
### 5.2 Priorité des actions
`ResolveAction()` calcule **une seule fois** ce que ferait un appui, et le résultat sert à la
fois au texte du prompt et à l'exécution. Deux cascades de `if` séparées finiraient par
diverger, et le joueur lirait une chose en en obtenant une autre.
| # | Condition | Action | Prompt |
|---|---|---|---|
| 1 | Emplacement visé, occupé, récupérable | `TakeItem` | `Pick up {objet}` |
| 2 | Emplacement visé libre + objet cuisinable en main | `PlaceItem` | `Cook {objet}` |
| 3 | Du bois dans l'inventaire + place au foyer | `AddFuel` | `Add {bois} to the fire` |
| — | Sinon | `None` | *(vide)* |
Récupérer passe avant tout : sans ça, impossible de sortir sa viande en ayant du bois en main.
Le bois n'a **pas besoin d'être en main** : `FindFuelSource()` prend le slot actif s'il
contient du carburant, sinon le premier slot de l'inventaire qui en contient. La main sert à
*choisir* le combustible, pas à autoriser le geste — et le prompt nomme toujours ce qui va
partir, pour qu'on ne brûle jamais ses planches par surprise.
**Un exemplaire par appui.** Donner du bois est irréversible ; vider vingt branches d'un coup
sur un feu qui n'en demandait qu'une ne se rattrape pas.
### 5.3 Ce qui peut être repris
`CanTakeFromSlot()` : on ne retire pas ce qui est **en train de cuire**.
Le critère est porté par l'objet lui-même, sans état à maintenir : **tant qu'il sait encore
cuire (`bCanBeCooked`), il est cru, donc engagé.** Dès qu'il ne sait plus que brûler, c'est
qu'il a fini sa cuisson et qu'on peut le sauver.
C'est plus subtil qu'un booléen « a déjà transformé » : avec celui-ci, reposer une viande
cuite sur le feu l'aurait re-verrouillée jusqu'à ce qu'elle carbonise.
Un second filet libère les objets dont la chaîne est cassée — sans lui, une donnée mal
branchée séquestrerait l'objet sur le grill pour toujours.
`bAllowTakingRawItems` permet de revenir sur cette règle sans recompiler.
---
## 6. Le cycle du feu
### 6.1 `UpdateFireState()`, point de décision unique
```
Le feu brûle ⟺ il y a du bois ET il y a du travail
```
Toutes les actions qui changent le contenu du foyer ou du grill y passent : dépôt, retrait,
ajout de bois, fin d'étape. Laisser chaque action décider aurait fini par oublier un cas —
typiquement le dernier objet retiré, laissant le feu se consumer dans le vide.
Il n'y a donc **pas d'action « allumer »**. Le joueur met du bois, pose sa viande, ça part.
### 6.2 « Du travail », précisément
`HasPendingWork()` compte comme travail **tout objet ayant une étape devant lui, y compris la
carbonisation**.
C'est un arbitrage délibéré : pris au pied de la lettre, « le feu s'arrête quand tout est
fini de cuire » l'éteindrait à l'instant précis où la viande devient cuite, et la
sur-cuisson ne se produirait jamais. Oublier sa viande doit rester puni. Le feu ne s'arrête
donc qu'une fois la viande brûlée — bout de chaîne.
*Si ce comportement doit changer un jour, c'est la seule condition de `HasPendingWork()`.*
### 6.3 Les timers
| Timer | Porté par | Ce qu'il fait |
|---|---|---|
| `FCookingSlotState::Timer` | Chaque emplacement | Fin d'étape → `HandleSlotFinished` |
| `FuelTimer` | La station | Fin de `Fuel[0]``HandleFuelConsumed` |
**Aucun `Tick`, et aucun timer périodique.** Éteindre le feu met les timers de cuisson en
**pause** : la cuisson reprend exactement où elle s'était arrêtée, sans qu'une seule ligne ne
compte le temps à la main.
Poser un objet sur un feu éteint est autorisé : le compte à rebours existe, il est
immédiatement mis en pause et attend qu'on alimente le foyer.
Le carburant, lui, ne se met pas en pause mais se **resynchronise** : `SyncFuelFromTimer()`
recopie le restant du timer dans `Fuel[0]` avant toute modification. Feu allumé, c'est le
timer qui fait autorité ; feu éteint, c'est `RemainingSeconds`.
`EndPlay` nettoie tous les timers. Ils portent un délégué vers `this`, et les laisser tourner
après la destruction de l'acteur ne se voit qu'au changement de niveau, sous la forme d'un
crash sans pile lisible.
### 6.4 Débordement
`TakeItemToInventory()` tente l'ajout **avant** de vider l'emplacement. Inventaire plein : la
nourriture reste sur le feu — et continue donc de cuire, ce qui est la bonne sanction plutôt
que de la faire disparaître.
---
## 7. Guide éditeur
### 7.1 La chaîne d'objets
Crée-les **du dernier vers le premier**, chacun référençant le suivant.
1. Content Browser → `Content/Game/Data/Item` → clic droit sur un DataAsset existant →
**Duplicate** (il hérite du mesh et de l'icône, c'est le plus rapide).
2. **`DA_BurntMeat`** : `Item Id` = `Meat_Burnt`, `Display Name` = `Burnt Meat`,
`Hunger Restore` = 2, `Health Restore` = -5. **Toute la section Cooking décochée** — c'est
ce qui arrête la chaîne.
3. **`DA_CookedMeat`** : `Hunger Restore` = 40, `Health Restore` = 5. `Can Be Cooked`
**décoché**, `Can Burn` **coché**, `Burn Duration` = 45, `Burnt Result``DA_BurntMeat`.
4. **`DA_RawMeat`** : `Hunger Restore` = 8, `Health Restore` = **-10** (manger cru punit).
`Can Be Cooked` **coché**, `Cook Duration` = 20, `Cooked Result``DA_CookedMeat`.
5. Donne à chaque étape un visuel distinct : soit un `SM_Food_XX` différent, soit **le même
mesh** avec un `World Mesh Material` différent (`MI_StylizedFood_01` / `02` / `04`).
6. Le combustible : sur `DA_Branch`, coche **`Is Fuel`** et règle `Fuel Duration`.
### 7.2 Le Blueprint
1. Clic droit → **Blueprint Class****All Classes**`CookingStation` → nomme-le
`BP_Campfire`.
2. `BaseMesh` → le mesh du foyer. Ajoute des Static Mesh enfants pour composer les pierres.
3. `FireEffect`**Niagara System Asset** = `NS_Stylized_Fire_01_Infinite`. Prends bien la
variante `_Infinite`, les autres s'arrêtent seules. **Laisse `Auto Activate` décoché.**
4. `FireLight` → ajuste intensité, couleur, rayon. Coche `Cast Shadows` **uniquement** sur le
feu principal du campement.
5. **Les bûches** : ajoute N Static Mesh (`Log_1``Log_5`), positionne-les en tas, puis sur
chacun → Details → **`Component Tags`** → `+`**`FuelLog`**.
`Log_1` au fond, `Log_N` au sommet.
6. **Les emplacements de cuisson** : sélectionne `CookSlot_0`, donne-lui un `Cube` de repérage
(Scale 0.15) pour le voir, place-le au-dessus des flammes. Le jeu efface ce mesh au
démarrage. Répète selon `CookingSlotCount`.
7. Racine → `Cooking` : ajuste `Cooking Slot Count` et `Cooking Mesh Scale`.
### 7.3 Vérifier que ça marche
1. Le foyer démarre **vide** : pas de bûche visible, pas de flamme. C'est normal.
2. Branche en main, vise le foyer → `[E] Add Branch to the fire`**une bûche apparaît**.
3. Répète : le tas se remplit, puis l'action disparaît une fois la capacité atteinte.
4. Viande crue en main, vise le grill → `[E] Cook Raw Meat`**les flammes partent seules**.
5. Pendant la cuisson : **aucun prompt**, impossible de reprendre la viande.
6. À la fin : le mesh change et le prompt devient `[E] Pick up Cooked Meat`.
7. Laisse traîner : le mesh repasse au brûlé, et le feu s'arrête de lui-même.
8. Retire tout le bois en cours de cuisson : les flammes s'éteignent et la cuisson **se fige**.
Remets du bois : elle reprend où elle en était, pas de zéro.
---
## 8. Dépannage
| Symptôme | Cause |
|---|---|
| `Cook X` n'apparaît jamais | Les `CookSlot_` sont restés à l'origine, donc **dans** le foyer. Repositionne-les, ou monte `Slot Aim Half Angle` |
| L'objet ne change jamais d'état, jamais récupérable | Chaîne auto-référencée : `Cooked Result` pointe sur l'asset lui-même. **L'Output Log le dit** |
| Rien ne se passe quand on pose | Vérifie que `bCanBeCooked` **et** `CookedResult` sont renseignés |
| Aucune bûche n'apparaît | Tag `FuelLog` absent, ou mal orthographié |
| Les bûches disparaissent dans le désordre | Ordre alphabétique des noms de composants |
| Le feu ne démarre pas malgré du bois | Rien à transformer : c'est le comportement voulu |
| Pas de flamme mais le feu tourne | `Niagara System Asset` vide, ou variante non-`_Infinite` |
| La lumière ne s'allume pas | `Mobility` du `FireLight` repassée à `Static` |
| Le mesh de repérage reste visible | Il est effacé au `BeginPlay` — tu regardes le viewport de l'éditeur |
| Nourriture énorme ou minuscule | `Cooking Mesh Scale` |
---
## 9. Étendre
**Un fourneau** est déjà possible sans une ligne de C++ : même classe, `CookSpeedMultiplier`
à 2, d'autres meshes, plus de `CookSlot_`. Un **séchoir** de même, avec des durées longues et
`bCanBurn` décoché sur ses résultats.
Ce qui demanderait du code :
- **Une file d'attente sans le joueur** (déposer dix minerais, revenir plus tard) : le modèle
actuel est un emplacement = un exemplaire.
- **Un allume-feu obligatoire** (silex, briquet) : `Light()` est déjà publique, il ne manque
qu'une condition et une action d'interaction.
- **La sauvegarde** : `Slots` et `Fuel` sont `Transient`. Le jour où la sauvegarde arrivera,
ce sont ces deux tableaux plus les temps restants qu'il faudra sérialiser.
- **Séparer « le feu éclaire » de « le feu consomme »** : aujourd'hui les deux sont liés, donc
un feu de camp n'éclaire pas quand il n'a rien à cuire. Invisible tant qu'il n'y a pas de
cycle jour/nuit, gênant le jour où il y en aura un. Une dizaine de lignes.
---
## 10. Ce que ce système a changé ailleurs
- **`IInteractable::GetInteractionPrompt` prend désormais l'`Interactor`.** Sans lui, la
station ne peut pas savoir quel emplacement le joueur regarde. Resservira pour les coffres.
- **`UInteractionComponent` met le prompt en cache et diffuse `OnPromptChanged`.** Le texte
peut changer sans que la cible change — en balayant d'un emplacement à l'autre sur le même
acteur. S'abonner à `OnFocusChanged` seul laissait ces cas figés à l'écran. Le composant ne
reconstruit le texte qu'une fois par trace, et ne réveille l'UI que sur un vrai changement.
- **`UItemDataAsset::ApplyWorldVisualsTo()`** et le champ `WorldMeshMaterial` (§2.3).
- Les `FText` d'`APickupItem` sont passés en anglais, conformément à la convention du projet.
+398
View File
@@ -0,0 +1,398 @@
# 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<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()` 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 :
| 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.