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

21 KiB

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_0CookSlot_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\|SoundLightSound, ExtinguishSound, PlaceItemSound. Tous facultatifs, un champ vide ne joue rien.

4.3 API publique

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 ResultDA_BurntMeat.
  4. DA_RawMeat : Hunger Restore = 8, Health Restore = -10 (manger cru punit). Can Be Cooked coché, Cook Duration = 20, Cooked ResultDA_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 ClassAll ClassesCookingStation → nomme-le BP_Campfire.
  2. BaseMesh → le mesh du foyer. Ajoute des Static Mesh enfants pour composer les pierres.
  3. FireEffectNiagara 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_1Log_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 fireune 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 Meatles 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.