Files
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

420 lines
21 KiB
Markdown

# 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.