882 lines
64 KiB
Markdown
882 lines
64 KiB
Markdown
# Survival_projet — Unreal Engine 5.8
|
||
|
||
Jeu de survie coop en ligne via steam p2p à la première personne, gameplay écrit en **C++**, Blueprints réservés au
|
||
câblage d'assets. Inspirations assumées : Raft, Aloft, Valheim, esthétique solarpunk.
|
||
Assets d'environnement : pack **SoStylized** (sa végétation n'est pas à l'échelle humaine).
|
||
|
||
## Rôle
|
||
|
||
Tu es un **développeur Unreal Engine senior**. Tu connais le moteur en profondeur : framework
|
||
Gameplay, UMG/Slate, Enhanced Input, cycle de vie des objets et GC, coût réel des choses au
|
||
runtime. L'auteur écrit en français — **répondre en français**.
|
||
|
||
Le code que tu produis est du code de production, pas du prototype :
|
||
|
||
- **Propre** : responsabilités séparées, pas de logique dupliquée, pas de valeur en dur qui
|
||
devrait être exposée, noms explicites, `const` et références là où c'est correct.
|
||
- **Optimisé** : pas de `Tick` quand un délégué ou un timer suffit, pas de `FindComponent` ni de
|
||
cast dans une boucle, cache des pointeurs, `TArray::Reserve`, pas d'allocation par frame.
|
||
Signaler quand une approche coûte cher et proposer l'alternative.
|
||
- **Commenté en français**, sur le *pourquoi* et les pièges — jamais de commentaire qui
|
||
paraphrase la ligne en dessous.
|
||
|
||
### Guider pas à pas dans l'éditeur
|
||
|
||
C'est le point le plus important. **Le code seul ne suffit jamais** : presque tout ce qu'on
|
||
écrit demande ensuite un branchement côté éditeur, et c'est là que l'auteur a besoin d'être
|
||
guidé. Donc pour chaque tâche :
|
||
|
||
1. Écrire et compiler le C++.
|
||
2. Puis donner la marche à suivre dans l'éditeur, **étape numérotée par étape numérotée**, en
|
||
nommant les choses telles qu'elles apparaissent à l'écran : quel Blueprint créer et depuis
|
||
quelle classe parente, où le ranger, quels widgets poser dans la Designer, leur nom exact
|
||
(les `BindWidget` échouent au moindre écart), quelle propriété régler dans le Details panel,
|
||
quel asset assigner où, quelle case cocher.
|
||
3. Terminer par **comment vérifier que ça marche** : ce qu'on doit voir en PIE, et le symptôme
|
||
typique si une étape a été ratée.
|
||
|
||
Exemple : pour un menu de réglages, livrer la classe `USettingsMenuWidget`, puis expliquer la
|
||
création de `WBP_Settings`, la hiérarchie de widgets à construire, les noms à respecter, le
|
||
branchement dans le PlayerController, et le test final.
|
||
|
||
Ne jamais supposer qu'une manipulation éditeur est évidente ou déjà faite. En cas de doute sur
|
||
l'état d'un asset, demander plutôt que d'écrire du code qui suppose un branchement inexistant.
|
||
|
||
### Décisions
|
||
|
||
L'auteur valide les décisions de design lui-même. Proposer **une recommandation argumentée**,
|
||
pas un catalogue d'options. Quand un choix change matériellement le code (modèle d'inventaire,
|
||
règles de mort), demander avant de coder plutôt que de deviner.
|
||
|
||
## Compiler
|
||
|
||
```powershell
|
||
& "C:\Program Files\Epic Games\UE_5.8\Engine\Build\BatchFiles\Build.bat" Survival_projetEditor Win64 Development -Project="d:\Projet\Perso\Unreal\Survival_projet\Survival_projet.uproject" -WaitMutex
|
||
```
|
||
|
||
- Un DLL nommé `UnrealEditor-Survival_projet-000N.dll` signifie que **l'éditeur était ouvert** :
|
||
c'est un hot reload. Prévenir l'auteur de fermer et relancer l'éditeur.
|
||
- `-Rebuild` échoue tant que l'éditeur tourne (DLL verrouillés).
|
||
- **Ne jamais réécrire en masse les timestamps** des fichiers source pour forcer un build.
|
||
- L'outil Edit renvoie parfois `EPERM` sur `FpsPlayer.cpp` (watcher qui tient le handle) :
|
||
réessayer, ou passer par Write sur le fichier entier.
|
||
|
||
## Règles d'architecture établies
|
||
|
||
**Un système = un `UActorComponent`.** Jamais de logique empilée dans `AFpsPlayer`, qui n'est
|
||
qu'un assemblage de composants plus la caméra et le déplacement.
|
||
|
||
**Où va quoi** — le critère est « si le pawn meurt, est-ce que ça doit survivre ? » :
|
||
|
||
| Nature | Emplacement |
|
||
|---|---|
|
||
| Le corps : déplacement, caméra, inventaire, stats, interaction | **Pawn** |
|
||
| Le joueur : widgets HUD, input d'interface (Tab, Q, hotbar, Échap) | **PlayerController** |
|
||
| Les règles de la partie, le respawn | **GameMode** |
|
||
| Ce qui survit au changement de map : réglages, sauvegarde | **GameInstance** |
|
||
| Ce que les autres savent de toi (plus tard, si multi) | **PlayerState** |
|
||
|
||
**Données vs état.** `UItemDataAsset` décrit ce qu'un objet **est** (nom, mesh, icône, effets).
|
||
Quantité, charges restantes, durabilité sont de l'**état** et vivent sur le slot ou l'acteur.
|
||
Mettre de l'état dans un DataAsset le partagerait entre toutes les instances du jeu.
|
||
|
||
**Un Blueprint par comportement, un DataAsset par objet.** 200 objets et 3 comportements
|
||
donnent 3 Blueprints et 200 DataAssets. `BP_Pickup` est générique, son `ItemData` change
|
||
par instance.
|
||
|
||
**Réglages exposés.** Toute valeur qui se règle à l'œil est une `UPROPERTY(EditAnywhere)`,
|
||
pour itérer en PIE sans recompiler.
|
||
|
||
**Pièges C++ Unreal rencontrés** : ne pas nommer un membre `Player`, `OldPawn`, `Slot` ou
|
||
`CrouchedEyeHeight` dans une classe dérivée du moteur (masquage → erreur C4458 / UHT).
|
||
`TWeakObjectPtr` ne permet pas de détecter une transition par simple comparaison de pointeurs,
|
||
il faut un booléen d'état à côté.
|
||
|
||
**Le mode d'input vit sur le viewport, pas sur le controller.** `OpenLevel` détruit le
|
||
PlayerController mais pas la fenêtre : un controller qui arrive sur une map ne doit jamais
|
||
supposer l'état laissé par la précédente, il repose son `SetInputMode`. Symptôme quand on
|
||
l'oublie, en Standalone seulement : la caméra ne répond qu'au clic droit maintenu.
|
||
|
||
**Enhanced Input en pause** : un jeu en pause ne fait qu'un tick d'input réduit, où seules les
|
||
actions cochées **« Trigger When Paused »** sont évaluées. C'est une case sur l'asset `IA_`,
|
||
pas du C++ — et son corollaire est qu'aucune action du pawn ne se déclenche en pause.
|
||
*Le projet ne met plus jamais le jeu en pause (voir le menu de pause plus bas), donc la case sur
|
||
`IA_Pause` ne sert plus à rien. La laisser cochée est sans effet ; la note reste ici parce que le
|
||
piège ressortira au premier `SetPause` réintroduit.*
|
||
|
||
**Le `namespace` anonyme n'isole rien.** Le build unity concatène les `.cpp` du module : deux
|
||
constantes de même nom dans deux fichiers différents deviennent une redéfinition (C2374). Les
|
||
noms de fichier doivent être **uniques à l'échelle du module**. Invisible en build incrémental,
|
||
le bug ne sort qu'au premier `-Rebuild` — donc vérifier avec un rebuild complet avant de croire
|
||
qu'une compilation propre l'est vraiment.
|
||
|
||
## Systèmes en place
|
||
|
||
| Système | Fichiers |
|
||
|---|---|
|
||
| Personnage, caméra, game feel | `FpsPlayer.h/.cpp` |
|
||
| Balancement de tête procédural | `HeadBobComponent` (profils par état, additionné dans `ApplyCameraTransform`) |
|
||
| 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`, `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` + le panneau de coffre d'`InventoryScreenWidget` et `UInventoryComponent::TransferSlot` / `TransferAllTo` — **doc complète : [Docs/Storage.md](../Docs/Storage.md)** |
|
||
| Écran à onglets (inventaire / craft) | `InventoryScreenWidget` |
|
||
| Menu de pause | `PauseMenuWidget` + la partie pause de `FpsPlayerController` |
|
||
| Réglages | `SurvivalUserSettings`, `SettingsMenuWidget`, `SettingsTabWidget`, `SettingsGameplayWidget`, `SettingRowWidget` |
|
||
| Musique | `MusicDirector`, `MusicSubsystem`, `MusicPlaylistDataAsset` |
|
||
| Audio (volumes) | `SurvivalAudioSettings`, `SettingsAudioWidget` + `USurvivalUserSettings::ApplyAudioSettings` |
|
||
| Bruits de pas | `FootstepComponent`, `FootstepSoundsDataAsset` + les `PhysicalSurfaces` de `DefaultEngine.ini` |
|
||
| Fondus, boîtes de dialogue | `ScreenFadeComponent`, `ConfirmDialogWidget` |
|
||
| Coop Steam (sessions) | `SessionSubsystem`, `SurvivalGameMode`, `SurvivalGameState`, `SurvivalPlayerState` |
|
||
| Pseudo au-dessus des joueurs | `PlayerNameplateComponent`, `PlayerNameplateWidget` |
|
||
| Corps 3e personne | `PlayerBodyAnimInstance` + `ABP_PlayerBody_A`, le `Mesh` de `BP_FpsPlayer` |
|
||
|
||
### Le corps vu par les autres — pièges du personnage modulaire
|
||
|
||
Le pack **Stylized Characters** (+ *CustomizationPack01*, tous deux dans `Content/External/`)
|
||
livre un personnage **en morceaux** : `SKM_BodyA` s'arrête au cou, la tête, les sourcils, les
|
||
cheveux, la barbe et la moustache sont des meshes séparés.
|
||
|
||
- **Un seul mesh porte l'animation, les autres recopient sa pose.** C'est le
|
||
**Leader Pose Component** : le corps est le leader avec son AnimBP, tous les autres sont des
|
||
`USkeletalMeshComponent` enfants **sans animation à eux**. La correspondance se fait **par nom
|
||
d'os**, donc elle marche même entre squelettes distincts — les têtes du pack de customisation
|
||
sont sur `SK_BodyA_empty` et se posent sans problème sur un corps `SK_BodyA`.
|
||
- **`LeaderPoseComponent` est `BlueprintReadOnly`, PAS `EditAnywhere`.** Elle n'apparaît donc
|
||
**jamais** dans le Details panel, et on la cherche longtemps : 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()` — Construction Script en Blueprint, ou C++.
|
||
- **La physique de cheveux est perdue en leader pose.** Coiffures, barbes et moustaches ont leur
|
||
propre squelette et leur physics asset, mais l'AnimBP post-process d'un *follower* ne tourne
|
||
pas. La démo du pack accepte déjà ce compromis.
|
||
- **Le squelette est celui du Mannequin UE5** (`ik_foot_root`, `ik_hand_gun`, `spine_01..05`,
|
||
`index_metacarpal_l` — les métacarpiens n'existaient pas sur le squelette UE4). Conséquence
|
||
précieuse : **toute animation vendue « UE5 Mannequin compatible » se branche sans retarget.**
|
||
C'est par là qu'arriveront l'accroupissement et les gestes d'outil, absents du pack.
|
||
- **`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,
|
||
tête comprise, mesure à peu près autant. Pour juger une taille, passer le viewport en vue
|
||
**Front** et se servir de la ligne verte (`Z = 0`, centre de la capsule) : le bas est à
|
||
`-HalfHeight`, le haut à `+HalfHeight`. Le personnage fait ~184 cm pour une capsule de 176,
|
||
et on **ne scale pas** — la capsule porte la collision et le game feel déjà calibré.
|
||
|
||
**Côté animation**, `UPlayerBodyAnimInstance` suit le partage des rôles de
|
||
`UFpsArmsAnimInstance` : le C++ lit l'état du pawn et le republie, le Blueprint choisit
|
||
l'animation. Deux points valent d'être retenus :
|
||
|
||
- **Rien n'y est répliqué, et c'est voulu.** Le corps est vu par tout le monde, mais chaque
|
||
variable **dérive** d'un état déjà synchronisé : `Velocity` par le CharacterMovement,
|
||
`bIsCrouched` par `ACharacter`, `bIsSprinting` par `AFpsPlayer`, et le tangage du regard par
|
||
`RemoteViewPitch` que `APawn` réplique tout seul — d'où `GetBaseAimRotation()` et surtout pas
|
||
`GetControlRotation()`, qui n'existe que sur la machine propriétaire.
|
||
- **`bIsFalling` ne distingue pas un saut d'une chute** : le moteur appelle « falling » tout ce
|
||
qui n'est pas au sol. C'est le **signe de `VerticalSpeed`** qui tranche, sans quoi sortir d'un
|
||
rebord jouerait l'animation de détente. Et la condition `Ground → Jump` a besoin du `AND` avec
|
||
`bIsFalling` : gravir une pente donne aussi une vitesse verticale positive.
|
||
- **Le `Cast` du pawn est relancé à chaque frame tant qu'il est nul.** Chez un client le mesh
|
||
arrive par réplication et peut s'animer **avant** le pawn : un `Cast` fait une seule fois dans
|
||
`NativeInitializeAnimation` laisserait le corps de l'ami figé en pose de référence pour toute
|
||
la partie. Invisible en solo comme chez l'hôte.
|
||
|
||
Enfin, le BlendSpace du pack posait `Walk_InPlace` à la vitesse 0 — un personnage qui piétine
|
||
sur place à l'arrêt. On y a mis l'idle à la place, plutôt qu'un état `Idle` séparé : un état,
|
||
c'est un **seuil** qui claque au démarrage et au freinage, là où le BlendSpace interpole en
|
||
continu. Ses points restent calés sur les **vitesses réelles mesurées des animations**
|
||
(225.6 et 500), ce qui est précisément ce qui évite le glissement de pieds — on ne les déplace
|
||
pas. Il s'arrête en revanche à 500 alors que le sprint est à 650 : un second échantillon de
|
||
course posé à 650 avec un **Rate Scale** de 1.3 le couvrirait.
|
||
|
||
### Coop Steam — décisions figées
|
||
|
||
- **Cible : 4 joueurs, serveur d'écoute, P2P par le relais Valve.** Plugins `OnlineSubsystemSteam`
|
||
et `SteamSockets`, AppID **4282850** (Emberwild). Le C++ ne parle **jamais** de Steam : tout passe
|
||
par `IOnlineSubsystem`, dont l'implémentation est choisie dans `DefaultEngine.ini`. Corollaire
|
||
précieux : sans Steam le moteur retombe sur `OnlineSubsystemNULL` et tout le code bascule en LAN
|
||
(`USessionSubsystem::IsLanMode`), ce qui rend le test à deux instances possible sur une machine.
|
||
- **L'OSS Steam est désactivé de force dans l'éditeur** (`IsEnabled()` teste `IsRunningGame()`).
|
||
Steam ne s'initialise donc **jamais en PIE** — c'est voulu par Epic, pas un bug de config. Pour
|
||
le tester : **Play > Standalone Game**, ou un build packagé.
|
||
- **Toute partie est une partie en ligne.** Un seul bouton *Jouer*, qui crée la session puis ouvre
|
||
la map en `?listen`. Pas de « Héberger » vs « Rejoindre » : le joueur devrait choisir avant de
|
||
savoir si un ami le rejoindra, et devrait relancer sa partie pour changer d'avis.
|
||
- **On rejoint UNIQUEMENT par invitation Steam**, depuis le bouton *Inviter* du menu de pause.
|
||
L'acceptation est écoutée par `USessionSubsystem` dès son `Initialize` et jamais coupée : une
|
||
invitation tombe à n'importe quel instant, y compris en pleine partie, et le sous-système est le
|
||
seul objet dont on soit certain qu'il vive à ce moment-là. La commande console **`EmberJoin`**
|
||
existe pour tester à deux instances, sans aucun bouton pour y mener.
|
||
- **Un échec de session ne bloque pas le lancement** : la map s'ouvre quand même en serveur
|
||
d'écoute, seul *Inviter* est grisé. Un joueur qui clique Jouer veut jouer ; le renvoyer à son
|
||
menu pour une panne de Steam serait une punition qui n'est pas la sienne.
|
||
- **`bUseLobbiesIfAvailable = true` est LE drapeau à ne pas oublier** : Steam ne route le P2P que
|
||
par un lobby. Sans lui la session se crée, apparaît même dans une recherche, mais aucune
|
||
connexion n'aboutit hors du LAN.
|
||
- **`AGameMode` n'existe que sur le serveur.** `GetGameMode()` rend `nullptr` sur un client,
|
||
toujours. Tout appel écrit côté client doit devenir un RPC serveur ou une lecture du GameState —
|
||
c'est notamment le cas du respawn dans `AFpsPlayerController`, **encore à faire**.
|
||
- **Autorité : le serveur, pour tout ce qui touche au monde.** Pickups, coffres, feux, établis et
|
||
inventaires sont modifiés **uniquement** côté serveur ; les clients demandent par RPC et
|
||
reçoivent le résultat par réplication. Coop entre amis, donc aucune anti-triche : les
|
||
validations serveur sont des garde-fous contre la désynchronisation, pas contre le joueur.
|
||
- **`Slots` de `UInventoryComponent` 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 d'un
|
||
coffre — or un coffre n'appartient à aucune connexion, et sa grille n'arriverait chez personne.
|
||
Le même composant sert le sac et le contenant, et les coffres sont à accès simultané. Coût réel :
|
||
4 × 30 slots de trois champs, envoyés seulement quand ils changent.
|
||
- **Coffres : accès simultané**, pas de verrou. `TransferSlot` diffuse déjà `OnInventoryChanged`
|
||
des deux côtés, l'ossature est là.
|
||
|
||
### Tester la coop — ça ne se teste QUE en build Steam
|
||
|
||
**PIE ne teste pas la coop de ce projet.** L'OSS Steam y est désactivé de force par Epic
|
||
(`IsEnabled()` teste `IsRunningGame()`), donc pas de session, pas d'invitation, pas de relais.
|
||
PIE en *Play As Listen Server* reste utile pour la réplication pure (voir l'autre joueur bouger,
|
||
un pickup disparaître), mais **tout ce qui touche à la session se teste en build packagé**,
|
||
distribué par le dépôt Steam. C'est la procédure de référence.
|
||
|
||
**1. Packager** — éditeur fermé :
|
||
|
||
```powershell
|
||
& "C:\Program Files\Epic Games\UE_5.8\Engine\Build\BatchFiles\RunUAT.bat" BuildCookRun `
|
||
-project="D:\Projet\Perso\Unreal\Survival_projet\Survival_projet.uproject" `
|
||
-noP4 -platform=Win64 -clientconfig=Development `
|
||
-build -cook -stage -pak -compressed -prereqs `
|
||
-archive -archivedirectory="D:\Projet\Perso\Unreal\Survival_projet\Build\Package" `
|
||
-utf8output -nocompileeditor
|
||
```
|
||
|
||
`Development` et pas `Shipping` : on garde la console (`~`), les logs dans `Saved/Logs` et la
|
||
commande `EmberJoin`. Sans eux, un échec chez le testeur ne dit rien. Premier cook ≈ 18 min,
|
||
les suivants sont bien plus courts (DDC chaud).
|
||
|
||
**2. Copier dans le dépôt** — le contenu de `Build\Package\Windows\` va **à la racine** de
|
||
`C:\sdk\tools\ContentBuilder\content\`, en excluant `steam_appid.txt` : Steam fournit lui-même
|
||
l'AppID au lancement, et le fichier l'écraserait.
|
||
|
||
**3. Uploader** — `C:\sdk\tools\ContentBuilder\upload_emberwild.bat` (App **4282850**, dépôt
|
||
**4282851**). Le `.vdf` n'a **pas** de `SetLive`, donc le build monte sans devenir live.
|
||
|
||
**4. Publier sur la branche `develop`**, jamais sur `default`. Et vérifier que *Launch Options*
|
||
pointe sur `Survival_projet.exe` — puis **publier les changements**, sinon rien n'est visible
|
||
côté client.
|
||
|
||
**5. Tester à deux** : les deux lancent le jeu **depuis Steam**, l'hôte clique *Jouer*, puis
|
||
Échap → *Inviter des amis*. Les logs à lire sont dans `Survival_projet/Saved/Logs/` de chaque
|
||
côté — `ShowInviteUI` chez l'hôte, `invitation acceptee` puis `voyage vers [...]` chez l'invité.
|
||
|
||
**Deux réglages qui cassent tout si on les oublie** : la *List of Maps to Include* dans les
|
||
Packaging Settings doit contenir `MenuScene` **et** `GameScene` — cette dernière n'est référencée
|
||
que par un `TSoftObjectPtr`, et une référence soft n'est pas suivie par le cooker ; sans elle le
|
||
build démarre sur le menu et *Jouer* ne charge rien. Et sans liste du tout, le cook embarque les
|
||
~25 maps de démo SoStylized.
|
||
|
||
**Se tester seul** reste possible malgré l'invitation obligatoire : deux instances lancées avec
|
||
`-nosteam` retombent sur `OnlineSubsystemNULL`, donc en LAN, et la commande console `EmberJoin`
|
||
fait recherche + connexion sur la seconde.
|
||
|
||
### Réplication — la règle, et où on en est
|
||
|
||
**La règle : rien de ce qui touche au monde ne s'exécute sur un client.** Un client trace, vise,
|
||
affiche, glisse — mais dès qu'il faut *changer* quelque chose, il envoie un RPC serveur et attend
|
||
la réplication. Le symptôme quand on l'oublie est toujours le même et il est fourbe : ça marche
|
||
parfaitement en solo et chez l'hôte, et l'objet réapparaît chez le client à la réplication suivante.
|
||
|
||
**Le point de passage unique : `IInteractable::Execute_Interact`.** Les quatre acteurs du monde
|
||
(`PickupItem`, `CookingStation`, `CraftingStation`, `StorageContainer`) passent tous par là.
|
||
`UInteractionComponent::TryInteract()` route donc vers `Server_Interact` quand on n'a pas
|
||
l'autorité — une seule RPC rend les quatre autoritaires. Ne jamais appeler `Execute_Interact`
|
||
ailleurs sans se poser la question de qui exécute.
|
||
|
||
**`SetIsReplicatedByDefault(true)` est obligatoire sur un composant qui porte un RPC serveur**,
|
||
même s'il ne réplique aucune propriété : sans lui le moteur **jette silencieusement** l'appel.
|
||
|
||
**`OnConstruction` s'exécute avant l'arrivée des propriétés répliquées.** Un acteur dont le
|
||
visuel dépend d'une `UPROPERTY` répliquée doit la déclarer `ReplicatedUsing` et reposer son
|
||
visuel dans l'`OnRep` — sinon l'objet est correct chez l'hôte et nu chez les clients. C'est
|
||
exactement le cas d'`APickupItem::OnRep_ItemData`.
|
||
|
||
**L'UI se recâble toute seule.** Tous les widgets sont abonnés à `OnInventoryChanged` et aucun
|
||
ne fait de Tick : il suffit qu'`OnRep_Slots()` rediffuse ce délégué. C'est le dividende de la
|
||
règle « les widgets s'abonnent à des délégués », et il faut la tenir pour les systèmes à venir.
|
||
|
||
**Un client arrive en SPECTATEUR, et la caméra y reste un instant.** C'est la cause du « on voit
|
||
sous la carte » à la connexion, et elle est contre-intuitive : le pawn est bien créé **et bien
|
||
placé**, mais `GetViewTarget()` vaut encore `SpectatorPawn_0` — né au centre du monde, donc sous
|
||
le terrain. Mesuré sur une vraie connexion :
|
||
|
||
```
|
||
FadeIn démarre ViewTarget=SpectatorPawn_0 PawnLoc=(11319, -16530, 1805)
|
||
AcknowledgePossession Pawn=BP_FpsPlayer_C_0 ViewTarget=SpectatorPawn_0
|
||
```
|
||
|
||
La caméra bascule sur le corps **après** l'acquittement de la possession. La bonne condition pour
|
||
éclaircir n'est donc pas « ai-je un pawn » mais **`GetViewTarget() == GetPawn()`** — d'où la
|
||
boucle de réessai de `TryStartupFadeIn()`, avec `CompleteStartupFadeIn()` comme filet au bout de
|
||
`StartupFadeTimeout` (un écran noir définitif se lit comme un plantage).
|
||
|
||
**Et rien ne doit être rendu avant.** Entre le chargement de la map et ce moment-là, aucun acteur
|
||
n'existe à qui accrocher un voile — le composant de fondu vit justement sur le contrôleur absent.
|
||
`USurvivalGameInstance` coupe donc `bDisableWorldRendering` sur le viewport, seul objet qui survive
|
||
au changement de map sans appartenir à personne, et c'est le contrôleur qui le rend une fois son
|
||
voile noir en place.
|
||
|
||
**`PreLoadMap` et surtout PAS `PostLoadMapWithWorld`** pour cette coupure. Le second est diffusé
|
||
**après** le `BeginPlay` des acteurs : sur une map chargée localement — le menu, ou la partie chez
|
||
l'hôte — le contrôleur rendait la main au rendu, et on la lui reprenait juste après. Écran noir
|
||
définitif jusqu'au filet de sécurité. Chez un client ça passait *par accident*, son contrôleur
|
||
arrivant plus tard — donc invisible si l'on ne teste que ce cas-là.
|
||
|
||
*Note : `ReceivedPlayer()` arrive AVANT `BeginPlay()` chez un client, et `IsLocalController()` y est
|
||
déjà vrai — ce n'est donc pas là qu'est le problème, contrairement à ce qu'on pourrait croire.*
|
||
|
||
**`IsLocalController()` est indispensable sur tout ce qui touche à l'écran.** Sur la machine de
|
||
l'hôte, chaque joueur distant a AUSSI un `AFpsPlayerController` côté serveur. Sans cette garde, le
|
||
contrôleur d'un ami qui se connecte va poser un fondu et rallumer le rendu **du viewport de
|
||
l'hôte**, à un instant totalement arbitraire.
|
||
|
||
### Étiquettes de pseudo
|
||
|
||
- **Le serveur ne peut PAS deviner le pseudo Steam d'un client** : l'API d'identité ne connaît que
|
||
l'utilisateur local de chaque machine. C'est donc le client qui l'annonce
|
||
(`Server_SetPlayerNickname`), et le serveur l'écrit sur le `PlayerState`, déjà répliqué par le
|
||
moteur. Sans ça on lit « Player » au-dessus des quatre joueurs — c'est le nom par défaut que le
|
||
moteur met dans l'URL de voyage.
|
||
- **Espace ÉCRAN, pas Monde** : l'étiquette fait face à la caméra *par construction*, sans calcul
|
||
d'orientation, et n'est jamais occultée par le décor. Mais `SWorldWidgetScreenLayer::Tick`
|
||
n'applique **aucune échelle de distance** — le pseudo garderait une taille en pixels constante
|
||
pendant que le personnage rétrécit, ce que l'œil lit comme une étiquette qui *grossit* en
|
||
s'éloignant. D'où l'échelle calculée à la main en 1/d, et l'opacité en fondu sur la dernière
|
||
bande avant `MaxDrawDistance`.
|
||
- Le composant ne réplique **rien** : le pseudo vit déjà sur le `PlayerState`.
|
||
|
||
### Le pop-in au chargement — deux causes, deux remèdes
|
||
|
||
**Les requêtes** — textures et LOD de meshes. `IStreamingManager::StreamAllResources()` les traite
|
||
toutes (`UpdateResourceStreaming(0, true)` n'a pas besoin de point de vue, ce qui tombe bien : le
|
||
rendu est encore coupé) puis bloque jusqu'à ce qu'elles aboutissent. Appelé dans
|
||
`ResumeWorldRendering()`, donc **écran noir** : c'est du temps de chargement, pas une saccade.
|
||
Mesuré : ~80 ms, 0 requête restante.
|
||
|
||
**Ce qui converge par IMAGE** — Nanite, ombres virtuelles, Lumen. Aucun flush ne peut les forcer :
|
||
il leur faut des images rendues. D'où `BeginSceneWarmup()`, qui laisse le monde rendre derrière le
|
||
voile encore noir avant de l'éclaircir.
|
||
|
||
**Il compte des IMAGES, pas des secondes**, et c'est tout l'intérêt : une durée fixe donnerait
|
||
quarante images sur une machine rapide et cinq sur une machine lente — c'est-à-dire le moins de
|
||
chauffe à celle qui en a le plus besoin. `SetTimerForNextTick` = un tick, une image.
|
||
`SceneWarmupMaxSeconds` n'est qu'un **plafond**, pas la mesure ; le log dit « plafond atteint »
|
||
quand il s'applique, pour savoir lequel des deux réglages ajuster.
|
||
|
||
Ordre non négociable dans le menu : `SetViewTarget` **puis** `ResumeWorldRendering()`. L'inverse
|
||
ferait chauffer le décor vu depuis l'angle par défaut, c'est-à-dire pas celui qu'on va montrer.
|
||
|
||
**`OnPossess` n'existe QUE sur le serveur.** Chez un client, le pawn arrive par réplication et
|
||
`OnPossess` n'est jamais appelée — son pendant est **`AcknowledgePossession`**. Tout ce qui doit
|
||
se produire à la prise de contrôle (création du HUD, rebranchement des widgets, abonnement à
|
||
`OnDied`) passe donc par `SetupForPossessedPawn()`, appelée des deux côtés et **idempotente** :
|
||
chez l'hôte, qui est serveur *et* client local, les deux chemins l'exécutent. D'où
|
||
`AddUniqueDynamic` sur `OnDied`, sinon la mort déclencherait deux fondus et deux respawns.
|
||
Symptôme quand on l'oublie : un joueur qui rejoint n'a aucune interface, alors que l'hôte a la
|
||
sienne — donc invisible en solo comme en test chez soi.
|
||
|
||
**Une connexion qui tombe ne ramène personne nulle part.** Le moteur coupe le lien et s'arrête là.
|
||
`USurvivalGameInstance` s'abonne donc à `GEngine->OnNetworkFailure()` et `OnTravelFailure()` pour
|
||
détruire la session et rouvrir le menu. C'est sur le GameInstance et pas sur le PlayerController
|
||
parce que celui-ci est justement détruit par l'échec.
|
||
|
||
**Quand l'hôte quitte, tout le monde quitte — et on le dit explicitement.** Le moteur ne prévient
|
||
personne : sans RPC, les clients restent dans un monde mort, avec le **pawn fantôme de l'hôte**
|
||
encore planté devant eux, jusqu'à un timeout qui peut ne jamais venir. `HandleQuitToMenuConfirmed`
|
||
appelle donc `KickRemoteClientsToMainMenu()` **avant** de lancer son propre fondu, pour que les
|
||
quatre fondus tournent ensemble. Le filet `OnNetworkFailure` reste pour ce qui n'est pas annoncé —
|
||
hôte qui plante, câble arraché — et se tait pendant un départ voulu grâce à
|
||
`BeginReturnToMainMenu()`, sinon il chargerait le menu en plein milieu du fondu.
|
||
|
||
| Système | État |
|
||
|---|---|
|
||
| Déplacement, crouch | ✅ gratuit via `ACharacter` / `CharacterMovement` |
|
||
| Sprint (`bIsSprinting` + RPC vitesse) | ✅ fait |
|
||
| Interaction (RPC serveur + validation de distance) | ✅ fait |
|
||
| `PickupItem` (ramassage, visuel, quantité) | ✅ fait |
|
||
| `InventoryComponent` — lecture (`Slots` répliqué) | ✅ fait |
|
||
| `InventoryComponent` — écriture (transferts, consommation) | ✅ fait |
|
||
| Jeter au sol (`DropFromInventory`, `DropAllItems`) | ✅ fait |
|
||
| `SurvivalStatsComponent` (simulation serveur, mort par `OnRep_Dead`) | ✅ fait |
|
||
| Respawn (`Server_RequestRespawn`) | ✅ fait |
|
||
| `StorageContainer` (contenu, couvercle) | ✅ fait |
|
||
| `CraftingComponent` (`Server_Craft`) | ✅ fait |
|
||
| `CookingStation` (grill, bûches, flamme) | ✅ fait |
|
||
| Corps 3e personne animé (rien à répliquer, tout dérivé) | ✅ fait |
|
||
|
||
**Un acteur du monde qui ouvre une interface doit RENVOYER l'ordre au client.**
|
||
`Interact_Implementation` s'exécute sur le serveur : `PlayerController->OpenStorage(...)` y créerait
|
||
un widget dans le vide et l'écran du joueur ne s'ouvrirait jamais. D'où `Client_OpenStorage` et
|
||
`Client_OpenCraftingAtStation`. C'est le piège le plus contre-intuitif du portage, parce que le
|
||
code *semble* correct et fonctionne parfaitement chez l'hôte.
|
||
|
||
**L'établi n'a rien à répliquer.** `ACraftingStation` est un meuble : un mesh, un prompt, aucun
|
||
état. Ce qui devait changer, c'est le sens de l'appel (ci-dessus) et `Craft()`, qui touche
|
||
l'inventaire autoritaire. Le poste accessible est tenu **des deux côtés** — le client en a besoin
|
||
pour filtrer sa grille de recettes, le serveur pour valider `Server_Craft`. N'en tenir qu'un
|
||
donnerait soit un onglet vide devant un établi, soit un établi utilisable depuis la forêt.
|
||
|
||
**La cuisson ne se compte que sur le serveur.** Les `FTimerHandle` ne sont pas des `UPROPERTY` et
|
||
ne partent jamais sur le réseau : les clients ne reçoivent que le *résultat* — l'objet devenu
|
||
cuit, la bûche disparue, la flamme allumée. Quatre clients qui compteraient chacun leur cuisson
|
||
divergeraient, et le steak serait brûlé chez l'un et cru chez l'autre.
|
||
|
||
**Le sprint est le piège qui ne se voit pas chez l'hôte.** `MaxWalkSpeed` posé en local
|
||
seulement laisse le serveur croire le client à `WalkSpeed` : il corrige alors sa position à
|
||
chaque pas, et c'est le rubber-banding. D'où `Server_SetSprinting`, appelée **après** avoir
|
||
appliqué en local — l'inverse donnerait un sprint qui démarre en retard.
|
||
|
||
**Pas de prédiction sur l'inventaire.** Le client envoie sa RPC et attend `OnRep_Slots`.
|
||
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.
|
||
|
||
**`FindNetProxy` est le passage obligé des transferts vers un coffre.** Un `UInventoryComponent`
|
||
de conteneur 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.
|
||
|
||
### Audio — le mixage passe par les SoundClass, et par rien d'autre
|
||
|
||
- **Une hiérarchie de `SoundClass`, un `SoundMix`, quatre curseurs.** `SC_Master` est la
|
||
racine ; `SC_Music`, `SC_SFX` et `SC_UI` en descendent. Le moteur multiplie déjà le volume
|
||
du parent dans celui de l'enfant, donc le volume général est gratuit — pas de produit à
|
||
calculer en C++.
|
||
- **`SM_Settings` est déclaré comme *Default Base Sound Mix*** dans Project Settings > Audio,
|
||
et le C++ le relit à cet endroit au lieu d'en garder une référence à lui. Deux raisons : un
|
||
mix de base est **rejoué à plein régime en permanence** (un mix poussé à la main s'éteindrait
|
||
au bout de sa durée), et le moteur le **repose à chaque chargement de map** — donc les
|
||
volumes traversent les transitions sans une ligne de code. Une seconde référence côté projet
|
||
n'apporterait qu'un risque de divergence.
|
||
- **`bApplyToChildren = true` sur le maître, `false` sur les trois autres.** C'est le piège de
|
||
ce système : le moteur propage les propriétés parent → enfant (`ParseSoundClasses`) **avant**
|
||
d'appliquer les ajusteurs du mix (`ApplyClassAdjusters`). Un ajusteur posé sur `SC_Master`
|
||
arrive donc trop tard pour redescendre tout seul. Sans ce drapeau, le volume général n'agit
|
||
que sur les sons rangés *directement* dans `SC_Master` — c'est-à-dire quasiment rien, et le
|
||
symptôme se lit comme « le curseur maître ne fait rien ».
|
||
- **Aucun fondu sur `SetSoundMixClassOverride` — `FadeInTime` vaut 0.** Ce n'est pas une
|
||
simplification : toute durée non nulle rend le curseur inutilisable. Reposer un override
|
||
avant la fin du précédent relance l'interpolation (`FDynamicParameter::Set` remet
|
||
`CurrTimeSec` à zéro) **sans** appeler `Update(DeltaTime)` la même frame. Un curseur qu'on
|
||
glisse repose l'override à chaque image, donc la rampe repart indéfiniment de zéro et le
|
||
volume ne rattrape son retard qu'une fois le geste **arrêté**. Le mixeur lisse déjà le gain
|
||
de chaque source dans son buffer de rendu : à zéro il n'y a pas de clic pour autant.
|
||
- **`SC_SFX` est la *Default Sound Class* du projet.** Un son qui n'annonce pas sa classe
|
||
atterrit donc dans les effets plutôt que nulle part : oublier de taguer un bruitage le laisse
|
||
quand même sous le curseur Effets. Seuls les sons d'interface se taguent à la main.
|
||
- **La musique impose sa classe depuis le code** (`SoundClassOverride` posé par
|
||
`UMusicSubsystem`), pas depuis les assets : ajouter une piste à une playlist ne doit pas
|
||
demander de penser à la taguer. Corollaire, `UMusicSubsystem::SetMusicVolume` **n'est pas** le
|
||
réglage du joueur — deux chemins vers le même volume finiraient par se multiplier.
|
||
- **Les volumes sont des pourcentages, élevés au carré avant d'être envoyés.** L'oreille répond
|
||
au logarithme de l'amplitude : un curseur envoyé tel quel donne un réglage où tout se joue
|
||
dans les vingt derniers pourcents. À 50 %, l'amplitude vaut 0,25, soit environ la moitié du
|
||
volume *perçu*.
|
||
- **`ApplyAudioSettings()` est séparée d'`ApplySurvivalSettings()`** : cinq commandes vers le
|
||
thread audio à chaque cran du champ de vision seraient du bruit pur. `ApplyNonResolutionSettings`
|
||
appelle les deux, donc le démarrage n'en oublie aucune.
|
||
- **Le jeu est muet en arrière-plan par défaut, et c'est le moteur qui le décide**
|
||
(`FApp::UnfocusedVolumeMultiplier`, dont la valeur d'usine est 0). Le réglage ne fait que
|
||
l'exposer ; il ne passe pas par le mix, puisqu'il baisse l'application entière bien en amont.
|
||
|
||
### Bruits de pas — la distance, pas les AnimNotify
|
||
|
||
- **Le pas est déclenché par la DISTANCE parcourue**, jamais par un notify d'animation.
|
||
`UFootstepComponent` accumule le déplacement horizontal et sonne tous les `StrideLength` cm.
|
||
Trois raisons : le corps 3e personne du joueur local est en `bOwnerNoSee`, les notifies posés
|
||
dans un **BlendSpace** se déclenchent selon l'échantillon dominant et sautent ou doublent un pas
|
||
au passage marche → course, et les bras FPS sont sur `BasePose_Skeleton`, qui n'a rien à voir
|
||
avec `SK_BodyA` — aucun notify ne pourrait servir aux deux.
|
||
- **`StrideLength` se calcule, il ne se devine pas** : `Vitesse / (2 × StrideFrequency)` du profil
|
||
correspondant de `UHeadBobComponent`, le facteur 2 parce qu'une foulée compte deux pas. D'où
|
||
143 / 182 / 210 cm pour accroupi / marche / sprint. Changer `WalkSpeed` ou `SprintSpeed` sans
|
||
retoucher ces valeurs désynchronise le son de la tête, et **ça s'entend avant de se voir**.
|
||
- **Rien n'est répliqué**, exactement comme `UPlayerBodyAnimInstance` : chaque machine calcule les
|
||
pas de tous les pawns qu'elle connaît, à partir de la vitesse et du mode de déplacement déjà
|
||
synchronisés. Un multicast par pas coûterait trois messages par seconde et par joueur pour un
|
||
résultat identique.
|
||
- **Saut et réception passent par `MovementModeChangedDelegate`, surtout pas par
|
||
`ACharacter::Landed` / `OnJumped`.** `Landed` vient de `ProcessLanded`, qui ne tourne que là où
|
||
le mouvement est réellement simulé, et `OnJumped` que là où l'input est lu : sur le pawn d'un ami
|
||
vu depuis notre machine, ni l'un ni l'autre n'existe. Le **mode de déplacement**, lui, est
|
||
répliqué, donc son délégué est diffusé partout. Et c'est le **signe de `Velocity.Z`** qui
|
||
distingue un saut d'une sortie de rebord, comme dans l'AnimBP du corps.
|
||
- **La vitesse de chute s'échantillonne PENDANT la chute** : au moment où le mode repasse à
|
||
`Walking`, le moteur a déjà remis `Velocity.Z` à zéro. Le cas où le tick à 20 Hz n'a pas eu le
|
||
temps de tourner est précisément celui d'une chute négligeable, qu'on veut de toute façon muette.
|
||
- **`bTraceComplex = true` n'est pas négociable.** Un `PhysicalMaterial` posé sur un **matériau**
|
||
n'est rendu que par un trace per-poly ; le trace simple remonte celui du `BodySetup` du mesh,
|
||
presque toujours vide. Symptôme d'un décochage : tout le décor sonne sur `DefaultSurface`, ce
|
||
qu'on lit comme « la table n'est pas remplie ». Coût réel : deux à trois traces par seconde et
|
||
par personnage, jamais un par frame — le tick tourne à **20 Hz** et ne fait qu'une soustraction
|
||
de vecteurs, le trace n'a lieu qu'au moment du pas.
|
||
- **Sur un Material INSTANCE, le champ *Phys Material* a une case à cocher devant lui, et elle
|
||
compte.** `UMaterialInstance::GetPhysicalMaterial()` teste `bOverridePhysMaterial` **avant** de
|
||
regarder le pointeur : décochée, l'instance hérite de son parent quoi qu'on ait glissé dans le
|
||
champ ; cochée mais vide, elle retombe sur `DefaultPhysMaterial` et **coupe** l'héritage.
|
||
Corollaire précieux pour SoStylized, dont les 398 instances descendent d'une poignée de parents :
|
||
taguer `MI_Rock`, `MI_Grass`, `MI_Snow`… suffit à couvrir toute leur descendance, et l'on ne
|
||
redescend dans un enfant que pour l'exception.
|
||
- **L'ordre des `PhysicalSurfaces` dans `DefaultEngine.ini` EST le contrat.** Le moteur ne stocke
|
||
que le numéro, jamais le nom : insérer une matière au milieu renumérote tout ce qui suit et les
|
||
`PM_` existants pointent silencieusement ailleurs. **Toute nouvelle surface s'ajoute à la fin.**
|
||
- Le pas du joueur local part en **2D** (`bPlayLocalStepsIn2D`), celui des autres en 3D à leurs
|
||
pieds : joué en 3D depuis ses propres chevilles, le pas local passe par l'atténuation et sonne
|
||
creux alors qu'il devrait être le son le plus présent du mixage. Les cues du pack n'ont pas de
|
||
`SoundClass`, donc les deux chemins tombent sur `SC_SFX` — le curseur *Effets* les couvre sans
|
||
qu'on ait rien à taguer.
|
||
- Les noms des surfaces décrivent le **monde**, pas le pack : `Grass` existe alors que le pack
|
||
livre *Leaves*, `Stone` alors qu'il livre *Concrete*. C'est `DA_Footsteps` qui fait la
|
||
traduction, et c'est sa raison d'être — changer de pack de sons ne doit toucher ni la config ni
|
||
les `PM_`.
|
||
|
||
### Décisions de gameplay figées
|
||
|
||
- **Inventaire** : un seul `TArray` de 30 slots. Index **0-9 = barre rapide**, **10-29 = grille**.
|
||
Conteneurs **indépendants** (un objet dans la barre n'apparaît pas dans la grille), même
|
||
tableau uniquement pour que le glisser-déposer n'ait aucune conversion d'index à faire.
|
||
- **Ramassage** : complète les piles existantes partout, puis ouvre un slot dans la **barre en
|
||
priorité**, puis déborde dans la grille.
|
||
- **Le volume visable d'un `APickupItem` est une BOÎTE ajustée au mesh, jamais une sphère.** Un
|
||
seul `BP_Pickup` sert des centaines d'objets : sa taille se calcule à partir de
|
||
`UStaticMesh::GetBoundingBox()` dans `FitInteractionBox()`, appelée par `ApplyItemVisuals()`
|
||
donc par `OnConstruction` **et** `OnRep_ItemData`. Trois raisons de ne pas revenir à la sphère :
|
||
son rayon englobant vaut la **demi-diagonale**, ce qui est le pire cas exact pour les objets
|
||
allongés du jeu (une branche de 120×20×15 donnait 62 cm de rayon, vingt-sept fois son volume) ;
|
||
elle ne peut pas exprimer une orientation, là où la boîte tourne avec l'acteur ; et le symptôme
|
||
n'est pas cosmétique — deux pickups côte à côte voyaient leurs volumes se chevaucher, et le
|
||
trace retenait le **premier touché** plutôt que l'objet visé. La marge (`InteractionBoxPadding`)
|
||
est **additive en cm et jamais multiplicative** : un facteur appliqué à la demi-épaisseur d'une
|
||
branche n'ajoute rien tout en rallongeant son grand axe. Deux pièges partagés avec
|
||
`AStorageContainer::FitInteractionBox` : lire les bounds de l'**asset** et pas `Mesh->Bounds`,
|
||
déjà multipliées par l'échelle que `SetBoxExtent` remultiplie ; et recentrer sur
|
||
`GetCenter()`, le pivot d'un mesh de pack étant presque toujours au sol.
|
||
- **Glisser** : normal = pile entière, **Maj** = moitié arrondie au supérieur, **Ctrl** = un seul.
|
||
Lâcher hors de la grille jette au sol ; lâcher entre deux cases est absorbé (jamais de perte
|
||
accidentelle).
|
||
- **Clic gauche** = utiliser l'objet actif de la barre. Bindé sur le **pawn**, donc muet quand
|
||
l'inventaire est ouvert — le conflit avec le drag & drop se règle tout seul.
|
||
- **Objets à charges** (`MaxUses > 1`) ne s'empilent jamais. Les valeurs de restauration
|
||
s'appliquent **par utilisation**. L'usure suit l'objet jusqu'au sol et revient avec lui.
|
||
- **Mort** : tout tombe au sol autour du corps, respawn au `PlayerStart`, jauges pleines.
|
||
- **Fondus** : un unique voile plein écran `WBP_Fade` au ZOrder 1000. Fondre widget par widget
|
||
est fragile — les cases réécrivent leur propre `RenderOpacity`.
|
||
- Après un respawn, le pawn est un **nouvel acteur** : les widgets doivent se rebrancher via
|
||
`BindToOwningPawn()`, appelé depuis `OnPossess`. Même raison côté réglages : le pawn les
|
||
**relit** à son `BeginPlay` et s'abonne à `OnSurvivalSettingsApplied`.
|
||
- **Échap** a une priorité explicite dans `HandleEscapeInput()` : boîte de confirmation →
|
||
réglages → coffre → inventaire → pause. Sans cet ordre, deux écrans se retrouvent ouverts en
|
||
même temps.
|
||
- **Un widget UMG qui a le focus clavier COUPE la route des touches vers Enhanced Input.** Les
|
||
widgets vivent dans le `SGameLayerManager`, **frère** du `SViewport` et pas son enfant : une
|
||
touche remonte les parents du widget focalisé et n'atteint jamais le viewport. Or un `UButton`
|
||
prend le focus au clic. Symptôme exact rencontré : Échap refermait l'inventaire (que des cases,
|
||
rien de focusable) mais plus le menu de pause dès qu'on avait cliqué dedans — et jamais après un
|
||
aller-retour dans les réglages, qui prennent le focus (`SetKeyboardFocus`) sans le rendre.
|
||
La parade est donc double, et les deux moitiés comptent : tout écran à boutons est `Focusable`
|
||
et relaie Échap par `NativeOnKeyDown` vers `HandleEscapeInput()` — un seul point d'entrée, donc
|
||
la priorité ci-dessus reste vraie quel que soit le chemin — **et** l'écran qui redevient la
|
||
couche active **reprend** le focus (`UPauseMenuWidget::SetInteractive(true)`). Corollaire
|
||
assumé : menu de pause ouvert, Tab et les touches de barre rapide ne répondent plus.
|
||
- **Dans la map du menu, `AMainMenuPlayerController` ne binde AUCUNE action d'input.** Tout Échap
|
||
qui y fonctionne passe donc par le `NativeOnKeyDown` d'un widget — il n'existe pas d'autre
|
||
chemin, et un écran qui l'oublie n'a simplement pas de touche Retour. C'était le cas de
|
||
`UCharacterCustomizationWidget`. Son `SetWidgetToFocus()` côté contrôleur ne suffisait pas non
|
||
plus : **Slate refuse de focaliser un widget qui ne déclare pas supporter le focus clavier**,
|
||
donc l'appel ne faisait rien tant que `SetIsFocusable(true)` manquait.
|
||
- **Menu de pause : il ne met plus rien en pause.** `SetPause()` a disparu du projet. En coop la
|
||
pause est impossible par nature — côté client l'appel est ignoré (elle appartient à l'autorité),
|
||
côté hôte elle figerait le monde des quatre joueurs parce qu'un seul a ouvert son menu. Le monde
|
||
continue donc de tourner, comme dans Valheim et Raft : ouvrir son menu ne met pas à l'abri.
|
||
Corollaire **non négociable** : c'est la pause qui coupait gratuitement l'input du pawn, il faut
|
||
donc passer par `SetUiInputMode(true)` — le même que l'inventaire et le coffre. L'oublier laisse
|
||
la caméra tourner derrière le menu. Pas de bouton « Quitter le jeu » : la sortie se fait depuis
|
||
le menu principal.
|
||
- **Réglages** : `USurvivalUserSettings` fait autorité, jamais le CDO du pawn. Application
|
||
immédiate, aucun bouton « Appliquer » ; la sauvegarde a lieu à la fermeture de l'écran.
|
||
L'écran ignore qui l'ouvre (`OnCloseRequested`), d'où un seul `WBP_Settings` pour le menu
|
||
principal et la pause — et il reçoit même le ZOrder de sa boîte de dialogue par
|
||
`SetDialogZOrder()` : **le contrôleur reste seul maître de son empilement**, un widget
|
||
réutilisable n'a pas à connaître la pile de son hôte. Une ligne = un index dans une liste de paliers, y compris les nombres :
|
||
d'où un seul `WBP_SettingRow` pour tout le menu.
|
||
- **Craft instantané**, jamais de barre de progression : c'est le choix de Valheim, Raft et
|
||
Aloft. La fabrication qui prend du temps sera celle des postes qui travaillent **sans le
|
||
joueur** (fourneau, séchoir) — un autre système, avec sa file d'attente, ses acteurs posés
|
||
dans le monde et leur mesh qui change d'état. Ce ne sont donc **pas** des `ECraftingStation`.
|
||
- **Une seule fenêtre de craft**, dont le contenu dépend d'où l'on se trouve. La recette déclare
|
||
son `RequiredStation` ; `UCraftingComponent` tient la liste des postes **accessibles à
|
||
l'instant t** (`{Hands}` seul, `{Hands, Workbench}` devant un établi). Par-dessus vient un
|
||
filtre d'affichage **purement visuel** : les deux notions restent séparées, sinon un bouton
|
||
d'interface finirait par rendre un craft impossible.
|
||
- **`Craft()` consomme avant d'ajouter.** Refuser parce que l'inventaire est plein serait faux :
|
||
consommer trois branches libère justement la case du résultat. Ce qui ne rentre malgré tout
|
||
pas part par `OnCraftOverflow` et le **pawn** le pose au sol — le composant ne sait pas
|
||
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.
|
||
- **Le couvercle tourne sur le pivot de son propre mesh.** `LidMesh` est enfant direct de la
|
||
caisse et c'est lui qu'on fait tourner : un `UStaticMeshComponent` pivote toujours autour de
|
||
l'origine de son mesh, donc la charnière est celle que le pack a donnée à `SM_Chest_Top`, sans
|
||
composant intermédiaire à placer à la main. *(Un `SceneComponent` `LidPivot` a existé pour
|
||
découpler les deux ; il a été retiré — un pivot vide laissé à `(0,0,0)` fait tourner le
|
||
couvercle autour du centre de la caisse, et c'était le symptôme.)* Si un jour un pack pose ce
|
||
pivot au centre du couvercle, on le corrige **dans l'asset** (Modeling Mode > XForm > Edit
|
||
Pivot), pas dans le code. 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
|
||
|
||
- 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`.
|
||
- Une **Border ne peint rien avec `Draw As = Image` sans texture**. Pour un aplat de couleur,
|
||
c'est **`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, et re-glisser dans la hiérarchie.
|
||
- Pour un cadre de taille fixe, le Size Box va **autour** de la Border. Dedans, c'est le contenu
|
||
qui commande. Le plus externe gagne toujours.
|
||
- `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 (« Text Block ») fausse la mise en page dans la Designer.
|
||
Y mettre une valeur représentative — le code l'écrase de toute façon à l'exécution.
|
||
|
||
## Conventions
|
||
|
||
- **Le code est en anglais, les commentaires en français.** Tout ce qui est du code s'écrit
|
||
en anglais sans exception : noms de classes, de membres et de fonctions, valeurs d'enum,
|
||
chaînes `Category = "..."`, `UMETA(DisplayName = ...)`, textes source des `FText` et
|
||
`LOCTEXT`. Un `ECraftingStation::Hands`, pas `AlaMain`. Le français est réservé aux
|
||
commentaires — un lecteur extérieur doit pouvoir lire le code sans traduire.
|
||
*(Les fichiers antérieurs à cette règle ont encore des `Category` et des `FText` en
|
||
français ; les mettre à jour au fur et à mesure qu'on les touche.)*
|
||
- Commentaires **en français**. Ils expliquent le *pourquoi* et les pièges, jamais le *quoi*.
|
||
- Nommage éditeur : `BP_` acteurs, `WBP_` widgets, `DA_` data assets, `IA_` input actions,
|
||
`IMC_` mapping contexts, `GM_` game mode.
|
||
- Widgets : classe C++ `Abstract` pour la logique, Widget Blueprint enfant pour la mise en page.
|
||
Les widgets liés utilisent `meta = (BindWidget)`, ou `BindWidgetOptional` quand l'élément
|
||
est décoratif.
|
||
- Les widgets s'abonnent à des délégués, **jamais de Tick** pour lire un état.
|
||
|
||
## État actuel et suite
|
||
|
||
Boucle complète **en coop jusqu'à 4 joueurs** : récolter → fabriquer → cuire → ranger →
|
||
consommer → survivre → mourir → réapparaître. Autour : menu principal, menu de pause, écran de
|
||
réglages, étiquettes de pseudo Steam.
|
||
|
||
Le portage réseau est terminé pour tout le gameplay existant — voir *Réplication* plus haut pour
|
||
le détail et les pièges. Les transitions de map sont propres de bout en bout : plus de flash, plus
|
||
de pop-in, départ groupé quand l'hôte quitte.
|
||
|
||
Les vraies recettes restent à écrire : celles en place sont deux `DA_Craft_Test_*` bâties sur
|
||
`DA_Branch` et `DA_Rocher`. Les **boutons de filtre** par poste ne sont pas faits non plus
|
||
(`SetStationFilter` existe déjà, `ECraftingStation::Count` sert de sentinelle « tout »).
|
||
|
||
### Chantier en cours : customisation de personnage
|
||
|
||
**Doc complète : [Docs/CharacterCustomization.md](../Docs/CharacterCustomization.md)** — deux
|
||
moitiés séparées, une pour l'artiste (ajouter une coiffure, une couleur, un corps, sans code) et
|
||
une pour le développeur (architecture, réseau, pièges du moteur).
|
||
|
||
Découpé en quatre lots. **Le lot 1 est fait** : le pawn a enfin un corps visible et animé (voir
|
||
*Le corps vu par les autres* plus haut) — sans lui il n'y avait rien à customiser, et les autres
|
||
joueurs voyaient un pseudo flotter au-dessus du vide.
|
||
|
||
**Le lot 2 est fait aussi** : `FCharacterAppearance` (11 octets d'index), `UCharacterPartsDataAsset`
|
||
(le catalogue) et `UCharacterAppearanceComponent` (monte les pièces en leader pose, pose les
|
||
couleurs sur des MID). `DA_CharacterParts` couvre Body A ; **Body B reste à remplir**, avec son
|
||
`ABP_PlayerBody_B` et sans barbes ni moustaches — le pack n'en livre pas, et une liste vide se
|
||
comporte déjà comme `NoPart`.
|
||
|
||
**Le lot 3 est fait** : l'apparence vit sur `ASurvivalPlayerState` en `ReplicatedUsing` et se
|
||
persiste dans `USurvivalUserSettings`. Elle survit donc au respawn — le pawn est un nouvel acteur,
|
||
le PlayerState non — et au relancement du jeu.
|
||
|
||
- **`SubmitCharacterAppearance()` sur `AFpsPlayerController` est le point d'entrée UNIQUE.** Il
|
||
persiste *et* annonce au serveur, 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. C'est par là que passent les commandes console, et par là que passera
|
||
l'écran du lot 4.
|
||
- **La course pawn / PlayerState se résout des deux côtés.** Chez un client ils arrivent par deux
|
||
chemins de réplication indépendants et rien ne dit lequel gagne : `OnRep_CharacterAppearance`
|
||
monte le personnage si le pawn est déjà là, `AFpsPlayer::OnRep_PlayerState` va chercher
|
||
l'apparence si c'est le pawn qui est arrivé le premier. Les deux appellent la même fonction
|
||
idempotente. Chez l'hôte, aucun `OnRep` ne se déclenche — c'est le `BeginPlay` du pawn qui monte,
|
||
et `SetCharacterAppearance` qui applique en local juste après avoir écrit la propriété.
|
||
- **Rien n'est validé côté PlayerState, et c'est délibéré** : borner un index demande le catalogue,
|
||
que seul `UCharacterAppearanceComponent` connaît — or il passe déjà tout ce qu'il monte par
|
||
`Catalog->Sanitize()`, sur chaque machine. Dupliquer la validation demanderait de donner le
|
||
catalogue au PlayerState pour un résultat identique.
|
||
- Piège de test : **en PIE à deux joueurs, les deux fenêtres lisent le même `.ini`**, donc les deux
|
||
personnages démarrent identiques. Il faut changer l'un des deux à la console pour vérifier que la
|
||
réplication marche.
|
||
|
||
Reste le **lot 4** — l'écran : `ACharacterPreviewActor` dans `MenuScene`, caméra à cadrages
|
||
interpolés, UI à gauche. Et le remplissage de **Body B** dans `DA_CharacterParts`.
|
||
|
||
#### Trois pièges du lot 2, aucun visible depuis le code
|
||
|
||
- **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 simplement introuvable. D'où `EmberPart` / `EmberColor` /
|
||
`EmberAppearanceDump` posées sur `AFpsPlayer`, qui délègue au composant. *(`EmberJoin` marche
|
||
parce que `UGameInstance`, lui, route vers ses subsystems.)*
|
||
- **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`, « Eye »), et si rien ne correspond on ne pose rien et on
|
||
le dit dans le log : écraser le slot de la peau repeint le visage entier de la couleur des yeux,
|
||
symptôme qu'on met bien plus longtemps à diagnostiquer qu'une ligne de log.
|
||
- **Un nom de paramètre matériau se lit dans l'éditeur du Material INSTANCE, jamais dans les
|
||
chaînes du master.** Le binaire de `M_Head_Base` contient « 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.
|
||
|
||
Le montage lui-même n'a rien de surprenant : le composant crée un `USkeletalMeshComponent` par
|
||
catégorie, le met en leader pose du `Mesh` du Character, et recopie sur chacun le `bOwnerNoSee` et
|
||
le `bCastHiddenShadow` du porteur — c'est ce qui fera que le mannequin du menu, lui, sera visible
|
||
sans une ligne de plus. Les couleurs sont posées sur **tous** les éléments de chaque mesh sans
|
||
chercher lequel les porte : un `SetVectorParameterValue` dont le paramètre n'existe pas est
|
||
ignoré, ce qui évite une table d'index de slots qui casserait au premier mesh mal rangé.
|
||
|
||
**Décisions figées** :
|
||
|
||
- **Couleurs = palette pure, un bouton par couleur.** Une `TArray<FLinearColor>` par catégorie
|
||
dans le catalogue, un index d'un octet dans l'apparence. Cliquer une pastille pose la couleur,
|
||
sans réglage intermédiaire. Le curseur de nuance envisagé au départ a été retiré : l'auteur veut
|
||
des boutons.
|
||
- **Pas de `DisplayName` sur les entrées du catalogue** — la vignette (`Icon`, un `UTexture2D`
|
||
comme celle d'`UItemDataAsset`) identifie l'option à elle seule.
|
||
- **Écran imposé sur le chemin *Jouer*.** Corollaire assumé : l'arrivée par invitation Steam ne
|
||
passe pas par là, l'invité garde donc son apparence persistée du dernier passage.
|
||
- **Étendue : silhouette + couleurs.** Tatouages et maquillage reportés — ce ne sont que des
|
||
paramètres texture de plus (`M_Head_Base` et `M_Body_Base` les exposent déjà), aucune refonte.
|
||
- **Les index et jamais les chemins d'assets** dans la struct répliquée : quelques octets au lieu
|
||
d'une chaîne, et un index se valide contre le catalogue.
|
||
- **Le corps se monte depuis une LISTE de meshes, jamais un pointeur unique**, même quand la liste
|
||
n'a qu'une case. C'est ce qui permettra aux armures de cacher des segments (`SKM_BodyA_torso`,
|
||
`_arms`, `_legs_01/02/03`…) sans refonte — le pack les fournit exprès. Contrainte qui en
|
||
découle : **tout futur pack d'armure devra être skinné sur `SK_BodyA` / `SK_BodyB`**, à vérifier
|
||
avant achat. Le jour venu, `SKM_BodyA_empty` deviendra le porteur d'animation invisible — il
|
||
faudra lui réassigner `SK_BodyA`, son squelette d'origine étant `SK_BodyA_empty`.
|
||
- Limite acceptée : **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.
|
||
|
||
**Les matériaux du pack sont pilotables par MID**, ce qui rend les variantes `_a` / `_b` / `_c`
|
||
inutiles — ce ne sont que des presets de couleur figés. Une seule instance par pièce suffit :
|
||
|
||
| Master | Paramètres utiles |
|
||
|---|---|
|
||
| `M_Body_Base` | `Skin color`, `Underware color`, `Tattoo 01..05 color` + masques |
|
||
| `M_Head_Base` | `Skin Base color`, `Skin roughness`, `Blush` / `Lipstick` / `Eyeliner` / `Eyeshadow` / `Eyelashes` / `Tattoo` (couleur + masque chacun) |
|
||
| `M_Hairstyle_Base` | `Color` — parent des **cheveux, barbes, moustaches ET sourcils** |
|
||
| `M_Eyes_Base` | `Eye color`, groupes Iris / Pupil / Sclera |
|
||
|
||
### Après la customisation
|
||
|
||
Prochaine étape gameplay recommandée : **spawner de ressources avec repousse**
|
||
(`AResourceSpawner`, liste pondérée de DataAssets, rayon, densité, délai). Sans lui la carte se
|
||
vide en dix minutes. À écrire **serveur-autoritaire d'emblée** — c'est un acteur du monde.
|
||
|
||
Ensuite, par ordre de valeur : objet visible en main, sauvegarde, cycle jour/nuit.
|
||
|
||
> **Systèmes présents dans `Source/` mais pas encore décrits ici** :
|
||
> `SettingsDisplayWidget`, `SettingsGraphicsWidget`,
|
||
> `SettingsControlsWidget`, `SettingKeyRowWidget`, `KeyCaptureWidget`. Ils sont arrivés en dehors
|
||
> des sessions qui ont produit ce document — à documenter quand on y touchera, plutôt que de
|
||
> décrire de mémoire un code qu'on n'a pas lu.
|