(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>
This commit is contained in:
2026-08-08 18:48:37 +02:00
parent 6d22ae561e
commit 5ba5fbfebf
10 changed files with 1378 additions and 2 deletions
+140
View File
@@ -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<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.
Binary file not shown.
Binary file not shown.
@@ -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<int32>(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<int32>(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<ACharacter>(Owner))
{
TargetMesh = OwningCharacter->GetMesh();
}
if (!TargetMesh)
{
TargetMesh = Owner->FindComponentByClass<USkeletalMeshComponent>();
}
return TargetMesh;
}
USkeletalMeshComponent* UCharacterAppearanceComponent::CreateFollowerComponent(FName ComponentName)
{
AActor* Owner = GetOwner();
if (!Owner || !TargetMesh)
{
return nullptr;
}
USkeletalMeshComponent* Follower = NewObject<USkeletalMeshComponent>(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<int32>(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<FCharacterPartEntry>& 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<int32>(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<int32>(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<FName> 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<FLinearColor>& 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<int32>(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<int32>(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<ECharacterBodyType>(FMath::Clamp(Index, 0, static_cast<int32>(ECharacterBodyType::Count) - 1));
}
else if (Part.Equals(TEXT("eyes"), ESearchCase::IgnoreCase))
{
Next.EyesIndex = static_cast<uint8>(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<uint8>(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<uint8>(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<int32>(Body), static_cast<int32>(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)"));
}
@@ -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<FCharacterPartEntry> 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<int32>(BodyType);
return BodySets.IsValidIndex(Index) ? &BodySets[Index] : nullptr;
}
const TArray<FCharacterPartEntry>& 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<uint8>(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());
}
@@ -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<UInventoryComponent>(TEXT("InventoryComponent"));
CraftingComponent = CreateDefaultSubobject<UCraftingComponent>(TEXT("CraftingComponent"));
SurvivalStats = CreateDefaultSubobject<USurvivalStatsComponent>(TEXT("SurvivalStats"));
AppearanceComponent = CreateDefaultSubobject<UCharacterAppearanceComponent>(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
@@ -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<UCharacterPartsDataAsset> 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<USkeletalMeshComponent> 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<TObjectPtr<USkeletalMeshComponent>> 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<TObjectPtr<USkeletalMeshComponent>> ExtraBodyComponents;
FCharacterAppearance Appearance;
/** Faux tant qu'aucun montage n'a eu lieu : force la premiere reconstruction. */
bool bBuilt = false;
};
@@ -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<USkeletalMesh> 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<UTexture2D> 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); }
};
@@ -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<UTexture2D> 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<TObjectPtr<USkeletalMesh>> BodyMeshes;
/** L'AnimBP du porteur. Il change avec la silhouette, d'ou sa place ici. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Body")
TSubclassOf<UAnimInstance> AnimClass;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
TArray<FCharacterPartEntry> Heads;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
TArray<FCharacterPartEntry> Hairstyles;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
TArray<FCharacterPartEntry> Eyebrows;
/** Vide pour une silhouette qui n'en porte pas -- le pack n'en donne qu'a A. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
TArray<FCharacterPartEntry> Beards;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Parts")
TArray<FCharacterPartEntry> 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<FCharacterBodySet> 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<FLinearColor> SkinColors;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
TArray<FLinearColor> HairColors;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
TArray<FLinearColor> EyeColors;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
TArray<FLinearColor> UnderwearColors;
/** Les materiaux d'iris, un par forme d'oeil proposee. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Catalog|Palettes")
TArray<TObjectPtr<UMaterialInterface>> 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<FCharacterPartEntry>& 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;
};
+44
View File
@@ -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<USurvivalStatsComponent> 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<UCharacterAppearanceComponent> AppearanceComponent;
// ------------------------------------------------------------------
// Enhanced Input : assets à assigner dans le Blueprint
// ------------------------------------------------------------------