#include "WorldMap/WorldMapTab.h" #include "AssetRegistry/ARFilter.h" #include "AssetRegistry/AssetRegistryModule.h" #include "Editor.h" #include "Framework/Docking/TabManager.h" #include "LevelEditorViewport.h" #include "SaltyEditor.h" #include "UI/SWorldMap.h" #include "Widgets/Docking/SDockTab.h" #include "Widgets/Input/SButton.h" #include "Widgets/Input/SCheckBox.h" #include "Widgets/Input/SComboBox.h" #include "Styling/AppStyle.h" #include "Widgets/SBoxPanel.h" #include "Widgets/Layout/SBorder.h" #include "Widgets/Layout/SSeparator.h" #include "Widgets/Text/STextBlock.h" #include "WorkspaceMenuStructure.h" #include "WorkspaceMenuStructureModule.h" #include "World/WorldMapDefinition.h" #define LOCTEXT_NAMESPACE "SaltyWorldMapTab" namespace SaltyWorldMapTab { const FName TabId("SaltyWorldMap"); } void SWorldMapPanel::Construct(const FArguments& InArgs) { FindDefinitions(); ChildSlot [ SNew(SVerticalBox) + SVerticalBox::Slot() .AutoHeight() [ SNew(SBorder) .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) .Padding(4.f) [ MakeToolbar() ] ] + SVerticalBox::Slot() .FillHeight(1.f) [ SAssignNew(Map, SWorldMap) .Definition(Selected.Get()) .ShowGrid(true) .ShowScaleBar(true) // No subsystem in an editor world, so neither of these has anything to draw. Said explicitly // rather than left to default, because "where is my player arrow" is otherwise a puzzle. .ShowMarkers(false) .ShowLocalPlayer(false) .OnClicked(FOnWorldMapClicked::CreateSP(this, &SWorldMapPanel::HandleMapClicked)) ] ]; } void SWorldMapPanel::FindDefinitions() { Definitions.Reset(); const FAssetRegistryModule& Registry = FModuleManager::LoadModuleChecked("AssetRegistry"); FARFilter Filter; Filter.ClassPaths.Add(UWorldMapDefinition::StaticClass()->GetClassPathName()); Filter.bRecursiveClasses = true; TArray Found; Registry.Get().GetAssets(Filter, Found); for (const FAssetData& Asset : Found) { if (UWorldMapDefinition* Definition = Cast(Asset.GetAsset())) { Definitions.Add(Definition); } } // A stable order, so the combo does not shuffle between sessions. Definitions.Sort([](const TWeakObjectPtr& A, const TWeakObjectPtr& B) { return A.IsValid() && B.IsValid() && A->GetName() < B->GetName(); }); if (!Selected.IsValid() && Definitions.Num() > 0) { Selected = Definitions[0]; } LayerOptions.Reset(); if (const UWorldMapDefinition* Definition = Selected.Get()) { for (const FName& Id : Definition->GetLayerIds()) { LayerOptions.Add(MakeShared(Id)); } } } void SWorldMapPanel::SelectDefinition(TWeakObjectPtr Choice) { Selected = Choice; LayerOptions.Reset(); if (const UWorldMapDefinition* Definition = Selected.Get()) { for (const FName& Id : Definition->GetLayerIds()) { LayerOptions.Add(MakeShared(Id)); } } if (Map.IsValid()) { Map->SetDefinition(Selected.Get()); Map->ZoomToFit(); } if (LayerCombo.IsValid()) { LayerCombo->RefreshOptions(); } } void SWorldMapPanel::HandleMapClicked(FVector2D WorldCm) { // Move every perspective viewport, because "the active one" is whichever was clicked last and that was // this tab. Keep the altitude: a click on a map is "look over there", not "drop to sea level". int32 Moved = 0; for (FLevelEditorViewportClient* Client : GEditor->GetLevelViewportClients()) { if (!Client || !Client->IsPerspective()) { continue; } FVector Location = Client->GetViewLocation(); Location.X = WorldCm.X; Location.Y = WorldCm.Y; Client->SetViewLocation(Location); Client->Invalidate(); ++Moved; } const FVector2D WorldM = WorldCm / 100.0; Status = Moved > 0 ? FText::Format(LOCTEXT("Moved", "Viewport moved to X {0} m, Y {1} m"), FText::AsNumber(FMath::RoundToInt(WorldM.X)), FText::AsNumber(FMath::RoundToInt(WorldM.Y))) : LOCTEXT("NoViewport", "No perspective viewport to move."); UE_LOG(LogSaltyEditor, Log, TEXT("World map: clicked X %.0f m, Y %.0f m; moved %d viewport(s)"), WorldM.X, WorldM.Y, Moved); } FText SWorldMapPanel::DefinitionLabel() const { const UWorldMapDefinition* Definition = Selected.Get(); return Definition ? FText::FromString(Definition->GetName()) : LOCTEXT("NoDefinition", "No map found"); } FText SWorldMapPanel::LayerLabel() const { return Map.IsValid() ? FText::FromName(Map->GetLayer()) : FText::GetEmpty(); } FText SWorldMapPanel::StatusLabel() const { if (!Status.IsEmpty()) { return Status; } const UWorldMapDefinition* Definition = Selected.Get(); return Definition ? FText::FromString(Definition->Projection.ToString()) : LOCTEXT("BuildHint", "Run Scripts/Authoring/build_world_map.sh to build one."); } TSharedRef SWorldMapPanel::MakeToolbar() { return SNew(SHorizontalBox) + SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center) [ SNew(SComboBox>) .OptionsSource(&Definitions) .OnGenerateWidget_Lambda([](TWeakObjectPtr Item) { return SNew(STextBlock).Text(FText::FromString(Item.IsValid() ? Item->GetName() : TEXT("(missing)"))); }) .OnSelectionChanged_Lambda([this](TWeakObjectPtr Item, ESelectInfo::Type) { if (Item.IsValid()) { SelectDefinition(Item); } }) [ SNew(STextBlock).Text(this, &SWorldMapPanel::DefinitionLabel) ] ] + SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center) [ SAssignNew(LayerCombo, SComboBox>) .OptionsSource(&LayerOptions) .OnGenerateWidget_Lambda([](TSharedPtr Item) { return SNew(STextBlock).Text(FText::FromName(Item.IsValid() ? *Item : NAME_None)); }) .OnSelectionChanged_Lambda([this](TSharedPtr Item, ESelectInfo::Type) { if (Item.IsValid() && Map.IsValid()) { Map->SetLayer(*Item); } }) [ SNew(STextBlock).Text(this, &SWorldMapPanel::LayerLabel) ] ] + SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center) [ SNew(SCheckBox) .IsChecked(ECheckBoxState::Checked) .OnCheckStateChanged_Lambda([this](ECheckBoxState State) { if (Map.IsValid()) { Map->SetShowGrid(State == ECheckBoxState::Checked); } }) [ SNew(STextBlock).Text(LOCTEXT("Grid", "Grid")) ] ] + SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center) [ SNew(SButton) .Text(LOCTEXT("Fit", "Fit")) .ToolTipText(LOCTEXT("FitTip", "Zoom out until the whole world fits. Right-double-click the map does the same.")) .OnClicked_Lambda([this]() { if (Map.IsValid()) { Map->ZoomToFit(); } return FReply::Handled(); }) ] + SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center) [ SNew(SButton) .Text(LOCTEXT("Refresh", "Refresh")) .ToolTipText(LOCTEXT("RefreshTip", "Look for map definitions again, after a rebuild.")) .OnClicked_Lambda([this]() { FindDefinitions(); if (Map.IsValid()) { Map->SetDefinition(Selected.Get()); } if (LayerCombo.IsValid()) { LayerCombo->RefreshOptions(); } return FReply::Handled(); }) ] + SHorizontalBox::Slot().FillWidth(1.f).VAlign(VAlign_Center).Padding(6, 0, 0, 0) [ SNew(STextBlock) .Text(this, &SWorldMapPanel::StatusLabel) .ColorAndOpacity(FSlateColor::UseSubduedForeground()) ]; } // --------------------------------------------------------------------------------------------------------- namespace SaltyWorldMapTab { static TSharedRef SpawnTab(const FSpawnTabArgs&) { return SNew(SDockTab) .TabRole(ETabRole::NomadTab) [ SNew(SWorldMapPanel) ]; } void Register() { FGlobalTabmanager::Get()->RegisterNomadTabSpawner(TabId, FOnSpawnTab::CreateStatic(&SpawnTab)) .SetDisplayName(LOCTEXT("TabTitle", "World Map")) .SetTooltipText(LOCTEXT("TabTooltip", "The world's map. Click it to move the viewport camera.")) .SetGroup(WorkspaceMenu::GetMenuStructure().GetLevelEditorCategory()) .SetIcon(FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.WorldBrowser")); } void Unregister() { FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(TabId); } } #undef LOCTEXT_NAMESPACE