(Feat) Add footsteps

This commit is contained in:
2026-08-11 15:23:10 +02:00
parent e8e128457a
commit 176beab9fc
4776 changed files with 18806 additions and 121 deletions
+405
View File
@@ -0,0 +1,405 @@
# Customisation de personnage
Le joueur choisit sa silhouette, son visage, sa coiffure et ses couleurs avant
de lancer une partie. Les trois autres joueurs le voient tel qu'il s'est fait.
Ce document a **deux moitiés**, à lire séparément :
- **Partie A — pour l'artiste** : ajouter une coiffure, une couleur, un corps.
Aucune ligne de code, aucune recompilation. Commence au §1.
- **Partie B — pour le développeur** : architecture, réseau, pièges du moteur.
Commence au §6.
---
# PARTIE A — Ajouter du contenu
## 1. Le principe en une phrase
**Tout passe par un seul asset : `DA_CharacterParts`.** C'est la table qui dit
« l'option n° 3 de la catégorie Cheveux, c'est ce mesh-là ». Le code ne connaît
que des numéros ; c'est le catalogue qui leur donne un sens.
Conséquence directe : **ajouter du contenu ne demande jamais de toucher au
code**, et jamais de fermer l'éditeur.
```
DA_CharacterParts
├── Body Sets une entrée par silhouette (A, B, …)
│ ├── Body Meshes les morceaux du corps
│ ├── Anim Class l'AnimBP qui l'anime
│ ├── Heads ┐
│ ├── Hairstyles │ les listes d'options,
│ ├── Eyebrows │ propres à CETTE silhouette
│ ├── Beards │
│ └── Mustaches ┘
└── Palettes partagées par toutes les silhouettes
├── Skin Colors
├── Hair Colors
├── Eye Colors
├── Underwear Colors
└── Eye Materials
```
## 2. Ajouter une coiffure
1. Ouvre `Content/Game/Data/Character/DA_CharacterParts`
2. Déplie **Body Sets** → l'entrée du corps concerné (`[0]` = A, `[1]` = B)
3. Déplie **Hairstyles** → clique le **+**
4. Dans la nouvelle entrée :
- **Mesh** = ton `SKM_…`
- **Icon** = la vignette (facultative, voir §5)
5. Sauvegarde
C'est tout. La coiffure apparaît dans l'écran au prochain lancement.
> **L'ORDRE DE LA LISTE EST LE CONTRAT.** Un index est ce qui part sur le réseau
> et ce qui se sauvegarde. **Insérer une entrée au milieu décale tout ce qui
> suit** : les personnages déjà créés changeront de coiffure sans prévenir.
> **Ajoute toujours à la fin.**
Même procédé pour **Heads**, **Eyebrows**, **Beards**, **Mustaches**.
## 3. Ajouter une couleur
1. Déplie **Palettes** → la palette voulue (`Skin Colors`, `Hair Colors`…)
2. Clique le **+**
3. Double-clique la case de couleur → dans le picker, colle une valeur dans le
champ **Hex sRGB** (le plus fiable) ou règle R/G/B à la main
4. Sauvegarde
Les palettes sont **partagées par toutes les silhouettes** : une teinte de peau
ajoutée profite à A comme à B. Deux nuanciers à tenir à jour divergeraient au
premier ajout.
> Même règle d'ordre qu'au §2 : **on ajoute à la fin**.
### Ce que chaque palette pilote
| Palette | Ce qu'elle teint |
|---|---|
| **Skin Colors** | le corps **et** le visage, ensemble |
| **Hair Colors** | cheveux, **barbe, moustache et sourcils** en même temps |
| **Eye Colors** | l'iris |
| **Underwear Colors** | le sous-vêtement |
Une barre de couleur de cheveux qui ne teindrait pas la barbe donnerait un
personnage incohérent — c'est voulu qu'elles suivent.
## 4. Ajouter une silhouette
Plus lourd, mais toujours sans code.
1. **Body Sets****+** → une nouvelle entrée
2. **Body Meshes** → ajoute ton `SKM_` de corps (une seule case aujourd'hui)
3. **Anim Class** → l'AnimBP à utiliser
> **Tu peux réutiliser `ABP_PlayerBody_A`** si ton nouveau corps partage les
> mêmes **noms d'os**. Le moteur valide la compatibilité par les noms, pas par
> l'identité du squelette. C'est ce qui permet à Body A et Body B de partager un
> seul AnimBP : une correction profite aux deux.
4. Remplis **Heads**, **Hairstyles**, **Eyebrows** — ces listes appartiennent à
la silhouette
5. Laisse **Beards** et **Mustaches** vides si elle n'en porte pas : les
catégories **disparaissent d'elles-mêmes** de la colonne de l'écran
## 5. Les vignettes
Le champ **Icon** est un `UTexture2D`, exactement comme l'icône d'un objet
d'inventaire.
**Il est facultatif.** Une entrée sans vignette affiche son **numéro** dans la
case — l'option reste choisissable. C'est délibéré : le catalogue doit rester
utilisable avant que les trente-six vignettes existent, et le moyen le plus
simple de les fabriquer est justement de capturer l'écran de customisation une
fois qu'il tourne.
## 6. Le décor de l'écran
Le personnage montré pendant la customisation est un acteur posé dans
`MenuScene` : **`BP_CharacterPreview`**. Trois choses comptent quand tu le
places :
- **Hors du champ des `MenuCameraSpot`**, sinon il apparaît en arrière-plan du
menu principal
- **Devant un fond qui tient debout** — c'est ce qu'on voit derrière lui pendant
toute la customisation
- **Éclairé de face.** On passe l'essentiel du temps en gros plan sur le
visage ; à contre-jour, les teints de peau deviennent illisibles et c'est
précisément ce que le joueur règle
### Les deux cadrages
`FullBodyViewSpot` et `FaceViewSpot` sont deux repères vides que tu **déplaces à
la souris** dans le viewport du Blueprint. La caméra glisse de l'un à l'autre
selon la catégorie ouverte :
| Catégorie ouverte | Cadrage |
|---|---|
| Silhouette, Teint, Sous-vêtement | `FullBodyViewSpot` |
| tout le reste | `FaceViewSpot` |
Pense à **décaler le personnage vers la droite** de l'image : la colonne d'UI
occupe la gauche.
---
# PARTIE B — Architecture
## 7. Vue d'ensemble
| Fichier | Rôle |
|---|---|
| `CharacterAppearanceTypes.h` | `FCharacterAppearance` (11 octets), les enums, `FCharacterPartEntry` |
| `CharacterPartsDataAsset` | le catalogue : index → assets, palettes, noms de paramètres matériau |
| `CharacterAppearanceComponent` | monte les pièces, pose les couleurs. Sur le pawn **et** sur le mannequin |
| `CharacterPreviewActor` | le mannequin du menu : mesh + caméra + cadrages |
| `CharacterCustomizationWidget` | l'écran, la colonne, la grille |
| `CustomizationCategoryWidget` | une rangée de la colonne |
| `CustomizationOptionWidget` | une case : vignette **ou** pastille |
| `SurvivalPlayerState` | porte l'apparence répliquée |
| `SurvivalUserSettings` | la persiste dans le `.ini` |
## 8. `FCharacterAppearance` — pourquoi des index
11 octets, **que des index**, jamais de `TSoftObjectPtr` ni de chemin d'asset.
Deux raisons qui vont dans le même sens :
- un chemin coûte une chaîne à chaque réplication, un index tient dans un octet ;
- un index se **valide** contre le catalogue, alors qu'un chemin envoyé par un
client pourrait désigner n'importe quel asset du jeu.
Bénéfice collatéral : la struct se sérialise telle quelle dans le `.ini`
(`CharacterAppearance=(BodyType=BodyA,HeadIndex=0,…)`). Une apparence faite de
références d'assets aurait donné une douzaine de chemins à écrire, qu'une seule
renommée aurait cassés silencieusement.
`NoPart` vaut **255** : c'est « aucune pièce », pour une barbe rasée.
## 9. Le montage — `UCharacterAppearanceComponent`
Un `USkeletalMeshComponent` par catégorie, enfant du mesh porteur, en **Leader
Pose**. Un seul mesh porte l'animation, les autres recopient sa pose **par nom
d'os** — ce qui marche même entre squelettes distincts.
Le composant recopie sur chaque pièce le `bOwnerNoSee` et le `bCastHiddenShadow`
du porteur. C'est ce qui fait que le **mannequin du menu est visible** sans une
ligne de code de plus, alors que le corps du joueur reste invisible à son
propriétaire.
### Le corps se monte depuis une LISTE
`BodyMeshes` est un `TArray` même s'il ne contient qu'une case. Le pack livre
exprès `SKM_BodyA_torso`, `_arms`, `_legs_01/02/03`, `_hands`, `_feet` : le jour
où une armure devra cacher les segments qu'elle recouvre, la case 0 deviendra
`SKM_BodyA_empty` — porteur d'animation invisible — et les huit segments
suivront derrière, **sans que ce code change**.
> **Contrainte à vérifier avant tout achat de pack d'armure : il doit être
> skinné sur `SK_BodyA` / `SK_BodyB`.**
### Les couleurs
Chaque paramètre est posé sur **tous les éléments** de chaque mesh, sans chercher
lequel le porte : un `SetVectorParameterValue` dont le paramètre n'existe pas est
simplement ignoré. Le slot des yeux ignore donc `Skin`, celui de la peau ignore
`Eye color`. Cela évite une table d'index de slots qui casserait au premier mesh
mal rangé.
## 10. Réseau et persistance
```
USurvivalUserSettings ──lit──► AnnounceCharacterAppearance()
(.ini du joueur) │
SubmitCharacterAppearance() ← POINT D'ENTRÉE UNIQUE
│ │
persiste Server_SetCharacterAppearance
ASurvivalPlayerState (autorité)
┌──────────┴──────────┐
OnRep (clients) appel direct (hôte)
└──────────┬──────────┘
ApplyAppearanceToPawn()
```
**`SubmitCharacterAppearance()` persiste ET annonce, jamais l'un sans l'autre.**
Persister sans annoncer laisserait le joueur seul à se voir changé ; annoncer
sans persister le ferait repartir au visage par défaut au lancement suivant.
L'apparence vit sur le **PlayerState** et pas sur le pawn : elle décrit le
JOUEUR, pas le corps. Le pawn meurt et réapparaît, le PlayerState non —
réapparaître avec le visage d'un autre n'aurait aucun sens, alors que perdre son
inventaire au sol est la règle du jeu.
**Rien n'est validé côté PlayerState, délibérément** : borner un index demande le
catalogue, que seul le composant connaît — or il passe déjà tout ce qu'il monte
par `Catalog->Sanitize()`, sur chaque machine.
## 11. Le parcours de l'écran
```
clic Jouer → fondu au noir → SetViewTarget(BP_CharacterPreview)
→ colonne affichée → fondu depuis le noir
├─ « Jouer » → apparence persistée → fondu de départ → GameScene
└─ « Retour » → fondu → nouvel angle de menu
```
On ne charge **aucune map** : le mannequin est posé dans `MenuScene`. La bascule
de caméra se fait pendant que l'écran est noir — un travelling depuis l'angle du
menu traverserait le décor.
**L'écran part toujours de l'apparence par défaut**, jamais de la persistée.
C'est un provisoire assumé : à terme l'apparence appartiendra à la **sauvegarde**
et non au joueur, et l'on regardera au clic sur *Jouer* si cette partie a déjà un
personnage. `SetupCustomization(Preview, Appearance)` prend déjà l'apparence en
paramètre — il n'y aura qu'à changer son origine.
Laisser **`Customization Menu Class` vide** sur `BP_MainMenuPlayerController`
restaure l'ancien comportement (Jouer lance directement). Pratique pour tester
autre chose sans traverser l'écran.
## 12. Les pièges du moteur rencontrés
Aucun n'était visible depuis le code. Ils sont ici pour ne pas être redécouverts.
### Les `UFUNCTION(Exec)` d'un `UActorComponent` ne sont JAMAIS appelées
La chaîne de routage (`UPlayer::Exec`, dans `Player.cpp`) interroge le monde, le
`PlayerInput`, le contrôleur, le **pawn**, le HUD, le GameMode, le `CheatManager`,
le GameState et le camera manager — **jamais les composants**, `AActor` ne
surchargeant pas `ProcessConsoleExec` pour les parcourir. Et rien ne proteste :
la commande est introuvable, sans message.
D'où `EmberPart` / `EmberColor` / `EmberAppearanceDump` posées sur `AFpsPlayer`,
qui délègue. *(`EmberJoin` marche parce que `UGameInstance`, lui, route vers ses
subsystems.)*
### `OverrideMaterials` survit à un changement de mesh
`SetSkeletalMeshAsset` remplace l'asset mais ne touche pas au tableau
`OverrideMaterials`, qui appartient au **composant**. Sans vidage préalable,
passer du corps A au corps B garde le MID dérivé de `MI_BodyA_a` : on obtient une
silhouette féminine peinte avec la peau et les abdominaux du modèle masculin.
Le symptôme trompe, parce que le mesh, lui, a bien changé.
D'où `SetPartMesh()`, qui appelle `EmptyOverrideMaterials()` avant d'assigner —
et ne fait rien si le mesh est déjà le bon, pour que les MID survivent à un simple
changement de couleur.
### L'ordre des slots de matériau varie d'un mesh à l'autre
Sur `SKM_HeadA_01`, l'**Element 0 est celui des YEUX** et l'Element 1 celui de la
peau — l'inverse de l'intuition. Les six têtes viennent de deux packs différents,
rien ne garantit qu'elles s'accordent.
On cherche donc le slot **par son nom** (`EyeMaterialSlotHint`, valeur `Eye`), et
si aucun ne correspond on ne pose rien et on l'écrit dans le log : écraser le slot
de la peau repeint le visage entier de la couleur des yeux, symptôme bien plus
long à diagnostiquer qu'une ligne de log.
### Un nom de paramètre matériau se lit dans le Material INSTANCE
Le binaire de `M_Head_Base` contient la chaîne « Skin Base color », mais le
paramètre réellement exposé s'appelle **`Skin`**. Le chercher sous l'autre nom
dans `MI_HeadA_01_a` ne rend aucun résultat.
Corollaire : les noms de paramètres sont des `UPROPERTY` du catalogue et pas des
constantes C++, pour qu'une erreur se corrige sans recompiler.
### `LeaderPoseComponent` est `BlueprintReadOnly`
Elle n'apparaît **jamais** dans le Details panel — la section « Leader Pose
Component » qu'on y voit ne contient que des cases de bornes et de LOD. Elle ne
s'assigne que par `SetLeaderPoseComponent()`, en C++ ou en Construction Script.
### `Approx Size` d'un Skeletal Mesh n'est pas sa hauteur visible
Les bounds incluent une marge et celles du Physics Asset. `SKM_BodyA` affiche 184
alors que le personnage entier mesure à peu près autant. Pour juger une taille :
viewport en vue **Front**, et la ligne verte (`Z = 0`) comme repère — le bas de la
capsule est à `-HalfHeight`, le haut à `+HalfHeight`.
### `Initialize` masque une virtuelle d'`UUserWidget`
`UUserWidget::Initialize()` existe et est virtuelle. Une surcharge de signature
différente la **masque** au lieu de la surcharger (C4263/C4264). D'où
`SetupCustomization`. Même famille de piège que les membres nommés `Player` ou
`Slot` dans une classe dérivée du moteur.
## 13. Les commandes console
Sur le pawn, en partie. Elles appliquent en local **et** soumettent (persistance
+ annonce au serveur), donc elles testent toute la chaîne.
```
EmberPart body 0..1 silhouette
EmberPart head 0..5 visage
EmberPart hair 0..12 coiffure
EmberPart eyebrows 0..5 sourcils
EmberPart beard 0..5 barbe
EmberPart mustache 0..5 moustache
EmberPart eyes 0..4 forme d'iris
EmberPart beard -1 retire la pièce
EmberColor skin 0..11 teint
EmberColor hair 0..5 cheveux + barbe + sourcils + moustache
EmberColor eye 0..4 iris
EmberColor underwear 0..3 sous-vêtement
EmberAppearanceDump état courant + nombre d'options par catégorie
```
`EmberAppearanceDump` est le premier réflexe de diagnostic : un compte à **0**
signale une liste vide dans le catalogue.
## 14. Dépannage
| Symptôme | Cause |
|---|---|
| La colonne de l'écran est vide | `Catalog`, `Category Widget Class` ou `Option Widget Class` non assignés dans les **Class Defaults** de `WBP_CharacterCustomization` (mode **Graph***Class Defaults*) |
| *Jouer* lance la partie sans montrer l'écran | `Customization Menu Class` vide sur `BP_MainMenuPlayerController` |
| Écran noir qui ne s'éclaircit pas | aucun `BP_CharacterPreview` dans la map — un avertissement l'écrit dans l'Output Log |
| Le visage ne suit pas la couleur de peau | `Head Skin Color Parameter` faux dans le catalogue (`Skin`, pas `Skin Base color`) |
| Le visage entier prend la couleur des yeux | `Eye Material Slot Hint` ne correspond à aucun slot — le log liste les slots disponibles |
| Le corps B garde la peau du corps A | régression de `SetPartMesh()` : les overrides ne sont plus vidés |
| Une pièce flotte détachée du corps | leader pose non posé sur ce composant |
| Corps figé en pose de référence | `Anim Class` absente du Body Set |
| Les pastilles de couleur sont invisibles | le pinceau d'`OptionImage` est en `Draw As = Image` sans texture — un pinceau en mode Image sans texture ne peint rien |
| En PIE à 2, les deux joueurs sont identiques | normal : les deux fenêtres lisent le même `.ini`. Change l'un des deux à la console |
## 15. Limites connues
- **Les bras vus en 1re personne ne sont pas customisés.** Leur squelette
(`BasePose_Skeleton`) n'a rien à voir avec `SK_BodyA` ; les rendre
customisables imposerait de retargeter toutes les animations FPS. Raft assume
le même compromis.
- **La physique de cheveux est perdue en leader pose.** Coiffures, barbes et
moustaches ont leur physics asset, mais l'AnimBP post-process d'un *follower*
ne tourne pas. La démo du pack accepte déjà ce compromis.
- **Tatouages et maquillage ne sont pas exposés.** `M_Head_Base` et
`M_Body_Base` en ont pourtant les paramètres : ce ne sont que des couleurs et
des masques de plus à ajouter au catalogue, aucune refonte.
- **Pas d'animation accroupie.** Le pack n'en livre pas. Mais le squelette est
celui du **Mannequin UE5** (`ik_foot_root`, `index_metacarpal_l`…), donc toute
animation vendue « UE5 Mannequin compatible » se branche **sans retarget**.
## 16. Étendre
**Ajouter une catégorie** (tatouages, maquillage) :
1. un champ dans `FCharacterAppearance`
2. une valeur dans `ECharacterPartCategory` (mesh) ou une palette (couleur)
3. une valeur dans `ECustomizationCategory` + son libellé dans
`CharacterCustomizationLabels::Get`
4. les branches correspondantes dans `GetOptionCountForCategory`,
`GetCurrentIndexForCategory` et `SetIndexForCategory`
L'écran, lui, n'a rien à changer : la colonne se construit depuis l'enum.
**Brancher les sauvegardes** : une seule ligne, dans
`AMainMenuPlayerController::OpenCustomization()` — passer l'apparence de la save
au lieu de `FCharacterAppearance()`.
+452
View File
@@ -0,0 +1,452 @@
# Coffres
Contenants posés dans le monde : on les ouvre, on y range, on y reprend. Le couvercle se lève
pour tout le monde, le contenu est partagé et accessible à plusieurs en même temps.
**Fichiers** : `StorageContainer.h/.cpp`, plus le panneau de coffre d'`InventoryScreenWidget`,
l'API de transfert d'`InventoryComponent` et le bloc `UI|Storage` de `FpsPlayerController`.
---
## 1. Le principe
**Un coffre n'a aucun modèle de données à lui.** Son contenu est un `UInventoryComponent`,
exactement le même que celui du joueur, simplement configuré sans barre rapide. Tout le reste
en découle :
- le glisser-déposer entre le sac et le coffre ne convertit rien — ce sont les mêmes cases,
le même `FInventorySlot` ;
- la durabilité d'une hache traverse le coffre **sans une ligne de code**, puisque l'usure
vit sur le slot ;
- il n'y a pas de « format coffre » à faire évoluer le jour où l'inventaire change.
**Le coffre ne décide de rien.** Il se contente d'appeler `OpenStorage(this)` sur le
controller. C'est ce dernier qui referme le couvercle — par Échap, par la mort du joueur, ou
parce qu'il s'est éloigné. Un coffre qui déciderait seul finirait ouvert dans la moitié des
cas, et il n'a de toute façon aucun moyen de savoir que l'écran s'est fermé.
**Ce n'est pas un écran à part**, c'est un panneau de plus sur `WBP_InventoryScreen` — voir §5.
---
## 2. L'acteur `AStorageContainer`
### 2.1 Composants
| Composant | Rôle |
|---|---|
| `BaseMesh` | La caisse : `SM_Chest_Bottom`. Racine, **purement visuelle**, collision désactivée |
| `InteractionBox` | L'**unique** collision du coffre. Fille de la caisse |
| `LidMesh` | Le couvercle : `SM_Chest_Top`. Enfant direct de la caisse, collision désactivée |
| `Storage` | Le contenu. Un `UInventoryComponent` ordinaire |
### 2.2 Réglages exposés
**Catégorie `Storage`**
| Propriété | Défaut | Effet |
|---|---|---|
| `SlotCount` | 20 | Nombre de cases. **Réglable par instance** : le même Blueprint donne un petit coffre de 12 et un grand de 30 |
| `ColumnCount` | 6 | Cases par ligne dans l'écran. Purement visuel |
| `DisplayName` | `Chest` | Titre affiché au-dessus de la grille |
| `InteractionPrompt` | `Ouvrir le coffre` | Texte affiché quand le joueur vise |
| `StartingItems` | — | Contenu versé **une seule fois** au `BeginPlay` |
`StartingItems` est de la donnée de level design, pas du gameplay : un coffre de départ, une
cache à trouver. Ce qui ne rentre pas part dans l'Output Log en warning plutôt que de
disparaître en silence.
**Catégorie `Storage|Collision`**
| Propriété | Défaut | Effet |
|---|---|---|
| `bAutoFitInteractionBox` | ☑ | La boîte épouse seule les deux meshes, couvercle fermé |
| `InteractionBoxPadding` | 0 cm | Marge **additive** autour du volume trouvé |
**Catégorie `Storage|Lid`**
| Propriété | Défaut | Effet |
|---|---|---|
| `OpenRotation` | `(-100, 0, 0)` | Rotation **relative** du couvercle ouvert. Le repos vaut toujours zéro |
| `LidDuration` | 0.35 s | Durée de l'ouverture |
**Catégorie `Storage|Sound`**`OpenSound`, `CloseSound`. Facultatifs : un champ vide ne joue
rien.
### 2.3 API publique
```cpp
void SetOpen(bool bInOpen); // SERVEUR uniquement. Lève ou rabat le couvercle
bool IsOpen() const;
UInventoryComponent* GetStorage() const;
FText GetDisplayName() const;
int32 GetColumnCount() const;
```
`SetOpen` est appelée par le **controller**, pas par le coffre : lui seul sait quand l'écran
se referme.
---
## 3. Le couvercle
### 3.1 La rotation se fait sur le pivot du mesh
**C'est `LidMesh` lui-même qu'on tourne**, et un `UStaticMeshComponent` pivote toujours autour
de l'origine de son mesh. La charnière est donc celle que l'artiste a donnée à `SM_Chest_Top`
rien à placer, rien à régler.
> Un `SceneComponent` vide `LidPivot` a existé entre la caisse et le couvercle, pour découpler
> la charnière du pivot du pack. Il a été **retiré** : il fallait le placer à la main dans
> chaque Blueprint, et laissé à `(0,0,0)` il faisait tourner le couvercle autour du centre de
> la caisse — c'est-à-dire à travers elle. C'était le symptôme, pas une configuration exotique.
Corollaire assumé : si un pack livre un couvercle dont le pivot est au centre, **on corrige
l'asset, pas le code** — Modeling Mode > XForm > **Edit Pivot** sur le Static Mesh, pivot posé
sur l'arête arrière, Accept. C'est payé une fois par pack de coffre.
`OpenRotation` est un `FRotator` complet et pas un simple angle : selon l'orientation dans
laquelle le mesh a été exporté, la charnière peut tomber sur le **pitch**, le **yaw** ou le
**roll**. On règle les trois à l'œil dans le viewport plutôt que d'aller comprendre quel axe
local du mesh pointe où.
### 3.2 L'animation
Un `Tick`, mais **éteint au repos** (`bStartWithTickEnabled = false`). `ApplyOpenState()` le
rallume, et le tick s'éteint lui-même en fin de course.
```cpp
LidAlpha = FMath::FInterpConstantTo(LidAlpha, Target, DeltaTime, 1.f / LidDuration);
const float Eased = FMath::SmoothStep(0.f, 1.f, LidAlpha);
LidMesh->SetRelativeRotation(OpenRotation * Eased);
```
- **`SmoothStep`** : un couvercle à vitesse constante démarre et s'arrête d'un coup, ce qui se
lit comme une saccade sur un objet lourd.
- **L'alpha repart de sa valeur courante**, donc refermer un coffre à moitié ouvert ne saute
pas.
- **La valeur exacte est forcée avant de se rendormir**. Sans ça le couvercle resterait à un
poil de sa position finale pour le reste de la partie.
- **`BeginPlay` repose l'alpha à zéro** quelle que soit la rotation laissée dans le Blueprint :
le repos *est* la référence de l'animation. Une rotation authorée sur `LidMesh` serait donc
perdue au lancement.
---
## 4. La collision — une seule boîte
**Les deux 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 tourne** :
- compter sur les collisions des meshes 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 —
viser le haut d'un coffre ouvert ne toucherait plus rien.
La boîte est fille de la **caisse**, jamais du couvercle : le coffre se vise exactement au même
endroit ouvert et fermé. Sinon le prompt disparaîtrait au moment précis où l'on s'en sert.
Son profil est `BlockAll`, posé explicitement : elle a deux rôles indissociables, empêcher le
joueur de traverser le coffre **et** bloquer le canal `Visibility` pour que le trace
d'interaction la trouve. Un profil qui laisserait passer `Visibility` rendrait le coffre
inutilisable sans qu'aucune erreur ne le signale.
### `FitInteractionBox()` — deux pièges
Appelée depuis `OnConstruction`, donc elle tourne **aussi dans l'éditeur** : changer de variante
de coffre recale la boîte à l'instant, sans lancer le jeu.
1. **Lire les bounds de l'ASSET (`UStaticMesh::GetBoundingBox()`), pas `Mesh->Bounds`** — ces
dernières sont déjà multipliées par l'échelle, que `SetBoxExtent` remultiplie.
2. **Composer avec la transform RELATIVE à la racine**, pas la transform monde : la boîte doit
rester juste où que l'acteur soit posé dans le niveau.
Même paire de pièges que dans `APickupItem::FitInteractionBox`.
---
## 5. L'écran
### 5.1 Un panneau, pas un écran
Le contenu du coffre s'affiche **à côté** de la grille du joueur, sur `WBP_InventoryScreen`,
comme **frère du WidgetSwitcher** et `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 ». Un écran distinct le ferait sauter à chaque fois.
Ce n'est **pas non plus un troisième onglet** : les deux grilles doivent être visibles
*ensemble*, ce qu'un WidgetSwitcher interdit par construction.
### 5.2 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, et UMG refuse de le compiler. D'où deux conséquences directes :
- **`bAutoBindToPawn` doit être DÉCOCHÉ** sur la grille de coffre. Sans ça elle s'accroche au
pawn dans son `NativeConstruct` — qui s'exécute avant que l'écran ait eu le temps de lui
donner le conteneur — et le sac du joueur s'affiche des **deux** côtés.
- **Le panneau de détail est coupé par le code, jamais par une case à cocher**
(`SetItemDetailsEnabled`). Un réglage par instance serait un bug qui attend qu'on oublie de
le décocher sur l'une des deux.
### 5.3 Ce que l'écran fait à l'ouverture
| | |
|---|---|
| `StorageGrid->SetColumnCount` | La forme vient du **coffre posé dans le niveau**, pas du Widget Blueprint |
| `StorageGrid->BindToInventory` | Branche la grille sur le contenu |
| Cibles de clic droit croisées | Sac → coffre et coffre → sac (§6.2) |
| `InventoryPage->SetItemDetailsEnabled(false)` | Le sac perd son panneau de détail : trois colonnes seraient illisibles |
| `ShowInventoryTab()` | Un coffre ouvert sous l'onglet Fabrication n'aurait aucun sens |
Et **l'onglet Fabrication est grisé** tant qu'un coffre est ouvert : le panneau étant frère du
WidgetSwitcher, il resterait affiché à côté de la grille de recettes. La touche de craft, elle,
**rend le coffre** plutôt que d'ignorer l'appui — la touche exprime une intention claire.
`HideStorage()` est appelée dès `NativeConstruct` : l'état par défaut est **reposé** à chaque
construction plutôt qu'hérité de la Designer ou de la session précédente. Elle coupe aussi les
transferts rapides **avant** de débrancher — une case qui garderait le coffre en mémoire y
enverrait encore des objets au prochain clic droit, panneau fermé.
Tous les éléments de coffre sont en **`BindWidgetOptional`** : l'écran doit rester valide dans
un projet où le panneau n'a pas encore été construit, sinon ajouter les coffres casserait la
compilation de `WBP_InventoryScreen` — et donc l'inventaire entier.
---
## 6. Les transferts
### 6.1 Un seul déplacement pour tout
```cpp
static bool TransferSlot(UInventoryComponent* From, int32 FromIndex,
UInventoryComponent* To, int32 ToIndex, int32 Quantity);
```
**Statique**, parce qu'elle n'appartient ni à la source ni à la destination : elle arbitre entre
les deux. `MoveItem()` et `MoveItemQuantity()` ne sont plus que des appels avec `From == To`.
Elle diffuse `OnInventoryChanged` **des deux côtés**. Oublier la seconde diffusion laisse la
grille d'en face afficher un état périmé jusqu'au prochain événement, ce qui se lit comme un
objet qui disparaît.
### 6.2 Clic droit = envoyer en face
`QuickTransferSlot()` passe par `AddItem()`, donc **exactement comme un ramassage** : piles
entamées d'abord, puis barre rapide, puis grille. Ce qui ne rentre pas reste sur place.
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.
La **barre rapide** reçoit elle aussi le coffre, pour que le clic droit y marche par-dessus
l'écran.
### 6.3 « Tout ranger » / « Tout prendre »
```cpp
int32 TransferAllTo(UInventoryComponent* To, int32 FirstIndex = 0);
```
- **Tout ranger** part de `GetBackpackStartIndex()` : la barre rapide porte les outils qu'on
vient d'utiliser, les ranger d'office serait une punition déguisée en confort.
- **Tout prendre** part de l'index 0 — un coffre n'a pas de barre rapide. Ce qui ne rentre pas
dans le sac reste dans le coffre.
---
## 7. Le cycle d'ouverture
Le controller est le seul maître. `ReleaseActiveStorage()` est le **point de sortie unique** :
toute fermeture d'écran y passe, quelle qu'en soit la cause.
| Déclencheur | Chemin |
|---|---|
| Interagir avec le coffre | `Interact_Implementation``OpenStorage()` |
| Échap | `HandlePauseInput()` — le coffre passe avant l'inventaire |
| Tab | `SetInventoryScreenOpen(false)` |
| Touche de craft | Rend le coffre, puis affiche l'onglet Fabrication |
| Mort du joueur | Fermeture de l'écran |
| Éloignement | `CheckStorageDistance()`, timer à 0,25 s |
| Coffre détruit | Idem, la `TWeakObjectPtr` est nulle |
| Ouvrir un autre coffre | `OpenStorage()` rend le précédent avant |
**`MaxStorageDistance` (400 cm)** : sans elle on viderait un coffre depuis l'autre bout de la
carte, rien n'obligeant le joueur à rester devant une fois l'écran ouvert. Surveillée par un
**timer**, jamais un Tick, et **seulement pendant que l'écran est ouvert** — un timer permanent
pour une condition vraie deux minutes par partie serait du gaspillage. `DistSquared`, pour ne
pas prendre une racine carrée quatre fois par seconde.
Le timer est aussi nettoyé à la destruction du HUD : il rappellerait une méthode d'un controller
en train de disparaître.
---
## 8. Réseau
**Autorité serveur sur tout**, comme pour le reste du monde.
| Élément | Réplication |
|---|---|
| `bOpen` | `ReplicatedUsing = OnRep_Open` — voir le couvercle d'un coffre se lever est le seul signe qu'un coéquipier fouille dedans |
| `Slots` du conteneur | Répliqué à **tout le monde** par `UInventoryComponent` |
| L'animation, le son | **Rien** : dérivés de `bOpen` des deux côtés par `ApplyOpenState()` |
### Les quatre pièges
1. **`Interact_Implementation` s'exécute sur le SERVEUR.** `PlayerController->OpenStorage(...)`
y créerait un widget dans le vide. D'où `Client_OpenStorage` : le serveur lève le couvercle
(état du monde) et **renvoie l'ordre d'écran** au client. C'est le piège le plus
contre-intuitif du portage, parce que le code *semble* correct et marche parfaitement chez
l'hôte.
2. **`SetOpen` ne fait rien sans autorité.** Chez un client, refermer l'écran doit passer par
`Server_ReleaseStorage` — sinon le coffre resterait ouvert pour les trois autres joueurs
alors que l'écran est fermé ici.
3. **`FindNetProxy` est le passage obligé des transferts.** Un `UInventoryComponent` de coffre
n'appartient à aucune connexion : une RPC émise depuis lui est **silencieusement jetée**. On
route donc toujours par l'inventaire du pawn local, et la source comme la destination sont
passées **en paramètres** — jamais déduites du récepteur, sinon « Tout prendre » viderait le
sac du joueur dans lui-même.
4. **`Slots` est répliqué à tout le monde, pas au seul propriétaire.** Ce n'est pas un
raccourci : les conditions de `GetLifetimeReplicatedProps` sont mises en cache **par classe**,
pas par instance. Un `COND_OwnerOnly` frapperait donc aussi le composant du coffre — or un
coffre n'appartient à personne, et sa grille n'arriverait chez aucun joueur.
**Accès simultané, pas de verrou.** Deux joueurs peuvent fouiller le même coffre : `TransferSlot`
diffuse déjà `OnInventoryChanged` des deux côtés, et tous les widgets y sont abonnés. L'UI se
recâble donc toute seule à chaque `OnRep_Slots()`.
**Pas de prédiction.** Le client envoie sa RPC et attend la réplication. Quelques dizaines de
millisecondes sur un relais Steam, invisibles pour un glisser-déposer, alors qu'une prédiction
fausse donnerait une pile qui saute.
---
## 9. Guide éditeur
### 9.1 Le Blueprint du coffre
1. Content Browser → `Content/Game/Building/Storage/` → clic droit → **Blueprint Class**
**All Classes**`StorageContainer` → nomme-le **`BP_Chest_Wooden`**.
2. Ouvre-le. Sélectionne **`BaseMesh`** → Details → **Static Mesh** = `SM_Chest_Bottom`.
3. Sélectionne **`LidMesh`** → **Static Mesh** = `SM_Chest_Top`.
4. Toujours sur `LidMesh`, Details → **Transform** :
- **Location** : les deux meshes viennent du même export, donc **`0, 0, 0`** les repose l'un
sur l'autre. Ajuste seulement s'il flotte ou s'enfonce.
- **Rotation** : **`0, 0, 0`** obligatoirement. `BeginPlay` écrase cette valeur — une
rotation laissée ici serait perdue au lancement.
5. Sélectionne l'acteur racine (`Self`) → Details → **Storage | Lid****`Open Rotation`**.
Le défaut est `Pitch = -100`. Tape des valeurs et regarde le viewport : si le couvercle
bascule sur le côté, essaie le **Roll**, puis le **Yaw**.
6. **`Storage`** → règle `Slot Count` et `Column Count`. Mets un `Display Name` (« Coffre en
bois ») et un `Interaction Prompt`.
7. **`Storage | Sound`** → `Open Sound` / `Close Sound` si tu en as.
8. Laisse **`Auto Fit Interaction Box`** coché : la boîte se cale seule sur les deux meshes.
Vérifie-la avec **Show > Collision** dans le viewport.
9. **Compile** et **Save**.
Pose-le dans le niveau. `Slot Count`, `Column Count`, `Display Name` et `Starting Items` se
règlent **par instance** dans le World Outliner : un seul Blueprint sert tous les coffres.
### 9.2 Le panneau de coffre dans `WBP_InventoryScreen`
À ne faire qu'une fois. Tout est en `BindWidgetOptional`, donc l'écran compile même à mi-chemin.
1. Ouvre **`WBP_InventoryScreen`**.
2. Dans la Designer, à côté du `TabSwitcher`**frère, pas enfant** — pose un **Horizontal Box**
ou une **Border**, et renomme-le **`StoragePanel`**. Mets sa **Visibility** à `Collapsed`
(le code la repose de toute façon).
3. Dedans, un **Vertical Box** contenant :
- un **Text Block** nommé **`StorageTitle`** ;
- ton **`WBP_Inventory`** (Palette → User Created), renommé **`StorageGrid`** ;
- un **Horizontal Box** avec deux **Button** nommés **`StoreAllButton`** et
**`TakeAllButton`**, chacun avec son Text Block (« Tout ranger », « Tout prendre »).
4. **⚠ L'étape qu'on rate** : sélectionne **`StorageGrid`** → Details → **Inventaire**
**DÉCOCHE `Auto Bind To Pawn`**.
5. Rappel : un **Canvas Panel a une taille désirée de zéro**. Si `StoragePanel` disparaît dans
un conteneur en `Auto`, c'est ça — et un enfant en `Size = Fill` ne compte pas non plus dans
la taille désirée de son parent.
6. **Compile** et **Save**.
### 9.3 Le controller
Un seul réglage : `BP_FpsPlayerController` → Details → **UI | Storage****`Max Storage
Distance`** (400 cm par défaut). Rien d'autre à brancher, le coffre appelle `OpenStorage`
lui-même.
### 9.4 Vérifier que ça marche
1. Vise le coffre : le prompt affiche ton `Interaction Prompt`, et **au même endroit** que le
coffre soit ouvert ou fermé.
2. Interagis : le couvercle se lève en ~0,35 s, avec un départ et un arrêt adoucis, et l'écran
d'inventaire s'ouvre avec **le sac inchangé** et le coffre à côté.
3. L'onglet **Fabrication est grisé**, et le panneau de détail du sac a disparu.
4. Glisse un objet du sac vers le coffre : **Maj** = la moitié, **Ctrl** = un seul.
5. **Clic droit** sur une case : elle part en face. Fais-le depuis la barre rapide aussi.
6. **Tout ranger** vide la grille mais **laisse la barre rapide** intacte.
7. Recule de plus de 4 m : l'écran se ferme seul et le couvercle se rabat.
8. Appuie sur la touche de craft coffre ouvert : le coffre est rendu et l'onglet Fabrication
s'affiche.
9. **En coop** (build Steam) : l'autre joueur voit le couvercle se lever, et un objet déposé
apparaît chez lui sans qu'il ait à refermer et rouvrir.
---
## 10. Dépannage
| Symptôme | Cause |
|---|---|
| Le couvercle traverse la caisse / tourne de travers | Mauvais axe d'`Open Rotation`, ou pivot de `SM_Chest_Top` posé au centre du mesh — corrige-le dans Modeling Mode > XForm > Edit Pivot |
| Le couvercle est décalé de la caisse | `Location` de `LidMesh` (§9.1 étape 4) |
| Le couvercle saute au lancement | Une `Rotation` a été laissée sur `LidMesh` : `BeginPlay` la remet à zéro |
| Le coffre n'est pas visable | `Auto Fit Interaction Box` décoché avec une boîte restée minuscule, ou profil de collision changé |
| Le prompt disparaît une fois ouvert | La boîte a été reparentée sous `LidMesh` — elle doit rester fille de `BaseMesh` |
| Le sac du joueur s'affiche **des deux côtés** | `Auto Bind To Pawn` resté coché sur `StorageGrid` |
| Le panneau ne s'affiche pas du tout | `StoragePanel` ou `StorageGrid` mal nommé — **l'Output Log le dit** |
| Le panneau est là mais vide et plat | Canvas Panel dans un conteneur `Auto`, ou tous les enfants en `Fill` |
| Le coffre s'ouvre chez l'hôte, pas chez le client | `Client_OpenStorage` court-circuité, ou `Interact` appelé hors de `Execute_Interact` |
| Le couvercle reste levé chez les autres après fermeture | Chemin de sortie qui ne passe pas par `ReleaseActiveStorage()` |
| Un objet réapparaît après un transfert | Transfert exécuté côté client sans RPC — c'est la réplication qui le remet |
| « Tout prendre » vide le sac dans lui-même | Source déduite du récepteur au lieu d'être passée en paramètre |
| Les objets de départ manquent | `Slot Count` trop petit : le débordement part en warning dans l'Output Log |
---
## 11. Étendre
Gratuit, sans une ligne de C++ :
- **D'autres coffres** — le Blueprint est générique, seuls les meshes changent. La boîte
d'interaction se recale seule.
- **Des tailles différentes** — `Slot Count` par instance.
- **Un coffre-piège, une cache** — `Starting Items`.
Ce qui demanderait du code :
- **La sauvegarde du contenu.** C'est le chantier réel : `Slots` n'est pas sérialisé
aujourd'hui. Le format est déjà le bon — le même `FInventorySlot` que le sac, donc une seule
sérialisation servira les deux.
- **Les ressources du coffre comptant dans les recettes** — c'est la raison pour laquelle
l'onglet Fabrication est grisé plutôt que masqué : le jour où `UCraftingComponent` saura lire
un inventaire voisin, il suffira de le dégriser.
- **Un cadenas, un propriétaire** — il n'y a aujourd'hui aucun verrou, et c'est délibéré : coop
entre amis.
- **Un coffre en un seul mesh** (tonneau, sac) — la `UBoxComponent` reste valable, il suffit de
laisser `LidMesh` vide et `OpenRotation` à zéro. `FitInteractionBox` ignore déjà un mesh
absent.
---
## 12. Ce que ce système a changé ailleurs
- **`UInventoryComponent::TransferSlot`** est devenue l'**unique** implémentation du déplacement.
`MoveItem` / `MoveItemQuantity` ne sont plus que des appels avec `From == To`.
- **`ConfigureAsContainer()`** : zéro barre rapide, donc `GetBackpackStartIndex()` vaut 0 et la
grille affiche tout. Aucun nouveau modèle de données.
- **`UInventoryWidget` a perdu son lien vers sa grille parente** : une case diffuse
`OnHoverChanged` au lieu de connaître son propriétaire. Avec deux grilles à l'écran, l'index
seul ne désignait plus rien de façon unique — d'où `GetHoveredSlot(inventaire, index)`.
- **`NativeOnMouseLeave` n'arrive jamais quand un widget s'efface sous le curseur**, ce qui 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.
- **`SetItemDetailsEnabled`** sur la grille, piloté par l'écran et non par une case à cocher.