BaseLevel selection popup with thumbnail grid

- Add Thumbnail field to FPS_Editor_BaseLevelEntry (UTexture2D)
- Replace BaseLevel combo box with button that opens a selection popup
- Popup displays base levels in a 3-column grid with thumbnails (2:1 ratio)
- Validate/Cancel buttons to confirm or dismiss selection
- Gray placeholder shown for entries without thumbnail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 08:42:03 +02:00
parent 27ff2e8d8f
commit 5859d3bf6a
3 changed files with 267 additions and 70 deletions

View File

@@ -54,7 +54,7 @@ struct FPS_Editor_BaseLevelEntry
{
GENERATED_BODY()
/** Display name shown in the editor combo box and used for scene filtering. */
/** Display name shown in the editor UI and used for scene filtering. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
FString DisplayName;
@@ -64,6 +64,10 @@ struct FPS_Editor_BaseLevelEntry
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
FString MapPath;
/** Thumbnail image shown in the BaseLevel selection popup. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TObjectPtr<UTexture2D> Thumbnail;
};
/**

View File

@@ -266,6 +266,69 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
]
]
]
// BaseLevel selection popup
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SAssignNew(BaseLevelPopup, SBorder)
.Visibility(EVisibility::Collapsed)
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
.BorderBackgroundColor(FLinearColor(0.08f, 0.08f, 0.08f, 0.97f))
.Padding(FMargin(24.0f, 16.0f))
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 0.0f, 0.0f, 12.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Select Base Level")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 14))
.ColorAndOpacity(FLinearColor::White)
]
+ SVerticalBox::Slot().FillHeight(1.0f).MaxHeight(400.0f)
[
SNew(SScrollBox)
.ConsumeMouseWheel(EConsumeMouseWheel::Always)
+ SScrollBox::Slot()
[
SAssignNew(BaseLevelGridContainer, SVerticalBox)
]
]
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 12.0f, 0.0f, 0.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(8.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
ConfirmBaseLevelSelection();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Validate")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor(0.2f, 0.9f, 0.2f))
]
]
+ SHorizontalBox::Slot().AutoWidth().Padding(8.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
CancelBaseLevelPopup();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Cancel")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 11))
]
]
]
]
]
// Close confirmation overlay
+ SOverlay::Slot()
.HAlign(HAlign_Center)
@@ -868,8 +931,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
{
if (*Opt == SceneLevelName)
{
if (BaseLevelCombo.IsValid()) BaseLevelCombo->SetSelectedItem(Opt);
if (BaseLevelComboText.IsValid()) BaseLevelComboText->SetText(FText::FromString(SceneLevelName));
if (BaseLevelButtonText.IsValid()) BaseLevelButtonText->SetText(FText::FromString(SceneLevelName));
break;
}
}
@@ -896,7 +958,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
]
]
]
// BaseLevel combo box
// BaseLevel selection button
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 2.0f)
@@ -911,54 +973,14 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
]
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
[
SAssignNew(BaseLevelCombo, SComboBox<TSharedPtr<FString>>)
.OptionsSource(&BaseLevelOptions)
.Method(EPopupMethod::UseCurrentWindow)
.OnSelectionChanged_Lambda([this](TSharedPtr<FString> Selected, ESelectInfo::Type SelectInfo)
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (!Selected.IsValid()) return;
// Ignore programmatic selection (e.g. from NativeConstruct init)
if (SelectInfo == ESelectInfo::Direct) return;
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return;
// Clear current scenario before switching BaseLevel
if (UPS_Editor_SceneSerializer* Ser = EditorPC->GetSceneSerializer())
{
if (UPS_Editor_SpawnManager* SM = EditorPC->GetSpawnManager())
{
Ser->ClearScene(SM);
}
}
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::GetEmpty());
}
FString SublevelPath = *Selected;
if (FString* Found = BaseLevelPathMap.Find(*Selected))
{
SublevelPath = *Found;
}
EditorPC->LoadBaseLevelAsSublevel(*Selected, SublevelPath);
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (BaseLevelComboText.IsValid())
{
BaseLevelComboText->SetText(FText::FromString(*Selected));
}
PopulateSceneList();
LastOutlinerActorCount = -1; // Force outliner refresh
})
.OnGenerateWidget_Lambda([](TSharedPtr<FString> Item) -> TSharedRef<SWidget>
{
return SNew(STextBlock)
.Text(FText::FromString(Item.IsValid() ? *Item : TEXT("")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9));
OpenBaseLevelPopup();
return FReply::Handled();
})
[
SAssignNew(BaseLevelComboText, STextBlock)
SAssignNew(BaseLevelButtonText, STextBlock)
.Text(FText::FromString(TEXT("Select...")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 9))
.ColorAndOpacity(FLinearColor(0.4f, 0.8f, 0.4f))
@@ -1022,7 +1044,7 @@ void UPS_Editor_MainWidget::NativeConstruct()
Super::NativeConstruct();
PopulateSpawnCatalog();
// Populate BaseLevel combo from MasterCatalog
// Populate BaseLevel options from MasterCatalog
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
BaseLevelOptions.Add(MakeShared<FString>(TEXT("None")));
@@ -1035,22 +1057,18 @@ void UPS_Editor_MainWidget::NativeConstruct()
{
BaseLevelPathMap.Add(Entry.DisplayName, Entry.MapPath);
}
if (Entry.Thumbnail)
{
BaseLevelThumbnails.Add(Entry.DisplayName, Entry.Thumbnail);
}
}
}
// If a BaseLevel was set (e.g. from subsystem), select it in the combo
// If a BaseLevel was set (e.g. from subsystem), update the button text
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (!CurrentLevelFilter.IsEmpty())
if (!CurrentLevelFilter.IsEmpty() && BaseLevelButtonText.IsValid())
{
for (const TSharedPtr<FString>& Opt : BaseLevelOptions)
{
if (*Opt == CurrentLevelFilter)
{
if (BaseLevelCombo.IsValid()) BaseLevelCombo->SetSelectedItem(Opt);
if (BaseLevelComboText.IsValid()) BaseLevelComboText->SetText(FText::FromString(CurrentLevelFilter));
break;
}
}
BaseLevelButtonText->SetText(FText::FromString(CurrentLevelFilter));
}
// Restore scenario name in save field (from subsystem LastEdited)
@@ -1134,14 +1152,9 @@ void UPS_Editor_MainWidget::PopulateSceneList()
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName, SceneMapPath);
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
for (const TSharedPtr<FString>& Opt : BaseLevelOptions)
if (BaseLevelButtonText.IsValid())
{
if (*Opt == SceneLevelName)
{
if (BaseLevelCombo.IsValid()) BaseLevelCombo->SetSelectedItem(Opt);
if (BaseLevelComboText.IsValid()) BaseLevelComboText->SetText(FText::FromString(SceneLevelName));
break;
}
BaseLevelButtonText->SetText(FText::FromString(SceneLevelName));
}
}
}
@@ -2305,6 +2318,177 @@ void UPS_Editor_MainWidget::ShowLoadingScreen(bool bShow)
}
}
void UPS_Editor_MainWidget::OpenBaseLevelPopup()
{
if (!BaseLevelGridContainer.IsValid() || !BaseLevelPopup.IsValid()) return;
BaseLevelGridContainer->ClearChildren();
PendingBaseLevelSelection.Empty();
const float CardWidth = 160.0f;
const float CardHeight = 80.0f; // 2:1 ratio
const int32 Columns = 3;
TSharedPtr<SHorizontalBox> CurrentRow;
int32 ColIndex = 0;
for (const TSharedPtr<FString>& Option : BaseLevelOptions)
{
if (!Option.IsValid()) continue;
const FString Name = *Option;
// Start new row if needed
if (ColIndex % Columns == 0)
{
CurrentRow = SNew(SHorizontalBox);
BaseLevelGridContainer->AddSlot().AutoHeight().Padding(0.0f, 4.0f)
[
CurrentRow.ToSharedRef()
];
}
TSharedRef<SVerticalBox> Card = SNew(SVerticalBox);
// Thumbnail
TObjectPtr<UTexture2D>* FoundThumb = BaseLevelThumbnails.Find(Name);
if (FoundThumb && *FoundThumb)
{
TSharedPtr<FSlateBrush> Brush = MakeShared<FSlateBrush>();
Brush->SetResourceObject(*FoundThumb);
Brush->ImageSize = FVector2D(CardWidth, CardHeight);
Brush->DrawAs = ESlateBrushDrawType::Image;
Brush->Tiling = ESlateBrushTileType::NoTile;
CachedBrushes.Add(Brush);
TWeakPtr<FSlateBrush> WeakBrush = Brush;
Card->AddSlot().AutoHeight().HAlign(HAlign_Center)
[
SNew(SImage)
.Image_Lambda([WeakBrush]() -> const FSlateBrush*
{
return WeakBrush.IsValid() ? WeakBrush.Pin().Get() : nullptr;
})
.DesiredSizeOverride(FVector2D(CardWidth, CardHeight))
];
}
else
{
Card->AddSlot().AutoHeight().HAlign(HAlign_Center)
[
SNew(SBorder)
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
.BorderBackgroundColor(FLinearColor(0.15f, 0.15f, 0.15f))
[
SNew(SBox)
.WidthOverride(CardWidth)
.HeightOverride(CardHeight)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(FText::FromString(Name))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
]
]
];
}
// Name label
Card->AddSlot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
SNew(STextBlock)
.Text(FText::FromString(Name))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor::White)
];
// Add card to current row
CurrentRow->AddSlot().AutoWidth().Padding(8.0f, 0.0f)
[
SNew(SBox)
.MinDesiredWidth(CardWidth + 16.0f)
[
SNew(SButton)
.OnClicked_Lambda([this, Name]() -> FReply
{
PendingBaseLevelSelection = Name;
return FReply::Handled();
})
[
Card
]
]
];
ColIndex++;
}
BaseLevelPopup->SetVisibility(EVisibility::Visible);
}
void UPS_Editor_MainWidget::ConfirmBaseLevelSelection()
{
if (BaseLevelPopup.IsValid())
{
BaseLevelPopup->SetVisibility(EVisibility::Collapsed);
}
if (PendingBaseLevelSelection.IsEmpty()) return;
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return;
// Clear current scenario before switching BaseLevel
if (UPS_Editor_SceneSerializer* Ser = EditorPC->GetSceneSerializer())
{
if (UPS_Editor_SpawnManager* SM = EditorPC->GetSpawnManager())
{
Ser->ClearScene(SM);
}
}
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::GetEmpty());
}
// Load the selected BaseLevel
FString SublevelPath = PendingBaseLevelSelection;
if (FString* Found = BaseLevelPathMap.Find(PendingBaseLevelSelection))
{
SublevelPath = *Found;
}
if (PendingBaseLevelSelection == TEXT("None"))
{
EditorPC->LoadBaseLevelAsSublevel(TEXT("None"));
}
else
{
EditorPC->LoadBaseLevelAsSublevel(PendingBaseLevelSelection, SublevelPath);
}
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (BaseLevelButtonText.IsValid())
{
BaseLevelButtonText->SetText(FText::FromString(
PendingBaseLevelSelection == TEXT("None") ? TEXT("Select...") : PendingBaseLevelSelection));
}
PopulateSceneList();
LastOutlinerActorCount = -1;
PendingBaseLevelSelection.Empty();
}
void UPS_Editor_MainWidget::CancelBaseLevelPopup()
{
if (BaseLevelPopup.IsValid())
{
BaseLevelPopup->SetVisibility(EVisibility::Collapsed);
}
PendingBaseLevelSelection.Empty();
}
void UPS_Editor_MainWidget::OnCloseClicked()
{
if (CloseConfirmOverlay.IsValid())

View File

@@ -77,14 +77,23 @@ private:
// Scene save/load
TSharedPtr<SEditableTextBox> SaveNameField;
TSharedPtr<SVerticalBox> SceneListContainer;
TSharedPtr<SComboBox<TSharedPtr<FString>>> BaseLevelCombo;
TArray<TSharedPtr<FString>> BaseLevelOptions;
TSharedPtr<STextBlock> BaseLevelComboText;
TSharedPtr<STextBlock> BaseLevelButtonText;
FString CurrentLevelFilter;
/** Maps DisplayName → full MapPath for base levels (from MasterCatalog). */
TMap<FString, FString> BaseLevelPathMap;
/** Maps DisplayName → Thumbnail texture for base levels. */
TMap<FString, TObjectPtr<UTexture2D>> BaseLevelThumbnails;
TArray<TSharedPtr<FSlateBrush>> CachedBrushes;
// BaseLevel selection popup
TSharedPtr<SWidget> BaseLevelPopup;
TSharedPtr<SVerticalBox> BaseLevelGridContainer;
FString PendingBaseLevelSelection;
void OpenBaseLevelPopup();
void ConfirmBaseLevelSelection();
void CancelBaseLevelPopup();
// Transform fields
TSharedPtr<SEditableTextBox> LocX, LocY, LocZ;
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;