diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4575838..4eeebbd 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -133,6 +133,62 @@ qu'une compilation propre l'est vraiment. | 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 @@ -354,6 +410,7 @@ hôte qui plante, câble arraché — et se tait pendant un départ voulu grâce | `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 @@ -616,6 +673,89 @@ Les vraies recettes restent à écrire : celles en place sont deux `DA_Craft_Tes `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` 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. diff --git a/Content/Game/Data/Character/DA_CharacterParts.uasset b/Content/Game/Data/Character/DA_CharacterParts.uasset new file mode 100644 index 0000000..a550d33 --- /dev/null +++ b/Content/Game/Data/Character/DA_CharacterParts.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d37d07022206cc8b2c4cff93799be45e7cce7fab6106116b660fa506d5b3d686 +size 13672 diff --git a/Content/Game/Player/BP_FpsPlayer.uasset b/Content/Game/Player/BP_FpsPlayer.uasset index 6d3af50..29143df 100644 --- a/Content/Game/Player/BP_FpsPlayer.uasset +++ b/Content/Game/Player/BP_FpsPlayer.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6ca4277186ca1ee30950d55f07fb8cabae044b6e098b9883931846444264420 -size 51831 +oid sha256:07dbbdb4eba5f306424850c93af180aeba676115aa1097f84c92d49cd790e965 +size 40452 diff --git a/Source/Survival_projet/Private/CharacterAppearanceComponent.cpp b/Source/Survival_projet/Private/CharacterAppearanceComponent.cpp new file mode 100644 index 0000000..606fc40 --- /dev/null +++ b/Source/Survival_projet/Private/CharacterAppearanceComponent.cpp @@ -0,0 +1,558 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "CharacterAppearanceComponent.h" + +#include "Components/SkeletalMeshComponent.h" +#include "CharacterPartsDataAsset.h" +#include "Engine/SkeletalMesh.h" +#include "GameFramework/Character.h" +#include "Materials/MaterialInstanceDynamic.h" + +namespace +{ + /** + * Vrai si les deux apparences designent exactement les memes PIECES. + * + * Les couleurs en sont volontairement exclues : elles se posent sur des + * materiaux deja en place, sans toucher au montage. C'est cette distinction + * qui evite qu'un curseur de couleur glisse reengendre six meshes par image. + * + * Prefixe du fichier oblige : le build unity concatene les .cpp du module, + * un namespace anonyme n'isole rien. + */ + bool AppearanceHasSameParts(const FCharacterAppearance& A, const FCharacterAppearance& B) + { + return A.BodyType == B.BodyType + && A.HeadIndex == B.HeadIndex + && A.HairIndex == B.HairIndex + && A.EyebrowsIndex == B.EyebrowsIndex + && A.BeardIndex == B.BeardIndex + && A.MustacheIndex == B.MustacheIndex + && A.EyesIndex == B.EyesIndex; + } + + /** Traduit l'argument d'une commande console en categorie. */ + bool AppearanceParsePartCategory(const FString& Name, ECharacterPartCategory& OutCategory) + { + if (Name.Equals(TEXT("head"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Head; return true; } + if (Name.Equals(TEXT("hair"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Hair; return true; } + if (Name.Equals(TEXT("eyebrows"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Eyebrows; return true; } + if (Name.Equals(TEXT("beard"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Beard; return true; } + if (Name.Equals(TEXT("mustache"), ESearchCase::IgnoreCase)) { OutCategory = ECharacterPartCategory::Mustache; return true; } + return false; + } + + /** L'index de piece stocke dans l'apparence, par categorie. */ + uint8* AppearancePartIndexPtr(FCharacterAppearance& Target, ECharacterPartCategory Category) + { + switch (Category) + { + case ECharacterPartCategory::Head: return &Target.HeadIndex; + case ECharacterPartCategory::Hair: return &Target.HairIndex; + case ECharacterPartCategory::Eyebrows: return &Target.EyebrowsIndex; + case ECharacterPartCategory::Beard: return &Target.BeardIndex; + case ECharacterPartCategory::Mustache: return &Target.MustacheIndex; + default: return nullptr; + } + } +} + +UCharacterAppearanceComponent::UCharacterAppearanceComponent() +{ + // Aucun Tick : le montage est evenementiel, il ne se declenche qu'a + // l'application d'une apparence. + PrimaryComponentTick.bCanEverTick = false; + + PartComponents.SetNum(static_cast(ECharacterPartCategory::Count)); +} + +void UCharacterAppearanceComponent::BeginPlay() +{ + Super::BeginPlay(); + + // L'apparence par defaut, faute de mieux. Le lot reseau branchera ici la + // lecture du PlayerState -- ce BeginPlay restera le repli du premier + // lancement, quand aucun reglage n'a encore ete sauvegarde. + ApplyAppearance(DefaultAppearance); +} + +void UCharacterAppearanceComponent::OnUnregister() +{ + // Les composants engendres appartiennent a l'acteur, pas a nous : ils + // disparaissent avec lui. On lache seulement nos references, pour ne pas + // retenir des objets que le GC doit pouvoir reprendre si le composant est + // retire d'un acteur qui, lui, survit. + PartComponents.Reset(); + PartComponents.SetNum(static_cast(ECharacterPartCategory::Count)); + ExtraBodyComponents.Reset(); + bBuilt = false; + + Super::OnUnregister(); +} + +void UCharacterAppearanceComponent::SetTargetMesh(USkeletalMeshComponent* InTargetMesh) +{ + if (TargetMesh == InTargetMesh) + { + return; + } + + TargetMesh = InTargetMesh; + + // Les pieces deja montees suivaient l'ancien porteur : les laisser en place + // donnerait une tete qui garde la pose d'un corps dont on ne se sert plus. + bBuilt = false; +} + +USkeletalMeshComponent* UCharacterAppearanceComponent::ResolveTargetMesh() +{ + if (TargetMesh) + { + return TargetMesh; + } + + AActor* Owner = GetOwner(); + if (!Owner) + { + return nullptr; + } + + // L'ACharacter d'abord, et surtout pas « le premier SkeletalMesh trouve » : + // AFpsPlayer porte aussi FirstPersonArms, et l'ordre des composants n'est + // garanti par rien. + if (const ACharacter* OwningCharacter = Cast(Owner)) + { + TargetMesh = OwningCharacter->GetMesh(); + } + + if (!TargetMesh) + { + TargetMesh = Owner->FindComponentByClass(); + } + + return TargetMesh; +} + +USkeletalMeshComponent* UCharacterAppearanceComponent::CreateFollowerComponent(FName ComponentName) +{ + AActor* Owner = GetOwner(); + if (!Owner || !TargetMesh) + { + return nullptr; + } + + USkeletalMeshComponent* Follower = NewObject(Owner, ComponentName); + if (!Follower) + { + return nullptr; + } + + Follower->SetupAttachment(TargetMesh); + + // Recopiees du porteur plutot que posees en dur : sur le pawn elles valent + // « invisible pour moi, mais je garde mon ombre », sur le mannequin du menu + // elles valent l'inverse. Le composant n'a pas a savoir dans lequel il vit. + Follower->SetOwnerNoSee(TargetMesh->bOwnerNoSee); + Follower->SetOnlyOwnerSee(TargetMesh->bOnlyOwnerSee); + Follower->SetCastHiddenShadow(TargetMesh->bCastHiddenShadow); + + // Une piece d'apparence n'est pas une surface de jeu : la capsule porte la + // collision, et une tete qui bloque un trace d'interaction se remarquerait + // tout de suite. + Follower->SetCollisionEnabled(ECollisionEnabled::NoCollision); + + Follower->RegisterComponent(); + + // Apres l'enregistrement : c'est la que le composant a son monde et sa + // hierarchie, donc que la table de correspondance des os peut se batir. + // La correspondance se fait PAR NOM D'OS, ce qui la rend indifferente au + // fait que les tetes du pack de customisation soient sur SK_BodyA_empty + // quand le corps est sur SK_BodyA. + Follower->SetLeaderPoseComponent(TargetMesh); + + return Follower; +} + +USkeletalMeshComponent* UCharacterAppearanceComponent::EnsurePartComponent(ECharacterPartCategory Category) +{ + const int32 Index = static_cast(Category); + if (!PartComponents.IsValidIndex(Index)) + { + return nullptr; + } + + if (PartComponents[Index]) + { + return PartComponents[Index]; + } + + const FName ComponentName(*FString::Printf(TEXT("AppearancePart_%d"), Index)); + PartComponents[Index] = CreateFollowerComponent(ComponentName); + + return PartComponents[Index]; +} + +void UCharacterAppearanceComponent::ApplyAppearance(const FCharacterAppearance& InAppearance) +{ + if (!Catalog) + { + UE_LOG(LogTemp, Warning, TEXT("UCharacterAppearanceComponent : aucun catalogue assigne sur %s, rien n'est monte."), + *GetNameSafe(GetOwner())); + return; + } + + if (!ResolveTargetMesh()) + { + UE_LOG(LogTemp, Warning, TEXT("UCharacterAppearanceComponent : aucun mesh porteur sur %s."), + *GetNameSafe(GetOwner())); + return; + } + + const FCharacterAppearance Previous = Appearance; + + Appearance = InAppearance; + + // Systematique, et pas seulement pour ce qui vient du reseau : un catalogue + // ampute entre deux versions du jeu laisserait des index qui ne designent + // plus rien, y compris dans les reglages sauvegardes du joueur. + Catalog->Sanitize(Appearance); + + const FCharacterBodySet* BodySet = Catalog->GetBodySet(Appearance.BodyType); + if (!BodySet) + { + UE_LOG(LogTemp, Warning, TEXT("UCharacterAppearanceComponent : le catalogue %s n'a aucune silhouette."), + *GetNameSafe(Catalog)); + return; + } + + if (!bBuilt || !AppearanceHasSameParts(Previous, Appearance)) + { + RebuildMeshes(*BodySet); + bBuilt = true; + } + + ApplyColors(*BodySet); + + OnAppearanceApplied.Broadcast(); +} + +void UCharacterAppearanceComponent::RebuildMeshes(const FCharacterBodySet& BodySet) +{ + // ------------------------------------------------------------------ + // Le corps. La case 0 est le porteur, les suivantes des composants + // enfants -- aujourd'hui aucune, demain les segments d'armure. + // ------------------------------------------------------------------ + + if (BodySet.BodyMeshes.Num() > 0 && BodySet.BodyMeshes[0]) + { + TargetMesh->SetSkeletalMeshAsset(BodySet.BodyMeshes[0]); + } + + if (BodySet.AnimClass) + { + // Reposee a chaque changement de silhouette : l'AnimBP est lie a un + // squelette, et passer de A a B sans le changer figerait le corps. + TargetMesh->SetAnimInstanceClass(BodySet.AnimClass); + } + + const int32 ExtraCount = FMath::Max(0, BodySet.BodyMeshes.Num() - 1); + for (int32 i = 0; i < ExtraCount; ++i) + { + if (!ExtraBodyComponents.IsValidIndex(i)) + { + const FName ComponentName(*FString::Printf(TEXT("AppearanceBody_%d"), i)); + ExtraBodyComponents.Add(CreateFollowerComponent(ComponentName)); + } + + if (USkeletalMeshComponent* Segment = ExtraBodyComponents[i]) + { + Segment->SetSkeletalMeshAsset(BodySet.BodyMeshes[i + 1]); + } + } + + // Les segments en trop apres un changement de silhouette : on les vide au + // lieu de les detruire, pour ne pas payer une reconstruction a chaque + // aller-retour entre deux corps dans l'ecran de customisation. + for (int32 i = ExtraCount; i < ExtraBodyComponents.Num(); ++i) + { + if (USkeletalMeshComponent* Segment = ExtraBodyComponents[i]) + { + Segment->SetSkeletalMeshAsset(nullptr); + } + } + + // ------------------------------------------------------------------ + // Les pieces. Un index a NoPart, ou une liste vide, laisse simplement le + // composant sans mesh -- rien a detruire, rien a recreer au retour. + // ------------------------------------------------------------------ + + auto ApplyPart = [this](ECharacterPartCategory Category, const TArray& Entries, uint8 PartIndex) + { + USkeletalMesh* Mesh = nullptr; + if (PartIndex != FCharacterAppearance::NoPart && Entries.IsValidIndex(PartIndex)) + { + Mesh = Entries[PartIndex].Mesh; + } + + // On n'engendre pas le composant pour rien : une silhouette sans barbe + // n'a aucune raison de porter un SkeletalMeshComponent vide. + const int32 Index = static_cast(Category); + if (!Mesh && (!PartComponents.IsValidIndex(Index) || !PartComponents[Index])) + { + return; + } + + if (USkeletalMeshComponent* PartComponent = EnsurePartComponent(Category)) + { + PartComponent->SetSkeletalMeshAsset(Mesh); + } + }; + + ApplyPart(ECharacterPartCategory::Head, BodySet.Heads, Appearance.HeadIndex); + ApplyPart(ECharacterPartCategory::Hair, BodySet.Hairstyles, Appearance.HairIndex); + ApplyPart(ECharacterPartCategory::Eyebrows, BodySet.Eyebrows, Appearance.EyebrowsIndex); + ApplyPart(ECharacterPartCategory::Beard, BodySet.Beards, Appearance.BeardIndex); + ApplyPart(ECharacterPartCategory::Mustache, BodySet.Mustaches, Appearance.MustacheIndex); + + // ------------------------------------------------------------------ + // Les yeux : un MATERIAU sur un slot du mesh de tete, pas un mesh. Il se + // pose ici et pas dans ApplyColors parce qu'un SetMaterial jette le + // materiau dynamique en place -- le refaire a chaque image en engendrerait + // un par frame, exactement ce que la separation des deux etapes evite. + // ------------------------------------------------------------------ + + const int32 HeadSlot = static_cast(ECharacterPartCategory::Head); + if (PartComponents.IsValidIndex(HeadSlot) && PartComponents[HeadSlot] + && Catalog->EyeMaterials.IsValidIndex(Appearance.EyesIndex)) + { + if (UMaterialInterface* EyeMaterial = Catalog->EyeMaterials[Appearance.EyesIndex]) + { + USkeletalMeshComponent* HeadComponent = PartComponents[HeadSlot]; + const int32 EyeSlot = FindEyeMaterialSlot(HeadComponent, BodySet.EyeMaterialSlotHint); + if (EyeSlot != INDEX_NONE) + { + HeadComponent->SetMaterial(EyeSlot, EyeMaterial); + } + } + } +} + +int32 UCharacterAppearanceComponent::FindEyeMaterialSlot(const USkeletalMeshComponent* HeadComponent, FName SlotHint) +{ + if (!HeadComponent || SlotHint.IsNone()) + { + return INDEX_NONE; + } + + // Contains et pas une egalite : les six tetes du pack nomment leur slot + // differemment (`M_Eye_A_01`, `M_Eye`, `M_Eyes_02`), mais toutes contiennent + // le fragment -- et aucun slot de peau ne le contient. + const FString Hint = SlotHint.ToString(); + const TArray SlotNames = HeadComponent->GetMaterialSlotNames(); + + for (int32 i = 0; i < SlotNames.Num(); ++i) + { + if (SlotNames[i].ToString().Contains(Hint, ESearchCase::IgnoreCase)) + { + return i; + } + } + + // Rien pose plutot qu'un index au hasard : ecraser le slot de la peau + // repeindrait le visage entier de la couleur des yeux, et on chercherait + // longtemps d'ou ca vient. Le log, lui, se lit tout de suite. + UE_LOG(LogTemp, Warning, + TEXT("UCharacterAppearanceComponent : aucun slot de materiau ne contient '%s' sur le mesh de tete. ") + TEXT("Les yeux ne seront pas poses. Slots disponibles : %s"), + *Hint, + *FString::JoinBy(SlotNames, TEXT(", "), [](const FName& Name) { return Name.ToString(); })); + + return INDEX_NONE; +} + +void UCharacterAppearanceComponent::ApplyColors(const FCharacterBodySet& BodySet) +{ + // Chaque parametre est pose sur TOUS les elements de chaque mesh, sans + // chercher lequel le porte : un SetVectorParameterValue dont le parametre + // n'existe pas dans le materiau est simplement ignore. Le slot des yeux + // ignore donc « Skin Base color », et celui de la peau « Eye color ». Cela + // evite de tenir a jour une table d'index de slots qui casserait au premier + // mesh dont l'ordre des materiaux differe. + + // Le blanc en repli et pas le noir : un materiau teinte par une couleur + // manquante doit rester lisible. Le blanc rend la texture telle qu'elle est, + // le noir donnerait un personnage en silhouette qu'on prendrait pour un bug + // de rendu plutot que pour une palette vide. + auto PickColor = [](const TArray& Palette, uint8 Index) + { + return Palette.IsValidIndex(Index) ? Palette[Index] : FLinearColor::White; + }; + + const FLinearColor SkinColor = PickColor(Catalog->SkinColors, Appearance.SkinColorIndex); + const FLinearColor HairColor = PickColor(Catalog->HairColors, Appearance.HairColorIndex); + const FLinearColor EyeColor = PickColor(Catalog->EyeColors, Appearance.EyeColorIndex); + const FLinearColor UnderwearColor = PickColor(Catalog->UnderwearColors, Appearance.UnderwearColorIndex); + + // Le corps : peau et sous-vetement partagent le meme master. + TintAllElements(TargetMesh, Catalog->SkinColorParameter, SkinColor); + TintAllElements(TargetMesh, Catalog->UnderwearColorParameter, UnderwearColor); + + for (USkeletalMeshComponent* Segment : ExtraBodyComponents) + { + TintAllElements(Segment, Catalog->SkinColorParameter, SkinColor); + TintAllElements(Segment, Catalog->UnderwearColorParameter, UnderwearColor); + } + + // La tete porte la peau ET les yeux, sur deux masters differents. + const int32 HeadSlot = static_cast(ECharacterPartCategory::Head); + if (PartComponents.IsValidIndex(HeadSlot)) + { + TintAllElements(PartComponents[HeadSlot], Catalog->HeadSkinColorParameter, SkinColor); + TintAllElements(PartComponents[HeadSlot], Catalog->EyeColorParameter, EyeColor); + } + + // Cheveux, sourcils, barbe et moustache descendent tous de M_Hairstyle_Base : + // un seul parametre les teint ensemble, ce qui est aussi le comportement + // qu'on veut -- une barbe blonde sur des cheveux bruns se remarque. + static const ECharacterPartCategory HairLikeCategories[] = { + ECharacterPartCategory::Hair, + ECharacterPartCategory::Eyebrows, + ECharacterPartCategory::Beard, + ECharacterPartCategory::Mustache + }; + + for (const ECharacterPartCategory Category : HairLikeCategories) + { + const int32 Index = static_cast(Category); + if (PartComponents.IsValidIndex(Index)) + { + TintAllElements(PartComponents[Index], Catalog->HairColorParameter, HairColor); + } + } +} + +void UCharacterAppearanceComponent::TintAllElements(USkeletalMeshComponent* MeshComponent, FName Parameter, const FLinearColor& Color) +{ + if (!MeshComponent || Parameter.IsNone() || !MeshComponent->GetSkeletalMeshAsset()) + { + return; + } + + const int32 ElementCount = MeshComponent->GetNumMaterials(); + for (int32 Element = 0; Element < ElementCount; ++Element) + { + // Rend le materiau dynamique DEJA en place s'il y en a un, et n'en cree + // un que la premiere fois (verifie dans PrimitiveComponent.cpp). C'est + // ce qui rend cette fonction sure a appeler par image. + if (UMaterialInstanceDynamic* Dynamic = MeshComponent->CreateAndSetMaterialInstanceDynamic(Element)) + { + Dynamic->SetVectorParameterValue(Parameter, Color); + } + } +} + +// ---------------------------------------------------------------------- +// Reglage par nom, appele par les commandes console d'AFpsPlayer +// ---------------------------------------------------------------------- + +void UCharacterAppearanceComponent::ApplyPartByName(const FString& Part, int32 Index) +{ + if (!Catalog) + { + UE_LOG(LogTemp, Warning, TEXT("EmberPart : aucun catalogue assigne.")); + return; + } + + FCharacterAppearance Next = Appearance; + + if (Part.Equals(TEXT("body"), ESearchCase::IgnoreCase)) + { + Next.BodyType = static_cast(FMath::Clamp(Index, 0, static_cast(ECharacterBodyType::Count) - 1)); + } + else if (Part.Equals(TEXT("eyes"), ESearchCase::IgnoreCase)) + { + Next.EyesIndex = static_cast(FMath::Max(0, Index)); + } + else + { + ECharacterPartCategory Category; + if (!AppearanceParsePartCategory(Part, Category)) + { + UE_LOG(LogTemp, Warning, TEXT("EmberPart : categorie inconnue '%s'. Attendu : body, head, hair, eyebrows, beard, mustache, eyes."), *Part); + return; + } + + if (uint8* Target = AppearancePartIndexPtr(Next, Category)) + { + // Un index negatif retire la piece : c'est la seule facon de se + // raser la barbe depuis la console, NoPart valant 255. + *Target = (Index < 0) ? FCharacterAppearance::NoPart : static_cast(Index); + } + } + + ApplyAppearance(Next); + + // Apres l'application, donc apres Sanitize : on affiche ce qui a REELLEMENT + // ete pose, pas ce qui a ete demande. Un index hors bornes se lit alors tout + // de suite, au lieu de laisser croire que la commande n'a rien fait. + UE_LOG(LogTemp, Log, TEXT("EmberPart %s %d -> applique."), *Part, Index); + LogAppearance(); +} + +void UCharacterAppearanceComponent::ApplyColorByName(const FString& Part, int32 ColorIndex) +{ + FCharacterAppearance Next = Appearance; + + const uint8 Index = static_cast(FMath::Max(0, ColorIndex)); + + if (Part.Equals(TEXT("skin"), ESearchCase::IgnoreCase)) + { + Next.SkinColorIndex = Index; + } + else if (Part.Equals(TEXT("hair"), ESearchCase::IgnoreCase)) + { + Next.HairColorIndex = Index; + } + else if (Part.Equals(TEXT("eye"), ESearchCase::IgnoreCase)) + { + Next.EyeColorIndex = Index; + } + else if (Part.Equals(TEXT("underwear"), ESearchCase::IgnoreCase)) + { + Next.UnderwearColorIndex = Index; + } + else + { + UE_LOG(LogTemp, Warning, TEXT("EmberColor : categorie inconnue '%s'. Attendu : skin, hair, eye, underwear."), *Part); + return; + } + + ApplyAppearance(Next); + LogAppearance(); +} + +void UCharacterAppearanceComponent::LogAppearance() const +{ + if (!Catalog) + { + UE_LOG(LogTemp, Warning, TEXT("EmberAppearanceDump : aucun catalogue assigne.")); + return; + } + + const ECharacterBodyType Body = Appearance.BodyType; + + UE_LOG(LogTemp, Log, TEXT("--- Apparence de %s ---"), *GetNameSafe(GetOwner())); + UE_LOG(LogTemp, Log, TEXT(" body = %d (0..%d)"), static_cast(Body), static_cast(ECharacterBodyType::Count) - 1); + UE_LOG(LogTemp, Log, TEXT(" head = %d (%d options)"), Appearance.HeadIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Head)); + UE_LOG(LogTemp, Log, TEXT(" hair = %d (%d options)"), Appearance.HairIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Hair)); + UE_LOG(LogTemp, Log, TEXT(" eyebrows = %d (%d options)"), Appearance.EyebrowsIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Eyebrows)); + UE_LOG(LogTemp, Log, TEXT(" beard = %d (%d options)"), Appearance.BeardIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Beard)); + UE_LOG(LogTemp, Log, TEXT(" mustache = %d (%d options)"), Appearance.MustacheIndex, Catalog->GetPartCount(Body, ECharacterPartCategory::Mustache)); + UE_LOG(LogTemp, Log, TEXT(" eyes = %d (%d options)"), Appearance.EyesIndex, Catalog->EyeMaterials.Num()); + UE_LOG(LogTemp, Log, TEXT(" skin = couleur %d (%d couleurs)"), Appearance.SkinColorIndex, Catalog->SkinColors.Num()); + UE_LOG(LogTemp, Log, TEXT(" hairColor = couleur %d (%d couleurs)"), Appearance.HairColorIndex, Catalog->HairColors.Num()); + UE_LOG(LogTemp, Log, TEXT(" eyeColor = couleur %d (%d couleurs)"), Appearance.EyeColorIndex, Catalog->EyeColors.Num()); + UE_LOG(LogTemp, Log, TEXT(" underwear = couleur %d (%d couleurs)"), Appearance.UnderwearColorIndex, Catalog->UnderwearColors.Num()); + UE_LOG(LogTemp, Log, TEXT(" 255 = aucune piece (barbe rasee, crane nu)")); +} diff --git a/Source/Survival_projet/Private/CharacterPartsDataAsset.cpp b/Source/Survival_projet/Private/CharacterPartsDataAsset.cpp new file mode 100644 index 0000000..7ac5be6 --- /dev/null +++ b/Source/Survival_projet/Private/CharacterPartsDataAsset.cpp @@ -0,0 +1,97 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "CharacterPartsDataAsset.h" + +namespace +{ + /** + * Rendue par GetParts quand la categorie n'existe pas, pour que l'appelant + * n'ait jamais a tester un pointeur. + * + * Le nom porte le prefixe du fichier : le build unity concatene les .cpp du + * module et un namespace anonyme n'isole plus rien. Une constante nommee + * "EmptyParts" tout court finirait par entrer en collision -- invisible en + * build incremental, elle ne sortirait qu'au premier -Rebuild. + */ + const TArray CharacterPartsEmptyList; + + /** Borne un index de piece OBLIGATOIRE : hors bornes, on retombe sur 0. */ + void ClampRequiredIndex(uint8& Index, int32 Count) + { + if (Count <= 0 || Index >= Count) + { + Index = 0; + } + } + + /** Borne un index de piece FACULTATIVE : hors bornes, on retire la piece. */ + void ClampOptionalIndex(uint8& Index, int32 Count) + { + if (Index == FCharacterAppearance::NoPart) + { + return; + } + + if (Count <= 0 || Index >= Count) + { + Index = FCharacterAppearance::NoPart; + } + } +} + +const FCharacterBodySet* UCharacterPartsDataAsset::GetBodySet(ECharacterBodyType BodyType) const +{ + const int32 Index = static_cast(BodyType); + return BodySets.IsValidIndex(Index) ? &BodySets[Index] : nullptr; +} + +const TArray& UCharacterPartsDataAsset::GetParts(ECharacterBodyType BodyType, ECharacterPartCategory Category) const +{ + const FCharacterBodySet* Set = GetBodySet(BodyType); + if (!Set) + { + return CharacterPartsEmptyList; + } + + switch (Category) + { + case ECharacterPartCategory::Head: return Set->Heads; + case ECharacterPartCategory::Hair: return Set->Hairstyles; + case ECharacterPartCategory::Eyebrows: return Set->Eyebrows; + case ECharacterPartCategory::Beard: return Set->Beards; + case ECharacterPartCategory::Mustache: return Set->Mustaches; + default: return CharacterPartsEmptyList; + } +} + +int32 UCharacterPartsDataAsset::GetPartCount(ECharacterBodyType BodyType, ECharacterPartCategory Category) const +{ + return GetParts(BodyType, Category).Num(); +} + +void UCharacterPartsDataAsset::Sanitize(FCharacterAppearance& InOut) const +{ + // La silhouette d'abord : tout le reste est indexe DANS son jeu de pieces, + // donc la borner apres ne servirait a rien. + if (static_cast(InOut.BodyType) >= BodySets.Num()) + { + InOut.BodyType = ECharacterBodyType::BodyA; + } + + ClampRequiredIndex(InOut.HeadIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Head)); + ClampRequiredIndex(InOut.EyebrowsIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Eyebrows)); + + // Cheveux, barbe et moustache sont facultatifs : un crane rase est un choix, + // pas une erreur. + ClampOptionalIndex(InOut.HairIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Hair)); + ClampOptionalIndex(InOut.BeardIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Beard)); + ClampOptionalIndex(InOut.MustacheIndex, GetPartCount(InOut.BodyType, ECharacterPartCategory::Mustache)); + + ClampRequiredIndex(InOut.EyesIndex, EyeMaterials.Num()); + + ClampRequiredIndex(InOut.SkinColorIndex, SkinColors.Num()); + ClampRequiredIndex(InOut.HairColorIndex, HairColors.Num()); + ClampRequiredIndex(InOut.EyeColorIndex, EyeColors.Num()); + ClampRequiredIndex(InOut.UnderwearColorIndex, UnderwearColors.Num()); +} diff --git a/Source/Survival_projet/Private/FpsPlayer.cpp b/Source/Survival_projet/Private/FpsPlayer.cpp index 35603db..d52c86a 100644 --- a/Source/Survival_projet/Private/FpsPlayer.cpp +++ b/Source/Survival_projet/Private/FpsPlayer.cpp @@ -15,6 +15,7 @@ #include "GameFramework/CharacterMovementComponent.h" #include "GameFramework/PlayerController.h" #include "InputActionValue.h" +#include "CharacterAppearanceComponent.h" #include "CraftingComponent.h" #include "HeadBobComponent.h" #include "InteractionComponent.h" @@ -107,6 +108,7 @@ AFpsPlayer::AFpsPlayer() InventoryComponent = CreateDefaultSubobject(TEXT("InventoryComponent")); CraftingComponent = CreateDefaultSubobject(TEXT("CraftingComponent")); SurvivalStats = CreateDefaultSubobject(TEXT("SurvivalStats")); + AppearanceComponent = CreateDefaultSubobject(TEXT("AppearanceComponent")); // En vue FPS on ne veut pas voir son propre corps (mesh 3e personne), // mais on garde son ombre. @@ -360,6 +362,36 @@ void AFpsPlayer::AddCrouchTilt(float Direction) CrouchTiltSpring.AddImpulse(Direction * UE_EULERS_NUMBER * Omega); } +// ---------------------------------------------------------------------- +// Commandes console d'apparence -- de simples delegations. Voir le commentaire +// de FpsPlayer.h : les UFUNCTION(Exec) d'un composant ne sont jamais routees, +// le pawn est le maillon le plus proche qui le soit. +// ---------------------------------------------------------------------- + +void AFpsPlayer::EmberPart(const FString& Part, int32 Index) +{ + if (AppearanceComponent) + { + AppearanceComponent->ApplyPartByName(Part, Index); + } +} + +void AFpsPlayer::EmberColor(const FString& Part, int32 ColorIndex) +{ + if (AppearanceComponent) + { + AppearanceComponent->ApplyColorByName(Part, ColorIndex); + } +} + +void AFpsPlayer::EmberAppearanceDump() +{ + if (AppearanceComponent) + { + AppearanceComponent->LogAppearance(); + } +} + void AFpsPlayer::AddCameraPunch(float Strength) { // Le reglage s'applique ICI et pas sur chaque source d'impact : tout ce qui diff --git a/Source/Survival_projet/Public/CharacterAppearanceComponent.h b/Source/Survival_projet/Public/CharacterAppearanceComponent.h new file mode 100644 index 0000000..4eb556f --- /dev/null +++ b/Source/Survival_projet/Public/CharacterAppearanceComponent.h @@ -0,0 +1,165 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "CharacterAppearanceTypes.h" +#include "CharacterAppearanceComponent.generated.h" + +class UCharacterPartsDataAsset; +class USkeletalMeshComponent; +struct FCharacterBodySet; + +/** Diffuse a chaque apparence appliquee, pour que l'UI se rafraichisse. */ +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnAppearanceApplied); + +/** + * Monte un personnage a partir d'une FCharacterAppearance : assigne les meshes, + * les met en leader pose, et pose les couleurs sur des materiaux dynamiques. + * + * Un systeme = un composant, comme le reste du projet. Mais celui-ci a une + * particularite qui vaut d'etre tenue : il ne suppose PAS vivre sur le pawn. Il + * cherche le mesh d'un ACharacter s'il en trouve un, accepte qu'on lui en + * designe un autre, et fonctionne donc a l'identique sur le mannequin de l'ecran + * de customisation. C'est ce qui garantit que le personnage montre au joueur et + * celui que ses amis verront ne peuvent pas diverger : c'est le meme code. + * + * Il ne replique rien -- l'apparence vivra sur ASurvivalPlayerState, comme le + * pseudo, et pour la meme raison : elle doit survivre a la mort du pawn. + */ +UCLASS(ClassGroup = (Survival), meta = (BlueprintSpawnableComponent)) +class SURVIVAL_PROJET_API UCharacterAppearanceComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + UCharacterAppearanceComponent(); + + virtual void BeginPlay() override; + + UPROPERTY(BlueprintAssignable, Category = "Appearance") + FOnAppearanceApplied OnAppearanceApplied; + + /** Ton DA_CharacterParts. Sans lui le composant ne monte rien et le dit. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Appearance") + TObjectPtr Catalog; + + /** + * L'apparence posee au BeginPlay, tant que rien ne l'a remplacee. + * + * Elle sert au mannequin de l'ecran, aux tests en PIE, et de repli si les + * reglages du joueur sont vides -- c'est-a-dire au tout premier lancement. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Appearance") + FCharacterAppearance DefaultAppearance; + + /** + * Designe le mesh porteur. A appeler AVANT la premiere application. + * + * Inutile sur un ACharacter, dont le mesh est trouve tout seul. Necessaire + * sur un acteur quelconque -- le mannequin de l'ecran de customisation. + */ + UFUNCTION(BlueprintCallable, Category = "Appearance") + void SetTargetMesh(USkeletalMeshComponent* InTargetMesh); + + /** + * Monte l'apparence demandee. Idempotente, et bon marche a la repetition : + * les meshes ne sont retouches que si une PIECE a change, jamais pour une + * simple couleur. C'est ce qui permet a un curseur d'appeler cette fonction + * a chaque image sans engendrer un materiau par frame. + */ + UFUNCTION(BlueprintCallable, Category = "Appearance") + void ApplyAppearance(const FCharacterAppearance& InAppearance); + + UFUNCTION(BlueprintPure, Category = "Appearance") + const FCharacterAppearance& GetAppearance() const { return Appearance; } + + // ------------------------------------------------------------------ + // Reglage par nom -- ce qui permet de tester le montage avant qu'il + // existe la moindre interface. + // + // Ces fonctions ne portent PAS le specificateur Exec, et ce n'est pas un + // oubli : la chaine de routage des commandes console (voir Player.cpp) + // 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 pour les + // parcourir. Une UFUNCTION(Exec) posee ici serait silencieusement ignoree. + // Les commandes vivent donc sur AFpsPlayer, qui delegue ici. + // ------------------------------------------------------------------ + + /** + * Cherche, parmi les slots de materiau du mesh de tete, celui dont le nom + * contient SlotHint. INDEX_NONE si aucun -- l'appelant ne pose alors rien. + */ + static int32 FindEyeMaterialSlot(const USkeletalMeshComponent* HeadComponent, FName SlotHint); + + /** Categories : body, head, hair, eyebrows, beard, mustache, eyes. */ + UFUNCTION(BlueprintCallable, Category = "Appearance|Debug") + void ApplyPartByName(const FString& Part, int32 Index); + + /** Categories : skin, hair, eye, underwear. */ + UFUNCTION(BlueprintCallable, Category = "Appearance|Debug") + void ApplyColorByName(const FString& Part, int32 ColorIndex); + + /** Ecrit l'apparence courante et le nombre d'options de chaque categorie. */ + UFUNCTION(BlueprintCallable, Category = "Appearance|Debug") + void LogAppearance() const; + +protected: + virtual void OnUnregister() override; + +private: + /** + * Trouve le mesh porteur : celui de l'ACharacter en priorite, sinon le + * premier SkeletalMesh de l'acteur. + * + * L'ordre compte et n'est pas anodin : AFpsPlayer porte AUSSI FirstPersonArms, + * et prendre « le premier trouve » y monterait la tete du personnage sur les + * bras vus a l'ecran. + */ + USkeletalMeshComponent* ResolveTargetMesh(); + + /** Cree le composant d'une categorie s'il manque, et le rend. */ + USkeletalMeshComponent* EnsurePartComponent(ECharacterPartCategory Category); + + /** Cree un composant enfant deja attache, leader-pose et configure. */ + USkeletalMeshComponent* CreateFollowerComponent(FName ComponentName); + + /** Assigne les meshes de corps, l'AnimBP, les pieces et le materiau d'iris. */ + void RebuildMeshes(const FCharacterBodySet& BodySet); + + /** Pose les quatre couleurs sur les materiaux dynamiques. */ + void ApplyColors(const FCharacterBodySet& BodySet); + + /** Pose un parametre vectoriel sur TOUS les elements d'un mesh. */ + static void TintAllElements(USkeletalMeshComponent* MeshComponent, FName Parameter, const FLinearColor& Color); + + /** Le mesh porteur, leader de tous les autres. */ + UPROPERTY(Transient) + TObjectPtr TargetMesh; + + /** + * Un composant par categorie, indexe par ECharacterPartCategory. + * + * Un tableau plutot qu'un membre nomme par piece : le montage tient alors + * dans une boucle, et ajouter une categorie ne demande qu'une valeur + * d'enum. Une case peut rester nulle -- une silhouette sans barbe n'engendre + * jamais le composant correspondant. + */ + UPROPERTY(Transient) + TArray> PartComponents; + + /** + * Les morceaux de corps au-dela du porteur. Vide aujourd'hui : le catalogue + * n'a qu'un mesh par silhouette. C'est ici qu'atterriront les segments le + * jour des armures, sans que la logique change. + */ + UPROPERTY(Transient) + TArray> ExtraBodyComponents; + + FCharacterAppearance Appearance; + + /** Faux tant qu'aucun montage n'a eu lieu : force la premiere reconstruction. */ + bool bBuilt = false; +}; diff --git a/Source/Survival_projet/Public/CharacterAppearanceTypes.h b/Source/Survival_projet/Public/CharacterAppearanceTypes.h new file mode 100644 index 0000000..f6690f6 --- /dev/null +++ b/Source/Survival_projet/Public/CharacterAppearanceTypes.h @@ -0,0 +1,143 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "CharacterAppearanceTypes.generated.h" + +class USkeletalMesh; +class UTexture2D; + +/** Les deux silhouettes livrees par le pack. */ +UENUM(BlueprintType) +enum class ECharacterBodyType : uint8 +{ + BodyA UMETA(DisplayName = "Body A"), + BodyB UMETA(DisplayName = "Body B"), + + /** Sentinelle, comme ECraftingStation::Count. Jamais une valeur valide. */ + Count UMETA(Hidden) +}; + +/** + * Les emplacements de pieces montees sur le corps. + * + * Sert d'index dans le tableau de composants du monteur : une seule fonction de + * montage plutot qu'un membre nomme par piece, et ajouter une categorie ne + * demande qu'une entree ici. + */ +UENUM(BlueprintType) +enum class ECharacterPartCategory : uint8 +{ + Head, + Hair, + Eyebrows, + Beard, + Mustache, + + Count UMETA(Hidden) +}; + +/** + * Une entree du catalogue : la piece que le joueur choisit dans une categorie. + */ +USTRUCT(BlueprintType) +struct FCharacterPartEntry +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Part") + TObjectPtr Mesh; + + /** + * La vignette du bouton, dans la grille de l'ecran de customisation. + * Meme type que l'icone d'UItemDataAsset, pour que les deux grilles du jeu + * se dessinent de la meme facon. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Part") + TObjectPtr Icon; +}; + +/** + * L'apparence d'un personnage, en 11 octets. + * + * Des INDEX et jamais des chemins d'assets, pour deux raisons qui vont dans le + * meme sens : un chemin coute une chaine de caracteres a chaque replication, + * alors qu'un index tient dans un octet ; et un index se VALIDE contre le + * catalogue, quand un chemin envoye par un client pourrait designer n'importe + * quel asset du jeu. + * + * Les couleurs suivent la meme logique : un index dans la palette, pas un + * FLinearColor. C'est ce qui permet a la struct entiere de tenir dans une + * poignee d'octets, et de se sauvegarder telle quelle dans le .ini de + * USurvivalUserSettings. + */ +USTRUCT(BlueprintType) +struct FCharacterAppearance +{ + GENERATED_BODY() + + /** Index qui signifie « aucune piece » -- barbe rasee, crane nu. */ + static constexpr uint8 NoPart = 255; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + ECharacterBodyType BodyType = ECharacterBodyType::BodyA; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + uint8 HeadIndex = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + uint8 HairIndex = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + uint8 EyebrowsIndex = 0; + + /** NoPart par defaut : un visage glabre est le point de depart naturel. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + uint8 BeardIndex = NoPart; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + uint8 MustacheIndex = NoPart; + + /** + * Les yeux ne sont pas un mesh mais un MATERIAU pose sur un slot du mesh de + * tete -- le pack ne livre aucun `SKM_Eyes`. D'ou une categorie a part, + * absente d'ECharacterPartCategory qui ne parle que de composants. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + uint8 EyesIndex = 0; + + // -------------------------------------------------------------- + // Couleurs : un index dans la palette correspondante du catalogue. + // Un bouton de l'ecran = un index, sans reglage intermediaire. + // -------------------------------------------------------------- + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance|Colors") + uint8 SkinColorIndex = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance|Colors") + uint8 HairColorIndex = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance|Colors") + uint8 EyeColorIndex = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance|Colors") + uint8 UnderwearColorIndex = 0; + + bool operator==(const FCharacterAppearance& Other) const + { + return BodyType == Other.BodyType + && HeadIndex == Other.HeadIndex + && HairIndex == Other.HairIndex + && EyebrowsIndex == Other.EyebrowsIndex + && BeardIndex == Other.BeardIndex + && MustacheIndex == Other.MustacheIndex + && EyesIndex == Other.EyesIndex + && SkinColorIndex == Other.SkinColorIndex + && HairColorIndex == Other.HairColorIndex + && EyeColorIndex == Other.EyeColorIndex + && UnderwearColorIndex == Other.UnderwearColorIndex; + } + + bool operator!=(const FCharacterAppearance& Other) const { return !(*this == Other); } +}; diff --git a/Source/Survival_projet/Public/CharacterPartsDataAsset.h b/Source/Survival_projet/Public/CharacterPartsDataAsset.h new file mode 100644 index 0000000..931e04c --- /dev/null +++ b/Source/Survival_projet/Public/CharacterPartsDataAsset.h @@ -0,0 +1,194 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "CharacterAppearanceTypes.h" +#include "CharacterPartsDataAsset.generated.h" + +class UAnimInstance; +class UMaterialInterface; +class USkeletalMesh; +class UTexture2D; + +/** + * Tout ce qui compose une silhouette : ses meshes de corps, son AnimBP, et les + * pieces disponibles pour chaque categorie. + */ +USTRUCT(BlueprintType) +struct FCharacterBodySet +{ + GENERATED_BODY() + + /** La vignette du bouton de silhouette, meme role que celle d'une piece. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Body") + TObjectPtr Icon; + + /** + * Les morceaux du corps, dans l'ordre. Le PREMIER est le porteur : c'est lui + * qui va sur le composant Mesh du Character et qui joue l'animation, les + * suivants deviennent des composants enfants en leader pose. + * + * Une LISTE alors qu'il n'y a qu'une case aujourd'hui, et ce n'est pas de la + * sur-conception : le pack livre exprès `SKM_BodyA_torso`, `_arms`, + * `_legs_01/02/03`, `_hands`, `_feet`, precisement pour qu'une armure puisse + * eteindre les segments qu'elle recouvre. Le jour venu, la case 0 deviendra + * `SKM_BodyA_empty` -- invisible, porteur d'animation -- et les huit segments + * suivront derriere, SANS que ce code change. Un TObjectPtr unique, lui, + * imposerait de tout reprendre au premier plastron. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Body") + TArray> BodyMeshes; + + /** L'AnimBP du porteur. Il change avec la silhouette, d'ou sa place ici. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Body") + TSubclassOf AnimClass; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts") + TArray Heads; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts") + TArray Hairstyles; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts") + TArray Eyebrows; + + /** Vide pour une silhouette qui n'en porte pas -- le pack n'en donne qu'a A. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts") + TArray Beards; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts") + TArray Mustaches; + + /** + * Fragment cherche dans le NOM des slots de materiau du mesh de tete pour y + * trouver celui des yeux. + * + * Les yeux ne sont pas un mesh : le pack les livre comme un element du mesh + * de tete. Il ne suffit donc pas de poser un parametre de couleur, il faut + * REMPLACER le materiau de ce slot -- encore faut-il savoir lequel. + * + * Un fragment de nom et pas un index, parce qu'un index est faux deux fois : + * il oblige a le verifier a la main dans l'editeur, et il suppose surtout + * que les six tetes rangent leurs slots dans le MEME ordre. Les slots du + * pack s'appellent `M_Eye_A_01`, `M_Eye`, `M_Eyes_02`... tous contiennent + * « Eye », aucun slot de peau ne le contient. + * + * Si le fragment ne trouve rien, le materiau n'est PAS pose et un + * avertissement part dans le log. C'est volontaire : poser l'oeil sur un + * slot au hasard repeindrait le visage entier, un symptome qu'on met bien + * plus longtemps a diagnostiquer qu'une ligne de log. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts") + FName EyeMaterialSlotHint = TEXT("Eye"); +}; + +/** + * Le catalogue : la table de correspondance entre les index d'une + * FCharacterAppearance et les assets reels. + * + * Un seul asset a remplir, et le SEUL endroit a toucher pour ajouter une + * coiffure. C'est aussi lui qui borne ce qu'un client peut demander : une + * apparence arrivee du reseau est passee a Sanitize(), qui ramene tout index + * hors bornes a une valeur sure. + */ +UCLASS(BlueprintType) +class SURVIVAL_PROJET_API UCharacterPartsDataAsset : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + /** + * Une entree par valeur d'ECharacterBodyType, dans l'ordre de l'enum. + * L'ordre EST le contrat : c'est lui qui donne son sens a BodyType. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog") + TArray BodySets; + + // ------------------------------------------------------------------ + // Palettes : une couleur = un bouton a l'ecran, sans reglage entre les + // deux. L'ordre EST l'ordre d'affichage, et l'index qui part sur le reseau. + // + // Partagees par les deux silhouettes : deux nuanciers de peau a tenir a + // jour divergeraient au premier ajout. + // ------------------------------------------------------------------ + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes") + TArray SkinColors; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes") + TArray HairColors; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes") + TArray EyeColors; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes") + TArray UnderwearColors; + + /** Les materiaux d'iris, un par forme d'oeil proposee. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes") + TArray> EyeMaterials; + + // ------------------------------------------------------------------ + // Noms des parametres des materiaux du pack. + // + // Ici et pas en dur dans le C++ : ils appartiennent aux assets, pas au + // code. Changer de pack de personnages ne doit demander qu'un nouveau + // catalogue -- et ces noms contiennent des ESPACES, ce qu'on n'invente + // pas de memoire. + // ------------------------------------------------------------------ + + /** `M_Body_Base`. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters") + FName SkinColorParameter = TEXT("Skin color"); + + /** + * `M_Head_Base` -- le meme role que ci-dessus, mais l'autre master, et + * surtout un NOM DIFFERENT : `Skin` cote tete, `Skin color` cote corps. + * + * Le master contient bien la chaine « Skin Base color », mais ce n'est pas + * le nom du parametre expose -- verifie dans `MI_HeadA_01_a`, ou le chercher + * ne rend aucun resultat alors que `Skin` apparait dans le groupe + * « Base colors ». Morale : un nom de parametre se lit dans l'editeur du + * Material Instance, jamais dans les chaines du binaire du master. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters") + FName HeadSkinColorParameter = TEXT("Skin"); + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters") + FName UnderwearColorParameter = TEXT("Underware color"); + + /** `M_Hairstyle_Base`, parent des cheveux, barbes, moustaches ET sourcils. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters") + FName HairColorParameter = TEXT("Color"); + + /** `M_Eyes_Base`. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Material Parameters") + FName EyeColorParameter = TEXT("Eye color"); + + // ------------------------------------------------------------------ + // Lecture + // ------------------------------------------------------------------ + + /** Le jeu de pieces d'une silhouette, ou nullptr si l'index est hors bornes. */ + const FCharacterBodySet* GetBodySet(ECharacterBodyType BodyType) const; + + /** La liste d'une categorie pour une silhouette, vide si rien n'est defini. */ + const TArray& GetParts(ECharacterBodyType BodyType, ECharacterPartCategory Category) const; + + /** Nombre d'options d'une categorie. Sert a borner l'UI comme la console. */ + UFUNCTION(BlueprintPure, Category = "Catalog") + int32 GetPartCount(ECharacterBodyType BodyType, ECharacterPartCategory Category) const; + + /** + * Ramene une apparence dans les bornes du catalogue. + * + * Appelee sur le SERVEUR a la reception d'une apparence client, et au + * chargement des reglages -- un catalogue ampute entre deux versions du jeu + * laisserait sinon des index qui ne designent plus rien. Les pieces + * facultatives (barbe, moustache) retombent sur NoPart, les obligatoires + * sur 0 : mieux vaut la mauvaise tete que pas de tete. + */ + void Sanitize(FCharacterAppearance& InOut) const; +}; diff --git a/Source/Survival_projet/Public/FpsPlayer.h b/Source/Survival_projet/Public/FpsPlayer.h index b428a82..8a81922 100644 --- a/Source/Survival_projet/Public/FpsPlayer.h +++ b/Source/Survival_projet/Public/FpsPlayer.h @@ -9,6 +9,7 @@ class APickupItem; class UAnimMontage; class UCameraComponent; +class UCharacterAppearanceComponent; class UCraftingComponent; class UHeadBobComponent; class UInputAction; @@ -122,6 +123,37 @@ public: UFUNCTION(BlueprintPure, Category = "Survie") USurvivalStatsComponent* GetSurvivalStats() const { return SurvivalStats; } + UFUNCTION(BlueprintPure, Category = "Apparence") + UCharacterAppearanceComponent* GetAppearanceComponent() const { return AppearanceComponent; } + + // ------------------------------------------------------------------ + // Commandes console d'apparence + // + // Elles vivent ICI et non sur UCharacterAppearanceComponent, ou leur place + // serait pourtant plus naturelle : la chaine de routage des commandes + // (UPlayer::Exec, dans Player.cpp) 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 pour les parcourir. Une UFUNCTION(Exec) posee sur un + // composant est silencieusement ignoree, sans le moindre message. + // + // Le pawn est le maillon le plus proche du composant dans cette chaine, + // d'ou ces trois delegations d'une ligne. Elles disparaitront le jour ou + // l'ecran de customisation existera. + // ------------------------------------------------------------------ + + /** `EmberPart hair 3` -- body, head, hair, eyebrows, beard, mustache, eyes. */ + UFUNCTION(Exec, Category = "Apparence") + void EmberPart(const FString& Part, int32 Index); + + /** `EmberColor skin 2` -- skin, hair, eye, underwear. Index dans la palette. */ + UFUNCTION(Exec, Category = "Apparence") + void EmberColor(const FString& Part, int32 ColorIndex); + + /** Ecrit l'apparence courante et le nombre d'options de chaque categorie. */ + UFUNCTION(Exec, Category = "Apparence") + void EmberAppearanceDump(); + /** Sert a l'UI pour afficher la vraie touche mappee, plutot qu'un "E" ecrit en dur. */ UFUNCTION(BlueprintPure, Category = "Input") const UInputAction* GetInteractAction() const { return InteractAction; } @@ -276,6 +308,18 @@ protected: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Survie") TObjectPtr SurvivalStats; + /** + * Monte la tete, les cheveux, la barbe et les couleurs sur le mesh 3e + * personne. + * + * Sur le pawn et non sur le controller, alors que l'apparence, elle, vivra + * sur le PlayerState : ce composant ne STOCKE rien, il assemble un corps. + * Or c'est le corps qui meurt et reapparait, et un nouveau pawn doit se + * remonter tout seul. + */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Apparence") + TObjectPtr AppearanceComponent; + // ------------------------------------------------------------------ // Enhanced Input : assets à assigner dans le Blueprint // ------------------------------------------------------------------