Files
Unreal_EmberWild/.claude/CLAUDE.md
T
Mathew 5ba5fbfebf (Feat) Customisation de personnage -- donnees et montage
Lot 2 du chantier : le corps du lot precedent devient assemblable. Tete,
coiffure, sourcils, barbe et moustache sont montes en leader pose sur le
mesh du Character, et les couleurs sont posees sur des MID.

FCharacterAppearance tient en 11 octets : des INDEX et jamais des chemins
d'assets. Un chemin couterait une chaine a chaque replication, alors qu'un
index tient dans un octet et se VALIDE contre le catalogue -- un client ne
peut donc pas demander un asset arbitraire.

Le corps se monte depuis une LISTE de meshes meme si elle n'a qu'une case.
Le pack livre exprès SKM_BodyA_torso, _arms, _legs_01/02/03 pour qu'une
armure puisse eteindre les segments qu'elle recouvre : le jour venu la case
0 devient SKM_BodyA_empty et les huit segments suivent, sans que ce code
change. Un pointeur unique imposerait de tout reprendre au premier plastron.

Trois pieges rencontres, aucun visible depuis le code :

- Les UFUNCTION(Exec) d'un UActorComponent ne sont JAMAIS appelees. La
  chaine de routage (UPlayer::Exec) interroge le monde, le PlayerInput, le
  controller, le pawn, le HUD, le GameMode, le CheatManager, le GameState et
  le camera manager -- jamais les composants, AActor ne surchargeant pas
  ProcessConsoleExec. Et rien ne proteste. D'ou EmberPart / EmberColor /
  EmberAppearanceDump posees sur AFpsPlayer, qui delegue.

- L'ordre des slots de materiau varie d'un mesh a l'autre : sur
  SKM_HeadA_01 l'Element 0 est celui des YEUX et le 1 celui de la peau. Les
  six tetes venant de deux packs, on cherche le slot par son NOM et on ne
  pose rien si aucun ne correspond -- ecraser le slot de la peau repeint le
  visage entier de la couleur des yeux.

- Un nom de parametre materiau se lit dans le Material INSTANCE, pas dans
  les chaines du master : M_Head_Base contient « Skin Base color » mais le
  parametre expose s'appelle « Skin ». D'ou des UPROPERTY de catalogue
  plutot que des constantes, pour corriger sans recompiler.

Body B reste a remplir dans DA_CharacterParts, avec son ABP_PlayerBody_B et
sans barbes ni moustaches -- le pack n'en livre pas, et une liste vide se
comporte deja comme NoPart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:48:37 +02:00

55 KiB
Raw Blame History

Survival_projet — Unreal Engine 5.8

Jeu de survie solo à 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

& "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 ItemDataAssetdoc complète : Docs/CookingSystem.md
Coffres StorageContainer, StorageScreenWidget + UInventoryComponent::TransferSlot / TransferAllTo
Écran à onglets (inventaire / craft) InventoryScreenWidget
Menu de pause PauseMenuWidget + la partie pause de FpsPlayerController
Réglages SurvivalUserSettings, SettingsMenuWidget, SettingsTabWidget, SettingsGameplayWidget, SettingRowWidget
Musique MusicDirector, MusicSubsystem, MusicPlaylistDataAsset
Audio (volumes) SurvivalAudioSettings, SettingsAudioWidget + USurvivalUserSettings::ApplyAudioSettings
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é :

& "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. UploaderC:\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 SetSoundMixClassOverrideFadeInTime 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.

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.
  • 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 HandlePauseInput() : boîte de confirmation → réglages → inventaire → pause. Sans cet ordre, deux écrans se retrouvent ouverts en même temps.
  • 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.
  • Un coffre EST un UInventoryComponent, configuré par ConfigureAsContainer() : zéro barre rapide, donc GetBackpackStartIndex() vaut 0 et la grille affiche tout. Aucun nouveau modèle de données — d'où la durabilité qui traverse le coffre sans une ligne de code. Le déplacement est désormais unique : UInventoryComponent::TransferSlot(From, i, To, j, N) est statique, gère indifféremment un ou deux inventaires, et diffuse OnInventoryChanged des deux côtés ; MoveItem/MoveItemQuantity ne sont plus que des appels avec From == To. Oublier la seconde diffusion laisse la grille d'en face afficher un état périmé, ce qui se lit comme un objet qui disparaît.
  • Le coffre n'a pas d'écran à lui : c'est un panneau de plus sur WBP_InventoryScreen, frère du WidgetSwitcher, Collapsed par défaut. La raison est du ressenti pur — le sac du joueur ne bouge pas d'un pixel entre « j'ouvre mon sac » et « j'ouvre un coffre », alors qu'un écran distinct le ferait sauter à chaque fois. Ce n'est pas un troisième onglet non plus : les deux grilles doivent être visibles ensemble, ce qu'un WidgetSwitcher interdit. Techniquement c'est une deuxième instance du même WBP_Inventory — elle ne peut pas vivre dans WBP_Inventory, un Widget Blueprint qui se contient lui-même est une référence circulaire qu'UMG refuse de compiler. D'où le flag bAutoBindToPawn : sans lui, la grille du coffre s'accroche au pawn dans son NativeConstruct et affiche le sac des deux côtés. Corollaire : l'onglet Fabrication est grisé tant qu'un coffre est ouvert (le panneau resterait affiché à côté de la grille de recettes), et la touche de craft rend le coffre.
  • Clic droit = envoyer en face, via AddItem() donc exactement comme un ramassage. La destination est poussée par l'écran (SetQuickTransferTarget), jamais devinée : hors de l'écran de coffre il n'existe aucun « autre côté », et deviner ferait disparaître des objets. « Tout ranger » part de GetBackpackStartIndex() : la barre rapide porte les outils, les ranger d'office serait une punition déguisée en confort.
  • Le coffre ne décide de rien. Il appelle OpenStorage(this) ; c'est le contrôleur qui referme le couvercle — par Échap, par la mort, ou parce que le joueur s'est éloigné (MaxStorageDistance, surveillé par un timer à 0,25 s, jamais un Tick). Un coffre qui déciderait seul finirait ouvert dans la moitié de ces cas.
  • LidPivot, un SceneComponent vide entre la caisse et le couvercle : le pivot de SM_Chest_Top est celui du pack, presque jamais la charnière. Sans lui le couvercle tourne autour de son centre et traverse la caisse. Le coffre ne tick que pendant l'animation.
  • Toute la collision du coffre tient dans une UBoxComponent, les meshes n'en portent aucune. C'est la seule exception à la règle « le mesh EST la surface d'interaction » posée par ACraftingStation, et elle vient de ce qu'un coffre est en deux morceaux dont un pivote : compter sur leurs collisions imposerait que le pack en fournisse deux correctes, et laisserait surtout un trou dans la surface visable dès que le couvercle se lève. La boîte est fille de la caisse, jamais du pivot — le coffre se vise donc au même endroit ouvert et fermé. Elle s'ajuste seule au volume des deux meshes dans OnConstruction (bAutoFitInteractionBox), donc changer de variante de coffre ne demande aucun retaillage.
  • La barre rapide se masque sur l'onglet Fabrication. UInventoryScreenWidget se contente de diffuser OnTabChanged ; c'est le contrôleur qui décide, via une unique UpdateHotbarVisibility(). Un seul point de décision, sinon fermer l'écran depuis l'onglet craft laisse la barre cachée pour le reste de la partie.
  • Le détail de l'objet survolé vit dans WBP_Inventory, à droite de la grille, et non sur WBP_InventoryScreen : c'est un panneau de la grille, il doit la suivre — l'onglet Fabrication l'emporte donc avec la page, sans une ligne de code. Contenu volontairement réduit : icône, nom, trait de séparation, description, barre d'usure. Pas de fond, la largeur est tenue par un Size Box — sans lui, un panneau vide ferait sauter la grille latéralement. UItemDetailsWidget reçoit un couple (inventaire, index), jamais une copie du FInventorySlot : une charge consommée sous le curseur doit se voir, d'où son abonnement à OnInventoryChanged. La grille masque ContentRoot en bloc quand rien n'est survolé, sinon le trait de séparation resterait seul à l'écran.
  • Aucune case à cocher pour ce panneau : c'est l'écran qui l'allume et l'éteint (SetItemDetailsEnabled). WBP_Inventory étant instancié deux fois sur l'écran de coffre, un réglage par instance serait un bug qui attend qu'on oublie de le décocher. La grille de coffre n'en a donc jamais, et le sac perd le sien tant qu'un coffre est ouvert — trois colonnes seraient illisibles. HideStorage() étant appelé dès NativeConstruct, l'état par défaut est reposé à chaque construction plutôt que hérité de la Designer.
  • Une case ne connaît plus sa grille, elle diffuse OnHoverChanged. La barre rapide pose les mêmes WBP_InventorySlot sans être un UInventoryWidget : lui passer nullptr comme parent était acceptable tant que personne n'écoutait le survol. La barre relaie vers le contrôleur, seul à posséder à la fois le HUD et l'écran — un widget de HUD n'a pas à connaître l'écran d'inventaire. Le panneau efface donc la case qu'on quitte (ClearSlot(inventaire, index)), jamais « le panneau » : passer d'une case de la barre à une case de la grille émet entre dans B puis sort de A, et un effacement inconditionnel viderait ce qui vient d'être affiché.
  • NativeOnMouseLeave n'arrive jamais quand un widget s'efface sous le curseur. C'est exactement ce qui se passe en fermant un coffre, d'où le passage obligé par SetHoveredSlot() dans ClearSlotWidgets() et BindToInventory() — cette dernière annonce la sortie avant de changer d'inventaire, sinon on ne sait plus quel affichage périmer.

Le détail du système de craft — API, câblage éditeur, dépannage — est dans Docs/Crafting.md.

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

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.

Restent les lots 3 et 4 :

  • Lot 3 — réplication par ASurvivalPlayerState en ReplicatedUsing, persistance dans USurvivalUserSettings
  • Lot 4 — l'écran : ACharacterPreviewActor dans MenuScene, caméra à cadrages interpolés, UI à gauche

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.