diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6599292..4c25fdd 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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, diff --git a/Content/Game/Building/Storage/BP_Chest_Wooden.uasset b/Content/Game/Building/Storage/BP_Chest_Wooden.uasset new file mode 100644 index 0000000..e597f54 --- /dev/null +++ b/Content/Game/Building/Storage/BP_Chest_Wooden.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e4e7d710d161ff9b3272f7c78cc17d9343fa108c48b4d60bea7adae358c649f +size 35641 diff --git a/Content/Game/Maps/Dev/Dev_Scene.umap b/Content/Game/Maps/Dev/Dev_Scene.umap index de65605..8f1aa58 100644 --- a/Content/Game/Maps/Dev/Dev_Scene.umap +++ b/Content/Game/Maps/Dev/Dev_Scene.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:362408b386cdda4b30f658daab5869daa200448646170389bb3f76f95ec9c095 -size 123356 +oid sha256:99720fda0ee1f8ca242950dd526aeb26f391b04a9d500bf6aef0846546ef7ee8 +size 125999 diff --git a/Content/Game/Player/BP_FpsPlayerController.uasset b/Content/Game/Player/BP_FpsPlayerController.uasset index 04db7bf..5e1b056 100644 --- a/Content/Game/Player/BP_FpsPlayerController.uasset +++ b/Content/Game/Player/BP_FpsPlayerController.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57b5612e1316512eaaa423c5c39935f66af69359008cb2f82047dd83fa1a1c03 -size 24284 +oid sha256:240eadd781119ea2b7bce0e1ec1bb7de822bc515044e028b1e51d2784c12bc3a +size 24288 diff --git a/Content/Game/UI/Craft/WBP_Crafting.uasset b/Content/Game/UI/Craft/WBP_Crafting.uasset index 8446872..6ab0c94 100644 --- a/Content/Game/UI/Craft/WBP_Crafting.uasset +++ b/Content/Game/UI/Craft/WBP_Crafting.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2ec47a264370ead3531ac56830c782149d44f8a9e14334a02f1bb35a05451cf -size 42152 +oid sha256:ce7493d08b64b78255e0760f8f523398cdc31f9ae9f3cc4a223f96c81c17d461 +size 43285 diff --git a/Content/Game/UI/Craft/WBP_InventoryScreen.uasset b/Content/Game/UI/Craft/WBP_InventoryScreen.uasset deleted file mode 100644 index 6cdaee4..0000000 --- a/Content/Game/UI/Craft/WBP_InventoryScreen.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e8744937c034303d548c928c32c4cd0b389a30c1fda002c168d453b503cf644e -size 38414 diff --git a/Content/Game/UI/Inventory/WBP_Inventory.uasset b/Content/Game/UI/Inventory/WBP_Inventory.uasset index 3d21823..83e5956 100644 --- a/Content/Game/UI/Inventory/WBP_Inventory.uasset +++ b/Content/Game/UI/Inventory/WBP_Inventory.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f0cafb0625b277c949c0e0b86f64d03cae55d14aec7ecea17f9edca82ca1b5a -size 28006 +oid sha256:dc79803d073261632deb74c045e35642138d5ff89705f727aeea2aecd169295d +size 30426 diff --git a/Content/Game/UI/Inventory/WBP_InventoryScreen.uasset b/Content/Game/UI/Inventory/WBP_InventoryScreen.uasset new file mode 100644 index 0000000..7b0977f --- /dev/null +++ b/Content/Game/UI/Inventory/WBP_InventoryScreen.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5ced7cf1ad61e9b1ee734382f716a761ac0a6d864e0bc63a30b28d51bb9e731 +size 38426 diff --git a/Content/Game/UI/Inventory/WBP_ItemDetails.uasset b/Content/Game/UI/Inventory/WBP_ItemDetails.uasset new file mode 100644 index 0000000..dc43d66 --- /dev/null +++ b/Content/Game/UI/Inventory/WBP_ItemDetails.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4b4795af2f3caaf7ca108629d8d4b86a0e6e617144781862bd5a272fa739523 +size 39683 diff --git a/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v1.uasset b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v1.uasset new file mode 100644 index 0000000..67a8505 --- /dev/null +++ b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe6a762b98bfb60c5c68438c881058d89d8d852ac849f1476058f5dfa14bc6c0 +size 103585 diff --git a/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v1_open.uasset b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v1_open.uasset new file mode 100644 index 0000000..3c70684 --- /dev/null +++ b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v1_open.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64a8f4b832a761218ceec103f4f3988f7588c39f3236217425d5e58591e4d7cd +size 100583 diff --git a/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v2.uasset b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v2.uasset new file mode 100644 index 0000000..6c6e3a2 --- /dev/null +++ b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a68f0328e5e5e4413831fc802714f7d32a49242875fe7416e1d78d9883f9f30d +size 103764 diff --git a/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v2_open.uasset b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v2_open.uasset new file mode 100644 index 0000000..c3b5f02 --- /dev/null +++ b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v2_open.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fceeef972c8a73c4e563b1f107738a92ceb202924ee88eb34c6b7add513a39c +size 100887 diff --git a/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v3.uasset b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v3.uasset new file mode 100644 index 0000000..5c2a862 --- /dev/null +++ b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d2779130d3321575ae38fda1fc1ef582c10d0068fe61e911183d47c1ad266d8 +size 113427 diff --git a/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v3_open.uasset b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v3_open.uasset new file mode 100644 index 0000000..da17629 --- /dev/null +++ b/Content/Wooden_Chest/BluePrints/BP_Wooden_Chest_v3_open.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01f20e0f9946be48c04f20b22de8a43ef4345a5b7f45809a2204fa5d444c9066 +size 113549 diff --git a/Content/Wooden_Chest/Maps/Asset_Overview.umap b/Content/Wooden_Chest/Maps/Asset_Overview.umap new file mode 100644 index 0000000..b65cf84 --- /dev/null +++ b/Content/Wooden_Chest/Maps/Asset_Overview.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87947ce62c0add905e007fd00f0e29fb1f96ec48be1100367ab2851d87e1dcbf +size 78023 diff --git a/Content/Wooden_Chest/Maps/Asset_Overview_BuiltData.uasset b/Content/Wooden_Chest/Maps/Asset_Overview_BuiltData.uasset new file mode 100644 index 0000000..17833ce --- /dev/null +++ b/Content/Wooden_Chest/Maps/Asset_Overview_BuiltData.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0dbc3ff52a043a1f3691b96a79af70209fdc79009708ff2f3dad013716bb56e0 +size 2598229 diff --git a/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v1.uasset b/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v1.uasset new file mode 100644 index 0000000..3656780 --- /dev/null +++ b/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ab476efc44f5cebaa1055763b6122a11a77c8a079b10fd2b3ecc79e7e6a2317 +size 136622 diff --git a/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v2.uasset b/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v2.uasset new file mode 100644 index 0000000..f81e45c --- /dev/null +++ b/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d593de5aacbf841114ab40cee1d2b84dbe25e6e0d9d8a28a8b17a0fafd1cb30 +size 134099 diff --git a/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v3.uasset b/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v3.uasset new file mode 100644 index 0000000..716476f --- /dev/null +++ b/Content/Wooden_Chest/Materials/MI_Wooden_Chest_v3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03d712a1cfdaf82f535a686fea5939e7717df1f1b5d31a4f0eea31e1cd35029e +size 135129 diff --git a/Content/Wooden_Chest/Materials/M_Wooden_Chest.uasset b/Content/Wooden_Chest/Materials/M_Wooden_Chest.uasset new file mode 100644 index 0000000..8a783c4 --- /dev/null +++ b/Content/Wooden_Chest/Materials/M_Wooden_Chest.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fe15a26363a660cf0fd6c579606a1e3f9e99fe65a6567c8cb2fa989be3c22a1 +size 106477 diff --git a/Content/Wooden_Chest/Meshes/SM_Chest_Bottom.uasset b/Content/Wooden_Chest/Meshes/SM_Chest_Bottom.uasset new file mode 100644 index 0000000..fe458f9 --- /dev/null +++ b/Content/Wooden_Chest/Meshes/SM_Chest_Bottom.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1a89bd7f794b5d1e37057b8a7ac5476def295c3fe45f346c9c428894b8a411b +size 112026 diff --git a/Content/Wooden_Chest/Meshes/SM_Chest_Top.uasset b/Content/Wooden_Chest/Meshes/SM_Chest_Top.uasset new file mode 100644 index 0000000..6486e48 --- /dev/null +++ b/Content/Wooden_Chest/Meshes/SM_Chest_Top.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c7b1920795c2f2f3c19738781e1f80002d887c56aa72bb67d320638f99b2219 +size 137949 diff --git a/Content/Wooden_Chest/Textures/T_Wooden_Chest_BaseColor.uasset b/Content/Wooden_Chest/Textures/T_Wooden_Chest_BaseColor.uasset new file mode 100644 index 0000000..6556817 --- /dev/null +++ b/Content/Wooden_Chest/Textures/T_Wooden_Chest_BaseColor.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60ac51bcd4759c8047eefd12426da8f148ad0fcaca00a8a2163b78e0df2bc56f +size 3888500 diff --git a/Content/Wooden_Chest/Textures/T_Wooden_Chest_Normal.uasset b/Content/Wooden_Chest/Textures/T_Wooden_Chest_Normal.uasset new file mode 100644 index 0000000..0c87ec3 --- /dev/null +++ b/Content/Wooden_Chest/Textures/T_Wooden_Chest_Normal.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c16d29bccfefba829f8049f5e7ccda9b1ecffa05c81f1a4c7979a75582017c3 +size 3149947 diff --git a/Content/Wooden_Chest/Textures/T_Wooden_Chest_OcclusionRoughnessMetallic.uasset b/Content/Wooden_Chest/Textures/T_Wooden_Chest_OcclusionRoughnessMetallic.uasset new file mode 100644 index 0000000..1cd9d26 --- /dev/null +++ b/Content/Wooden_Chest/Textures/T_Wooden_Chest_OcclusionRoughnessMetallic.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ef1c2a737cbb0a6efaaff2010b5958456c9206df9ac6ab4d9e0967cda22d879 +size 4408612 diff --git a/Content/Wooden_Chest/Textures/T_Wooden_Chest_v1_BaseColor.uasset b/Content/Wooden_Chest/Textures/T_Wooden_Chest_v1_BaseColor.uasset new file mode 100644 index 0000000..012c717 --- /dev/null +++ b/Content/Wooden_Chest/Textures/T_Wooden_Chest_v1_BaseColor.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dce7b1cf5a8428f411d50a59d73a3f6721954ac383663a928ca8996892b301d5 +size 3888423 diff --git a/Content/Wooden_Chest/Textures/T_Wooden_Chest_v2_BaseColor.uasset b/Content/Wooden_Chest/Textures/T_Wooden_Chest_v2_BaseColor.uasset new file mode 100644 index 0000000..42192f4 --- /dev/null +++ b/Content/Wooden_Chest/Textures/T_Wooden_Chest_v2_BaseColor.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2795c01f11b7d751c5c717abb97d36d4d492eb1f2324dd908d811fd1645432f6 +size 3668288 diff --git a/Content/Wooden_Chest/Textures/T_Wooden_Chest_v3_BaseColor.uasset b/Content/Wooden_Chest/Textures/T_Wooden_Chest_v3_BaseColor.uasset new file mode 100644 index 0000000..74d0a93 --- /dev/null +++ b/Content/Wooden_Chest/Textures/T_Wooden_Chest_v3_BaseColor.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18ab76007e997a20f58155024679e7c8bfef5bc1669b9a09faf76e40f072e9df +size 3568299 diff --git a/Docs/CookingSystem.md b/Docs/CookingSystem.md new file mode 100644 index 0000000..b5b789b --- /dev/null +++ b/Docs/CookingSystem.md @@ -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` : 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` 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. diff --git a/Docs/Crafting.md b/Docs/Crafting.md new file mode 100644 index 0000000..318138b --- /dev/null +++ b/Docs/Crafting.md @@ -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` de **compteurs**, pas un `TSet`. Deux établis dont les portées +se chevauchent s'ajoutent tous les deux ; sortir du premier ne doit pas retirer l'accès alors +qu'on est encore devant le second. `Hands` est initialisée à 1 et ne peut pas être retirée. + +La diffusion de `OnAvailableStationsChanged` n'a lieu que sur les transitions **0 → 1** et +**1 → 0**. Entrer dans un deuxième établi ne reconstruit pas la grille pour rien. + +**Disponibilité et filtre d'affichage sont deux notions distinctes.** La disponibilité est une +*condition* de fabrication, le filtre est *visuel*. Les mélanger finirait par rendre un craft +impossible à cause d'un bouton d'interface, et ce genre de bug ne se comprend jamais sur le +moment. + +### `Craft()` — l'ordre compte + +``` +1. CanCraft() → refus, rien n'est touché +2. RemoveItem() par ingrédient → consommation +3. AddItem(résultat) → ajout +4. reste > 0 ? OnCraftOverflow → le pawn le pose au sol +``` + +**On consomme avant d'ajouter.** Refuser parce que l'inventaire est plein serait faux : +consommer trois branches libère justement la case du résultat. Et si le résultat ne rentre +malgré tout pas, il part par `OnCraftOverflow` — **aucune ressource ne disparaît, ni à l'entrée +ni à la sortie**. + +Le composant ne fait pas apparaître le pickup lui-même : il ne sait pas engendrer un acteur, et +un établi qui porterait le même composant déposerait son surplus sur sa propre table. C'est +`AFpsPlayer::HandleCraftOverflow()` qui s'en charge, abonné dans son `BeginPlay`. Si personne +n'écoute, un `UE_LOG(Error)` le signale au lieu de perdre l'objet en silence. + +Le résultat passe par `AddItem()`, donc **exactement comme un ramassage** : piles entamées +d'abord, puis barre rapide, puis grille. + +--- + +## 4. L'interface + +### `UInventoryScreenWidget` — l'écran à onglets + +Ce que Tab ouvre. Il ne contient aucune logique : les deux pages restent des widgets autonomes, +et **`UInventoryWidget` n'a pas changé d'une ligne** en devenant une page. + +| BindWidget | Type | Obligatoire | +|---|---|---| +| `TabSwitcher` | Widget Switcher | oui | +| `InventoryTabButton` | Button | oui | +| `CraftingTabButton` | Button | oui | +| `InventoryPage` | ton `WBP_Inventory` | oui | +| `CraftingPage` | ton `WBP_Crafting` | oui | + +- `GetHoveredSlotIndex()` renvoie `INDEX_NONE` dès que l'onglet Fabrication est affiché, sinon + la touche « jeter » agirait sur une case cachée sous le panneau de craft. +- `ShowPage()` prend le **widget**, jamais un index : l'ordre des enfants d'un WidgetSwitcher se + change d'un glisser dans la Designer, un `0`/`1` en dur inverserait silencieusement les onglets. +- Diffuse `OnTabChanged(bool bInventoryTabActive)` et **rien d'autre**. Ce que ça implique pour + le HUD ne le regarde pas. + +### `UCraftingWidget` — la page de fabrication + +| BindWidget | Type | | +|---|---|---| +| `RecipeGrid` | Uniform Grid Panel | obligatoire | +| `ResultIcon` | Image | obligatoire | +| `ResultNameText` | Text Block | obligatoire | +| `CraftButton` | Button | obligatoire | +| `DetailsPanel` | n'importe quel Widget | optionnel | +| `IngredientList` | n'importe quel Panel | optionnel | +| `ResultQuantityText` | Text Block | optionnel | +| `ResultDescriptionText` | Text Block | optionnel | +| `EmptyGridMessage` | n'importe quel Widget | optionnel | + +Réglages : `RecipeSlotClass`, `IngredientRowClass`, `ColumnCount`. + +`IngredientList` est déclaré en **`UPanelWidget`** et non en `UVerticalBox` : le code ne fait qu'y +ajouter des enfants, donc passer à un Wrap Box ou un Grid Panel ne demande pas une ligne de C++. + +`DetailsPanel` est déclaré en `UWidget` : le nom peut donc être porté par une **Border**, ce qui +masque le fond en même temps que le contenu quand rien n'est sélectionné. + +**Rafraîchissement, sans jamais de Tick :** + +- abonné à `OnInventoryChanged` → recalcule seulement les teintes et l'état du bouton +- abonné à `OnAvailableStationsChanged` → recalcule la liste + +`RebuildGrid` compare la nouvelle liste à l'ancienne et **sort immédiatement si elle est +identique**. Les lignes d'ingrédient sont **recyclées** et non recréées, puisque `RefreshDetails` +repasse à chaque objet ramassé. + +`HandleCraftClicked` ne re-vérifie pas `CanCraft` : `Craft()` le refait de toute façon, et +dupliquer la condition, c'est se garantir qu'elles divergeront. + +### `UCraftingRecipeSlotWidget` — une case + +`RecipeIcon` obligatoire ; `RecipeNameText` et `SelectionBorder` optionnels. +Réglages : `CraftableTint`, `UncraftableTint`. + +Les recettes non réalisables restent **visibles et cliquables**, seulement assombries : le joueur +doit pouvoir consulter ce qui lui manque, sinon il ne sait pas quoi aller chercher. + +> **La racine du Widget Blueprint doit avoir `Visibility = Visible`.** En +> `Self Hit Test Invisible`, la case ne reçoit jamais le clic. + +### `UCraftingIngredientRowWidget` — une ligne de coût + +`IngredientCountText` obligatoire ; `IngredientIcon` et `IngredientNameText` optionnels. +Réglages : `EnoughColor`, `MissingColor`, `CountFormat` (`{0}` possédé, `{1}` requis). + +Elle n'interroge jamais l'inventaire : le composant lui remet un `FCraftIngredientStatus` déjà +calculé. Une ligne qui compterait elle-même referait le travail autant de fois qu'il y a +d'ingrédients, et pourrait afficher autre chose que ce sur quoi le bouton s'appuie. + +--- + +## 5. Le contrôleur + +### Ouverture et fermeture + +Tout passe par **`SetInventoryScreenOpen(bOpen, bShowCraftingTab)`** — point d'entrée unique. Le +mode d'input, le curseur, la coupure d'input du pawn, la barre rapide et le poste actif sont +décidés à un seul endroit. Deux chemins parallèles finiraient par diverger. + +| Appel | Comportement | +|---|---| +| `ToggleInventory()` | Tab — ouvre/ferme, toujours sur l'onglet Inventaire | +| `ToggleCrafting()` | touche dédiée — ouvre sur Fabrication, bascule depuis Inventaire, referme depuis Fabrication | +| `OpenCraftingAtStation(Station)` | depuis un établi — accorde le poste puis ouvre | + +Quand l'écran est **déjà ouvert**, on ne fait que changer d'onglet : repasser par le mode d'input +serait au mieux inutile, au pire nuisible — `SetIgnoreLookInput` gère un compteur qu'un appel en +trop déséquilibrerait durablement. + +### Le poste actif + +`ActiveStation` retient le poste accordé par le dernier `OpenCraftingAtStation`. Il est rendu +dans `SetInventoryScreenOpen(false, …)`, donc **quel que soit le chemin de sortie** — Tab, Échap, +la mort. On fabrique à l'établi parce qu'on est en train de s'en servir, pas parce qu'on en a +croisé un un jour. + +### La barre rapide + +| Situation | Barre rapide | +|---|---| +| écran fermé | visible | +| onglet Inventaire | visible | +| onglet Fabrication | masquée | + +Une seule fonction décide : `UpdateHotbarVisibility()`. Sinon, fermer l'écran depuis l'onglet +craft laisserait la barre cachée pour le reste de la partie. + +Sa visibilité d'origine est **relevée à la création** et restaurée telle quelle, plutôt que +forcée à `Visible` : le Blueprint a peut-être choisi `SelfHitTestInvisible`, et l'écraser +changerait son comportement au clic sans qu'on l'ait demandé. + +### Remappage + +L'action est exposée dans l'onglet Touches sous l'identifiant **`ToggleCrafting`**, qui doit +correspondre **exactement** au champ `Name` des *Player Mappable Key Settings* du mapping dans +`IMC_Default`. Une faute de frappe donne une ligne « Non assignée » sans le moindre message. + +--- + +## 6. L'établi — `ACraftingStation` + +Un acteur interactif, détecté par le trace de `UInteractionComponent` comme un objet au sol. + +Il ne fabrique rien et ne contient aucune recette : il ouvre l'écran en annonçant **son** type de +poste, et le composant du joueur filtre en conséquence. + +**Un seul `BP_CraftingStation` suffit pour tous les postes** — c'est `StationType` qui change par +instance, comme `ItemData` sur `BP_Pickup`. + +### Pourquoi pas de sphère d'interaction + +`APickupItem` en a une parce que les meshes d'asset packs sont souvent livrés **sans collision +simple**, et que le trace est lancé en `bTraceComplex = false` — il les traverserait. + +Pour un meuble construit, c'est l'inverse : le trace s'arrête au **premier bloqueur**, donc une +sphère englobant la structure serait touchée avant le mesh. Le prompt apparaîtrait en visant le +vide autour, et la sphère masquerait un objet posé au pied du meuble. + +Le `Mesh` est donc réglé explicitement sur le profil **`BlockAll`** : il empêche le joueur de +traverser *et* bloque `Visibility` pour que le trace le trouve. Un profil laissant passer +`Visibility` rendrait le poste inutilisable sans qu'aucune erreur ne le signale. + +> **Prérequis** : le Static Mesh doit avoir une collision simple. Vérifie avec +> **Show → Simple Collision** dans l'éditeur de mesh. Sinon, `Collision → Add Box Simplified +> Collision`, ou pose une Box Collision dédiée dans le Blueprint. + +--- + +## 7. Ajouter une recette + +1. `Content/Game/Data/Crafting` → clic droit → **Miscellaneous → Data Asset** → classe + **`CraftingRecipeDataAsset`** → nommer `DA_Craft_`. +2. Remplir : `RecipeId`, `Ingredients`, `ResultItem`, `ResultQuantity`, `RequiredStation`. +3. Ouvrir **`DA_RecipeBook`** et ajouter la recette dans `Recipes`, **à sa place dans l'ordre + d'affichage**. +4. Vérifier que le `ResultItem` a bien une **icône** — sans elle la case sera vide et tu croiras + à un bug de code. +5. Sélectionner les assets → clic droit → **Asset Actions → Validate Assets** → 0 erreur. + +Aucune recompilation, aucun Blueprint à toucher. + +## 8. Ajouter un poste de travail + +1. Ajouter la valeur dans `ECraftingStation`, **avant `Count`**. +2. Créer un `BP_` dérivé de `CraftingStation`, régler son `Mesh` et son `StationType`. +3. Poser l'acteur dans le niveau. + +Rien d'autre : la grille, le filtrage et le bouton suivent. + +--- + +## 9. Récapitulatif des noms à ne pas casser + +Un `BindWidget` obligatoire mal orthographié **empêche le Blueprint de compiler**, avec le nom du +coupable dans le Compiler Results. Un `BindWidgetOptional` mal orthographié **échoue en silence**. + +| Widget Blueprint | Noms obligatoires | Noms optionnels | +|---|---|---| +| `WBP_InventoryScreen` | `TabSwitcher`, `InventoryTabButton`, `CraftingTabButton`, `InventoryPage`, `CraftingPage` | — | +| `WBP_Crafting` | `RecipeGrid`, `ResultIcon`, `ResultNameText`, `CraftButton` | `DetailsPanel`, `IngredientList`, `ResultQuantityText`, `ResultDescriptionText`, `EmptyGridMessage` | +| `WBP_RecipeSlot` | `RecipeIcon` | `RecipeNameText`, `SelectionBorder` | +| `WBP_IngredientRow` | `IngredientCountText` | `IngredientIcon`, `IngredientNameText` | + +Assignations à ne pas oublier : + +| Où | Champ | Valeur | +|---|---|---| +| `BP_FpsPlayer` → `CraftingComponent` | Recipe Book | `DA_RecipeBook` | +| `BP_FpsPlayerController` | Inventory Screen Class | `WBP_InventoryScreen` | +| `BP_FpsPlayerController` | Toggle Crafting Action | `IA_ToggleCrafting` | +| `WBP_Crafting` | Recipe Slot Class | `WBP_RecipeSlot` | +| `WBP_Crafting` | Ingredient Row Class | `WBP_IngredientRow` | + +--- + +## 10. Pièges UMG rencontrés + +Tous ont coûté du temps au moins une fois. + +- **Un Canvas Panel a une taille désirée de zéro.** Imbriqué dans un conteneur en `Auto`, il + disparaît. C'est ce qui arrive à `WBP_Inventory` devenu page d'un WidgetSwitcher. +- **Dans un Vertical/Horizontal Box, un enfant en `Size = Fill` ne compte pas** dans la taille + désirée du parent. Tous les enfants en Fill → le parent mesure zéro, et la Border qui l'entoure + se réduit à son padding. +- **Un Size Box impose une taille désirée, pas une taille allouée.** Un parent qui l'aligne en + `Fill` l'étire quand même. Il faut `Auto` + `Center`. +- **Pour un cadre de taille fixe, le Size Box va autour de la Border.** Le widget le plus externe + gagne toujours. +- **Une Border ne peint rien avec `Draw As = Image` sans texture.** Pour un aplat, `Rounded Box`. + Contour creux : Tint en alpha 0 + Outline Width. +- **« Wrap With… » sur l'unique enfant d'une Border le fait sortir de la Border.** Vérifier + l'indentation juste après. +- **Un Uniform Grid Panel répartit la largeur qu'on lui donne en parts égales.** En `Fill`, les + cases se retrouvent espacées ; il faut `Left` ou `Center` sur son slot pour qu'il prenne sa + taille désirée. L'espacement se règle avec `Slot Padding`. +- **`Scale Box` + `Stretch = Scale To Fit`** est le seul « keep aspect ratio » d'UMG. Un Size Box + avec `Min/Max Aspect Ratio` à 1.0 suffit pour des icônes carrées. +- **Le texte placeholder d'un Text Block fausse la mise en page dans la Designer.** Y mettre une + valeur représentative — le code l'écrase à l'exécution. +- **L'aperçu `Fill Screen`** étire la racine à 1920 × 1080. Basculer sur `Desired Size` pour voir + la vraie taille. + +--- + +## 11. Dépannage + +| Symptôme | Cause probable | +|---|---| +| Grille vide, message « aucune recette » | `Recipe Book` non assigné, ou toutes les recettes en `Workbench` | +| Grille vide, aucun message | `RecipeSlotClass` non assignée — voir Output Log | +| Une case ne réagit pas au clic | racine de `WBP_RecipeSlot` en `Self Hit Test Invisible` | +| Cases vides sans icône | le `ResultItem` n'a pas d'icône | +| Panneau de détail toujours affiché | `DetailsPanel` non nommé, donc non lié | +| Liste d'ingrédients vide | `IngredientRowClass` non assignée | +| Le bouton reste grisé avec les ressources | `RequiredStation` inaccessible — pas devant l'établi | +| Ligne « Non assignée » dans le menu Touches | `Name` des Player Mappable Key Settings ≠ `ToggleCrafting` | +| Le prompt de l'établi n'apparaît jamais | le mesh n'a pas de collision simple | +| Objets perdus après un craft | `OnCraftOverflow` sans abonné — cherche l'`Error` dans l'Output Log | + +Les messages du système sont préfixés `UCraftingComponent`, `UCraftingWidget` ou +`UInventoryWidget` dans l'Output Log. + +--- + +## 12. Reste à faire + +- **Boutons de filtre par poste** — `SetStationFilter()` existe, `ECraftingStation::Count` est la + sentinelle « tout ». +- **Recettes réelles** — les deux `DA_Craft_Test_*` sont du jetable bâti sur `DA_Branch` et + `DA_Rocher`. +- **Recettes à découvrir** — `RecipeId` est prévu pour ça ; la liste des identifiants connus ira + dans la sauvegarde, jamais dans le DataAsset. +- **Retour joueur** — son de fabrication sur `OnCraftSucceeded`, message quand le résultat tombe + au sol. +- **Postes qui travaillent sans le joueur** — fourneau, séchoir. Un **autre système** : acteurs du + monde avec file d'attente et changement de mesh, sans passer par ce menu. diff --git a/Source/Survival_projet/Private/FpsPlayer.cpp b/Source/Survival_projet/Private/FpsPlayer.cpp index ad4294e..14bb26a 100644 --- a/Source/Survival_projet/Private/FpsPlayer.cpp +++ b/Source/Survival_projet/Private/FpsPlayer.cpp @@ -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; } diff --git a/Source/Survival_projet/Private/FpsPlayerController.cpp b/Source/Survival_projet/Private/FpsPlayerController.cpp index 84431d8..42bb774 100644 --- a/Source/Survival_projet/Private/FpsPlayerController.cpp +++ b/Source/Survival_projet/Private/FpsPlayerController.cpp @@ -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::HandleHotbarSlotHovered(UInventoryComponent* Inventory, int32 SlotIndex, bool bHovered) +{ + if (InventoryScreen && bInventoryOpen) + { + InventoryScreen->NotifyExternalSlotHovered(Inventory, SlotIndex, bHovered); + } +} + void AFpsPlayerController::DropHoveredItem() { - if (!bInventoryOpen || !InventoryScreen) - { - return; - } + UInventoryComponent* HoveredInventory = nullptr; + int32 SlotIndex = INDEX_NONE; - // 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) + // 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(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(); diff --git a/Source/Survival_projet/Private/HotbarWidget.cpp b/Source/Survival_projet/Private/HotbarWidget.cpp index d43735e..27bf90b 100644 --- a/Source/Survival_projet/Private/HotbarWidget.cpp +++ b/Source/Survival_projet/Private/HotbarWidget.cpp @@ -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)) { diff --git a/Source/Survival_projet/Private/InventoryComponent.cpp b/Source/Survival_projet/Private/InventoryComponent.cpp index d4ca712..95228e0 100644 --- a/Source/Survival_projet/Private/InventoryComponent.cpp +++ b/Source/Survival_projet/Private/InventoryComponent.cpp @@ -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()) { - To = From; - From.Clear(); - OnInventoryChanged.Broadcast(); + // 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(); + 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; } diff --git a/Source/Survival_projet/Private/InventoryScreenWidget.cpp b/Source/Survival_projet/Private/InventoryScreenWidget.cpp index 253c879..5769e8d 100644 --- a/Source/Survival_projet/Private/InventoryScreenWidget.cpp +++ b/Source/Survival_projet/Private/InventoryScreenWidget.cpp @@ -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(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(OwningController->GetPawn())) { - PlayerPawn->DropSlot(Payload->SourceSlotIndex, Payload->Quantity); + PlayerPawn->DropFromInventory(Payload->SourceInventory.Get(), Payload->SourceSlotIndex, Payload->Quantity); } } diff --git a/Source/Survival_projet/Private/InventorySlotWidget.cpp b/Source/Survival_projet/Private/InventorySlotWidget.cpp index 3773a96..5c8280e 100644 --- a/Source/Survival_projet/Private/InventorySlotWidget.cpp +++ b/Source/Survival_projet/Private/InventorySlotWidget.cpp @@ -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); } diff --git a/Source/Survival_projet/Private/InventoryWidget.cpp b/Source/Survival_projet/Private/InventoryWidget.cpp index c7d08a6..afcde97 100644 --- a/Source/Survival_projet/Private/InventoryWidget.cpp +++ b/Source/Survival_projet/Private/InventoryWidget.cpp @@ -10,48 +10,58 @@ #include "InventoryComponent.h" #include "InventoryDragDropOperation.h" #include "InventorySlotWidget.h" +#include "ItemDetailsWidget.h" void UInventoryWidget::NativeConstruct() { Super::NativeConstruct(); - BindToOwningPawn(); + // 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(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& 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); } diff --git a/Source/Survival_projet/Private/ItemDetailsWidget.cpp b/Source/Survival_projet/Private/ItemDetailsWidget.cpp new file mode 100644 index 0000000..2f2387c --- /dev/null +++ b/Source/Survival_projet/Private/ItemDetailsWidget.cpp @@ -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& 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(HoveredSlot->RemainingUses) / static_cast(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); + } +} diff --git a/Source/Survival_projet/Private/StorageContainer.cpp b/Source/Survival_projet/Private/StorageContainer.cpp new file mode 100644 index 0000000..ec70699 --- /dev/null +++ b/Source/Survival_projet/Private/StorageContainer.cpp @@ -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(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(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(TEXT("LidPivot")); + LidPivot->SetupAttachment(BaseMesh); + + LidMesh = CreateDefaultSubobject(TEXT("LidMesh")); + LidMesh->SetupAttachment(LidPivot); + LidMesh->SetCollisionProfileName(UCollisionProfile::NoCollision_ProfileName); + + Storage = CreateDefaultSubobject(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(Interactor); + AFpsPlayerController* PlayerController = InteractingPawn ? Cast(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 diff --git a/Source/Survival_projet/Public/CookingStation.h b/Source/Survival_projet/Public/CookingStation.h index 43166f0..be526ed 100644 --- a/Source/Survival_projet/Public/CookingStation.h +++ b/Source/Survival_projet/Public/CookingStation.h @@ -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; diff --git a/Source/Survival_projet/Public/FpsPlayer.h b/Source/Survival_projet/Public/FpsPlayer.h index 561dadf..b9ee635 100644 --- a/Source/Survival_projet/Public/FpsPlayer.h +++ b/Source/Survival_projet/Public/FpsPlayer.h @@ -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(); diff --git a/Source/Survival_projet/Public/FpsPlayerController.h b/Source/Survival_projet/Public/FpsPlayerController.h index 7405957..ce8eb00 100644 --- a/Source/Survival_projet/Public/FpsPlayerController.h +++ b/Source/Survival_projet/Public/FpsPlayerController.h @@ -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 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 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 ActiveStorage; + + bool bStorageOpen = false; + bool bInventoryOpen = false; /** Onglet Fabrication affiche. N'a de sens que si bInventoryOpen est vrai. */ diff --git a/Source/Survival_projet/Public/HotbarWidget.h b/Source/Survival_projet/Public/HotbarWidget.h index 647e9b0..e728f1d 100644 --- a/Source/Survival_projet/Public/HotbarWidget.h +++ b/Source/Survival_projet/Public/HotbarWidget.h @@ -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 BoundInventory; + /** Destination du clic droit, recopiee sur chaque case creee. */ + TWeakObjectPtr QuickTransferTarget; + /** UPROPERTY obligatoire : sans ca le GC collecterait les cases. */ UPROPERTY(Transient) TArray> SlotWidgets; diff --git a/Source/Survival_projet/Public/InventoryComponent.h b/Source/Survival_projet/Public/InventoryComponent.h index fe1ea76..937ab78 100644 --- a/Source/Survival_projet/Public/InventoryComponent.h +++ b/Source/Survival_projet/Public/InventoryComponent.h @@ -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; }; diff --git a/Source/Survival_projet/Public/InventoryScreenWidget.h b/Source/Survival_projet/Public/InventoryScreenWidget.h index 5c36518..3710ea6 100644 --- a/Source/Survival_projet/Public/InventoryScreenWidget.h +++ b/Source/Survival_projet/Public/InventoryScreenWidget.h @@ -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 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 StoragePanel; + + /** Un deuxieme WBP_Inventory, renomme "StorageGrid". DECOCHE-lui Auto Bind To Pawn. */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr StorageGrid; + + /** Nom du coffre ouvert. */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr StorageTitle; + + /** Deverse la grille du sac dans le coffre. La barre rapide n'y passe pas. */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr StoreAllButton; + + /** Vide le coffre dans le sac, dans la limite de la place disponible. */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr 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 BoundStorage; }; diff --git a/Source/Survival_projet/Public/InventorySlotWidget.h b/Source/Survival_projet/Public/InventorySlotWidget.h index 38b3fef..8f64317 100644 --- a/Source/Survival_projet/Public/InventorySlotWidget.h +++ b/Source/Survival_projet/Public/InventorySlotWidget.h @@ -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 OwningInventory; - /** La grille parente, pour lui signaler le survol. */ - TWeakObjectPtr OwningGrid; + /** Destination du clic droit. Voir SetQuickTransferTarget(). */ + TWeakObjectPtr QuickTransferTarget; int32 SlotIndex = INDEX_NONE; diff --git a/Source/Survival_projet/Public/InventoryWidget.h b/Source/Survival_projet/Public/InventoryWidget.h index 195c6d7..956b902 100644 --- a/Source/Survival_projet/Public/InventoryWidget.h +++ b/Source/Survival_projet/Public/InventoryWidget.h @@ -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 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 BoundInventory; + /** Destination du clic droit, recopiee sur chaque case creee. */ + TWeakObjectPtr QuickTransferTarget; + int32 HoveredSlotIndex = INDEX_NONE; + /** Voir SetItemDetailsEnabled(). */ + bool bItemDetailsEnabled = true; + /** UPROPERTY obligatoire : sans ca le GC collecterait les cases. */ UPROPERTY(Transient) TArray> SlotWidgets; diff --git a/Source/Survival_projet/Public/ItemDetailsWidget.h b/Source/Survival_projet/Public/ItemDetailsWidget.h new file mode 100644 index 0000000..150d504 --- /dev/null +++ b/Source/Survival_projet/Public/ItemDetailsWidget.h @@ -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 ItemIcon; + + /** A nommer exactement "ItemNameText". */ + UPROPERTY(meta = (BindWidget)) + TObjectPtr ItemNameText; + + /** Pense a cocher "Auto Wrap Text" dessus, sinon le panneau s'elargit. */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr 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 UsesPanel; + + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr UsesBar; + + /** "3 / 5", pose par-dessus la barre. */ + UPROPERTY(meta = (BindWidgetOptional)) + TObjectPtr 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 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 BoundInventory; + + int32 SlotIndex = INDEX_NONE; +}; diff --git a/Source/Survival_projet/Public/StorageContainer.h b/Source/Survival_projet/Public/StorageContainer.h new file mode 100644 index 0000000..24fe72c --- /dev/null +++ b/Source/Survival_projet/Public/StorageContainer.h @@ -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 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 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 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 LidPivot; + + /** Le couvercle : SM_Chest_Top, enfant du pivot. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage") + TObjectPtr LidMesh; + + /** Le contenu. Un inventaire ordinaire, sans barre rapide. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage") + TObjectPtr 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 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 OpenSound; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Storage|Sound") + TObjectPtr 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; +};