(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
+83 -3
View File
@@ -115,10 +115,12 @@ qu'une compilation propre l'est vraiment.
| HUD, input d'interface, fondus, respawn | `FpsPlayerController.h/.cpp` |
| Interaction (trace + interface) | `Interactable.h`, `InteractionComponent`, `InteractionPromptWidget` |
| Objets | `ItemDataAsset`, `PickupItem` |
| Inventaire | `InventoryComponent`, `InventoryWidget`, `InventorySlotWidget`, `HotbarWidget`, `InventoryDragDropOperation` |
| Inventaire | `InventoryComponent`, `InventoryWidget`, `InventorySlotWidget`, `HotbarWidget`, `InventoryDragDropOperation`, `ItemDetailsWidget` |
| Survie | `SurvivalStatsComponent`, `SurvivalStatsWidget` |
| Menu principal | `MainMenuGameMode`, `MainMenuPlayerController`, `MainMenuWidget`, `MenuCameraSpot` |
| Fabrication | `CraftingRecipeDataAsset`, `CraftingRecipeBook`, `CraftingComponent`, `CraftingWidget`, `CraftingRecipeSlotWidget`, `CraftingIngredientRowWidget` |
| Cuisson | `CookingStation` + bloc *Cooking* / *Fuel* de `ItemDataAsset`**doc complète : [Docs/CookingSystem.md](../Docs/CookingSystem.md)** |
| Coffres | `StorageContainer`, `StorageScreenWidget` + `UInventoryComponent::TransferSlot` / `TransferAllTo` |
| Écran à onglets (inventaire / craft) | `InventoryScreenWidget` |
| Menu de pause | `PauseMenuWidget` + la partie pause de `FpsPlayerController` |
| Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` |
@@ -171,10 +173,88 @@ qu'une compilation propre l'est vraiment.
engendrer un acteur, et un établi déposerait son surplus sur sa propre table.
- Le résultat passe par `AddItem()`, donc **exactement comme un ramassage** : piles entamées
d'abord, puis barre rapide, puis grille.
- **Cuisson : aucune interface, tout dans le monde.** On dépose l'objet sur le feu, on regarde
le mesh changer. Le prompt n'affiche **que des actions réalisables** — jamais l'état du
poste, qui se lit sur les flammes, le nombre de bûches et le mesh de la nourriture. Cuire
n'est **pas une recette** mais une chaîne de DataAssets (cru → cuit → brûlé), donc le
changement de visuel est gratuit. Le carburant est une **pile d'objets**, pas une jauge de
secondes : une bûche visible = un exemplaire donné, et la capacité EST le nombre de bûches
posées dans le Blueprint. Le feu s'allume et s'arrête seul — il brûle si et seulement s'il a
du bois **et** du travail, la carbonisation comptant comme du travail pour que la
sur-cuisson reste punitive. Détail et pièges : [Docs/CookingSystem.md](../Docs/CookingSystem.md).
- **Un coffre EST un `UInventoryComponent`**, configuré par `ConfigureAsContainer()` : zéro
barre rapide, donc `GetBackpackStartIndex()` vaut 0 et la grille affiche tout. Aucun nouveau
modèle de données — d'où la durabilité qui traverse le coffre sans une ligne de code.
Le déplacement est désormais **unique** : `UInventoryComponent::TransferSlot(From, i, To, j, N)`
est statique, gère indifféremment un ou deux inventaires, et diffuse `OnInventoryChanged`
**des deux côtés** ; `MoveItem`/`MoveItemQuantity` ne sont plus que des appels avec
`From == To`. Oublier la seconde diffusion laisse la grille d'en face afficher un état périmé,
ce qui se lit comme un objet qui disparaît.
- **Le coffre n'a pas d'écran à lui : c'est un panneau de plus sur `WBP_InventoryScreen`**,
frère du WidgetSwitcher, `Collapsed` par défaut. La raison est du ressenti pur — le sac du
joueur ne bouge pas d'un pixel entre « j'ouvre mon sac » et « j'ouvre un coffre », alors
qu'un écran distinct le ferait sauter à chaque fois. Ce n'est **pas** un troisième onglet non
plus : les deux grilles doivent être visibles **ensemble**, ce qu'un WidgetSwitcher interdit.
Techniquement c'est une **deuxième instance du même `WBP_Inventory`** — elle ne peut pas
vivre *dans* `WBP_Inventory`, un Widget Blueprint qui se contient lui-même est une référence
circulaire qu'UMG refuse de compiler. D'où le flag `bAutoBindToPawn` : sans lui, la grille du
coffre s'accroche au pawn dans son `NativeConstruct` et affiche le sac des deux côtés.
Corollaire : l'onglet Fabrication est **grisé** tant qu'un coffre est ouvert (le panneau
resterait affiché à côté de la grille de recettes), et la touche de craft rend le coffre.
- **Clic droit = envoyer en face**, via `AddItem()` donc exactement comme un ramassage. La
destination est **poussée** par l'écran (`SetQuickTransferTarget`), jamais devinée : hors de
l'écran de coffre il n'existe aucun « autre côté », et deviner ferait disparaître des objets.
« Tout ranger » part de `GetBackpackStartIndex()` : la barre rapide porte les outils, les
ranger d'office serait une punition déguisée en confort.
- **Le coffre ne décide de rien.** Il appelle `OpenStorage(this)` ; c'est le contrôleur qui
referme le couvercle — par Échap, par la mort, ou parce que le joueur s'est éloigné
(`MaxStorageDistance`, surveillé par un timer à 0,25 s, jamais un Tick). Un coffre qui
déciderait seul finirait ouvert dans la moitié de ces cas.
- **`LidPivot`**, un `SceneComponent` vide entre la caisse et le couvercle : le pivot de
`SM_Chest_Top` est celui du pack, presque jamais la charnière. Sans lui le couvercle tourne
autour de son centre et traverse la caisse. Le coffre ne tick **que** pendant l'animation.
- **Toute la collision du coffre tient dans une `UBoxComponent`, les meshes n'en portent
aucune.** C'est la seule exception à la règle « le mesh EST la surface d'interaction » posée
par `ACraftingStation`, et elle vient de ce qu'un coffre est en **deux morceaux dont un
pivote** : compter sur leurs collisions imposerait que le pack en fournisse deux correctes,
et laisserait surtout un trou dans la surface visable dès que le couvercle se lève. La boîte
est fille de la **caisse**, jamais du pivot — le coffre se vise donc au même endroit ouvert
et fermé. Elle s'ajuste seule au volume des deux meshes dans `OnConstruction`
(`bAutoFitInteractionBox`), donc changer de variante de coffre ne demande aucun retaillage.
- **La barre rapide se masque sur l'onglet Fabrication.** `UInventoryScreenWidget` se contente
de diffuser `OnTabChanged` ; c'est le contrôleur qui décide, via une unique
`UpdateHotbarVisibility()`. Un seul point de décision, sinon fermer l'écran depuis l'onglet
craft laisse la barre cachée pour le reste de la partie.
- **Le détail de l'objet survolé vit *dans* `WBP_Inventory`**, à droite de la grille, et non sur
`WBP_InventoryScreen` : c'est un panneau de la grille, il doit la suivre — l'onglet
Fabrication l'emporte donc avec la page, sans une ligne de code. Contenu volontairement
réduit : icône, nom, trait de séparation, description, barre d'usure. Pas de fond, la largeur
est tenue par un Size Box — sans lui, un panneau vide ferait sauter la grille latéralement.
`UItemDetailsWidget` reçoit un couple **(inventaire, index)**, jamais une copie du
`FInventorySlot` : une charge consommée sous le curseur doit se voir, d'où son abonnement à
`OnInventoryChanged`. La grille masque `ContentRoot` en bloc quand rien n'est survolé, sinon
le trait de séparation resterait seul à l'écran.
- **Aucune case à cocher pour ce panneau : c'est l'écran qui l'allume et l'éteint**
(`SetItemDetailsEnabled`). `WBP_Inventory` étant instancié **deux fois** sur l'écran de coffre,
un réglage par instance serait un bug qui attend qu'on oublie de le décocher. La grille de
coffre n'en a donc jamais, et le sac perd le sien tant qu'un coffre est ouvert — trois
colonnes seraient illisibles. `HideStorage()` étant appelé dès `NativeConstruct`, l'état par
défaut est reposé à chaque construction plutôt que hérité de la Designer.
- **Une case ne connaît plus sa grille, elle diffuse `OnHoverChanged`.** La barre rapide pose
les mêmes `WBP_InventorySlot` sans être un `UInventoryWidget` : lui passer `nullptr` comme
parent était acceptable tant que personne n'écoutait le survol. La barre relaie vers le
**contrôleur**, seul à posséder à la fois le HUD et l'écran — un widget de HUD n'a pas à
connaître l'écran d'inventaire. Le panneau efface donc **la case qu'on quitte**
(`ClearSlot(inventaire, index)`), jamais « le panneau » : passer d'une case de la barre à une
case de la grille émet *entre dans B* puis *sort de A*, et un effacement inconditionnel
viderait ce qui vient d'être affiché.
- **`NativeOnMouseLeave` n'arrive jamais quand un widget s'efface sous le curseur.** C'est
exactement ce qui se passe en fermant un coffre, d'où le passage obligé par
`SetHoveredSlot()` dans `ClearSlotWidgets()` et `BindToInventory()` — cette dernière annonce
la sortie **avant** de changer d'inventaire, sinon on ne sait plus quel affichage périmer.
Le détail du système de craft — API, câblage éditeur, dépannage — est dans
[`Docs/Crafting.md`](../Docs/Crafting.md).
### Pièges UMG rencontrés sur l'écran de craft
@@ -215,8 +295,8 @@ qu'une compilation propre l'est vraiment.
## État actuel et suite
Boucle complète : récolter → **fabriquer** → ranger → consommer → survivre → mourir →
réapparaître. Autour : menu principal, menu de pause, et un écran de réglages dont **seul
Boucle complète : récolter → **fabriquer** **cuire** ranger → consommer → survivre →
mourir → réapparaître. Autour : menu principal, menu de pause, et un écran de réglages dont **seul
l'onglet « Jeu & Contrôles » est rempli**.
Le craft est en place de bout en bout : `DA_RecipeBook`, onglets Inventaire / Fabrication,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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.
+13 -3
View File
@@ -540,6 +540,16 @@ APickupItem* AFpsPlayer::SpawnPickup(const FInventorySlot& Slot, const FVector&
bool AFpsPlayer::DropSlot(int32 SlotIndex, int32 Quantity)
{
return DropFromInventory(InventoryComponent, SlotIndex, Quantity);
}
bool AFpsPlayer::DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity)
{
if (!Inventory)
{
return false;
}
if (!DroppedPickupClass)
{
UE_LOG(LogTemp, Warning, TEXT("AFpsPlayer : DroppedPickupClass n'est pas assignee, impossible de jeter un objet."));
@@ -547,7 +557,7 @@ bool AFpsPlayer::DropSlot(int32 SlotIndex, int32 Quantity)
}
FInventorySlot Taken;
if (!InventoryComponent->TakeFromSlot(SlotIndex, Quantity, Taken) || Taken.IsEmpty())
if (!Inventory->TakeFromSlot(SlotIndex, Quantity, Taken) || Taken.IsEmpty())
{
return false;
}
@@ -567,8 +577,8 @@ bool AFpsPlayer::DropSlot(int32 SlotIndex, int32 Quantity)
if (!SpawnPickup(Taken, PlaceOnGround(DropLocation)))
{
// On ne perd pas l'objet : il retourne dans l'inventaire, usure comprise.
InventoryComponent->AddItem(Taken.Item, Taken.Quantity, Taken.RemainingUses);
// On ne perd pas l'objet : il retourne d'ou il vient, usure comprise.
Inventory->AddItem(Taken.Item, Taken.Quantity, Taken.RemainingUses);
return false;
}
@@ -20,6 +20,7 @@
#include "PauseMenuWidget.h"
#include "ScreenFadeComponent.h"
#include "SettingsMenuWidget.h"
#include "StorageContainer.h"
#include "SurvivalStatsWidget.h"
#define LOCTEXT_NAMESPACE "PauseMenu"
@@ -43,6 +44,13 @@ namespace
constexpr int32 ZOrderInventory = 20;
constexpr int32 ZOrderHotbar = 30;
/**
* Periode de surveillance de l'eloignement du coffre. Quatre fois par
* seconde suffit largement : c'est une comparaison de distance, pas une
* requete physique, et un quart de seconde de tolerance ne se voit pas.
*/
constexpr float StorageDistanceCheckPeriod = 0.25f;
// Le menu de pause passe devant tout le HUD, et la boite de confirmation
// devant lui. Meme echelle que dans le menu principal.
//
@@ -119,8 +127,9 @@ void AFpsPlayerController::OnPossess(APawn* InPawn)
void AFpsPlayerController::HandlePawnDied()
{
// L'inventaire ouvert au moment de la mort resterait affiche par-dessus
// l'ecran noir, curseur compris.
// L'ecran ouvert au moment de la mort resterait affiche par-dessus l'ecran
// noir, curseur compris. Sa fermeture rend le coffre au passage, couvercle
// compris.
if (bInventoryOpen)
{
ToggleInventory();
@@ -265,17 +274,23 @@ void AFpsPlayerController::UpdateHotbarVisibility()
Hotbar->SetVisibility(bHideHotbar ? ESlateVisibility::Collapsed : HotbarDefaultVisibility);
}
void AFpsPlayerController::DropHoveredItem()
void AFpsPlayerController::HandleHotbarSlotHovered(UInventoryComponent* Inventory, int32 SlotIndex, bool bHovered)
{
if (!bInventoryOpen || !InventoryScreen)
if (InventoryScreen && bInventoryOpen)
{
return;
InventoryScreen->NotifyExternalSlotHovered(Inventory, SlotIndex, bHovered);
}
}
// Renvoie INDEX_NONE quand l'onglet Fabrication est affiche : la touche ne
// doit pas agir sur une case cachee derriere la grille de craft.
const int32 SlotIndex = InventoryScreen->GetHoveredSlotIndex();
if (SlotIndex == INDEX_NONE)
void AFpsPlayerController::DropHoveredItem()
{
UInventoryComponent* HoveredInventory = nullptr;
int32 SlotIndex = INDEX_NONE;
// L'ecran renvoie INDEX_NONE quand l'onglet Fabrication est affiche : la
// touche ne doit pas agir sur une case cachee derriere la grille de craft.
// Il consulte le panneau de coffre en premier quand il est ouvert.
if (!bInventoryOpen || !InventoryScreen || !InventoryScreen->GetHoveredSlot(HoveredInventory, SlotIndex))
{
return;
}
@@ -291,8 +306,10 @@ void AFpsPlayerController::DropHoveredItem()
// modal, pas une commande distincte.
const bool bSingle = IsInputKeyDown(EKeys::LeftControl) || IsInputKeyDown(EKeys::RightControl);
// MAX_int32 pour "tout" : DropSlot borne de lui-meme a ce que contient le slot.
PlayerPawn->DropSlot(SlotIndex, bSingle ? 1 : MAX_int32);
// MAX_int32 pour "tout" : la fonction borne d'elle-meme a ce que contient
// le slot. On passe l'inventaire survole et non celui du pawn : viser une
// case du coffre doit sortir l'objet du coffre.
PlayerPawn->DropFromInventory(HoveredInventory, SlotIndex, bSingle ? 1 : MAX_int32);
}
void AFpsPlayerController::CreateHudWidgets()
@@ -351,6 +368,12 @@ void AFpsPlayerController::CreateHudWidgets()
// reviendra en quittant l'onglet Fabrication.
HotbarDefaultVisibility = Hotbar->GetVisibility();
Hotbar->AddToViewport(ZOrderHotbar);
// La barre est un widget de HUD, l'ecran d'inventaire un autre : ni
// l'un ni l'autre ne se connait. Le controller les possede tous les
// deux, c'est donc lui qui fait le pont -- exactement comme pour la
// visibilite de la barre sur l'onglet Fabrication.
Hotbar->OnSlotHovered.AddDynamic(this, &AFpsPlayerController::HandleHotbarSlotHovered);
}
}
@@ -418,6 +441,11 @@ void AFpsPlayerController::ToggleCrafting()
return;
}
// Le panneau de coffre est un FRERE du TabSwitcher : il resterait affiche a
// cote de la grille de recettes. On rend le coffre plutot que d'ignorer la
// touche, qui exprime une intention claire.
ReleaseActiveStorage();
SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true);
}
@@ -493,12 +521,13 @@ void AFpsPlayerController::SetInventoryScreenOpen(bool bOpen, bool bShowCrafting
bInventoryOpen = bOpen;
// Une fermeture, quelle qu'en soit la cause -- Tab, Echap, la mort --
// rend le poste. Le faire ici et nulle part ailleurs garantit qu'aucun
// chemin de sortie ne laisse l'etabli accessible depuis l'autre bout de
// la carte.
// rend le poste ET le coffre. Le faire ici et nulle part ailleurs garantit
// qu'aucun chemin de sortie ne laisse l'etabli accessible depuis l'autre
// bout de la carte, ni un couvercle leve derriere soi.
if (!bInventoryOpen)
{
ReleaseActiveStation();
ReleaseActiveStorage();
}
if (bInventoryOpen)
@@ -519,9 +548,14 @@ void AFpsPlayerController::SetInventoryScreenOpen(bool bOpen, bool bShowCrafting
// sur l'onglet Fabrication.
UpdateHotbarVisibility();
SetUiInputMode(bInventoryOpen);
}
void AFpsPlayerController::SetUiInputMode(bool bUiOpen)
{
AFpsPlayer* PlayerPawn = Cast<AFpsPlayer>(GetPawn());
if (bInventoryOpen)
if (bUiOpen)
{
// GameAndUI et non UIOnly : en UIOnly, plus aucune action Enhanced
// Input ne passerait et on ne pourrait plus refermer avec Tab.
@@ -564,6 +598,109 @@ void AFpsPlayerController::SetInventoryScreenOpen(bool bOpen, bool bShowCrafting
}
}
void AFpsPlayerController::OpenStorage(AStorageContainer* Container)
{
if (!Container || !IsLocalController() || !InventoryScreen)
{
return;
}
// Deja devant un autre coffre : on rend le precedent avant d'ouvrir le
// nouveau, sinon son couvercle resterait leve pour le reste de la partie.
ReleaseActiveStorage();
ActiveStorage = Container;
bStorageOpen = true;
InventoryScreen->ShowStorage(Container);
Container->SetOpen(true);
// La barre rapide reste visible et cliquable par-dessus l'ecran : on lui
// donne le coffre pour que le clic droit y marche aussi.
if (Hotbar)
{
Hotbar->SetQuickTransferTarget(Container->GetStorage());
}
// Le coffre n'est pas un ecran a part : c'est un panneau de plus sur
// l'ecran d'inventaire, sur l'onglet Inventaire. Consequence directe -- le
// sac du joueur ne bouge pas d'un pixel entre les deux situations.
SetInventoryScreenOpen(true, /*bShowCraftingTab=*/false);
// La surveillance ne tourne QUE pendant l'ecran : un timer permanent pour
// une condition vraie deux minutes par partie serait du gaspillage.
GetWorldTimerManager().SetTimer(
StorageDistanceTimerHandle, this, &AFpsPlayerController::CheckStorageDistance,
StorageDistanceCheckPeriod, /*bLoop=*/true);
}
void AFpsPlayerController::CloseStorage()
{
if (!bStorageOpen)
{
return;
}
// Le panneau vit dans l'ecran d'inventaire : le refermer ferme l'ecran
// entier, qui rend le coffre au passage par ReleaseActiveStorage().
SetInventoryScreenOpen(false, /*bShowCraftingTab=*/false);
}
void AFpsPlayerController::ReleaseActiveStorage()
{
if (!bStorageOpen)
{
return;
}
bStorageOpen = false;
GetWorldTimerManager().ClearTimer(StorageDistanceTimerHandle);
if (AStorageContainer* Container = ActiveStorage.Get())
{
Container->SetOpen(false);
}
ActiveStorage.Reset();
if (InventoryScreen)
{
InventoryScreen->HideStorage();
}
if (Hotbar)
{
Hotbar->SetQuickTransferTarget(nullptr);
}
}
void AFpsPlayerController::CheckStorageDistance()
{
if (!bStorageOpen)
{
return;
}
const AStorageContainer* Container = ActiveStorage.Get();
const APawn* PlayerPawn = GetPawn();
// Coffre detruit ou joueur sans corps : dans les deux cas l'ecran pilote
// quelque chose qui n'existe plus.
if (!Container || !PlayerPawn)
{
CloseStorage();
return;
}
// DistSquared : on evite une racine carree quatre fois par seconde pour une
// comparaison qui n'a pas besoin de la distance reelle.
const float MaxDistSquared = FMath::Square(MaxStorageDistance);
if (FVector::DistSquared(Container->GetActorLocation(), PlayerPawn->GetActorLocation()) > MaxDistSquared)
{
CloseStorage();
}
}
void AFpsPlayerController::HandlePauseInput()
{
// La boite a le focus et consomme deja Echap elle-meme. Si elle l'a perdu,
@@ -582,8 +719,15 @@ void AFpsPlayerController::HandlePauseInput()
return;
}
// Echap sert d'abord a annuler ce qui est ouvert. Fermer l'inventaire est
// ce que le joueur attend en premier.
// Echap sert d'abord a annuler ce qui est ouvert. Le coffre passe avant
// l'inventaire : les deux ne sont jamais ouverts ensemble, mais l'ordre
// resterait juste le jour ou ca changerait.
if (bStorageOpen)
{
CloseStorage();
return;
}
if (bInventoryOpen)
{
ToggleInventory();
@@ -847,6 +991,10 @@ void AFpsPlayerController::EndPlay(const EEndPlayReason::Type EndPlayReason)
InventoryScreen = nullptr;
}
// Le timer d'eloignement rappellerait une methode d'un controller en train
// de disparaitre. ClearTimer est sans effet s'il ne tourne pas.
GetWorldTimerManager().ClearTimer(StorageDistanceTimerHandle);
if (InteractionPrompt)
{
InteractionPrompt->RemoveFromParent();
@@ -80,6 +80,19 @@ void UHotbarWidget::HandleInventoryChanged()
Refresh();
}
void UHotbarWidget::SetQuickTransferTarget(UInventoryComponent* InTarget)
{
QuickTransferTarget = InTarget;
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->SetQuickTransferTarget(InTarget);
}
}
}
void UHotbarWidget::HandleSelectionChanged(int32 NewIndex)
{
// Seule la surbrillance bouge : inutile de repousser tout le contenu.
@@ -92,6 +105,11 @@ void UHotbarWidget::HandleSelectionChanged(int32 NewIndex)
}
}
void UHotbarWidget::HandleSlotHovered(int32 SlotIndex, bool bHovered)
{
OnSlotHovered.Broadcast(BoundInventory.Get(), SlotIndex, bHovered);
}
void UHotbarWidget::Refresh()
{
if (!SlotContainer)
@@ -161,7 +179,9 @@ void UHotbarWidget::RebuildSlots(int32 DesiredCount)
// Meme indexation que la grille : la case 0 de la barre EST le slot 0
// de l'inventaire. C'est ce qui rend le glisser-deposer entre les deux
// gratuit -- ils manipulent le meme tableau.
NewSlot->InitSlot(BoundInventory.Get(), Index, nullptr);
NewSlot->InitSlot(BoundInventory.Get(), Index);
NewSlot->OnHoverChanged.AddDynamic(this, &UHotbarWidget::HandleSlotHovered);
NewSlot->SetQuickTransferTarget(QuickTransferTarget.Get());
if (UHorizontalBoxSlot* BoxSlot = SlotContainer->AddChildToHorizontalBox(NewSlot))
{
@@ -123,7 +123,7 @@ int32 UInventoryComponent::AddItem(UItemDataAsset* Item, int32 Quantity, int32 R
if (Remaining < Quantity)
{
OnInventoryChanged.Broadcast();
BroadcastChanged();
}
return Remaining;
@@ -172,7 +172,7 @@ int32 UInventoryComponent::RemoveItem(UItemDataAsset* Item, int32 Quantity)
const int32 Removed = Quantity - Remaining;
if (Removed > 0)
{
OnInventoryChanged.Broadcast();
BroadcastChanged();
}
return Removed;
@@ -202,7 +202,7 @@ bool UInventoryComponent::ConsumeUse(int32 SlotIndex)
Slot.Clear();
}
OnInventoryChanged.Broadcast();
BroadcastChanged();
return true;
}
@@ -223,7 +223,7 @@ bool UInventoryComponent::ConsumeUse(int32 SlotIndex)
}
}
OnInventoryChanged.Broadcast();
BroadcastChanged();
return true;
}
@@ -327,86 +327,50 @@ bool UInventoryComponent::TakeFromSlot(int32 SlotIndex, int32 Quantity, FInvento
Slot.Clear();
}
OnInventoryChanged.Broadcast();
BroadcastChanged();
return true;
}
bool UInventoryComponent::MoveItemQuantity(int32 FromIndex, int32 ToIndex, int32 Quantity)
{
if (Quantity <= 0 || FromIndex == ToIndex || !Slots.IsValidIndex(FromIndex) || !Slots.IsValidIndex(ToIndex))
{
return false;
}
FInventorySlot& From = Slots[FromIndex];
if (From.IsEmpty())
{
return false;
}
// Deplacement total : on retombe sur le comportement complet, echange compris.
if (Quantity >= From.Quantity)
{
return MoveItem(FromIndex, ToIndex);
}
FInventorySlot& To = Slots[ToIndex];
const int32 MaxStack = From.Item->GetEffectiveMaxStack();
if (To.IsEmpty())
{
const int32 Transferred = FMath::Min(Quantity, MaxStack);
To.Item = From.Item;
To.Quantity = Transferred;
To.RemainingUses = From.RemainingUses;
From.Quantity -= Transferred;
OnInventoryChanged.Broadcast();
return true;
}
if (To.Item == From.Item)
{
const int32 Transferable = FMath::Min(Quantity, MaxStack - To.Quantity);
if (Transferable <= 0)
{
return false;
}
To.Quantity += Transferable;
From.Quantity -= Transferable;
OnInventoryChanged.Broadcast();
return true;
}
// Objet different et transfert partiel : aucune interpretation raisonnable.
// On refuse plutot que d'inventer un comportement que le joueur ne
// comprendrait pas.
return false;
return TransferSlot(this, FromIndex, this, ToIndex, Quantity);
}
bool UInventoryComponent::MoveItem(int32 FromIndex, int32 ToIndex)
{
if (FromIndex == ToIndex || !Slots.IsValidIndex(FromIndex) || !Slots.IsValidIndex(ToIndex))
// MAX_int32 depasse forcement la pile : TransferSlot bascule alors sur le
// deplacement total, echange compris.
return TransferSlot(this, FromIndex, this, ToIndex, MAX_int32);
}
bool UInventoryComponent::ApplySlotMove(FInventorySlot& From, FInventorySlot& To, int32 Quantity)
{
if (Quantity <= 0 || From.IsEmpty())
{
return false;
}
FInventorySlot& From = Slots[FromIndex];
if (From.IsEmpty())
{
return false;
}
const int32 MaxStack = From.Item->GetEffectiveMaxStack();
const bool bTotal = (Quantity >= From.Quantity);
FInventorySlot& To = Slots[ToIndex];
// Cible vide : la pile entiere part, quelle que soit sa taille.
if (To.IsEmpty())
{
// Deplacement total : la pile entiere part, quelle que soit sa taille --
// on ne la tronque pas a MaxStack, elle etait deja legale a la source.
if (bTotal)
{
To = From;
From.Clear();
OnInventoryChanged.Broadcast();
return true;
}
const int32 Transferred = FMath::Min(Quantity, MaxStack);
To.Item = From.Item;
To.Quantity = Transferred;
// L'usure suit l'objet : diviser une pile de canettes entamees donne
// deux piles au meme niveau, jamais une neuve.
To.RemainingUses = From.RemainingUses;
From.Quantity -= Transferred;
return true;
}
@@ -415,15 +379,21 @@ bool UInventoryComponent::MoveItem(int32 FromIndex, int32 ToIndex)
// l'echange -- deux canettes entamees ne fusionnent jamais.
if (To.Item == From.Item)
{
const int32 MaxStack = From.Item->GetEffectiveMaxStack();
const int32 Transferable = FMath::Min(From.Quantity, MaxStack - To.Quantity);
const int32 Wanted = bTotal ? From.Quantity : Quantity;
const int32 Transferable = FMath::Min(Wanted, MaxStack - To.Quantity);
// Cible deja pleine : on echange plutot que de ne rien faire, sinon le
// geste du joueur resterait sans effet et passerait pour un bug.
if (Transferable <= 0)
{
// Cible pleine. Sur un deplacement total on echange plutot que de
// ne rien faire, sinon le geste du joueur passerait pour un bug ;
// sur un deplacement partiel on refuse, echanger la moitie d'une
// pile contre autre chose n'ayant aucun sens.
if (!bTotal)
{
return false;
}
Swap(From, To);
OnInventoryChanged.Broadcast();
return true;
}
@@ -434,16 +404,168 @@ bool UInventoryComponent::MoveItem(int32 FromIndex, int32 ToIndex)
From.Clear();
}
OnInventoryChanged.Broadcast();
return true;
}
// Objets differents : simple echange.
// Objets differents : echange, et seulement sur un deplacement total. Il
// n'existe aucune facon sensee d'echanger la moitie d'une pile contre autre
// chose -- on refuse plutot que d'inventer un comportement incomprehensible.
if (!bTotal)
{
return false;
}
Swap(From, To);
OnInventoryChanged.Broadcast();
return true;
}
bool UInventoryComponent::TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity)
{
if (!From || !To || Quantity <= 0)
{
return false;
}
// Meme inventaire et meme case : rien a faire. Le test doit porter sur le
// couple et non sur l'index seul, deux inventaires differents ayant tous
// les deux une case 3.
if (From == To && FromIndex == ToIndex)
{
return false;
}
if (!From->Slots.IsValidIndex(FromIndex) || !To->Slots.IsValidIndex(ToIndex))
{
return false;
}
// Deux references dans le meme tableau quand From == To : legal ici parce
// qu'aucun redimensionnement n'a lieu entre les deux acces.
if (!ApplySlotMove(From->Slots[FromIndex], To->Slots[ToIndex], Quantity))
{
return false;
}
From->BroadcastChanged();
if (To != From)
{
To->BroadcastChanged();
}
return true;
}
int32 UInventoryComponent::PushSlotInto(int32 SlotIndex, UInventoryComponent& To)
{
if (!Slots.IsValidIndex(SlotIndex))
{
return 0;
}
FInventorySlot& Slot = Slots[SlotIndex];
if (Slot.IsEmpty())
{
return 0;
}
const int32 Left = To.AddItem(Slot.Item, Slot.Quantity, Slot.RemainingUses);
const int32 Moved = Slot.Quantity - Left;
if (Moved <= 0)
{
return 0;
}
Slot.Quantity = Left;
if (Slot.Quantity <= 0)
{
Slot.Clear();
}
return Moved;
}
int32 UInventoryComponent::QuickTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To)
{
if (!From || !To || From == To)
{
return 0;
}
const int32 Moved = From->PushSlotInto(FromIndex, *To);
if (Moved > 0)
{
From->BroadcastChanged();
}
return Moved;
}
int32 UInventoryComponent::TransferAllTo(UInventoryComponent* To, int32 FirstIndex)
{
if (!To || To == this)
{
return 0;
}
To->BeginBatch();
int32 Moved = 0;
for (int32 Index = FMath::Max(0, FirstIndex); Index < Slots.Num(); ++Index)
{
Moved += PushSlotInto(Index, *To);
}
To->EndBatch();
if (Moved > 0)
{
BroadcastChanged();
}
return Moved;
}
void UInventoryComponent::ConfigureAsContainer(int32 NewSlotCount)
{
// Aucune barre rapide : GetBackpackStartIndex() vaut alors 0, et la grille
// affiche la totalite du conteneur au lieu d'en masquer les dix premieres
// cases.
HotbarSlotCount = 0;
BonusSlotCount = 0;
BackpackSlotCount = FMath::Max(0, NewSlotCount);
SelectedHotbarIndex = 0;
EnsureSlotCount();
}
void UInventoryComponent::BroadcastChanged()
{
if (BatchDepth > 0)
{
bBatchDirty = true;
return;
}
OnInventoryChanged.Broadcast();
}
void UInventoryComponent::BeginBatch()
{
++BatchDepth;
}
void UInventoryComponent::EndBatch()
{
BatchDepth = FMath::Max(0, BatchDepth - 1);
if (BatchDepth > 0 || !bBatchDirty)
{
return;
}
bBatchDirty = false;
OnInventoryChanged.Broadcast();
}
bool UInventoryComponent::SetBonusSlotCount(int32 NewBonusSlots)
{
const int32 NewTotal = FMath::Max(0, HotbarSlotCount + BackpackSlotCount + FMath::Max(0, NewBonusSlots));
@@ -463,7 +585,7 @@ bool UInventoryComponent::SetBonusSlotCount(int32 NewBonusSlots)
CompactSlots();
Slots.SetNum(NewTotal);
OnInventoryChanged.Broadcast();
BroadcastChanged();
return true;
}
@@ -4,12 +4,15 @@
#include "InventoryScreenWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#include "Components/WidgetSwitcher.h"
#include "CraftingWidget.h"
#include "FpsPlayer.h"
#include "GameFramework/PlayerController.h"
#include "InventoryComponent.h"
#include "InventoryDragDropOperation.h"
#include "InventoryWidget.h"
#include "StorageContainer.h"
void UInventoryScreenWidget::NativeConstruct()
{
@@ -25,6 +28,30 @@ void UInventoryScreenWidget::NativeConstruct()
CraftingTabButton->OnClicked.AddDynamic(this, &UInventoryScreenWidget::HandleCraftingTabClicked);
}
if (StoreAllButton)
{
StoreAllButton->OnClicked.AddDynamic(this, &UInventoryScreenWidget::HandleStoreAllClicked);
}
if (TakeAllButton)
{
TakeAllButton->OnClicked.AddDynamic(this, &UInventoryScreenWidget::HandleTakeAllClicked);
}
// La grille de coffre n'affiche jamais de detail : c'est le meme
// WBP_Inventory que le sac, elle en porte donc un exemplaire qu'on coupe une
// fois pour toutes ici plutot que de compter sur une case a cocher.
if (StorageGrid)
{
StorageGrid->SetItemDetailsEnabled(false);
}
// Le panneau de coffre ne se montre qu'a l'ouverture d'un coffre. On le
// masque en C++ plutot que de dependre de ce qui a ete regle dans la
// Designer : un panneau laisse Visible par erreur afficherait une grille
// vide en permanence a cote du sac.
HideStorage();
ShowInventoryTab();
}
@@ -56,6 +83,152 @@ int32 UInventoryScreenWidget::GetHoveredSlotIndex() const
return InventoryPage->GetHoveredSlotIndex();
}
bool UInventoryScreenWidget::GetHoveredSlot(UInventoryComponent*& OutInventory, int32& OutIndex) const
{
// Le coffre en premier : c'est le panneau le plus a droite, donc celui que
// le curseur survole quand les deux sont affiches.
if (bStorageVisible && StorageGrid && StorageGrid->GetHoveredSlot(OutInventory, OutIndex))
{
return true;
}
if (InventoryPage && IsInventoryTabActive() && InventoryPage->GetHoveredSlot(OutInventory, OutIndex))
{
return true;
}
OutInventory = nullptr;
OutIndex = INDEX_NONE;
return false;
}
void UInventoryScreenWidget::NotifyExternalSlotHovered(UInventoryComponent* Inventory, int32 SlotIndex, bool bHovered)
{
// Rien a faire sous l'onglet Fabrication : le panneau est parti avec la
// page, et la barre rapide y est de toute facon masquee.
if (InventoryPage && IsInventoryTabActive())
{
InventoryPage->NotifyExternalSlotHovered(Inventory, SlotIndex, bHovered);
}
}
UInventoryComponent* UInventoryScreenWidget::GetPlayerInventory() const
{
const APlayerController* OwningController = GetOwningPlayer();
AFpsPlayer* PlayerPawn = OwningController ? Cast<AFpsPlayer>(OwningController->GetPawn()) : nullptr;
return PlayerPawn ? PlayerPawn->GetInventoryComponent() : nullptr;
}
void UInventoryScreenWidget::ShowStorage(AStorageContainer* Container)
{
UInventoryComponent* Contents = Container ? Container->GetStorage() : nullptr;
if (!Contents)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryScreenWidget::ShowStorage : coffre nul ou sans inventaire."));
return;
}
if (!StoragePanel || !StorageGrid)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryScreenWidget : 'StoragePanel' ou 'StorageGrid' manque dans WBP_InventoryScreen, le coffre ne s'affichera pas."));
return;
}
BoundStorage = Contents;
bStorageVisible = true;
StorageGrid->SetColumnCount(Container->GetColumnCount());
StorageGrid->BindToInventory(Contents);
// Le clic droit envoie d'un cote a l'autre. Le pawn a pu changer depuis la
// derniere ouverture (respawn), d'ou la relecture plutot qu'une valeur
// gardee en cache.
StorageGrid->SetQuickTransferTarget(GetPlayerInventory());
if (InventoryPage)
{
InventoryPage->SetQuickTransferTarget(Contents);
// Le detail disparait en mode coffre : l'ecran passe deja a deux grilles
// cote a cote, une troisieme colonne le rendrait illisible.
InventoryPage->SetItemDetailsEnabled(false);
}
if (StorageTitle)
{
StorageTitle->SetText(Container->GetDisplayName());
}
StoragePanel->SetVisibility(ESlateVisibility::Visible);
// Un coffre ouvert sous l'onglet Fabrication n'aurait aucun sens : on force
// l'onglet Inventaire, et ShowPage grise le bouton Fabrication tant que
// bStorageVisible est vrai.
ShowInventoryTab();
}
void UInventoryScreenWidget::HideStorage()
{
BoundStorage.Reset();
bStorageVisible = false;
// On coupe les transferts rapides AVANT de debrancher : une case qui garde
// un coffre en memoire y enverrait encore des objets au prochain clic droit,
// panneau ferme.
if (InventoryPage)
{
InventoryPage->SetQuickTransferTarget(nullptr);
// Le sac retrouve son panneau. Repose a chaque fermeture, y compris a la
// construction : c'est l'etat par defaut de l'ecran, il ne doit dependre
// ni de la Designer ni de ce qu'a laisse la session precedente.
InventoryPage->SetItemDetailsEnabled(true);
}
if (StorageGrid)
{
StorageGrid->SetQuickTransferTarget(nullptr);
StorageGrid->BindToInventory(nullptr);
}
if (StoragePanel)
{
StoragePanel->SetVisibility(ESlateVisibility::Collapsed);
}
// Rend la main a l'onglet Fabrication, que ShowPage avait grise.
if (CraftingTabButton)
{
CraftingTabButton->SetIsEnabled(IsInventoryTabActive());
}
}
void UInventoryScreenWidget::HandleStoreAllClicked()
{
UInventoryComponent* PlayerInventory = GetPlayerInventory();
if (!PlayerInventory || !BoundStorage.IsValid())
{
return;
}
// A partir de la grille seulement : la barre rapide porte les outils qu'on
// vient d'utiliser, et les ranger a chaque retour d'expedition serait une
// punition deguisee en confort.
PlayerInventory->TransferAllTo(BoundStorage.Get(), PlayerInventory->GetBackpackStartIndex());
}
void UInventoryScreenWidget::HandleTakeAllClicked()
{
UInventoryComponent* PlayerInventory = GetPlayerInventory();
if (!PlayerInventory || !BoundStorage.IsValid())
{
return;
}
// Depuis l'index 0 : un coffre n'a pas de barre rapide. Ce qui ne rentre
// pas dans le sac reste dans le coffre.
BoundStorage->TransferAllTo(PlayerInventory, 0);
}
void UInventoryScreenWidget::ShowInventoryTab()
{
ShowPage(InventoryPage);
@@ -96,7 +269,10 @@ void UInventoryScreenWidget::ShowPage(UWidget* Page)
if (CraftingTabButton)
{
CraftingTabButton->SetIsEnabled(bInventoryActive);
// Grise aussi tant qu'un coffre est ouvert : ses ressources ne comptent
// pas encore dans les recettes, et un clic dont le resultat serait
// incomprehensible vaut mieux etre rendu impossible.
CraftingTabButton->SetIsEnabled(bInventoryActive && !bStorageVisible);
}
OnTabChanged.Broadcast(bInventoryActive);
@@ -114,11 +290,13 @@ bool UInventoryScreenWidget::NativeOnDrop(const FGeometry& InGeometry, const FDr
return true;
}
// Depuis l'inventaire SOURCE et non celui du joueur : sortir une pile du
// coffre en la lachant dans le vide doit la retirer du coffre.
if (const APlayerController* OwningController = GetOwningPlayer())
{
if (AFpsPlayer* PlayerPawn = Cast<AFpsPlayer>(OwningController->GetPawn()))
{
PlayerPawn->DropSlot(Payload->SourceSlotIndex, Payload->Quantity);
PlayerPawn->DropFromInventory(Payload->SourceInventory.Get(), Payload->SourceSlotIndex, Payload->Quantity);
}
}
@@ -8,7 +8,6 @@
#include "Components/TextBlock.h"
#include "Components/Widget.h"
#include "InventoryDragDropOperation.h"
#include "InventoryWidget.h"
#include "ItemDataAsset.h"
void UInventorySlotWidget::NativeConstruct()
@@ -53,31 +52,29 @@ void UInventorySlotWidget::ConfigureAsDragVisual()
SetVisibility(ESlateVisibility::HitTestInvisible);
}
void UInventorySlotWidget::InitSlot(UInventoryComponent* InInventory, int32 InSlotIndex, UInventoryWidget* InOwningGrid)
void UInventorySlotWidget::InitSlot(UInventoryComponent* InInventory, int32 InSlotIndex)
{
OwningInventory = InInventory;
SlotIndex = InSlotIndex;
OwningGrid = InOwningGrid;
}
void UInventorySlotWidget::SetQuickTransferTarget(UInventoryComponent* InTarget)
{
QuickTransferTarget = InTarget;
}
void UInventorySlotWidget::NativeOnMouseEnter(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
Super::NativeOnMouseEnter(InGeometry, InMouseEvent);
if (OwningGrid.IsValid())
{
OwningGrid->NotifySlotHovered(SlotIndex, true);
}
OnHoverChanged.Broadcast(SlotIndex, true);
}
void UInventorySlotWidget::NativeOnMouseLeave(const FPointerEvent& InMouseEvent)
{
Super::NativeOnMouseLeave(InMouseEvent);
if (OwningGrid.IsValid())
{
OwningGrid->NotifySlotHovered(SlotIndex, false);
}
OnHoverChanged.Broadcast(SlotIndex, false);
}
void UInventorySlotWidget::SetSlot(const FInventorySlot& InSlot)
@@ -142,6 +139,18 @@ void UInventorySlotWidget::SetSlot(const FInventorySlot& InSlot)
FReply UInventorySlotWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
// Clic droit : transfert rapide vers l'inventaire d'en face, quand il y en
// a un. Traite avant le glisser pour que le bouton droit ne demarre jamais
// de DetectDrag -- ce qui laisserait une operation orpheline en cours.
if (!CachedSlot.IsEmpty() && InMouseEvent.GetEffectingButton() == EKeys::RightMouseButton)
{
if (QuickTransferTarget.IsValid() && OwningInventory.IsValid() && SlotIndex != INDEX_NONE)
{
UInventoryComponent::QuickTransferSlot(OwningInventory.Get(), SlotIndex, QuickTransferTarget.Get());
return FReply::Handled();
}
}
if (CachedSlot.IsEmpty() || !InMouseEvent.IsMouseButtonDown(EKeys::LeftMouseButton))
{
return Super::NativeOnMouseButtonDown(InGeometry, InMouseEvent);
@@ -225,15 +234,12 @@ bool UInventorySlotWidget::NativeOnDrop(const FGeometry& InGeometry, const FDrag
return Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
}
// On refuse les transferts entre deux inventaires differents : ce sera le
// jour ou tu ajouteras les coffres, avec ses propres regles.
if (!Payload->SourceInventory.IsValid() || Payload->SourceInventory != OwningInventory)
{
return false;
}
// MoveItemQuantity diffuse OnInventoryChanged, donc la grille se rafraichit
// seule. Une quantite egale a la pile entiere retombe sur le comportement
// complet, echange compris.
return OwningInventory->MoveItemQuantity(Payload->SourceSlotIndex, SlotIndex, Payload->Quantity);
// TransferSlot diffuse OnInventoryChanged des DEUX cotes, donc les grilles
// se rafraichissent seules. Elle gere indifferemment le meme inventaire et
// deux inventaires distincts : glisser vers un coffre, vers la barre rapide
// ou vers la case d'a cote emprunte exactement le meme chemin.
return UInventoryComponent::TransferSlot(
Payload->SourceInventory.Get(), Payload->SourceSlotIndex,
OwningInventory.Get(), SlotIndex,
Payload->Quantity);
}
@@ -10,48 +10,58 @@
#include "InventoryComponent.h"
#include "InventoryDragDropOperation.h"
#include "InventorySlotWidget.h"
#include "ItemDetailsWidget.h"
void UInventoryWidget::NativeConstruct()
{
Super::NativeConstruct();
// Une grille de coffre n'a rien a chercher toute seule : son contenu lui
// est donne par l'ecran, apres coup.
if (bAutoBindToPawn)
{
BindToOwningPawn();
}
}
void UInventoryWidget::BindToOwningPawn()
{
UInventoryComponent* NewInventory = ResolveInventory();
if (BoundInventory.Get() == NewInventory)
if (!NewInventory)
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryWidget : aucun UInventoryComponent trouve sur le pawn possede."));
}
BindToInventory(NewInventory);
}
void UInventoryWidget::BindToInventory(UInventoryComponent* InInventory)
{
if (BoundInventory.Get() == InInventory)
{
return;
}
// On annonce la sortie de survol AVANT de changer d'inventaire : le panneau
// de detail a besoin de savoir QUEL affichage devient perime, et une fois
// BoundInventory reaffecte cette information est perdue.
SetHoveredSlot(INDEX_NONE);
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.RemoveDynamic(this, &UInventoryWidget::HandleInventoryChanged);
}
BoundInventory = NewInventory;
BoundInventory = InInventory;
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.AddDynamic(this, &UInventoryWidget::HandleInventoryChanged);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryWidget : aucun UInventoryComponent trouve sur le pawn possede."));
}
// Les cases gardent l'ancien composant en memoire : on force leur
// recreation en vidant la grille.
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->RemoveFromParent();
}
}
SlotWidgets.Reset();
ClearSlotWidgets();
Refresh();
}
@@ -79,20 +89,138 @@ void UInventoryWidget::HandleInventoryChanged()
Refresh();
}
void UInventoryWidget::SetQuickTransferTarget(UInventoryComponent* InTarget)
{
QuickTransferTarget = InTarget;
// Les cases deja creees ne reliront rien d'elles-memes : on leur pousse.
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->SetQuickTransferTarget(InTarget);
}
}
}
void UInventoryWidget::SetColumnCount(int32 NewColumnCount)
{
const int32 Clamped = FMath::Max(1, NewColumnCount);
if (ColumnCount == Clamped)
{
return;
}
ColumnCount = Clamped;
// La position d'une case dans le UniformGrid est figee a son ajout : on
// repart des cases plutot que de recalculer chaque UUniformGridSlot. Ca
// n'arrive qu'a l'ouverture d'un coffre, pas a chaque rafraichissement.
ClearSlotWidgets();
Refresh();
}
void UInventoryWidget::NotifySlotHovered(int32 Index, bool bHovered)
{
if (bHovered)
{
HoveredSlotIndex = Index;
SetHoveredSlot(Index);
}
else if (HoveredSlotIndex == Index)
{
// Le test d'egalite evite d'effacer le survol quand les evenements
// arrivent dans l'ordre "entre dans B" puis "sort de A".
HoveredSlotIndex = INDEX_NONE;
SetHoveredSlot(INDEX_NONE);
}
}
void UInventoryWidget::SetHoveredSlot(int32 NewIndex)
{
if (HoveredSlotIndex == NewIndex)
{
return;
}
const int32 PreviousIndex = HoveredSlotIndex;
HoveredSlotIndex = NewIndex;
if (!ItemDetailsPanel || !bItemDetailsEnabled)
{
return;
}
if (HoveredSlotIndex != INDEX_NONE)
{
ItemDetailsPanel->ShowSlot(BoundInventory.Get(), HoveredSlotIndex);
}
else
{
// On efface la case qu'on QUITTE, pas le panneau : passer d'une case de
// la barre rapide a une case de la grille declenche "entre" puis "sort",
// et un effacement inconditionnel viderait ce qui vient d'etre affiche.
ItemDetailsPanel->ClearSlot(BoundInventory.Get(), PreviousIndex);
}
}
void UInventoryWidget::NotifyExternalSlotHovered(UInventoryComponent* InInventory, int32 InSlotIndex, bool bHovered)
{
if (!ItemDetailsPanel || !bItemDetailsEnabled || InSlotIndex == INDEX_NONE)
{
return;
}
if (bHovered)
{
ItemDetailsPanel->ShowSlot(InInventory, InSlotIndex);
}
else
{
ItemDetailsPanel->ClearSlot(InInventory, InSlotIndex);
}
}
void UInventoryWidget::UpdateItemDetails()
{
if (!ItemDetailsPanel)
{
return;
}
if (!bItemDetailsEnabled || HoveredSlotIndex == INDEX_NONE)
{
ItemDetailsPanel->Clear();
}
else
{
ItemDetailsPanel->ShowSlot(BoundInventory.Get(), HoveredSlotIndex);
}
}
void UInventoryWidget::SetItemDetailsEnabled(bool bEnabled)
{
bItemDetailsEnabled = bEnabled;
if (ItemDetailsPanel)
{
// On repose la visibilite a chaque appel, meme quand la valeur ne change
// pas : c'est ce qui evite de dependre de ce qui a ete regle dans la
// Designer, exactement comme le HideStorage() de l'ecran.
// Collapsed et non Hidden -- coupe, le panneau ne doit pas non plus
// reserver sa largeur, sinon la grille trainerait une colonne vide.
ItemDetailsPanel->SetVisibility(bEnabled ? ESlateVisibility::SelfHitTestInvisible : ESlateVisibility::Collapsed);
}
UpdateItemDetails();
}
bool UInventoryWidget::GetHoveredSlot(UInventoryComponent*& OutInventory, int32& OutIndex) const
{
OutInventory = BoundInventory.Get();
OutIndex = HoveredSlotIndex;
return OutInventory != nullptr && OutIndex != INDEX_NONE;
}
bool UInventoryWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
@@ -114,11 +242,13 @@ bool UInventoryWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDrop
}
// Lache hors de la grille : on jette au sol, comme dans Valheim ou Raft.
// Depuis l'inventaire SOURCE et non celui de cette grille : sortir une pile
// du coffre en la lachant dans le vide doit la retirer du coffre.
if (const APlayerController* OwningController = GetOwningPlayer())
{
if (AFpsPlayer* Player = Cast<AFpsPlayer>(OwningController->GetPawn()))
{
Player->DropSlot(Payload->SourceSlotIndex, Payload->Quantity);
Player->DropFromInventory(Payload->SourceInventory.Get(), Payload->SourceSlotIndex, Payload->Quantity);
}
}
@@ -133,16 +263,21 @@ void UInventoryWidget::Refresh()
return;
}
// Debranchee : la grille se vide au lieu de laisser a l'ecran le contenu
// d'un conteneur qu'on ne pilote plus. Pas un avertissement -- c'est l'etat
// normal d'une grille de coffre entre deux ouvertures.
if (!BoundInventory.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("UInventoryWidget::Refresh : aucun inventaire lie."));
ClearSlotWidgets();
return;
}
const TArray<FInventorySlot>& Slots = BoundInventory->GetSlots();
// La grille commence APRES la barre rapide : les deux conteneurs sont
// independants, un objet dans la barre n'apparait pas ici.
// independants, un objet dans la barre n'apparait pas ici. Sur un coffre,
// ConfigureAsContainer() met la barre a zero, donc l'offset vaut 0 et tout
// s'affiche.
const int32 Offset = BoundInventory->GetBackpackStartIndex();
const int32 GridCount = FMath::Max(0, Slots.Num() - Offset);
@@ -161,6 +296,25 @@ void UInventoryWidget::Refresh()
}
}
void UInventoryWidget::ClearSlotWidgets()
{
for (UInventorySlotWidget* SlotWidget : SlotWidgets)
{
if (SlotWidget)
{
SlotWidget->RemoveFromParent();
}
}
SlotWidgets.Reset();
// La case survolee vient de disparaitre : sans cette remise a zero, la
// touche "jeter" agirait sur un index qui ne designe plus rien -- et le
// panneau de detail continuerait de decrire un objet qui n'est plus la.
// NativeOnMouseLeave n'arrive JAMAIS quand le widget s'efface sous le
// curseur, ce qui est exactement ce qui se passe en fermant un coffre.
SetHoveredSlot(INDEX_NONE);
}
void UInventoryWidget::RebuildGrid(int32 DesiredCount)
{
if (SlotWidgets.Num() == DesiredCount)
@@ -185,6 +339,11 @@ void UInventoryWidget::RebuildGrid(int32 DesiredCount)
SlotWidgets.RemoveAt(Last);
}
if (!BoundInventory.IsValid())
{
return;
}
// Pas assez : on en cree, en les placant en ligne / colonne.
const int32 Columns = FMath::Max(1, ColumnCount);
while (SlotWidgets.Num() < DesiredCount)
@@ -205,7 +364,10 @@ void UInventoryWidget::RebuildGrid(int32 DesiredCount)
// grille. C'est ce qui rend le glisser entre la barre et la grille
// gratuit : les deux manipulent les memes index.
const int32 Absolute = BoundInventory->GetBackpackStartIndex() + Index;
NewSlot->InitSlot(BoundInventory.Get(), Absolute, this);
NewSlot->InitSlot(BoundInventory.Get(), Absolute);
NewSlot->OnHoverChanged.AddDynamic(this, &UInventoryWidget::NotifySlotHovered);
NewSlot->SetQuickTransferTarget(QuickTransferTarget.Get());
SlotGrid->AddChildToUniformGrid(NewSlot, Index / Columns, Index % Columns);
SlotWidgets.Add(NewSlot);
}
@@ -0,0 +1,181 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "ItemDetailsWidget.h"
#include "Components/Image.h"
#include "Components/ProgressBar.h"
#include "Components/TextBlock.h"
#include "Components/Widget.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
void UItemDetailsWidget::NativeConstruct()
{
Super::NativeConstruct();
// Etat vide des la construction : sans ca le panneau afficherait les valeurs
// placeholder saisies dans la Designer jusqu'au premier survol.
Clear();
}
void UItemDetailsWidget::NativeDestruct()
{
BindInventory(nullptr);
Super::NativeDestruct();
}
void UItemDetailsWidget::BindInventory(UInventoryComponent* InInventory)
{
if (BoundInventory.Get() == InInventory)
{
return;
}
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.RemoveDynamic(this, &UItemDetailsWidget::HandleInventoryChanged);
}
BoundInventory = InInventory;
if (BoundInventory.IsValid())
{
BoundInventory->OnInventoryChanged.AddDynamic(this, &UItemDetailsWidget::HandleInventoryChanged);
}
}
void UItemDetailsWidget::ShowSlot(UInventoryComponent* InInventory, int32 InSlotIndex)
{
BindInventory(InInventory);
SlotIndex = InSlotIndex;
Refresh();
}
void UItemDetailsWidget::Clear()
{
BindInventory(nullptr);
SlotIndex = INDEX_NONE;
Refresh();
}
void UItemDetailsWidget::ClearSlot(const UInventoryComponent* InInventory, int32 InSlotIndex)
{
if (BoundInventory.Get() == InInventory && SlotIndex == InSlotIndex)
{
Clear();
}
}
void UItemDetailsWidget::HandleInventoryChanged()
{
// La case survolee a change sous le curseur : une charge consommee, une pile
// deplacee. On relit plutot que de garder une copie perimee a l'ecran.
Refresh();
}
void UItemDetailsWidget::Refresh()
{
// Surtout pas "Slot" : UWidget a deja un membre de ce nom, et le masquer est
// une erreur de compilation (C4458) traitee comme telle par le moteur.
const FInventorySlot* HoveredSlot = nullptr;
if (BoundInventory.IsValid())
{
const TArray<FInventorySlot>& Slots = BoundInventory->GetSlots();
if (Slots.IsValidIndex(SlotIndex) && !Slots[SlotIndex].IsEmpty())
{
HoveredSlot = &Slots[SlotIndex];
}
}
if (ContentRoot)
{
// Hidden et non Collapsed : le panneau garde sa taille, donc rien ne
// bouge dans la mise en page quand le curseur quitte une case.
ContentRoot->SetVisibility(HoveredSlot ? ESlateVisibility::SelfHitTestInvisible : ESlateVisibility::Hidden);
}
if (!HoveredSlot)
{
ClearDisplay();
return;
}
const UItemDataAsset* Item = HoveredSlot->Item;
if (ItemIcon)
{
if (Item->Icon)
{
ItemIcon->SetBrushFromTexture(Item->Icon, /*bMatchSize=*/false);
ItemIcon->SetVisibility(ESlateVisibility::HitTestInvisible);
}
else
{
ItemIcon->SetVisibility(ESlateVisibility::Hidden);
}
}
if (ItemNameText)
{
ItemNameText->SetText(Item->DisplayName);
}
if (ItemDescriptionText)
{
ItemDescriptionText->SetText(Item->Description);
}
// --- Usure ---
// Meme regle que sur la case de la grille : rien a montrer sur un objet a
// usage unique, et le bloc se replie plutot que de laisser un vide.
const int32 MaxUses = FMath::Max(1, Item->MaxUses);
const bool bHasUses = MaxUses > 1;
if (UsesPanel)
{
UsesPanel->SetVisibility(bHasUses ? ESlateVisibility::SelfHitTestInvisible : ESlateVisibility::Collapsed);
}
if (bHasUses)
{
if (UsesBar)
{
UsesBar->SetPercent(FMath::Clamp(static_cast<float>(HoveredSlot->RemainingUses) / static_cast<float>(MaxUses), 0.f, 1.f));
}
if (UsesText)
{
UsesText->SetText(FText::Format(INVTEXT("{0} / {1}"),
FText::AsNumber(HoveredSlot->RemainingUses), FText::AsNumber(MaxUses)));
}
}
}
void UItemDetailsWidget::ClearDisplay()
{
if (ItemIcon)
{
// Hidden et non Collapsed : l'icone garde sa place, donc le nom et la
// description ne remontent pas d'un cran au moment de disparaitre.
ItemIcon->SetVisibility(ESlateVisibility::Hidden);
}
if (ItemNameText)
{
ItemNameText->SetText(FText::GetEmpty());
}
if (ItemDescriptionText)
{
ItemDescriptionText->SetText(FText::GetEmpty());
}
if (UsesPanel)
{
UsesPanel->SetVisibility(ESlateVisibility::Collapsed);
}
}
@@ -0,0 +1,224 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "StorageContainer.h"
#include "Components/BoxComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/CollisionProfile.h"
#include "Engine/StaticMesh.h"
#include "FpsPlayerController.h"
#include "GameFramework/Pawn.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Kismet/GameplayStatics.h"
#define LOCTEXT_NAMESPACE "StorageContainer"
AStorageContainer::AStorageContainer()
{
// Le tick n'existe que pour animer le couvercle : il demarre eteint et ne
// s'allume que le temps du mouvement.
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = false;
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
SetRootComponent(BaseMesh);
// Les meshes ne portent AUCUNE collision : elle vit entierement dans la
// boite. Un mesh de pack sans collision simple rendrait le coffre muet sans
// qu'aucune erreur ne le signale, et celle du couvercle partirait avec lui
// des l'ouverture.
BaseMesh->SetCollisionProfileName(UCollisionProfile::NoCollision_ProfileName);
InteractionBox = CreateDefaultSubobject<UBoxComponent>(TEXT("InteractionBox"));
InteractionBox->SetupAttachment(BaseMesh);
// Explicite plutot que de compter sur le profil par defaut : ce composant a
// deux roles indissociables, empecher le joueur de traverser le coffre et
// bloquer le canal Visibility pour que le trace d'interaction le trouve.
// Un profil qui laisserait passer Visibility rendrait le coffre inutilisable
// sans qu'aucune erreur ne le signale.
InteractionBox->SetCollisionProfileName(UCollisionProfile::BlockAll_ProfileName);
// Valeur de depart pour un coffre qui n'aurait pas encore de mesh :
// FitInteractionBox() l'ecrase des qu'il y en a un.
InteractionBox->SetBoxExtent(FVector(40.f, 30.f, 30.f));
LidPivot = CreateDefaultSubobject<USceneComponent>(TEXT("LidPivot"));
LidPivot->SetupAttachment(BaseMesh);
LidMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("LidMesh"));
LidMesh->SetupAttachment(LidPivot);
LidMesh->SetCollisionProfileName(UCollisionProfile::NoCollision_ProfileName);
Storage = CreateDefaultSubobject<UInventoryComponent>(TEXT("Storage"));
DisplayName = LOCTEXT("ChestName", "Chest");
InteractionPrompt = LOCTEXT("OpenChest", "Ouvrir le coffre");
}
void AStorageContainer::PreInitializeComponents()
{
// Avant InitializeComponents(), donc avant que le composant fige sa taille
// par defaut de 30 slots avec une barre rapide.
if (Storage)
{
Storage->ConfigureAsContainer(SlotCount);
}
Super::PreInitializeComponents();
}
void AStorageContainer::BeginPlay()
{
Super::BeginPlay();
// Le couvercle repose a zero, quelle que soit la rotation laissee dans le
// Blueprint : le repos EST la reference de l'animation.
LidAlpha = 0.f;
ApplyLidRotation();
if (!Storage)
{
return;
}
for (const FStorageStartingItem& Entry : StartingItems)
{
if (!Entry.Item || Entry.Quantity <= 0)
{
continue;
}
const int32 Left = Storage->AddItem(Entry.Item, Entry.Quantity);
if (Left > 0)
{
UE_LOG(LogTemp, Warning, TEXT("%s : %d x %s n'ont pas tenu dans le coffre (%d cases)."),
*GetName(), Left, *GetNameSafe(Entry.Item), SlotCount);
}
}
}
void AStorageContainer::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
// Tourne aussi dans l'editeur : la boite se recale a l'instant ou tu
// changes de mesh, sans avoir a lancer le jeu pour t'en assurer.
if (bAutoFitInteractionBox)
{
FitInteractionBox();
}
}
void AStorageContainer::FitInteractionBox()
{
if (!InteractionBox || !BaseMesh)
{
return;
}
// Volume combine des deux meshes, exprime dans le repere de la caisse --
// celui de la boite, qui en est fille.
FBox Combined(ForceInit);
const UStaticMeshComponent* Meshes[] = { BaseMesh, LidMesh };
for (const UStaticMeshComponent* Component : Meshes)
{
const UStaticMesh* Mesh = Component ? Component->GetStaticMesh() : nullptr;
if (!Mesh)
{
continue;
}
// Transform RELATIVE a la racine et non absolue : la boite doit rester
// juste quel que soit l'endroit ou l'acteur est pose dans le niveau.
const FTransform Relative = Component->GetComponentTransform().GetRelativeTransform(BaseMesh->GetComponentTransform());
Combined += Mesh->GetBoundingBox().TransformBy(Relative);
}
if (!Combined.IsValid)
{
return;
}
InteractionBox->SetRelativeLocation(Combined.GetCenter());
InteractionBox->SetBoxExtent(Combined.GetExtent() + FVector(InteractionBoxPadding));
}
FText AStorageContainer::GetInteractionPrompt_Implementation(AActor* Interactor) const
{
return InteractionPrompt;
}
void AStorageContainer::Interact_Implementation(AActor* Interactor)
{
// L'ecran vit sur le controller, pas sur le pawn : c'est lui qu'on cherche.
const APawn* InteractingPawn = Cast<APawn>(Interactor);
AFpsPlayerController* PlayerController = InteractingPawn ? Cast<AFpsPlayerController>(InteractingPawn->GetController()) : nullptr;
if (!PlayerController)
{
return;
}
PlayerController->OpenStorage(this);
}
void AStorageContainer::SetOpen(bool bInOpen)
{
if (bOpen == bInOpen)
{
return;
}
bOpen = bInOpen;
if (USoundBase* Sound = bOpen ? OpenSound : CloseSound)
{
UGameplayStatics::PlaySoundAtLocation(this, Sound, GetActorLocation());
}
// Le tick s'eteint tout seul en fin de course. Le rallumer ici suffit, y
// compris si l'animation precedente n'etait pas terminee : l'alpha repart
// de sa valeur courante, donc refermer un coffre a moitie ouvert ne saute
// pas.
SetActorTickEnabled(true);
}
void AStorageContainer::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
const float Target = bOpen ? 1.f : 0.f;
const float Speed = 1.f / FMath::Max(0.05f, LidDuration);
LidAlpha = FMath::FInterpConstantTo(LidAlpha, Target, DeltaTime, Speed);
ApplyLidRotation();
if (FMath::IsNearlyEqual(LidAlpha, Target))
{
// On force la valeur exacte avant de se rendormir : sans ca le couvercle
// resterait a un poil de sa position finale pour le reste de la partie.
LidAlpha = Target;
ApplyLidRotation();
SetActorTickEnabled(false);
}
}
void AStorageContainer::ApplyLidRotation()
{
if (!LidPivot)
{
return;
}
// Lissage aux extremites : un couvercle a vitesse constante demarre et
// s'arrete d'un coup, ce qui se lit comme une saccade sur un objet lourd.
const float Eased = FMath::SmoothStep(0.f, 1.f, LidAlpha);
LidPivot->SetRelativeRotation(OpenRotation * Eased);
}
#undef LOCTEXT_NAMESPACE
@@ -310,7 +310,6 @@ protected:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking")
bool bShowRemainingTimeInPrompt = false;
/** Active le Custom Depth quand le poste est vise, comme les ramassables. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Cooking")
bool bHighlightOnFocus = true;
+13
View File
@@ -121,6 +121,19 @@ public:
UFUNCTION(BlueprintCallable, Category = "Inventaire")
bool DropSlot(int32 SlotIndex, int32 Quantity);
/**
* Meme chose, depuis un inventaire quelconque : celui d'un coffre ouvert,
* typiquement.
*
* C'est le PAWN qui pose au sol et pas le composant, pour la meme raison
* que le surplus de fabrication : un UInventoryComponent ne sait pas
* engendrer un acteur, et un coffre qui deposerait lui-meme laisserait le
* butin dans sa propre caisse. L'objet part donc toujours devant les yeux
* du joueur, quel que soit le contenant d'ou il sort.
*/
UFUNCTION(BlueprintCallable, Category = "Inventaire")
bool DropFromInventory(UInventoryComponent* Inventory, int32 SlotIndex, int32 Quantity);
/** Vide l'inventaire entier en un tas autour du personnage. */
UFUNCTION(BlueprintCallable, Category = "Inventaire")
void DropAllItems();
@@ -11,6 +11,7 @@
#include "GameFramework/PlayerController.h"
#include "FpsPlayerController.generated.h"
class AStorageContainer;
class UConfirmDialogWidget;
class UCraftingComponent;
class UHotbarWidget;
@@ -66,6 +67,27 @@ public:
UFUNCTION(BlueprintPure, Category = "UI")
bool IsInventoryOpen() const { return bInventoryOpen; }
/**
* Ouvre l'ecran d'inventaire avec le panneau du coffre a cote : le sac a
* gauche, son contenu a droite.
*
* Ce n'est PAS un ecran a part. Le sac du joueur reste exactement au meme
* endroit qu'à l'ouverture par Tab -- c'est tout l'interet, un ecran
* distinct le ferait sauter a chaque fois qu'on ouvre un coffre.
*
* Comme pour l'etabli, c'est le controller qui pilote et non le coffre : lui
* seul sait quand l'ecran se referme, et donc quand refermer le couvercle.
*/
UFUNCTION(BlueprintCallable, Category = "UI|Storage")
void OpenStorage(AStorageContainer* Container);
/** Referme l'ecran de coffre. Sans effet s'il n'y en a pas d'ouvert. */
UFUNCTION(BlueprintCallable, Category = "UI|Storage")
void CloseStorage();
UFUNCTION(BlueprintPure, Category = "UI|Storage")
bool IsStorageOpen() const { return bStorageOpen; }
/** Ferme le menu de pause et relance le monde. */
UFUNCTION(BlueprintCallable, Category = "UI|Pause")
void ResumeGame();
@@ -189,6 +211,15 @@ protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
TSubclassOf<UInventoryScreenWidget> InventoryScreenClass;
/**
* Distance au-dela de laquelle l'ecran de coffre se referme tout seul.
*
* Sans elle, on viderait un coffre depuis l'autre bout de la carte : rien
* n'oblige le joueur a rester devant une fois l'ecran ouvert.
*/
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI|Storage", meta = (ClampMin = "50", Units = "cm"))
float MaxStorageDistance = 400.f;
/** Ton WBP_Hotbar, affiche en permanence. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UI")
TSubclassOf<UHotbarWidget> HotbarClass;
@@ -297,6 +328,29 @@ private:
/** Rend le poste accorde par le dernier OpenCraftingAtStation. Sans effet sinon. */
void ReleaseActiveStation();
/**
* Rend le coffre ouvert : couvercle referme, panneau masque, surveillance
* arretee. Sans effet s'il n'y en a pas.
*
* Separee de CloseStorage() pour la meme raison que ReleaseActiveStation :
* c'est la fermeture de l'ECRAN qui l'appelle, quelle qu'en soit la cause --
* Tab, Echap, la mort, un autre coffre. Un seul point de sortie, donc aucun
* chemin qui laisse un couvercle leve derriere soi.
*/
void ReleaseActiveStorage();
/**
* Mode d'input et curseur pour un ecran plein cadre.
*
* Partage par l'inventaire et le coffre : deux copies de ces huit lignes
* finiraient par diverger, et le symptome -- une camera qui ne repond plus,
* un sprint bloque -- se cherche tres longtemps.
*/
void SetUiInputMode(bool bUiOpen);
/** Le joueur s'est-il trop eloigne du coffre ouvert. Appele par un timer. */
void CheckStorageDistance();
/** Raccourci vers le composant de fabrication du pawn possede. */
UCraftingComponent* GetPawnCrafting() const;
@@ -311,6 +365,17 @@ private:
*/
void UpdateHotbarVisibility();
/**
* Relaie le survol d'une case de la barre rapide vers le panneau de detail
* de l'ecran d'inventaire.
*
* Ignore ecran ferme : sans curseur il n'y a pas de survol, mais un widget
* masque sous la souris peut encore emettre sa sortie -- et decrire un objet
* dans un panneau invisible n'aurait aucun sens.
*/
UFUNCTION()
void HandleHotbarSlotHovered(UInventoryComponent* Inventory, int32 SlotIndex, bool bHovered);
/** Jette la pile survolee dans la grille, si l'inventaire est ouvert. */
void DropHoveredItem();
@@ -329,6 +394,14 @@ private:
FTimerHandle RespawnTimerHandle;
/** Surveillance de l'eloignement, actif seulement pendant qu'un coffre est ouvert. */
FTimerHandle StorageDistanceTimerHandle;
/** Le coffre actuellement ouvert, pour lui refermer son couvercle. */
TWeakObjectPtr<AStorageContainer> ActiveStorage;
bool bStorageOpen = false;
bool bInventoryOpen = false;
/** Onglet Fabrication affiche. N'a de sens que si bInventoryOpen est vrai. */
@@ -10,6 +10,15 @@ class UHorizontalBox;
class UInventoryComponent;
class UInventorySlotWidget;
/**
* Le curseur entre ou sort d'une case de la barre rapide.
*
* La barre n'affiche rien elle-meme : le panneau de detail vit dans l'ecran
* d'inventaire, qu'elle ne connait pas. C'est le PlayerController qui tient les
* deux widgets et qui fait le pont -- il est deja le proprietaire du HUD.
*/
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnHotbarSlotHovered, UInventoryComponent*, Inventory, int32, SlotIndex, bool, bHovered);
/**
* Barre rapide en bas de l'ecran, affichee en permanence.
*
@@ -34,6 +43,19 @@ public:
/** (Re)branche sur l'inventaire du pawn possede. Voir UInventoryWidget. */
void BindToOwningPawn();
/**
* Destination du clic droit pour les cases de la barre.
*
* La barre reste affichee et cliquable par-dessus l'ecran de coffre : sans
* ca, le clic droit marcherait dans la grille et pas dans la barre, ce qui
* passerait pour un bug. Le controller lui pousse le coffre a l'ouverture
* et nullptr a la fermeture.
*/
void SetQuickTransferTarget(UInventoryComponent* InTarget);
UPROPERTY(BlueprintAssignable, Category = "Inventory")
FOnHotbarSlotHovered OnSlotHovered;
protected:
/** A nommer exactement "SlotContainer" dans le Widget Blueprint. */
UPROPERTY(meta = (BindWidget))
@@ -54,12 +76,19 @@ private:
UFUNCTION()
void HandleSelectionChanged(int32 NewIndex);
/** Abonne aux cases : relaie le survol vers l'exterieur. */
UFUNCTION()
void HandleSlotHovered(int32 SlotIndex, bool bHovered);
void RebuildSlots(int32 DesiredCount);
UInventoryComponent* ResolveInventory() const;
TWeakObjectPtr<UInventoryComponent> BoundInventory;
/** Destination du clic droit, recopiee sur chaque case creee. */
TWeakObjectPtr<UInventoryComponent> QuickTransferTarget;
/** UPROPERTY obligatoire : sans ca le GC collecterait les cases. */
UPROPERTY(Transient)
TArray<TObjectPtr<UInventorySlotWidget>> SlotWidgets;
@@ -143,6 +143,65 @@ public:
UFUNCTION(BlueprintCallable, Category = "Inventaire")
bool MoveItemQuantity(int32 FromIndex, int32 ToIndex, int32 Quantity);
// ------------------------------------------------------------------
// Transferts entre deux inventaires (coffres)
// ------------------------------------------------------------------
/**
* Deplace le contenu d'un slot vers un autre, dans le MEME inventaire ou
* dans un autre. C'est desormais l'unique implementation du deplacement :
* MoveItem() et MoveItemQuantity() ne sont que des appels avec From == To.
*
* Statique parce qu'elle n'appartient ni a la source ni a la destination --
* elle arbitre entre les deux, et doit diffuser OnInventoryChanged sur les
* DEUX. Oublier la seconde diffusion laisse la grille d'en face afficher un
* etat perime jusqu'au prochain evenement, ce qui se lit comme un objet
* qui disparait.
*
* @param Quantity nombre d'exemplaires ; MAX_int32 pour la pile entiere.
* @return true si quelque chose a change.
*/
UFUNCTION(BlueprintCallable, Category = "Inventaire")
static bool TransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To, int32 ToIndex, int32 Quantity);
/**
* Envoie le contenu d'un slot vers un autre inventaire sans designer de
* case d'arrivee : c'est le clic droit de l'ecran de coffre.
*
* Passe par AddItem(), donc exactement comme un ramassage -- piles entamees
* d'abord, puis barre rapide, puis grille. Ce qui ne rentre pas reste sur
* place : un transfert rapide ne perd jamais rien.
*
* @return le nombre d'exemplaires reellement partis.
*/
UFUNCTION(BlueprintCallable, Category = "Inventaire")
static int32 QuickTransferSlot(UInventoryComponent* From, int32 FromIndex, UInventoryComponent* To);
/**
* Deverse tout ce qui peut entrer vers un autre inventaire : les boutons
* "Tout ranger" et "Tout prendre".
*
* @param FirstIndex premier slot considere. Le joueur passe
* GetBackpackStartIndex() pour que "Tout ranger" laisse sa barre
* rapide tranquille -- ranger ses outils dans le coffre a chaque
* retour d'expedition serait une punition, pas un confort.
* @return le nombre d'exemplaires reellement partis.
*/
UFUNCTION(BlueprintCallable, Category = "Inventaire")
int32 TransferAllTo(UInventoryComponent* To, int32 FirstIndex = 0);
/**
* Transforme ce composant en contenant du monde : NewSlotCount cases de
* grille et AUCUNE barre rapide, pour que GetBackpackStartIndex() vaille 0
* et que la grille affiche la totalite du coffre.
*
* A appeler avant que le composant contienne quoi que ce soit -- typiquement
* depuis PreInitializeComponents() de l'acteur porteur. Reduire la taille
* d'un conteneur deja rempli fait disparaitre ce qui depasse.
*/
UFUNCTION(BlueprintCallable, Category = "Inventaire")
void ConfigureAsContainer(int32 NewSlotCount);
/** Index du premier slot libre, ou INDEX_NONE si l'inventaire est plein. */
UFUNCTION(BlueprintPure, Category = "Inventaire")
int32 FindFirstEmptySlot() const;
@@ -226,5 +285,33 @@ private:
/** Regroupe les slots occupes au debut du tableau. */
void CompactSlots();
/**
* Regles de deplacement entre deux cases, sans savoir a quel inventaire
* chacune appartient. Ne diffuse rien : c'est l'appelant qui sait combien
* de composants ont bouge.
*/
static bool ApplySlotMove(FInventorySlot& From, FInventorySlot& To, int32 Quantity);
/**
* Verse un slot dans un autre inventaire via AddItem(). Ne diffuse que du
* cote destination (AddItem s'en charge) : la source reste a la charge de
* l'appelant, qui peut en enchainer vingt.
* @return exemplaires reellement partis.
*/
int32 PushSlotInto(int32 SlotIndex, UInventoryComponent& To);
/**
* Diffusion differee. Un "Tout ranger" deplace jusqu'a vingt piles : sans
* regroupement, l'UI se reconstruirait vingt fois pour un seul clic.
* Toutes les modifications passent par BroadcastChanged() plutot que par
* OnInventoryChanged.Broadcast() directement, sinon le lot fuit.
*/
void BroadcastChanged();
void BeginBatch();
void EndBatch();
int32 SelectedHotbarIndex = 0;
int32 BatchDepth = 0;
bool bBatchDirty = false;
};
@@ -6,9 +6,12 @@
#include "Blueprint/UserWidget.h"
#include "InventoryScreenWidget.generated.h"
class AStorageContainer;
class UButton;
class UCraftingWidget;
class UInventoryComponent;
class UInventoryWidget;
class UTextBlock;
class UWidgetSwitcher;
/**
@@ -51,6 +54,19 @@ public:
*/
int32 GetHoveredSlotIndex() const;
/**
* Meme chose, avec l'inventaire auquel la case appartient. Depuis les
* coffres, l'index seul ne designe plus rien de facon unique.
*/
bool GetHoveredSlot(UInventoryComponent*& OutInventory, int32& OutIndex) const;
/**
* Survol d'une case exterieure a l'ecran -- la barre rapide, qui reste
* affichee par-dessus lui. Alimente le panneau de detail de la page
* d'inventaire, seul panneau existant.
*/
void NotifyExternalSlotHovered(UInventoryComponent* Inventory, int32 SlotIndex, bool bHovered);
/** Revient sur l'onglet Inventaire. Appele a chaque ouverture par Tab. */
UFUNCTION(BlueprintCallable, Category = "UI")
void ShowInventoryTab();
@@ -62,6 +78,29 @@ public:
UPROPERTY(BlueprintAssignable, Category = "UI")
FOnInventoryScreenTabChanged OnTabChanged;
// ------------------------------------------------------------------
// Panneau de coffre
//
// Le contenu d'un coffre s'affiche A COTE de la grille du joueur, dans ce
// meme ecran, et non dans un ecran a part. La raison est du ressenti pur :
// le sac ne bouge pas d'un pixel entre "j'ouvre mon sac" et "j'ouvre un
// coffre". Deux ecrans distincts le feraient sauter a chaque fois.
//
// Techniquement c'est une deuxieme instance du MEME WBP_Inventory que
// InventoryPage. Elle ne peut pas vivre a l'interieur de WBP_Inventory :
// un Widget Blueprint qui se contient lui-meme est une reference
// circulaire, et UMG refuse de le compiler.
// ------------------------------------------------------------------
/** Branche et affiche le panneau de droite. Force l'onglet Inventaire. */
void ShowStorage(AStorageContainer* Container);
/** Masque et debranche le panneau. Sans effet s'il est deja cache. */
void HideStorage();
UFUNCTION(BlueprintPure, Category = "UI|Storage")
bool IsStorageVisible() const { return bStorageVisible; }
protected:
/**
* Absorbe ou traite les depots qui ne visent aucune page.
@@ -91,7 +130,45 @@ protected:
UPROPERTY(meta = (BindWidget))
TObjectPtr<UCraftingWidget> CraftingPage;
/**
* Tout le bloc de droite -- cadre, titre, grille, boutons -- qu'on montre et
* qu'on cache d'un bloc.
*
* BindWidgetOptional et non BindWidget, comme tous les elements de coffre
* qui suivent : l'ecran doit rester valide dans un projet ou personne n'a
* encore construit ce panneau. Sans ca, ajouter les coffres casserait la
* compilation de WBP_InventoryScreen tant que la mise en page n'est pas
* faite -- et donc l'inventaire entier.
*/
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UWidget> StoragePanel;
/** Un deuxieme WBP_Inventory, renomme "StorageGrid". DECOCHE-lui Auto Bind To Pawn. */
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UInventoryWidget> StorageGrid;
/** Nom du coffre ouvert. */
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UTextBlock> StorageTitle;
/** Deverse la grille du sac dans le coffre. La barre rapide n'y passe pas. */
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> StoreAllButton;
/** Vide le coffre dans le sac, dans la limite de la place disponible. */
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> TakeAllButton;
private:
UFUNCTION()
void HandleStoreAllClicked();
UFUNCTION()
void HandleTakeAllClicked();
/** Inventaire du pawn possede, ou nullptr. */
UInventoryComponent* GetPlayerInventory() const;
UFUNCTION()
void HandleInventoryTabClicked();
@@ -108,4 +185,16 @@ private:
void ShowPage(UWidget* Page);
bool IsInventoryTabActive() const;
/**
* Le panneau de coffre est affiche.
*
* Tant qu'il l'est, l'onglet Fabrication est grise : fabriquer avec un
* coffre ouvert a cote n'a pas de sens tant que ses ressources ne comptent
* pas dans les recettes, et laisser le bouton actif inviterait a un clic
* dont le resultat serait incomprehensible.
*/
bool bStorageVisible = false;
TWeakObjectPtr<UInventoryComponent> BoundStorage;
};
@@ -8,10 +8,18 @@
#include "InventorySlotWidget.generated.h"
class UImage;
class UInventoryWidget;
class UProgressBar;
class UTextBlock;
/**
* Le curseur est entre sur cette case, ou en est sorti.
*
* Un delegue et non un pointeur vers la grille : la barre rapide pose les
* memes cases sans etre un UInventoryWidget, et elle a autant besoin du survol
* depuis que l'inventaire affiche un panneau de detail.
*/
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInventorySlotHoverChanged, int32, SlotIndex, bool, bHovered);
/**
* Une case de la grille d'inventaire : une icone et une quantite.
*
@@ -29,7 +37,11 @@ public:
virtual void NativeConstruct() override;
/** Appele une fois a la creation : dit a la case qui elle represente. */
void InitSlot(UInventoryComponent* InInventory, int32 InSlotIndex, UInventoryWidget* InOwningGrid);
void InitSlot(UInventoryComponent* InInventory, int32 InSlotIndex);
/** Le conteneur qui a cree cette case s'y abonne pour suivre le survol. */
UPROPERTY()
FOnInventorySlotHoverChanged OnHoverChanged;
/** Affiche le contenu d'un slot, ou se vide si le slot est vide. */
UFUNCTION(BlueprintCallable, Category = "Inventaire")
@@ -45,6 +57,15 @@ public:
UFUNCTION(BlueprintCallable, Category = "Inventaire")
void SetSelected(bool bSelected);
/**
* Inventaire vers lequel le CLIC DROIT envoie le contenu de cette case.
*
* Nul en temps normal : hors de l'ecran de coffre il n'existe aucun "autre
* cote" evident, et deviner une destination reviendrait a faire disparaitre
* des objets sur un clic malheureux. C'est l'ecran qui sait, et qui pousse.
*/
void SetQuickTransferTarget(UInventoryComponent* InTarget);
protected:
// --- Glisser-deposer ---
virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
@@ -101,8 +122,8 @@ private:
TWeakObjectPtr<UInventoryComponent> OwningInventory;
/** La grille parente, pour lui signaler le survol. */
TWeakObjectPtr<UInventoryWidget> OwningGrid;
/** Destination du clic droit. Voir SetQuickTransferTarget(). */
TWeakObjectPtr<UInventoryComponent> QuickTransferTarget;
int32 SlotIndex = INDEX_NONE;
+106 -4
View File
@@ -8,13 +8,19 @@
class UInventoryComponent;
class UInventorySlotWidget;
class UItemDetailsWidget;
class UUniformGridPanel;
/**
* Grille d'inventaire. Cree ses cases dynamiquement a partir du nombre de
* slots du composant, et se remet a jour uniquement quand l'inventaire change.
* Grille de cases. Cree ses cases dynamiquement a partir du nombre de slots du
* composant, et se remet a jour uniquement quand l'inventaire change.
*
* Le nombre de cases n'est PAS fige : quand un sac ajoutera des slots, la
* Elle ne suppose PLUS qu'elle affiche le sac du joueur : le meme widget sert
* de grille de coffre en lui donnant simplement un autre UInventoryComponent.
* C'est ce qui permet d'avoir un seul WBP_Inventory pose des deux cotes de
* l'ecran de coffre -- et donc la meme apparence, sans duplication.
*
* Le nombre de cases n'est pas fige : quand un sac ajoutera des slots, la
* grille s'agrandira toute seule au prochain rafraichissement.
*/
UCLASS(Abstract)
@@ -34,9 +40,41 @@ public:
UFUNCTION(BlueprintPure, Category = "Inventaire")
int32 GetHoveredSlotIndex() const { return HoveredSlotIndex; }
/** Appele par les cases quand le curseur entre ou sort. */
/**
* Case survolee ET inventaire auquel elle appartient.
*
* L'index seul ne suffit plus : la touche "jeter" doit savoir de quel
* conteneur sortir l'objet, et la case 3 existe aussi bien dans le sac que
* dans le coffre.
*/
bool GetHoveredSlot(UInventoryComponent*& OutInventory, int32& OutIndex) const;
/** Abonne aux cases : le curseur entre ou sort de l'une d'elles. */
UFUNCTION()
void NotifySlotHovered(int32 Index, bool bHovered);
/**
* Survol d'une case qui n'appartient PAS a cette grille -- la barre rapide.
*
* Ne touche pas a HoveredSlotIndex : celui-ci sert a la touche "jeter", qui
* doit continuer de viser la grille et elle seule. Seul le panneau de detail
* repond, parce qu'il n'y en a qu'un a l'ecran et qu'il appartient a la
* grille du sac.
*/
void NotifyExternalSlotHovered(UInventoryComponent* InInventory, int32 InSlotIndex, bool bHovered);
/**
* Autorise ou coupe le panneau de detail de cette grille.
*
* C'est l'ECRAN qui decide, jamais une case a cocher dans la Designer : le
* WBP_Inventory est instancie deux fois sur l'ecran de coffre, et un reglage
* qu'on peut oublier de decocher sur l'une des deux instances est un bug qui
* attend. Coupe : la grille de coffre n'en a jamais, et le sac perd le sien
* tant qu'un coffre est ouvert -- l'ecran est deja assez large a trois
* colonnes.
*/
void SetItemDetailsEnabled(bool bEnabled);
/**
* (Re)branche le widget sur l'inventaire du pawn actuellement possede.
* Indispensable apres un respawn : le nouveau pawn a un nouveau composant,
@@ -44,6 +82,29 @@ public:
*/
void BindToOwningPawn();
/**
* (Re)branche le widget sur un inventaire quelconque -- celui d'un coffre,
* typiquement. nullptr vide la grille : une grille de coffre referme ne doit
* pas continuer d'afficher un contenu qu'on ne pilote plus.
*/
void BindToInventory(UInventoryComponent* InInventory);
UFUNCTION(BlueprintPure, Category = "Inventaire")
UInventoryComponent* GetBoundInventory() const { return BoundInventory.Get(); }
/** Destination du clic droit pour toutes les cases de cette grille. */
void SetQuickTransferTarget(UInventoryComponent* InTarget);
/**
* Change le nombre de colonnes et redispose les cases.
*
* Sert a la grille de coffre : c'est le coffre pose dans le niveau qui
* decide de sa forme, pas le Widget Blueprint -- un coffre de 12 cases et
* un de 30 ne se lisent pas bien avec le meme nombre de colonnes.
*/
UFUNCTION(BlueprintCallable, Category = "Inventaire")
void SetColumnCount(int32 NewColumnCount);
protected:
/** A nommer exactement "SlotGrid" dans le Widget Blueprint. */
UPROPERTY(meta = (BindWidget))
@@ -57,6 +118,29 @@ protected:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire", meta = (ClampMin = "1"))
int32 ColumnCount = 6;
/**
* La grille va chercher toute seule l'inventaire du pawn a sa construction.
*
* DECOCHE-LE sur la grille de coffre. Sans ca elle s'accrocherait au pawn
* dans son NativeConstruct -- qui s'execute avant que l'ecran ait eu le
* temps de lui donner le conteneur -- et afficherait le sac du joueur des
* deux cotes de l'ecran.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inventaire")
bool bAutoBindToPawn = true;
/**
* Le panneau de detail pose a cote de la grille, dans le meme Widget
* Blueprint. A nommer "ItemDetailsPanel".
*
* Optionnel : la grille doit rester valide dans un WBP_Inventory ou ce
* panneau n'existe pas encore, sinon ajouter cette fonctionnalite casserait
* la compilation de l'inventaire entier tant que la mise en page n'est pas
* faite.
*/
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UItemDetailsWidget> ItemDetailsPanel;
protected:
/**
* Absorbe les depots qui ne visent aucune case. Sans ca, lacher un objet
@@ -72,13 +156,31 @@ private:
/** Cree ou supprime des cases pour coller au nombre de slots. */
void RebuildGrid(int32 DesiredCount);
/** Detruit toutes les cases : leur inventaire a change, elles sont perimees. */
void ClearSlotWidgets();
/** Retrouve l'inventaire du pawn possede. */
UInventoryComponent* ResolveInventory() const;
/**
* Point de passage UNIQUE du survol : met a jour l'index et le panneau de
* detail. Ne fait rien si l'index ne change pas.
*/
void SetHoveredSlot(int32 NewIndex);
/** Pousse la case survolee dans le panneau, ou l'efface. */
void UpdateItemDetails();
TWeakObjectPtr<UInventoryComponent> BoundInventory;
/** Destination du clic droit, recopiee sur chaque case creee. */
TWeakObjectPtr<UInventoryComponent> QuickTransferTarget;
int32 HoveredSlotIndex = INDEX_NONE;
/** Voir SetItemDetailsEnabled(). */
bool bItemDetailsEnabled = true;
/** UPROPERTY obligatoire : sans ca le GC collecterait les cases. */
UPROPERTY(Transient)
TArray<TObjectPtr<UInventorySlotWidget>> SlotWidgets;
@@ -0,0 +1,114 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "ItemDetailsWidget.generated.h"
class UImage;
class UInventoryComponent;
class UProgressBar;
class UTextBlock;
/**
* Le panneau qui decrit l'objet survole : icone, nom, description, usure.
* Rien d'autre -- volontairement.
*
* Il ne connait ni la grille ni l'ecran. On lui pousse un couple
* (inventaire, index) et il lit -- et NON une copie du FInventorySlot :
* le contenu peut changer pendant le survol (une charge consommee, un craft
* qui vide la pile), donc le panneau s'abonne a OnInventoryChanged de
* l'inventaire affiche et se relit tout seul. Jamais de Tick.
*
* Aucun texte en dur ici en dehors du format numerique de l'usure : la mise en
* page et les libelles appartiennent au Widget Blueprint.
*/
UCLASS(Abstract)
class SURVIVAL_PROJET_API UItemDetailsWidget : public UUserWidget
{
GENERATED_BODY()
public:
virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
/** Affiche la case InSlotIndex de InInventory. Index invalide = etat vide. */
void ShowSlot(UInventoryComponent* InInventory, int32 InSlotIndex);
/** Repasse en etat vide et coupe l'abonnement. */
void Clear();
/**
* Efface, mais seulement si c'est bien CETTE case qui est affichee.
*
* Le panneau est unique et trois conteneurs le nourrissent -- grille, barre
* rapide, et la grille elle-meme quand elle se rebranche. Passer de l'un a
* l'autre emet "entre dans B" PUIS "sort de A" : un effacement
* inconditionnel viderait l'affichage qui vient d'arriver.
*/
void ClearSlot(const UInventoryComponent* InInventory, int32 InSlotIndex);
protected:
/** A nommer exactement "ItemIcon" dans le Widget Blueprint. */
UPROPERTY(meta = (BindWidget))
TObjectPtr<UImage> ItemIcon;
/** A nommer exactement "ItemNameText". */
UPROPERTY(meta = (BindWidget))
TObjectPtr<UTextBlock> ItemNameText;
/** Pense a cocher "Auto Wrap Text" dessus, sinon le panneau s'elargit. */
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UTextBlock> ItemDescriptionText;
/**
* Bloc d'usure entier -- barre et texte ensemble -- replie sur tout objet
* qui n'a pas de charges multiples. Sans lui, la barre et son texte
* devraient etre replies separement et finiraient un jour desynchronises.
*/
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UWidget> UsesPanel;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UProgressBar> UsesBar;
/** "3 / 5", pose par-dessus la barre. */
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UTextBlock> UsesText;
/**
* Le conteneur qui empile tout le panneau -- a renommer "ContentRoot", ce
* n'est pas un widget de plus.
*
* Il est masque en bloc quand rien n'est survole. Sans lui, la barre de
* separation resterait seule a l'ecran : c'est une decoration du Blueprint,
* le C++ n'a aucune raison de la connaitre individuellement.
*/
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UWidget> ContentRoot;
private:
UFUNCTION()
void HandleInventoryChanged();
/** Relit la case et repeint tout le panneau. */
void Refresh();
/**
* Etat vide : plus rien a l'ecran.
*
* Le panneau n'a pas de fond, donc vide il est simplement invisible -- et sa
* largeur est tenue par le Size Box du Blueprint, pas par son contenu. C'est
* ce qui empeche la grille de sauter lateralement quand le curseur quitte
* une case.
*/
void ClearDisplay();
/** (Re)branche OnInventoryChanged. Un seul abonnement a la fois. */
void BindInventory(UInventoryComponent* InInventory);
TWeakObjectPtr<UInventoryComponent> BoundInventory;
int32 SlotIndex = INDEX_NONE;
};
@@ -0,0 +1,239 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Interactable.h"
#include "StorageContainer.generated.h"
class UBoxComponent;
class UInventoryComponent;
class UItemDataAsset;
class USoundBase;
class UStaticMeshComponent;
/**
* Contenu initial d'un coffre pose dans le niveau.
*
* De la donnee de level design, pas du gameplay : un coffre de depart, une
* cache a trouver. Verse une seule fois au BeginPlay, jamais relu ensuite.
*/
USTRUCT(BlueprintType)
struct FStorageStartingItem
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<UItemDataAsset> Item = nullptr;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage", meta = (ClampMin = "1"))
int32 Quantity = 1;
};
/**
* Coffre pose dans le monde.
*
* Il ne contient AUCUN modele de donnees a lui : son contenu est un
* UInventoryComponent, exactement le meme que celui du joueur, simplement
* configure sans barre rapide. C'est ce qui rend le glisser-deposer entre le
* sac et le coffre gratuit -- ce sont les memes cases, le meme FInventorySlot,
* et donc la durabilite d'une hache traverse le coffre sans une ligne de code.
*
* Toute la collision tient dans UNE boite, et les deux meshes n'en portent
* aucune. C'est la difference avec ACraftingStation, qui se contente de son
* mesh : un coffre est en deux morceaux dont l'un PIVOTE. Se reposer sur leurs
* collisions imposerait que le pack en fournisse deux correctes, et laisserait
* surtout un trou dans la surface visable des que le couvercle se leve --
* viser le haut du coffre ouvert ne toucherait plus rien.
*
* La boite est fille de la caisse et ne bouge donc jamais : le coffre se vise
* exactement pareil ouvert ou ferme. Elle bloque aussi le joueur physiquement,
* ce qui evite d'avoir a se demander si le mesh du pack sait le faire.
*
* Aucun Tick au repos : il ne s'active que le temps d'animer le couvercle, et
* se rendort des que l'angle est atteint.
*/
UCLASS()
class SURVIVAL_PROJET_API AStorageContainer : public AActor, public IInteractable
{
GENERATED_BODY()
public:
AStorageContainer();
virtual void Tick(float DeltaTime) override;
// --- IInteractable ---
virtual FText GetInteractionPrompt_Implementation(AActor* Interactor) const override;
virtual void Interact_Implementation(AActor* Interactor) override;
UFUNCTION(BlueprintPure, Category = "Storage")
UInventoryComponent* GetStorage() const { return Storage; }
/** Nom affiche en titre de l'ecran de coffre. */
UFUNCTION(BlueprintPure, Category = "Storage")
FText GetDisplayName() const { return DisplayName; }
/** Cases par ligne souhaitees pour la grille de l'ecran. */
UFUNCTION(BlueprintPure, Category = "Storage")
int32 GetColumnCount() const { return ColumnCount; }
/**
* Ouvre ou ferme le couvercle.
*
* Appele par le controller, pas par le coffre lui-meme : c'est lui qui sait
* quand l'ecran se referme, que ce soit par Echap, par la mort du joueur ou
* parce qu'il s'est trop eloigne. Un coffre qui deciderait seul finirait
* ouvert dans la moitie de ces cas.
*/
UFUNCTION(BlueprintCallable, Category = "Storage")
void SetOpen(bool bInOpen);
UFUNCTION(BlueprintPure, Category = "Storage")
bool IsOpen() const { return bOpen; }
protected:
/**
* Dimensionne le composant AVANT que son InitializeComponent() fige la
* taille par defaut. PostInitializeComponents serait deja trop tard, et le
* constructeur trop tot -- il ne connait pas les valeurs reglees sur
* l'instance posee dans le niveau.
*/
virtual void PreInitializeComponents() override;
virtual void BeginPlay() override;
/** Redimensionne la boite quand un mesh change dans le Blueprint. */
virtual void OnConstruction(const FTransform& Transform) override;
// ------------------------------------------------------------------
// Composants
// ------------------------------------------------------------------
/** La caisse : SM_Chest_Bottom. Racine, purement visuelle. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<UStaticMeshComponent> BaseMesh;
/**
* L'unique collision du coffre : ce que le trace d'interaction vient
* toucher, et ce qui empeche le joueur de traverser.
*
* Fille de la caisse et non du pivot : elle ne suit surtout pas le
* couvercle. Un coffre ouvert doit se viser au meme endroit qu'un coffre
* ferme, sinon le prompt disparait au moment precis ou l'on s'en sert.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<UBoxComponent> InteractionBox;
/**
* La charniere. Place-la a la souris sur l'arete arriere du coffre.
*
* Ce composant vide n'est pas un luxe : le pivot de SM_Chest_Top est celui
* que le pack lui a donne, presque jamais la charniere. Faire tourner le
* mesh directement le ferait pivoter autour de son centre et traverser la
* caisse. Ici on tourne le pivot, et le mesh suit avec son decalage.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<USceneComponent> LidPivot;
/** Le couvercle : SM_Chest_Top, enfant du pivot. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<UStaticMeshComponent> LidMesh;
/** Le contenu. Un inventaire ordinaire, sans barre rapide. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<UInventoryComponent> Storage;
// ------------------------------------------------------------------
// Reglages
// ------------------------------------------------------------------
/**
* Nombre de cases. Reglable PAR INSTANCE : le meme Blueprint donne un petit
* coffre de 12 et un grand de 30 selon celui qu'on pose.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage", meta = (ClampMin = "1", ClampMax = "60"))
int32 SlotCount = 20;
/** Cases par ligne dans l'ecran. Purement visuel. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage", meta = (ClampMin = "1"))
int32 ColumnCount = 6;
/** Nom affiche en titre de l'ecran. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage")
FText DisplayName;
/** Texte affiche quand le joueur vise le coffre. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage")
FText InteractionPrompt;
/** Verse dans le coffre au demarrage. Laisse vide pour un coffre a remplir. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage")
TArray<FStorageStartingItem> StartingItems;
// ------------------------------------------------------------------
// Collision
// ------------------------------------------------------------------
/**
* La boite epouse toute seule les deux meshes, couvercle ferme.
*
* Coche par defaut : changer SM_Chest_v1 pour le v3 doit suffire, sans
* avoir a retailler une boite a la main derriere. Decoche-le seulement si
* tu veux une zone volontairement differente du volume du coffre.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage|Collision")
bool bAutoFitInteractionBox = true;
/**
* Marge ajoutee autour des meshes par l'ajustement automatique.
*
* Garde-la petite. Le trace d'interaction est deja un balayage spherique
* tolerant : gonfler la boite ne rendrait pas le coffre plus facile a viser,
* ca l'empecherait juste de se coller a un mur.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage|Collision", meta = (ClampMin = "0", Units = "cm"))
float InteractionBoxPadding = 0.f;
// ------------------------------------------------------------------
// Couvercle
// ------------------------------------------------------------------
/**
* Rotation RELATIVE du pivot couvercle ouvert. Le repos vaut toujours zero.
*
* Un FRotator complet et pas un simple angle : selon l'orientation que tu
* donnes au pivot, la charniere peut tomber sur le pitch, le yaw ou le roll.
* Regle les trois a l'oeil dans le viewport plutot que d'aller comprendre
* quel axe local du mesh pointe ou.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage|Lid")
FRotator OpenRotation = FRotator(-100.f, 0.f, 0.f);
/** Duree de l'ouverture, en secondes. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage|Lid", meta = (ClampMin = "0.05", Units = "s"))
float LidDuration = 0.35f;
// ------------------------------------------------------------------
// Sons. Facultatifs : un champ vide ne joue rien.
// ------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage|Sound")
TObjectPtr<USoundBase> OpenSound;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage|Sound")
TObjectPtr<USoundBase> CloseSound;
private:
/** Recopie LidAlpha dans la rotation du pivot. */
void ApplyLidRotation();
/** Cale la boite sur le volume combine des deux meshes, couvercle ferme. */
void FitInteractionBox();
bool bOpen = false;
/** 0 = ferme, 1 = ouvert. Interpole, jamais pose directement. */
float LidAlpha = 0.f;
};