// Copyright Benoit Pelletier 2023 - 2025 All Rights Reserved. // // This software is available under different licenses depending on the source from which it was obtained: // - The Fab EULA (https://fab.com/eula) applies when obtained from the Fab marketplace. // - The CeCILL-C license (https://cecill.info/licences/Licence_CeCILL-C_V1-en.html) applies when obtained from any other source. // Please refer to the accompanying LICENSE file for further details. #include "SProceduralDungeonEdModeWidget.h" #include "EditorModeManager.h" #include "EditorStyleSet.h" #include "ISinglePropertyView.h" #include "Subsystems/AssetEditorSubsystem.h" #include "Widgets/Layout/SScrollBox.h" #include "Widgets/Notifications/SErrorText.h" #include "Widgets/Text/STextBlock.h" #include "Widgets/SOverlay.h" #include "Widgets/Input/SSpinBox.h" #include "FileHelpers.h" #include "Engine/LevelScriptBlueprint.h" #include "Kismet2/KismetEditorUtilities.h" #include "Misc/EngineVersionComparison.h" #include "GameFramework/Volume.h" #include "Builders/CubeBuilder.h" #include "Engine/Selection.h" #include "ProceduralDungeonEdLog.h" #include "ProceduralDungeonEditor.h" #include "ProceduralDungeonEdMode.h" #include "ProceduralDungeonEdModeToolkit.h" #include "ProceduralDungeonEditorSettings.h" #include "Tools/ProceduralDungeonEditorTool.h" #include "Room.h" // TODO: remove the need to include Room.h when including RoomLevel.h #include "RoomLevel.h" #include "RoomData.h" #if UE_VERSION_OLDER_THAN(5, 1, 0) using StyleProvider = FEditorStyle; #else using StyleProvider = FAppStyle; #endif void SProceduralDungeonEdModeWidget::Construct(const FArguments& InArgs, TSharedRef InParentToolkit) { ParentToolkit = InParentToolkit; FProceduralDungeonEdMode* EdMode = InParentToolkit->GetDungeonEditorMode(); FText LevelName = FText::FromString(GetNameSafe(EdMode->GetWorld())); FSlateFontInfo TitleFont = StyleProvider::GetFontStyle("DetailsView.CategoryFontStyle"); TitleFont.Size = 24; FSlateFontInfo SubTitleFont = StyleProvider::GetFontStyle("DetailsView.CategoryFontStyle"); SubTitleFont.Size = 16; const UProceduralDungeonEditorSettings* EditorSettings = GetDefault(); VolumeMargins = EditorSettings->DefaultMargins; TSharedPtr DataScrollBox = nullptr; ChildSlot [ SNew(SVerticalBox) + SVerticalBox::Slot() .VAlign(VAlign_Top) .Padding(5.f) .AutoHeight() [ SNew(STextBlock) .Text(LevelName) .Justification(ETextJustify::Center) .AutoWrapText(true) .WrappingPolicy(ETextWrappingPolicy::AllowPerCharacterWrapping) .Font(TitleFont) ] + SVerticalBox::Slot() .AutoHeight() .Padding(5.0f) [ SAssignNew(LevelPropertyContainer, SBorder) .Visibility(this, &SProceduralDungeonEdModeWidget::ShowDetails) .BorderBackgroundColor(FLinearColor(0.0f, 0.0f, 0.0f, 0.0f)) ] + SVerticalBox::Slot() .Padding(5.0f) .AutoHeight() [ SAssignNew(Error, SErrorText) ] + SVerticalBox::Slot() .Padding(5.0f) [ SNew(SBorder) .BorderImage(StyleProvider::GetBrush("DetailsView.CollapsedCategory")) .BorderBackgroundColor(FLinearColor(0.2f, 0.2f, 0.2f, 1.0f)) .Padding(5.0f) .Visibility(this, &SProceduralDungeonEdModeWidget::ShowDataDetails) [ SNew(SVerticalBox) + SVerticalBox::Slot() .VAlign(VAlign_Top) .AutoHeight() [ SNew(SOverlay) + SOverlay::Slot() .HAlign(EHorizontalAlignment::HAlign_Fill) [ SNew(STextBlock) .Text(this, &SProceduralDungeonEdModeWidget::GetDataAssetName) .Justification(ETextJustify::Center) .Font(SubTitleFont) ] + SOverlay::Slot() .HAlign(EHorizontalAlignment::HAlign_Left) .VAlign(EVerticalAlignment::VAlign_Center) [ SNew(SButton) .Text(FText::FromString(TEXT("Edit"))) .OnClicked(this, &SProceduralDungeonEdModeWidget::EditData) .IsEnabled(this, &SProceduralDungeonEdModeWidget::IsValidRoomData) .HAlign(EHorizontalAlignment::HAlign_Center) ] + SOverlay::Slot() .HAlign(EHorizontalAlignment::HAlign_Right) .VAlign(EVerticalAlignment::VAlign_Center) [ SNew(SButton) .Text(FText::FromString(TEXT("Save"))) .OnClicked(this, &SProceduralDungeonEdModeWidget::SaveData) .IsEnabled(this, &SProceduralDungeonEdModeWidget::IsDataDirty) .ButtonColorAndOpacity(this, &SProceduralDungeonEdModeWidget::GetSaveButtonColor) .HAlign(EHorizontalAlignment::HAlign_Center) ] ] + SVerticalBox::Slot() .FillHeight(1.0f) .Padding(0.0f, 5.0f, 0.0f, 0.0f) [ SAssignNew(DataScrollBox, SScrollBox) //.IsEnabled(this, &SProceduralDungeonEdModeWidget::MatchingDataLevel) ] ] ] + SVerticalBox::Slot() .Padding(5.0f) .AutoHeight() [ SNew(SVerticalBox) .Visibility(this, &SProceduralDungeonEdModeWidget::ShowDataDetails) + SVerticalBox::Slot() .AutoHeight() .HAlign(EHorizontalAlignment::HAlign_Center) [ SNew(STextBlock) .Text(FText::FromString(TEXT("Utilities"))) .Font(SubTitleFont) ] + SVerticalBox::Slot() .AutoHeight() .Padding(0.0f, 5.0f, 0.0f, 0.0f) [ SNew(SHorizontalBox) + SHorizontalBox::Slot() .AutoWidth() [ SNew(SButton) .Text(FText::FromString(TEXT("Update Selected Volumes"))) .IsEnabled_Lambda([this]() { return SelectedVolumeCount > 0; }) .OnClicked(this, &SProceduralDungeonEdModeWidget::UpdateSelectedVolumes) .ToolTipText(FText::FromString(TEXT("Selected volumes will be positioned and sized on the room's bounds (defined in data asset).\nAn optional margin can be set."))) ] + SHorizontalBox::Slot() .AutoWidth() .Padding(20.0f, 0.0f, 0.0f, 0.0f) .VAlign(EVerticalAlignment::VAlign_Center) [ SNew(STextBlock) .Text(FText::FromString(TEXT("Margins"))) .ToolTipText(FText::FromString(TEXT("The amount (in Unreal Unit) to extend the volumes on each side of the room bounds (can be negative)."))) ] + SHorizontalBox::Slot() .AutoWidth() .Padding(10.0f, 0.0f, 5.0f, 0.0f) .VAlign(EVerticalAlignment::VAlign_Center) [ SNew(STextBlock) .Text(FText::FromString(TEXT("X"))) ] + SHorizontalBox::Slot() .FillWidth(1) [ SNew(SSpinBox) .Value(VolumeMargins.XAxis.X) .OnValueChanged_Lambda([this](float Value) {VolumeMargins.XAxis.X = Value; }) ] + SHorizontalBox::Slot() .FillWidth(1) [ SNew(SSpinBox) .Value(VolumeMargins.XAxis.Y) .OnValueChanged_Lambda([this](float Value) {VolumeMargins.XAxis.Y = Value; }) ] + SHorizontalBox::Slot() .AutoWidth() .Padding(10.0f, 0.0f, 5.0f, 0.0f) .VAlign(EVerticalAlignment::VAlign_Center) [ SNew(STextBlock) .Text(FText::FromString(TEXT("Y"))) ] + SHorizontalBox::Slot() .FillWidth(1) [ SNew(SSpinBox) .Value(VolumeMargins.YAxis.X) .OnValueChanged_Lambda([this](float Value) {VolumeMargins.YAxis.X = Value; }) ] + SHorizontalBox::Slot() .FillWidth(1) [ SNew(SSpinBox) .Value(VolumeMargins.YAxis.Y) .OnValueChanged_Lambda([this](float Value) {VolumeMargins.YAxis.Y = Value; }) ] + SHorizontalBox::Slot() .AutoWidth() .Padding(10.0f, 0.0f, 5.0f, 0.0f) .VAlign(EVerticalAlignment::VAlign_Center) [ SNew(STextBlock) .Text(FText::FromString(TEXT("Z"))) ] + SHorizontalBox::Slot() .FillWidth(1) [ SNew(SSpinBox) .Value(VolumeMargins.ZAxis.X) .OnValueChanged_Lambda([this](float Value) {VolumeMargins.ZAxis.X = Value; }) ] + SHorizontalBox::Slot() .FillWidth(1) [ SNew(SSpinBox) .Value(VolumeMargins.ZAxis.Y) .OnValueChanged_Lambda([this](float Value) {VolumeMargins.ZAxis.Y = Value; }) ] ] + SVerticalBox::Slot() .AutoHeight() .Padding(0.0f, 5.0f, 0.0f, 0.0f) [ SNew(SHorizontalBox) + SHorizontalBox::Slot() .AutoWidth() [ SNew(SButton) .Text(FText::FromString(TEXT("Remove All Invalid Doors"))) //.IsEnabled_Lambda([this]() { return SelectedVolumeCount > 0; }) .OnClicked(this, &SProceduralDungeonEdModeWidget::RemoveInvalidDoors) .ToolTipText(FText::FromString(TEXT("All invalid doors (drawn in orange) will be removed."))) ] ] ] + SVerticalBox::Slot() .Padding(5.0f) .AutoHeight() [ SNew(SBorder) .HAlign(EHorizontalAlignment::HAlign_Center) .Visibility(this, &SProceduralDungeonEdModeWidget::ShowNote) .BorderBackgroundColor(FLinearColor(0.0f, 0.0f, 0.0f, 0.0f)) .Padding(0.0f) [ SNew(SButton) .Text(FText::FromString(TEXT("Reparent Level Blueprint"))) .OnClicked(this, &SProceduralDungeonEdModeWidget::ReparentLevelActor) .ButtonColorAndOpacity(this, &SProceduralDungeonEdModeWidget::GetReparentButtonColor) .HAlign(EHorizontalAlignment::HAlign_Center) ] ] ]; FPropertyEditorModule& PropertyEditorModule = FModuleManager::GetModuleChecked("PropertyEditor"); // RoomData details view FDetailsViewArgs DetailsViewArgs; DetailsViewArgs.bAllowSearch = false; DetailsViewArgs.bLockable = false; DetailsViewArgs.NameAreaSettings = FDetailsViewArgs::HideNameArea; DataContentWidget = PropertyEditorModule.CreateDetailView(DetailsViewArgs); DataContentWidget->OnFinishedChangingProperties().AddLambda([this](const FPropertyChangedEvent& Event) { UpdateErrorText(); }); DataScrollBox->AddSlot() [ DataContentWidget.ToSharedRef() ]; OnLevelChanged(); RegisterSelectionDelegate(true); OnSelectedActorsChanged(nullptr); } SProceduralDungeonEdModeWidget::~SProceduralDungeonEdModeWidget() { RegisterSelectionDelegate(false); ResetCachedData(); ResetCachedLevel(); } void SProceduralDungeonEdModeWidget::OnLevelChanged() { FProceduralDungeonEdMode* EdMode = GetEditorMode(); ResetCachedLevel(); if (!IsValidRoomLevel(EdMode, &CachedLevel)) { UpdateErrorText(); return; } DungeonEd_LogInfo("Slate Editor Level: %s", *GetNameSafe(CachedLevel.Get())); LevelDelegateHandle = CachedLevel->OnPropertiesChanged.AddLambda([this](ARoomLevel* RoomLevel) { OnDataAssetChanged(); }); // RoomLevel Data property FPropertyEditorModule& PropertyEditorModule = FModuleManager::GetModuleChecked("PropertyEditor"); TSharedPtr SinglePropView = PropertyEditorModule.CreateSingleProperty(CachedLevel.Get(), "Data", {}); LevelPropertyContainer->SetContent(SinglePropView.ToSharedRef()); OnDataAssetChanged(); } void SProceduralDungeonEdModeWidget::OnDataAssetChanged() { auto EdMode = GetEditorMode(); ResetCachedData(); if (IsValidRoomData(EdMode, &CachedData)) { DataContentWidget->SetObject(CachedData.Get()); if (CachedData->Level.IsNull()) { CachedData->Modify(); CachedData->Level = EdMode->GetWorld(); DungeonEd_LogInfo("Room Data's Level asset filled with current editor's Level."); } DataDelegateHandle = CachedData->OnPropertiesChanged.AddLambda([this](URoomData* Data) { UpdateErrorText(); auto EdMode = GetEditorMode(); if (EdMode) { FProceduralDungeonEditorTool* ActiveTool = EdMode->GetActiveTool(); if (ActiveTool) ActiveTool->OnDataPropertiesChanged(CachedData.Get()); } }); } UpdateErrorText(); EdMode->SetDefaultTool(); FProceduralDungeonEditorTool* ActiveTool = EdMode->GetActiveTool(); if (ActiveTool) ActiveTool->OnDataChanged(CachedData.Get()); } FReply SProceduralDungeonEdModeWidget::ReparentLevelActor() { auto EdMode = GetEditorMode(); auto World = EdMode->GetWorld(); ULevelScriptBlueprint* LevelBlueprint = World->PersistentLevel->GetLevelScriptBlueprint(); if (!IsValid(LevelBlueprint)) { DungeonEd_LogError("ERROR: Can't Reparent Level Blueprint for an unknown reason."); return FReply::Unhandled(); } LevelBlueprint->ParentClass = ARoomLevel::StaticClass(); FKismetEditorUtilities::CompileBlueprint(LevelBlueprint); DungeonEd_LogInfo("Level Blueprint '%s' successfully reparented!", *LevelBlueprint->GetName()); EdMode->UpdateLevelBlueprint(); return FReply::Unhandled(); } FReply SProceduralDungeonEdModeWidget::EditData() { UAssetEditorSubsystem* AssetEditorSubsystem = GEditor->GetEditorSubsystem(); if (!IsValid(AssetEditorSubsystem)) return FReply::Unhandled(); TWeakObjectPtr Data; if (!IsValidRoomData(nullptr, &Data)) return FReply::Unhandled(); AssetEditorSubsystem->OpenEditorForAsset(Data.Get()); return FReply::Handled(); } FReply SProceduralDungeonEdModeWidget::SaveData() { TWeakObjectPtr Data; if (!IsValidRoomData(nullptr, &Data)) return FReply::Unhandled(); auto Result = FEditorFileUtils::PromptForCheckoutAndSave({Data->GetPackage()}, /*bCheckDirty = */true, /*bPromptToSave = */false); if (Result == FEditorFileUtils::EPromptReturnCode::PR_Success) DungeonEd_LogInfo("Successfully Saved Data Asset: '%s'", *GetNameSafe(Data.Get())); return FReply::Handled(); } FReply SProceduralDungeonEdModeWidget::UpdateSelectedVolumes() { DungeonEd_LogInfo("Update Selected Volumes."); auto EdMode = GetEditorMode(); TWeakObjectPtr Level; TWeakObjectPtr Data; if (!IsValidRoomData(EdMode, &Data, &Level)) { DungeonEd_LogError("Can't update selected volumes: RoomData is not valid."); return FReply::Unhandled(); } FBoxCenterAndExtent RoomBounds = Data->GetBounds(); RoomBounds = VolumeMargins.Apply(RoomBounds); GEditor->BeginTransaction(FText::FromString(TEXT("Update Selected Volumes"))); for (auto It = GEditor->GetSelectedActorIterator(); It; ++It) { AVolume* Volume = Cast(*It); if (!IsValid(Volume)) continue; UCubeBuilder* CubeBrush = Cast(Volume->BrushBuilder); if (!IsValid(CubeBrush)) { DungeonEd_LogWarning("Volume's brush is not a cube. Ignoring this volume."); continue; } DungeonEd_LogInfo("Updating volume: '%s'", *Volume->GetName()); Volume->Modify(); Volume->SetActorLocationAndRotation(RoomBounds.Center, FQuat::Identity); CubeBrush->Modify(); CubeBrush->X = 2.0f * RoomBounds.Extent.X; CubeBrush->Y = 2.0f * RoomBounds.Extent.Y; CubeBrush->Z = 2.0f * RoomBounds.Extent.Z; // Rebuild volume after changing its builder values CubeBrush->Build(Volume->GetWorld(), Volume); } GEditor->EndTransaction(); return FReply::Handled(); } FReply SProceduralDungeonEdModeWidget::RemoveInvalidDoors() { TWeakObjectPtr Data; if (!IsValidRoomData(nullptr, &Data)) return FReply::Unhandled(); GEditor->BeginTransaction(FText::FromString(TEXT("Remove Invalid Doors"))); Data->Modify(); for (int i = Data->Doors.Num() - 1; i >= 0; --i) { if (!Data->IsDoorValid(i) || Data->IsDoorDuplicate(i)) Data->Doors.RemoveAt(i); } GEditor->EndTransaction(); return FReply::Handled(); } FSlateColor SProceduralDungeonEdModeWidget::GetSaveButtonColor() const { const FLinearColor& Default = FLinearColor::White; const FLinearColor& Highlight = FLinearColor::Green; return IsDataDirty() ? GetHighlightButtonColor(Highlight, Default) : Default; } FSlateColor SProceduralDungeonEdModeWidget::GetReparentButtonColor() const { return GetHighlightButtonColor(FLinearColor::Green); } void SProceduralDungeonEdModeWidget::UpdateErrorText() { auto EdMode = GetEditorMode(); checkf(EdMode, TEXT("EdMode is Invalid in UpdateErrorText")); if (!IsValidRoomLevel(EdMode)) Error->SetError(TEXT("Persistent Level is not a Room Level.")); else if (!IsValidRoomData(EdMode)) Error->SetError(TEXT("Room Level has no Room Data set.")); else if (!MatchingDataLevel(EdMode)) Error->SetError(TEXT("Level's Data and Data's Level do not match.")); else Error->SetError(FText::GetEmpty()); } void SProceduralDungeonEdModeWidget::ResetCachedData() { if (!CachedData.IsValid()) return; CachedData->OnPropertiesChanged.Remove(DataDelegateHandle); DataDelegateHandle.Reset(); CachedData.Reset(); } void SProceduralDungeonEdModeWidget::ResetCachedLevel() { if (!CachedLevel.IsValid()) return; CachedLevel->OnPropertiesChanged.Remove(LevelDelegateHandle); LevelDelegateHandle.Reset(); CachedLevel.Reset(); } FProceduralDungeonEdMode* SProceduralDungeonEdModeWidget::GetEditorMode() const { checkf(ParentToolkit.IsValid(), TEXT("ParentToolkit is invalid. This should never happen. There is a leakage somewhere.")); return ParentToolkit.Pin()->GetDungeonEditorMode(); } void SProceduralDungeonEdModeWidget::RegisterSelectionDelegate(bool Register) { USelection* SelectedActors = GEditor->GetSelectedActors(); checkf(IsValid(SelectedActors), TEXT("Editor Actor Selection is not valid!")); if (Register) { if (SelectionDelegateHandle.IsValid()) { DungeonEd_LogWarning("Can't register SelectionChanged callback: callback is already registered."); } else { DungeonEd_LogInfo("Register SelectionChanged callback."); SelectionDelegateHandle = SelectedActors->SelectionChangedEvent.AddRaw(this, &SProceduralDungeonEdModeWidget::OnSelectedActorsChanged); } } else { if (SelectionDelegateHandle.IsValid()) { DungeonEd_LogInfo("Unregister SelectionChanged callback."); SelectedActors->SelectionChangedEvent.Remove(SelectionDelegateHandle); SelectionDelegateHandle.Reset(); } else { DungeonEd_LogWarning("Can't unregister SelectionChanged callback: callback is not registered."); } } } void SProceduralDungeonEdModeWidget::OnSelectedActorsChanged(UObject* NewSelectedObject) { USelection* SelectedActors = GEditor->GetSelectedActors(); SelectedVolumeCount = SelectedActors->CountSelections(); } bool SProceduralDungeonEdModeWidget::IsValidRoomLevel(FProceduralDungeonEdMode* EdMode, TWeakObjectPtr* OutLevel) const { if (!EdMode) EdMode = GetEditorMode(); auto Level = EdMode->GetLevel(); if (OutLevel) *OutLevel = Level; return Level.IsValid(); } bool SProceduralDungeonEdModeWidget::IsValidRoomData(FProceduralDungeonEdMode* EdMode, TWeakObjectPtr* OutData, TWeakObjectPtr* OutLevel) const { if (!EdMode) EdMode = GetEditorMode(); TWeakObjectPtr Level; if (!IsValidRoomLevel(EdMode, &Level)) return false; if (OutLevel) *OutLevel = Level; if (OutData) *OutData = Level->Data; return IsValid(Level->Data); } bool SProceduralDungeonEdModeWidget::MatchingDataLevel(FProceduralDungeonEdMode* EdMode) const { if (!EdMode) EdMode = GetEditorMode(); TWeakObjectPtr Data; if (!IsValidRoomData(EdMode, &Data)) return false; return Data->Level.GetUniqueID() == EdMode->GetWorld()->GetPathName(); } bool SProceduralDungeonEdModeWidget::IsDataDirty(FProceduralDungeonEdMode* EdMode) const { TWeakObjectPtr Data; if (!IsValidRoomData(EdMode, &Data)) return false; return Data->GetPackage()->IsDirty(); } EVisibility SProceduralDungeonEdModeWidget::ShowDetails() const { return IsValidRoomLevel() ? EVisibility::Visible : EVisibility::Collapsed; } EVisibility SProceduralDungeonEdModeWidget::ShowDataDetails() const { return IsValidRoomData() ? EVisibility::Visible : EVisibility::Collapsed; } EVisibility SProceduralDungeonEdModeWidget::ShowNote() const { return IsValidRoomLevel() ? EVisibility::Collapsed : EVisibility::Visible; } FText SProceduralDungeonEdModeWidget::GetDataAssetName() const { auto EdMode = GetEditorMode(); TWeakObjectPtr Data; if (!IsValidRoomData(EdMode, &Data)) return FText::GetEmpty(); FString Dirty = IsDataDirty(EdMode) ? "*" : ""; return FText::FromString(GetNameSafe(Data.Get()) + Dirty); } FLinearColor SProceduralDungeonEdModeWidget::GetHighlightButtonColor(const FLinearColor& HighlightColor, const FLinearColor& NormalColor, float Speed) { uint32 ticks = FDateTime::Now().GetTicks(); // needs this line to avoid compiler optimization that prevent getting Now() each frame. float seconds = static_cast(ticks) / ETimespan::TicksPerSecond; float t = FMath::Clamp(FMath::Cos(Speed * seconds), 0.0f, 1.0f); return FMath::Lerp(NormalColor, HighlightColor, t); }