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'"),
*Sub->PendingBaseLevel, *Sub->PendingScenarioName);
// Resolve full map path from MasterCatalog
// Resolve full map path and extra sublevels from MasterCatalog
FString ResolvedMapPath;
TArray<FString> ResolvedExtras;
for (const FPS_Editor_BaseLevelEntry& Entry : Master->BaseLevels)
{
if (Entry.DisplayName == Sub->PendingBaseLevel)
{
ResolvedMapPath = Entry.MapPath;
ResolvedExtras = Entry.AdditionalSublevels;
break;
}
}
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel, ResolvedMapPath);
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel, ResolvedMapPath, ResolvedExtras);
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)
{
LoadBaseLevelAsSublevel(LevelName, MapPath, TArray<FString>());
}
void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelName, const FString& MapPath, const TArray<FString>& ExtraSublevels)
{
// Unload previous sublevel first
if (CurrentSublevel)
@@ -392,6 +399,18 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa
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.)
// Exclude PS_Editor system actors (pawn, controller, gizmo, gamemode, spawned objects)
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);
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
{
@@ -470,8 +515,24 @@ void APS_Editor_PlayerController::DisableSublevelPostProcessMaterials()
ULevel* Level = CurrentSublevel->GetLoadedLevel();
if (!Level) return;
// Force lighting/rendering resources to initialize properly for baked lightmaps
Level->InitializeRenderingResources();
// Deferred lightmap refresh: MapBuildData may be loaded async and not ready yet
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;

View File

@@ -75,11 +75,15 @@ public:
bool IsSimulating() const { return bSimulating; }
/** Load a base level as sublevel for visual context. Unloads previous sublevel.
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
* @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse").
* If empty, LevelName is used as-is (legacy behavior).
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
* @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse").
* 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")
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(""));
// ---- Editor Mode (Normal / SplinePlacement) ----
@@ -157,6 +161,10 @@ private:
UPROPERTY()
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. */
bool bWaitingForSublevelFadeIn = false;

View File

@@ -67,4 +67,14 @@ public:
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
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. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
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;
}
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName, SceneMapPath);
TArray<FString> SceneExtras;
if (TArray<FString>* FoundExtras = BaseLevelExtraSublevels.Find(SceneLevelName))
{
SceneExtras = *FoundExtras;
}
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName, SceneMapPath, SceneExtras);
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
for (const TSharedPtr<FString>& Opt : BaseLevelOptions)
{
@@ -1092,6 +1097,10 @@ void UPS_Editor_MainWidget::NativeConstruct()
{
BaseLevelPathMap.Add(Entry.DisplayName, Entry.MapPath);
}
if (Entry.AdditionalSublevels.Num() > 0)
{
BaseLevelExtraSublevels.Add(Entry.DisplayName, Entry.AdditionalSublevels);
}
if (Entry.Thumbnail)
{
BaseLevelThumbnails.Add(Entry.DisplayName, Entry.Thumbnail);
@@ -1184,7 +1193,12 @@ void UPS_Editor_MainWidget::PopulateSceneList()
{
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;
if (BaseLevelButtonText.IsValid())
@@ -1890,7 +1904,50 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
// Value widget depends on property type
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)
else
{
UEnum* FoundEnum = nullptr;
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)
[ ValueWidget ];
@@ -2050,7 +2109,31 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
UObject* Target = FindPropertyTarget(Actor, Row.ComponentName);
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
int64 EnumValue = 0;
@@ -2187,7 +2270,12 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
// Build new value text from UI and import
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();
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;
BaseLevelGridContainer->ClearChildren();
BaseLevelCardBorders.Empty();
PendingBaseLevelSelection.Empty();
const float CardWidth = 160.0f;
@@ -2457,23 +2546,32 @@ void UPS_Editor_MainWidget::OpenBaseLevelPopup()
.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)
[
SNew(SBox)
.MinDesiredWidth(CardWidth + 16.0f)
[
SNew(SButton)
.OnClicked_Lambda([this, Name]() -> FReply
{
PendingBaseLevelSelection = Name;
return FReply::Handled();
})
SAssignNew(CardBorder, SBorder)
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
.BorderBackgroundColor(FLinearColor(0.1f, 0.1f, 0.1f, 0.0f))
.Padding(FMargin(2.0f))
[
Card
SNew(SButton)
.OnClicked_Lambda([this, Name]() -> FReply
{
PendingBaseLevelSelection = Name;
UpdateBaseLevelHighlight();
return FReply::Handled();
})
[
Card
]
]
]
];
BaseLevelCardBorders.Add(Name, CardBorder);
ColIndex++;
}
@@ -2519,7 +2617,12 @@ void UPS_Editor_MainWidget::ConfirmBaseLevelSelection()
}
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;
@@ -2535,6 +2638,20 @@ void UPS_Editor_MainWidget::ConfirmBaseLevelSelection()
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()
{
if (BaseLevelPopup.IsValid())

View File

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