Additional sublevels, custom property options, BaseLevel highlight, lightmap fix

- Add AdditionalSublevels to FPS_Editor_BaseLevelEntry for always-loaded
  streaming levels that aren't auto-loaded when base level is a sublevel
- Add LoadBaseLevelAsSublevel overload with ExtraSublevels parameter
- Add GetCustomOptionsForProperty to IPS_Editor_EditableInterface for
  data-driven dropdowns (e.g. weapon lists from DataTables)
- Add custom options combo in property panel (interface-driven, no CachedEnum)
- Add BaseLevel selection highlight: selected card stays highlighted until
  validate or change
- Fix deferred lightmap refresh with 0.5s timer instead of NextTick
- Fix enum detection scope error in RebuildDynamicProperties

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 18:13:38 +02:00
parent 1a2cc87160
commit 82e76c01ff
6 changed files with 229 additions and 20 deletions

View File

@@ -75,17 +75,19 @@ void APS_Editor_PlayerController::BeginPlay()
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Restoring from subsystem - BaseLevel='%s', Scenario='%s'"), UE_LOG(LogTemp, Log, TEXT("PS_Editor: Restoring from subsystem - BaseLevel='%s', Scenario='%s'"),
*Sub->PendingBaseLevel, *Sub->PendingScenarioName); *Sub->PendingBaseLevel, *Sub->PendingScenarioName);
// Resolve full map path from MasterCatalog // Resolve full map path and extra sublevels from MasterCatalog
FString ResolvedMapPath; FString ResolvedMapPath;
TArray<FString> ResolvedExtras;
for (const FPS_Editor_BaseLevelEntry& Entry : Master->BaseLevels) for (const FPS_Editor_BaseLevelEntry& Entry : Master->BaseLevels)
{ {
if (Entry.DisplayName == Sub->PendingBaseLevel) if (Entry.DisplayName == Sub->PendingBaseLevel)
{ {
ResolvedMapPath = Entry.MapPath; ResolvedMapPath = Entry.MapPath;
ResolvedExtras = Entry.AdditionalSublevels;
break; break;
} }
} }
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel, ResolvedMapPath); LoadBaseLevelAsSublevel(Sub->PendingBaseLevel, ResolvedMapPath, ResolvedExtras);
if (!Sub->PendingScenarioName.IsEmpty() && SpawnManager && SceneSerializer) if (!Sub->PendingScenarioName.IsEmpty() && SpawnManager && SceneSerializer)
{ {
@@ -381,6 +383,11 @@ void APS_Editor_PlayerController::ShowLoadingScreen(bool bShow)
} }
void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelName, const FString& MapPath) void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelName, const FString& MapPath)
{
LoadBaseLevelAsSublevel(LevelName, MapPath, TArray<FString>());
}
void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelName, const FString& MapPath, const TArray<FString>& ExtraSublevels)
{ {
// Unload previous sublevel first // Unload previous sublevel first
if (CurrentSublevel) if (CurrentSublevel)
@@ -392,6 +399,18 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Unloaded previous sublevel")); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Unloaded previous sublevel"));
} }
// Unload additional sublevels from previous base level
for (TObjectPtr<ULevelStreamingDynamic>& Extra : AdditionalSublevels)
{
if (Extra)
{
Extra->SetShouldBeLoaded(false);
Extra->SetShouldBeVisible(false);
Extra->SetIsRequestingUnloadAndRemoval(true);
}
}
AdditionalSublevels.Empty();
// Hide/show persistent level scene actors (lights, meshes, post-process, etc.) // Hide/show persistent level scene actors (lights, meshes, post-process, etc.)
// Exclude PS_Editor system actors (pawn, controller, gizmo, gamemode, spawned objects) // Exclude PS_Editor system actors (pawn, controller, gizmo, gamemode, spawned objects)
if (ULevel* PersistentLevel = GetWorld()->PersistentLevel) if (ULevel* PersistentLevel = GetWorld()->PersistentLevel)
@@ -456,6 +475,32 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa
CurrentSublevel->OnLevelShown.AddUniqueDynamic(this, &APS_Editor_PlayerController::DisableSublevelPostProcessMaterials); CurrentSublevel->OnLevelShown.AddUniqueDynamic(this, &APS_Editor_PlayerController::DisableSublevelPostProcessMaterials);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Loaded sublevel '%s' (path: '%s')"), *LevelName, *LevelToLoad); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Loaded sublevel '%s' (path: '%s')"), *LevelName, *LevelToLoad);
// Load additional sublevels (always-loaded streaming levels)
for (const FString& ExtraPath : ExtraSublevels)
{
if (ExtraPath.IsEmpty()) continue;
bool bExtraSuccess = false;
ULevelStreamingDynamic* ExtraSublevel = ULevelStreamingDynamic::LoadLevelInstance(
GetWorld(),
ExtraPath,
FVector::ZeroVector,
FRotator::ZeroRotator,
bExtraSuccess);
if (bExtraSuccess && ExtraSublevel)
{
ExtraSublevel->SetShouldBeLoaded(true);
ExtraSublevel->SetShouldBeVisible(true);
AdditionalSublevels.Add(ExtraSublevel);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Loaded additional sublevel '%s'"), *ExtraPath);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load additional sublevel '%s'"), *ExtraPath);
}
}
} }
else else
{ {
@@ -470,8 +515,24 @@ void APS_Editor_PlayerController::DisableSublevelPostProcessMaterials()
ULevel* Level = CurrentSublevel->GetLoadedLevel(); ULevel* Level = CurrentSublevel->GetLoadedLevel();
if (!Level) return; if (!Level) return;
// Force lighting/rendering resources to initialize properly for baked lightmaps // Deferred lightmap refresh: MapBuildData may be loaded async and not ready yet
Level->InitializeRenderingResources(); TWeakObjectPtr<ULevelStreamingDynamic> WeakSublevel = CurrentSublevel;
FTimerHandle LightmapTimer;
GetWorldTimerManager().SetTimer(LightmapTimer, [WeakSublevel]()
{
if (!WeakSublevel.IsValid()) return;
ULevel* DelayedLevel = WeakSublevel->GetLoadedLevel();
if (!DelayedLevel) return;
for (AActor* Actor : DelayedLevel->Actors)
{
if (Actor)
{
Actor->MarkComponentsRenderStateDirty();
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Deferred lightmap refresh on sublevel"));
}, 0.5f, false);
APS_Editor_CameraStart* CameraStart = nullptr; APS_Editor_CameraStart* CameraStart = nullptr;

View File

@@ -78,8 +78,12 @@ public:
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering). * @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
* @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse"). * @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse").
* If empty, LevelName is used as-is (legacy behavior). * If empty, LevelName is used as-is (legacy behavior).
* @param ExtraSublevels Additional sublevels to load alongside (e.g. always-loaded streaming levels).
*/ */
UFUNCTION(BlueprintCallable, Category = "PS Editor") UFUNCTION(BlueprintCallable, Category = "PS Editor")
void LoadBaseLevelAsSublevel(const FString& LevelName, const FString& MapPath, const TArray<FString>& ExtraSublevels);
/** Overload without extra sublevels. */
void LoadBaseLevelAsSublevel(const FString& LevelName, const FString& MapPath = TEXT("")); void LoadBaseLevelAsSublevel(const FString& LevelName, const FString& MapPath = TEXT(""));
// ---- Editor Mode (Normal / SplinePlacement) ---- // ---- Editor Mode (Normal / SplinePlacement) ----
@@ -157,6 +161,10 @@ private:
UPROPERTY() UPROPERTY()
TObjectPtr<ULevelStreamingDynamic> CurrentSublevel; TObjectPtr<ULevelStreamingDynamic> CurrentSublevel;
/** Additional sublevels loaded alongside the base level. */
UPROPERTY()
TArray<TObjectPtr<ULevelStreamingDynamic>> AdditionalSublevels;
/** True when a sublevel is loading and needs fade-in when ready. */ /** True when a sublevel is loading and needs fade-in when ready. */
bool bWaitingForSublevelFadeIn = false; bool bWaitingForSublevelFadeIn = false;

View File

@@ -67,4 +67,14 @@ public:
*/ */
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor") UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
void OnEditorTick(float DeltaTime); void OnEditorTick(float DeltaTime);
/**
* Return custom dropdown options for a given property.
* If the returned array is non-empty, the editor shows a combo box with these options
* instead of the default widget for that property type.
* @param PropertyPath The property path (e.g. "WeaponName" or "MyComponent.WeaponType").
* @return List of option strings. Empty = use default widget.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
TArray<FString> GetCustomOptionsForProperty(const FString& PropertyPath) const;
}; };

View File

@@ -68,6 +68,15 @@ struct FPS_Editor_BaseLevelEntry
/** Thumbnail image shown in the BaseLevel selection popup. */ /** Thumbnail image shown in the BaseLevel selection popup. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TObjectPtr<UTexture2D> Thumbnail; TObjectPtr<UTexture2D> Thumbnail;
/**
* Additional sublevels to load alongside this base level in the editor.
* Used for streaming levels that are "always loaded" in the persistent level
* but not automatically loaded when the base level is a sublevel itself.
* Full map paths, e.g. "/Game/Maps/Warehouse_Lighting"
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TArray<FString> AdditionalSublevels;
}; };
/** /**

View File

@@ -960,7 +960,12 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
{ {
SceneMapPath = *Found; SceneMapPath = *Found;
} }
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName, SceneMapPath); TArray<FString> SceneExtras;
if (TArray<FString>* FoundExtras = BaseLevelExtraSublevels.Find(SceneLevelName))
{
SceneExtras = *FoundExtras;
}
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName, SceneMapPath, SceneExtras);
CurrentLevelFilter = EditorPC->CurrentBaseLevel; CurrentLevelFilter = EditorPC->CurrentBaseLevel;
for (const TSharedPtr<FString>& Opt : BaseLevelOptions) for (const TSharedPtr<FString>& Opt : BaseLevelOptions)
{ {
@@ -1092,6 +1097,10 @@ void UPS_Editor_MainWidget::NativeConstruct()
{ {
BaseLevelPathMap.Add(Entry.DisplayName, Entry.MapPath); BaseLevelPathMap.Add(Entry.DisplayName, Entry.MapPath);
} }
if (Entry.AdditionalSublevels.Num() > 0)
{
BaseLevelExtraSublevels.Add(Entry.DisplayName, Entry.AdditionalSublevels);
}
if (Entry.Thumbnail) if (Entry.Thumbnail)
{ {
BaseLevelThumbnails.Add(Entry.DisplayName, Entry.Thumbnail); BaseLevelThumbnails.Add(Entry.DisplayName, Entry.Thumbnail);
@@ -1184,7 +1193,12 @@ void UPS_Editor_MainWidget::PopulateSceneList()
{ {
SceneMapPath = *Found; SceneMapPath = *Found;
} }
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName, SceneMapPath); TArray<FString> ScnExtras;
if (TArray<FString>* FoundExtras = BaseLevelExtraSublevels.Find(SceneLevelName))
{
ScnExtras = *FoundExtras;
}
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName, SceneMapPath, ScnExtras);
CurrentLevelFilter = EditorPC->CurrentBaseLevel; CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (BaseLevelButtonText.IsValid()) if (BaseLevelButtonText.IsValid())
@@ -1890,7 +1904,50 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
// Value widget depends on property type // Value widget depends on property type
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget; TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
// Check for custom dropdown options from the interface
TArray<FString> CustomOptions;
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
}
if (CustomOptions.Num() > 0)
{
Row.EnumOptions = MakeShared<TArray<TSharedPtr<FString>>>();
for (const FString& Opt : CustomOptions)
{
Row.EnumOptions->Add(MakeShared<FString>(Opt));
}
ValueWidget = SAssignNew(Row.EnumCombo, SComboBox<TSharedPtr<FString>>)
.OptionsSource(Row.EnumOptions.Get())
.Method(EPopupMethod::UseCurrentWindow)
.IsFocusable(true)
.OnSelectionChanged_Lambda([this, RowIndex](TSharedPtr<FString> Selected, ESelectInfo::Type SelectInfo)
{
if (!Selected.IsValid() || SelectInfo == ESelectInfo::Direct) return;
FPS_Editor_PropertyRowWidgets& R = DynamicPropertyRows[RowIndex];
if (R.EnumComboText.IsValid())
{
R.EnumComboText->SetText(FText::FromString(*Selected));
}
ApplyPropertyFromUI(RowIndex);
})
.OnGenerateWidget_Lambda([](TSharedPtr<FString> Item) -> TSharedRef<SWidget>
{
return SNew(STextBlock)
.Text(FText::FromString(Item.IsValid() ? *Item : TEXT("")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9));
})
[
SAssignNew(Row.EnumComboText, STextBlock)
.Text(FText::FromString(TEXT("Select...")))
.Font(ValueFont)
];
}
// Detect enum type (FEnumProperty for C++ enum class, FByteProperty with UEnum for legacy) // Detect enum type (FEnumProperty for C++ enum class, FByteProperty with UEnum for legacy)
else
{
UEnum* FoundEnum = nullptr; UEnum* FoundEnum = nullptr;
if (FEnumProperty* EnumProp = CastField<FEnumProperty>(Row.CachedProperty)) if (FEnumProperty* EnumProp = CastField<FEnumProperty>(Row.CachedProperty))
{ {
@@ -2024,6 +2081,8 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
}); });
} }
} // end else (enum/struct/default type detection)
DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 2.0f) DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 2.0f)
[ ValueWidget ]; [ ValueWidget ];
@@ -2050,7 +2109,31 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
UObject* Target = FindPropertyTarget(Actor, Row.ComponentName); UObject* Target = FindPropertyTarget(Actor, Row.ComponentName);
if (!Target) continue; if (!Target) continue;
if (Row.CachedEnum && Row.EnumCombo.IsValid() && !Row.EnumCombo->IsOpen()) // Custom options combo (no CachedEnum, has EnumCombo)
if (!Row.CachedEnum && Row.EnumCombo.IsValid() && !Row.EnumCombo->IsOpen())
{
// Read current property value as text
FString CurrentVal;
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != CurrentVal)
{
// Find matching option
if (Row.EnumOptions.IsValid())
{
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
{
if (*Opt == CurrentVal)
{
Row.EnumCombo->SetSelectedItem(Opt);
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
break;
}
}
}
}
}
else if (Row.CachedEnum && Row.EnumCombo.IsValid() && !Row.EnumCombo->IsOpen())
{ {
// Read current enum value index // Read current enum value index
int64 EnumValue = 0; int64 EnumValue = 0;
@@ -2187,7 +2270,12 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
// Build new value text from UI and import // Build new value text from UI and import
FString NewValueText; FString NewValueText;
if (Row.CachedEnum && Row.EnumComboText.IsValid()) if (!Row.CachedEnum && Row.EnumComboText.IsValid())
{
// Custom options combo: use selected text directly as property value
NewValueText = Row.EnumComboText->GetText().ToString();
}
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
{ {
FString DisplayName = Row.EnumComboText->GetText().ToString(); FString DisplayName = Row.EnumComboText->GetText().ToString();
for (int32 Idx = 0; Idx < Row.CachedEnum->NumEnums() - 1; ++Idx) for (int32 Idx = 0; Idx < Row.CachedEnum->NumEnums() - 1; ++Idx)
@@ -2378,6 +2466,7 @@ void UPS_Editor_MainWidget::OpenBaseLevelPopup()
if (!BaseLevelGridContainer.IsValid() || !BaseLevelPopup.IsValid()) return; if (!BaseLevelGridContainer.IsValid() || !BaseLevelPopup.IsValid()) return;
BaseLevelGridContainer->ClearChildren(); BaseLevelGridContainer->ClearChildren();
BaseLevelCardBorders.Empty();
PendingBaseLevelSelection.Empty(); PendingBaseLevelSelection.Empty();
const float CardWidth = 160.0f; const float CardWidth = 160.0f;
@@ -2457,23 +2546,32 @@ void UPS_Editor_MainWidget::OpenBaseLevelPopup()
.ColorAndOpacity(FLinearColor::White) .ColorAndOpacity(FLinearColor::White)
]; ];
// Add card to current row // Add card to current row with highlight border
TSharedPtr<SBorder> CardBorder;
CurrentRow->AddSlot().AutoWidth().Padding(8.0f, 0.0f) CurrentRow->AddSlot().AutoWidth().Padding(8.0f, 0.0f)
[ [
SNew(SBox) SNew(SBox)
.MinDesiredWidth(CardWidth + 16.0f) .MinDesiredWidth(CardWidth + 16.0f)
[
SAssignNew(CardBorder, SBorder)
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
.BorderBackgroundColor(FLinearColor(0.1f, 0.1f, 0.1f, 0.0f))
.Padding(FMargin(2.0f))
[ [
SNew(SButton) SNew(SButton)
.OnClicked_Lambda([this, Name]() -> FReply .OnClicked_Lambda([this, Name]() -> FReply
{ {
PendingBaseLevelSelection = Name; PendingBaseLevelSelection = Name;
UpdateBaseLevelHighlight();
return FReply::Handled(); return FReply::Handled();
}) })
[ [
Card Card
] ]
] ]
]
]; ];
BaseLevelCardBorders.Add(Name, CardBorder);
ColIndex++; ColIndex++;
} }
@@ -2519,7 +2617,12 @@ void UPS_Editor_MainWidget::ConfirmBaseLevelSelection()
} }
else else
{ {
EditorPC->LoadBaseLevelAsSublevel(PendingBaseLevelSelection, SublevelPath); TArray<FString> Extras;
if (TArray<FString>* Found = BaseLevelExtraSublevels.Find(PendingBaseLevelSelection))
{
Extras = *Found;
}
EditorPC->LoadBaseLevelAsSublevel(PendingBaseLevelSelection, SublevelPath, Extras);
} }
CurrentLevelFilter = EditorPC->CurrentBaseLevel; CurrentLevelFilter = EditorPC->CurrentBaseLevel;
@@ -2535,6 +2638,20 @@ void UPS_Editor_MainWidget::ConfirmBaseLevelSelection()
PendingBaseLevelSelection.Empty(); PendingBaseLevelSelection.Empty();
} }
void UPS_Editor_MainWidget::UpdateBaseLevelHighlight()
{
for (auto& Pair : BaseLevelCardBorders)
{
if (Pair.Value.IsValid())
{
const bool bSelected = (Pair.Key == PendingBaseLevelSelection);
Pair.Value->SetBorderBackgroundColor(bSelected
? FLinearColor(0.2f, 0.6f, 1.0f, 0.8f)
: FLinearColor(0.1f, 0.1f, 0.1f, 0.0f));
}
}
}
void UPS_Editor_MainWidget::CancelBaseLevelPopup() void UPS_Editor_MainWidget::CancelBaseLevelPopup()
{ {
if (BaseLevelPopup.IsValid()) if (BaseLevelPopup.IsValid())

View File

@@ -82,6 +82,8 @@ private:
FString CurrentLevelFilter; FString CurrentLevelFilter;
/** Maps DisplayName → full MapPath for base levels (from MasterCatalog). */ /** Maps DisplayName → full MapPath for base levels (from MasterCatalog). */
TMap<FString, FString> BaseLevelPathMap; TMap<FString, FString> BaseLevelPathMap;
/** Maps DisplayName → additional sublevels to load (from MasterCatalog). */
TMap<FString, TArray<FString>> BaseLevelExtraSublevels;
/** Maps DisplayName → Thumbnail texture for base levels. */ /** Maps DisplayName → Thumbnail texture for base levels. */
TMap<FString, TObjectPtr<UTexture2D>> BaseLevelThumbnails; TMap<FString, TObjectPtr<UTexture2D>> BaseLevelThumbnails;
TArray<TSharedPtr<FSlateBrush>> CachedBrushes; TArray<TSharedPtr<FSlateBrush>> CachedBrushes;
@@ -90,7 +92,9 @@ private:
TSharedPtr<SWidget> BaseLevelPopup; TSharedPtr<SWidget> BaseLevelPopup;
TSharedPtr<SVerticalBox> BaseLevelGridContainer; TSharedPtr<SVerticalBox> BaseLevelGridContainer;
FString PendingBaseLevelSelection; FString PendingBaseLevelSelection;
TMap<FString, TSharedPtr<SBorder>> BaseLevelCardBorders;
void OpenBaseLevelPopup(); void OpenBaseLevelPopup();
void UpdateBaseLevelHighlight();
void ConfirmBaseLevelSelection(); void ConfirmBaseLevelSelection();
void CancelBaseLevelPopup(); void CancelBaseLevelPopup();