// Fill out your copyright notice in the Description page of Project Settings. #include "CharacterCustomizationWidget.h" #include "CharacterPartsDataAsset.h" #include "Components/Button.h" #include "Components/PanelWidget.h" #include "CustomizationCategoryWidget.h" #include "CustomizationOptionWidget.h" #define LOCTEXT_NAMESPACE "CharacterCustomization" namespace CharacterCustomizationLabels { /** * Le libelle de chaque categorie, dans l'ordre de l'enum. * * Ici et pas en UPROPERTY : ce sont des textes d'interface, pas des * reglages. Les exposer inviterait a les vider par erreur, et une colonne * aux rangees anonymes est inutilisable. * * Nom de namespace unique dans TOUT le module : le build unity concatene * les .cpp, et un namespace anonyme n'isolerait rien. */ static FText Get(ECustomizationCategory Category) { switch (Category) { case ECustomizationCategory::Body: return LOCTEXT("CatBody", "Silhouette"); case ECustomizationCategory::Head: return LOCTEXT("CatHead", "Visage"); case ECustomizationCategory::Hair: return LOCTEXT("CatHair", "Cheveux"); case ECustomizationCategory::Eyebrows: return LOCTEXT("CatEyebrows", "Sourcils"); case ECustomizationCategory::Beard: return LOCTEXT("CatBeard", "Barbe"); case ECustomizationCategory::Mustache: return LOCTEXT("CatMustache", "Moustache"); case ECustomizationCategory::Eyes: return LOCTEXT("CatEyes", "Yeux"); case ECustomizationCategory::SkinColor: return LOCTEXT("CatSkinColor", "Teint"); case ECustomizationCategory::HairColor: return LOCTEXT("CatHairColor", "Couleur des cheveux"); case ECustomizationCategory::EyeColor: return LOCTEXT("CatEyeColor", "Couleur des yeux"); case ECustomizationCategory::UnderwearColor: return LOCTEXT("CatUnderwear", "Sous-vetement"); default: return FText::GetEmpty(); } } /** Une categorie de couleur remplit la grille de pastilles, pas de vignettes. */ static bool IsColor(ECustomizationCategory Category) { return Category >= ECustomizationCategory::SkinColor; } /** * Le cadrage a montrer. Seules la silhouette et le sous-vetement demandent * de reculer : tout le reste se juge sur le visage. */ static ECharacterPreviewFraming GetFraming(ECustomizationCategory Category) { const bool bWholeBody = (Category == ECustomizationCategory::Body) || (Category == ECustomizationCategory::SkinColor) || (Category == ECustomizationCategory::UnderwearColor); return bWholeBody ? ECharacterPreviewFraming::FullBody : ECharacterPreviewFraming::Face; } /** La categorie de pieces correspondante, pour interroger le catalogue. */ static ECharacterPartCategory ToPartCategory(ECustomizationCategory Category) { switch (Category) { case ECustomizationCategory::Head: return ECharacterPartCategory::Head; case ECustomizationCategory::Hair: return ECharacterPartCategory::Hair; case ECustomizationCategory::Eyebrows: return ECharacterPartCategory::Eyebrows; case ECustomizationCategory::Beard: return ECharacterPartCategory::Beard; default: return ECharacterPartCategory::Mustache; } } } UCharacterCustomizationWidget::UCharacterCustomizationWidget(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer) { // Sans ce drapeau, NativeOnKeyDown ne serait jamais appele -- et le // SetWidgetToFocus() pose par AMainMenuPlayerController::OpenCustomization // serait lui aussi sans effet : Slate REFUSE de focaliser un widget qui ne // declare pas supporter le focus clavier. SetIsFocusable(true); } void UCharacterCustomizationWidget::NativeOnInitialized() { Super::NativeOnInitialized(); // Sans ca, le clic-glisser qui fait tourner le personnage ne repond jamais : // un UUserWidget ne recoit NativeOnMouseButtonDown que si sa Visibility vaut // Visible, et le defaut d'un widget cree est SelfHitTestInvisible -- il // laisse passer les clics au lieu de les capter. Impose ici plutot que // laisse a la Designer, ou l'oubli serait silencieux. // // Les boutons ne sont pas prives de leurs clics pour autant : Slate teste // les enfants AVANT le parent, donc seul ce qui est lache dans le vide // arrive jusqu'ici -- c'est-a-dire sur le personnage. SetVisibility(ESlateVisibility::Visible); if (PlayButton) { PlayButton->OnClicked.AddDynamic(this, &UCharacterCustomizationWidget::HandlePlayClicked); } if (BackButton) { BackButton->OnClicked.AddDynamic(this, &UCharacterCustomizationWidget::HandleBackClicked); } } void UCharacterCustomizationWidget::NativeConstruct() { Super::NativeConstruct(); BuildCategories(); BuildOptions(); RefreshPreview(); // Le focus est repris ici et pas seulement par le SetWidgetToFocus du // controller : les deux chemins existent, mais celui-ci est le seul qui vaut // encore apres une reconstruction du widget. SetKeyboardFocus(); } void UCharacterCustomizationWidget::SetupCustomization(ACharacterPreviewActor* InPreview, const FCharacterAppearance& InAppearance) { Preview = InPreview; Appearance = InAppearance; // Systematique : un catalogue ampute entre deux versions du jeu laisserait // dans les reglages sauvegardes des index qui ne designent plus rien. if (Catalog) { Catalog->Sanitize(Appearance); } } void UCharacterCustomizationWidget::BuildCategories() { if (!CategoryList || !CategoryWidgetClass) { return; } CategoryList->ClearChildren(); CategoryWidgets.Reset(); CategoryWidgets.Reserve(static_cast(ECustomizationCategory::Count)); for (int32 i = 0; i < static_cast(ECustomizationCategory::Count); ++i) { const ECustomizationCategory Category = static_cast(i); // Une categorie sans option n'a pas de rangee : c'est ce qui fait // disparaitre « Barbe » et « Moustache » quand on passe sur le corps B, // pour lequel le pack n'en livre aucune. Une rangee qui ouvre une grille // vide se lit comme un bug. if (GetOptionCountForCategory(Category) <= 0) { continue; } UCustomizationCategoryWidget* Row = CreateWidget(this, CategoryWidgetClass); if (!Row) { continue; } Row->Setup(i, CharacterCustomizationLabels::Get(Category)); Row->OnCategoryClicked.AddDynamic(this, &UCharacterCustomizationWidget::HandleCategoryClicked); CategoryList->AddChild(Row); CategoryWidgets.Add(Row); } // La categorie active a pu disparaitre en changeant de silhouette : on // retombe sur la premiere rangee encore presente plutot que sur une grille // vide. if (GetOptionCountForCategory(ActiveCategory) <= 0 && CategoryWidgets.Num() > 0) { ActiveCategory = ECustomizationCategory::Body; } } void UCharacterCustomizationWidget::BuildOptions() { if (!OptionGrid || !OptionWidgetClass || !Catalog) { return; } OptionGrid->ClearChildren(); OptionWidgets.Reset(); const int32 Count = GetOptionCountForCategory(ActiveCategory); OptionWidgets.Reserve(Count); for (int32 i = 0; i < Count; ++i) { UCustomizationOptionWidget* Option = CreateWidget(this, OptionWidgetClass); if (!Option) { continue; } if (CharacterCustomizationLabels::IsColor(ActiveCategory)) { const TArray* Palette = nullptr; switch (ActiveCategory) { case ECustomizationCategory::SkinColor: Palette = &Catalog->SkinColors; break; case ECustomizationCategory::HairColor: Palette = &Catalog->HairColors; break; case ECustomizationCategory::EyeColor: Palette = &Catalog->EyeColors; break; default: Palette = &Catalog->UnderwearColors; break; } Option->SetupAsColor(i, (*Palette)[i]); } else if (ActiveCategory == ECustomizationCategory::Body) { Option->SetupAsPart(i, Catalog->BodySets.IsValidIndex(i) ? Catalog->BodySets[i].Icon : nullptr); } else if (ActiveCategory == ECustomizationCategory::Eyes) { // Les yeux n'ont pas de vignette : ce sont des materiaux, pas des // entrees de catalogue. La case retombe sur son numero. Option->SetupAsPart(i, nullptr); } else { const ECharacterPartCategory PartCategory = CharacterCustomizationLabels::ToPartCategory(ActiveCategory); const TArray& Entries = Catalog->GetParts(Appearance.BodyType, PartCategory); Option->SetupAsPart(i, Entries.IsValidIndex(i) ? Entries[i].Icon : nullptr); } Option->OnOptionClicked.AddDynamic(this, &UCharacterCustomizationWidget::HandleOptionClicked); OptionGrid->AddChild(Option); OptionWidgets.Add(Option); } RefreshSelection(); } void UCharacterCustomizationWidget::RefreshPreview() { if (Preview) { Preview->ApplyAppearance(Appearance); Preview->SetFraming(CharacterCustomizationLabels::GetFraming(ActiveCategory)); } } void UCharacterCustomizationWidget::RefreshSelection() { const int32 Current = GetCurrentIndexForCategory(ActiveCategory); for (int32 i = 0; i < OptionWidgets.Num(); ++i) { if (OptionWidgets[i]) { OptionWidgets[i]->SetSelected(OptionWidgets[i]->GetOptionIndex() == Current); } } for (UCustomizationCategoryWidget* Row : CategoryWidgets) { if (Row) { Row->SetSelected(false); } } // La rangee active n'est pas au meme rang que la categorie dans l'enum : // les categories vides ont ete sautees. On la retrouve par son libelle // d'index, pose dans Setup. const int32 ActiveIndex = static_cast(ActiveCategory); for (UCustomizationCategoryWidget* Row : CategoryWidgets) { if (Row && Row->GetCategoryIndex() == ActiveIndex) { Row->SetSelected(true); break; } } } int32 UCharacterCustomizationWidget::GetCurrentIndexForCategory(ECustomizationCategory Category) const { switch (Category) { case ECustomizationCategory::Body: return static_cast(Appearance.BodyType); case ECustomizationCategory::Head: return Appearance.HeadIndex; case ECustomizationCategory::Hair: return Appearance.HairIndex; case ECustomizationCategory::Eyebrows: return Appearance.EyebrowsIndex; case ECustomizationCategory::Beard: return Appearance.BeardIndex; case ECustomizationCategory::Mustache: return Appearance.MustacheIndex; case ECustomizationCategory::Eyes: return Appearance.EyesIndex; case ECustomizationCategory::SkinColor: return Appearance.SkinColorIndex; case ECustomizationCategory::HairColor: return Appearance.HairColorIndex; case ECustomizationCategory::EyeColor: return Appearance.EyeColorIndex; case ECustomizationCategory::UnderwearColor: return Appearance.UnderwearColorIndex; default: return 0; } } void UCharacterCustomizationWidget::SetIndexForCategory(ECustomizationCategory Category, int32 NewIndex) { const uint8 Value = static_cast(FMath::Clamp(NewIndex, 0, 254)); switch (Category) { case ECustomizationCategory::Body: Appearance.BodyType = static_cast(Value); break; case ECustomizationCategory::Head: Appearance.HeadIndex = Value; break; case ECustomizationCategory::Hair: Appearance.HairIndex = Value; break; case ECustomizationCategory::Eyebrows: Appearance.EyebrowsIndex = Value; break; case ECustomizationCategory::Beard: Appearance.BeardIndex = Value; break; case ECustomizationCategory::Mustache: Appearance.MustacheIndex = Value; break; case ECustomizationCategory::Eyes: Appearance.EyesIndex = Value; break; case ECustomizationCategory::SkinColor: Appearance.SkinColorIndex = Value; break; case ECustomizationCategory::HairColor: Appearance.HairColorIndex = Value; break; case ECustomizationCategory::EyeColor: Appearance.EyeColorIndex = Value; break; case ECustomizationCategory::UnderwearColor: Appearance.UnderwearColorIndex = Value; break; default: break; } } int32 UCharacterCustomizationWidget::GetOptionCountForCategory(ECustomizationCategory Category) const { if (!Catalog) { return 0; } switch (Category) { case ECustomizationCategory::Body: return Catalog->BodySets.Num(); case ECustomizationCategory::Eyes: return Catalog->EyeMaterials.Num(); case ECustomizationCategory::SkinColor: return Catalog->SkinColors.Num(); case ECustomizationCategory::HairColor: return Catalog->HairColors.Num(); case ECustomizationCategory::EyeColor: return Catalog->EyeColors.Num(); case ECustomizationCategory::UnderwearColor: return Catalog->UnderwearColors.Num(); default: return Catalog->GetPartCount(Appearance.BodyType, CharacterCustomizationLabels::ToPartCategory(Category)); } } void UCharacterCustomizationWidget::HandleCategoryClicked(int32 CategoryIndex) { ActiveCategory = static_cast(CategoryIndex); BuildOptions(); RefreshPreview(); } void UCharacterCustomizationWidget::HandleOptionClicked(int32 OptionIndex) { SetIndexForCategory(ActiveCategory, OptionIndex); // Changer de silhouette change TOUT le reste : les listes de tetes, de // coiffures et de sourcils appartiennent au corps, et le corps B n'a ni // barbe ni moustache. On rebatit donc la colonne entiere, apres avoir // laisse Sanitize ramener les index devenus hors bornes. if (ActiveCategory == ECustomizationCategory::Body) { if (Catalog) { Catalog->Sanitize(Appearance); } BuildCategories(); BuildOptions(); } else { RefreshSelection(); } RefreshPreview(); } void UCharacterCustomizationWidget::HandlePlayClicked() { OnConfirmed.Broadcast(); } void UCharacterCustomizationWidget::HandleBackClicked() { OnCancelled.Broadcast(); } FReply UCharacterCustomizationWidget::NativeOnKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent) { const FKey Key = InKeyEvent.GetKey(); // GetVirtualKey() et non la constante, depreciee en 5.8 : la resolution se // fait a l'appel, donc selon la manette reellement branchee. if (Key == EKeys::Escape || Key == EKeys::Virtual_Gamepad_Back.GetVirtualKey()) { // Exactement le bouton Retour, jamais un second chemin vers le menu : le // controller a un fondu et une bascule de camera a jouer au retour, et // c'est OnCancelled qui les declenche. HandleBackClicked(); // Handled : sans ca la touche continue de remonter et serait consommee // une seconde fois par ce qui se trouve derriere. return FReply::Handled(); } return Super::NativeOnKeyDown(InGeometry, InKeyEvent); } // ---------------------------------------------------------------------- // Rotation du personnage // // Sur le widget plein ecran et pas sur une zone dediee : les boutons de la // colonne consomment deja leurs propres clics, donc tout ce qui arrive ici a // forcement ete lache dans le vide -- c'est-a-dire sur le personnage. // ---------------------------------------------------------------------- FReply UCharacterCustomizationWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) { if (InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton) { bDragging = true; // CaptureMouse : sans elle, sortir du widget en glissant relacherait le // suivi et laisserait bDragging a vrai pour toujours. return FReply::Handled().CaptureMouse(TakeWidget()); } return Super::NativeOnMouseButtonDown(InGeometry, InMouseEvent); } FReply UCharacterCustomizationWidget::NativeOnMouseButtonUp(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) { if (InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton && bDragging) { bDragging = false; return FReply::Handled().ReleaseMouseCapture(); } return Super::NativeOnMouseButtonUp(InGeometry, InMouseEvent); } FReply UCharacterCustomizationWidget::NativeOnMouseMove(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) { if (bDragging && Preview) { // Signe inverse : glisser vers la droite doit tourner le personnage // vers la droite, donc le faire pivoter dans l'autre sens. Preview->AddPreviewYaw(-InMouseEvent.GetCursorDelta().X * Preview->GetYawPerPixel()); return FReply::Handled(); } return Super::NativeOnMouseMove(InGeometry, InMouseEvent); } #undef LOCTEXT_NAMESPACE