Tooling
This commit is contained in:
@@ -3,8 +3,18 @@
|
||||
|
||||
#include "Core/TelemetrySubsystem.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/GameViewportClient.h"
|
||||
#include "Engine/World.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "HAL/IConsoleManager.h"
|
||||
#include "Salty.h"
|
||||
#include "Styling/CoreStyle.h"
|
||||
#include "UI/SWorldMap.h"
|
||||
#include "Widgets/Layout/SBorder.h"
|
||||
#include "Widgets/Layout/SBox.h"
|
||||
#include "Widgets/SBoxPanel.h"
|
||||
#include "World/WorldMapDefinition.h"
|
||||
#include "World/WorldMapSubsystem.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -14,6 +24,92 @@ namespace
|
||||
return GameInstance ? GameInstance->GetSubsystem<UTelemetrySubsystem>() : nullptr;
|
||||
}
|
||||
|
||||
// The world map overlay, per world. Per world rather than one static, because PIE with two clients is two
|
||||
// worlds in one process and a single pointer would hand the second client the first one's widget.
|
||||
struct FWorldMapOverlay
|
||||
{
|
||||
TWeakObjectPtr<UWorld> World;
|
||||
TSharedPtr<SWorldMap> Map;
|
||||
TSharedPtr<SWidget> Container;
|
||||
};
|
||||
TArray<FWorldMapOverlay> WorldMapOverlays;
|
||||
|
||||
FWorldMapOverlay* FindOverlay(const UWorld* World)
|
||||
{
|
||||
return WorldMapOverlays.FindByPredicate([World](const FWorldMapOverlay& Entry) { return Entry.World.Get() == World; });
|
||||
}
|
||||
|
||||
void CloseWorldMap(UWorld* World)
|
||||
{
|
||||
const int32 Index = WorldMapOverlays.IndexOfByPredicate(
|
||||
[World](const FWorldMapOverlay& Entry) { return Entry.World.Get() == World; });
|
||||
if (Index == INDEX_NONE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (UGameViewportClient* Viewport = World ? World->GetGameViewport() : nullptr)
|
||||
{
|
||||
Viewport->RemoveViewportWidgetContent(WorldMapOverlays[Index].Container.ToSharedRef());
|
||||
}
|
||||
if (APlayerController* Controller = World ? World->GetFirstPlayerController() : nullptr)
|
||||
{
|
||||
Controller->SetInputMode(FInputModeGameOnly());
|
||||
Controller->bShowMouseCursor = false;
|
||||
}
|
||||
WorldMapOverlays.RemoveAt(Index);
|
||||
}
|
||||
|
||||
void OpenWorldMap(UWorld* World)
|
||||
{
|
||||
UGameViewportClient* Viewport = World ? World->GetGameViewport() : nullptr;
|
||||
UWorldMapSubsystem* Subsystem = World ? World->GetSubsystem<UWorldMapSubsystem>() : nullptr;
|
||||
if (!Viewport || !Subsystem)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// const_cast for the same reason UWorldMapWidget does it: the subsystem hands out a const view of
|
||||
// shared content and the widget holds a strong pointer it cannot make const. Nothing writes to it.
|
||||
UWorldMapDefinition* Definition = const_cast<UWorldMapDefinition*>(Subsystem->GetDefinition());
|
||||
|
||||
FWorldMapOverlay Entry;
|
||||
Entry.World = World;
|
||||
|
||||
// Slate straight into the viewport rather than a UMG asset, so opening the map needs no content at
|
||||
// all. When there is a HUD this becomes a key on it and WBP_WorldMap owns the frame instead.
|
||||
Entry.Container = SNew(SBox)
|
||||
.Padding(48.f)
|
||||
[
|
||||
SNew(SBorder)
|
||||
.BorderImage(FCoreStyle::Get().GetBrush("GenericWhiteBox"))
|
||||
.BorderBackgroundColor(FLinearColor(0.f, 0.f, 0.f, 0.85f))
|
||||
.Padding(2.f)
|
||||
[
|
||||
SAssignNew(Entry.Map, SWorldMap)
|
||||
.Definition(Definition)
|
||||
.MarkerSource(Subsystem)
|
||||
]
|
||||
];
|
||||
|
||||
Entry.Map->SetFollowLocalPlayer(true);
|
||||
Viewport->AddViewportWidgetContent(Entry.Container.ToSharedRef(), 100);
|
||||
|
||||
if (APlayerController* Controller = World->GetFirstPlayerController())
|
||||
{
|
||||
// GameAndUI, not UIOnly: this is a map you glance at, and the game does not stop while it is open.
|
||||
FInputModeGameAndUI InputMode;
|
||||
InputMode.SetWidgetToFocus(Entry.Container);
|
||||
InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
|
||||
Controller->SetInputMode(InputMode);
|
||||
Controller->bShowMouseCursor = true;
|
||||
}
|
||||
|
||||
Subsystem->NoteMapOpened(Entry.Map->GetLayer(),
|
||||
static_cast<float>(Entry.Map->GetMetresPerPixel()), TEXT("bs.WorldMap"));
|
||||
|
||||
WorldMapOverlays.Add(MoveTemp(Entry));
|
||||
}
|
||||
|
||||
// bs.TelemetryTest: proves the seam end to end from the console. Emits cheat_used and taints the session.
|
||||
FAutoConsoleCommandWithWorld CmdTelemetryTest(
|
||||
TEXT("bs.TelemetryTest"),
|
||||
@@ -25,4 +121,81 @@ namespace
|
||||
Telemetry->MarkCheatUsed(TEXT("bs.TelemetryTest"));
|
||||
}
|
||||
}));
|
||||
|
||||
// bs.WorldMap: shows the world map over the viewport, or takes it away again. The only way in until there
|
||||
// is a HUD with a key on it; the widget it opens is the one the HUD will open.
|
||||
FAutoConsoleCommandWithWorld CmdWorldMap(
|
||||
TEXT("bs.WorldMap"),
|
||||
TEXT("Toggles the world map. Drag to pan, wheel to zoom, right-double-click to fit."),
|
||||
FConsoleCommandWithWorldDelegate::CreateLambda([](UWorld* World)
|
||||
{
|
||||
if (!World)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (UTelemetrySubsystem* Telemetry = TelemetryFor(World))
|
||||
{
|
||||
Telemetry->MarkCheatUsed(TEXT("bs.WorldMap"));
|
||||
}
|
||||
if (FindOverlay(World))
|
||||
{
|
||||
CloseWorldMap(World);
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenWorldMap(World);
|
||||
}
|
||||
}));
|
||||
|
||||
// bs.WorldMapFollow: put the view back on the player and keep it there. Panning drops the follow on purpose
|
||||
// - a map that snaps back as you let go cannot be read - so this is the way back to it. Left-double-click
|
||||
// on the map does the same thing without a console.
|
||||
FAutoConsoleCommandWithWorld CmdWorldMapFollow(
|
||||
TEXT("bs.WorldMapFollow"),
|
||||
TEXT("Centres the open world map on the local player and follows them again."),
|
||||
FConsoleCommandWithWorldDelegate::CreateLambda([](UWorld* World)
|
||||
{
|
||||
if (UTelemetrySubsystem* Telemetry = TelemetryFor(World))
|
||||
{
|
||||
Telemetry->MarkCheatUsed(TEXT("bs.WorldMapFollow"));
|
||||
}
|
||||
FWorldMapOverlay* Overlay = FindOverlay(World);
|
||||
if (!Overlay || !Overlay->Map.IsValid())
|
||||
{
|
||||
UE_LOG(LogSalty, Display, TEXT("bs.WorldMapFollow: the map is not open. bs.WorldMap first."));
|
||||
return;
|
||||
}
|
||||
if (!Overlay->Map->FocusOnLocalPlayer())
|
||||
{
|
||||
UE_LOG(LogSalty, Display, TEXT("bs.WorldMapFollow: there is no local body to follow."));
|
||||
}
|
||||
}));
|
||||
|
||||
// bs.WorldMapLayer <id>: relief, colour, satellite, climate - whatever RawContent/World/MapArt/layers.json
|
||||
// says. With no argument it cycles, which is what you want while looking at one place.
|
||||
FAutoConsoleCommandWithWorldAndArgs CmdWorldMapLayer(
|
||||
TEXT("bs.WorldMapLayer"),
|
||||
TEXT("Switches the open world map to a layer by id, or cycles when given none."),
|
||||
FConsoleCommandWithWorldAndArgsDelegate::CreateLambda([](const TArray<FString>& Args, UWorld* World)
|
||||
{
|
||||
if (UTelemetrySubsystem* Telemetry = TelemetryFor(World))
|
||||
{
|
||||
Telemetry->MarkCheatUsed(TEXT("bs.WorldMapLayer"));
|
||||
}
|
||||
FWorldMapOverlay* Overlay = FindOverlay(World);
|
||||
if (!Overlay || !Overlay->Map.IsValid())
|
||||
{
|
||||
UE_LOG(LogSalty, Display, TEXT("bs.WorldMapLayer: the map is not open. bs.WorldMap first."));
|
||||
return;
|
||||
}
|
||||
if (Args.Num() > 0)
|
||||
{
|
||||
Overlay->Map->SetLayer(FName(*Args[0]));
|
||||
}
|
||||
else
|
||||
{
|
||||
Overlay->Map->NextLayer();
|
||||
}
|
||||
UE_LOG(LogSalty, Display, TEXT("World map layer: %s"), *Overlay->Map->GetLayer().ToString());
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ public class Salty : ModuleRules
|
||||
"StateTreeModule",
|
||||
"GameplayStateTreeModule",
|
||||
"UMG",
|
||||
"Slate"
|
||||
"Slate",
|
||||
"SlateCore" // SWorldMap is a Slate widget: SLeafWidget, FSlateBrush, the draw elements
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[] { "Json" });
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
#include "UI/SWorldMap.h"
|
||||
|
||||
#include "Engine/Texture2D.h"
|
||||
#include "Brushes/SlateColorBrush.h"
|
||||
#include "Fonts/FontMeasure.h"
|
||||
#include "Framework/Application/SlateApplication.h"
|
||||
#include "Rendering/DrawElements.h"
|
||||
#include "Styling/CoreStyle.h"
|
||||
#include "World/WorldMapDefinition.h"
|
||||
#include "World/WorldMapSubsystem.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// Solid colour brushes, so nothing here depends on a style set existing. The editor tab and the game have
|
||||
// different ones and the map should look the same in both.
|
||||
const FSlateColorBrush& WhiteBrush()
|
||||
{
|
||||
static const FSlateColorBrush Brush(FLinearColor::White);
|
||||
return Brush;
|
||||
}
|
||||
|
||||
const FLinearColor Backdrop(0.045f, 0.055f, 0.070f, 1.f); // outside the map, and behind a missing layer
|
||||
const FLinearColor GridInk(1.f, 1.f, 1.f, 0.10f);
|
||||
const FLinearColor Ink(0.92f, 0.94f, 0.96f, 1.f);
|
||||
const FLinearColor Shadow(0.f, 0.f, 0.f, 0.55f);
|
||||
|
||||
FSlateFontInfo SmallFont() { return FCoreStyle::GetDefaultFontStyle("Regular", 9); }
|
||||
FSlateFontInfo LabelFont() { return FCoreStyle::GetDefaultFontStyle("Bold", 9); }
|
||||
|
||||
/** 1, 2 or 5 times a power of ten - the step a person reads as round, at or below the one asked for. */
|
||||
double NiceStep(double Target)
|
||||
{
|
||||
if (!(Target > 0.0))
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
const double Exponent = FMath::Floor(FMath::LogX(10.0, Target));
|
||||
const double Base = FMath::Pow(10.0, Exponent);
|
||||
const double Normalised = Target / Base;
|
||||
const double Multiplier = Normalised >= 5.0 ? 5.0 : (Normalised >= 2.0 ? 2.0 : 1.0);
|
||||
return Multiplier * Base;
|
||||
}
|
||||
|
||||
FString FormatDistance(double Metres)
|
||||
{
|
||||
return Metres >= 1000.0
|
||||
? FString::Printf(TEXT("%g km"), Metres / 1000.0)
|
||||
: FString::Printf(TEXT("%g m"), Metres);
|
||||
}
|
||||
|
||||
FVector2f ToF(const FVector2D& V) { return FVector2f(static_cast<float>(V.X), static_cast<float>(V.Y)); }
|
||||
|
||||
// Always the two-argument form. FSlateLayoutTransform(FVector2f) is ambiguous against its scale constructor,
|
||||
// and picking the wrong one puts everything at the origin at a scale of whatever the first component was.
|
||||
FSlateLayoutTransform LayoutAt(const FVector2f& Position) { return FSlateLayoutTransform(1.f, Position); }
|
||||
|
||||
FVector2D MeasureText(const FString& Text, const FSlateFontInfo& Font)
|
||||
{
|
||||
const TSharedRef<FSlateFontMeasure> Measure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
|
||||
return Measure->Measure(Text, Font);
|
||||
}
|
||||
}
|
||||
|
||||
void SWorldMap::Construct(const FArguments& InArgs)
|
||||
{
|
||||
Definition.Reset(InArgs._Definition);
|
||||
MarkerSource = InArgs._MarkerSource;
|
||||
Layer = InArgs._Layer;
|
||||
bShowGrid = InArgs._ShowGrid;
|
||||
bShowScaleBar = InArgs._ShowScaleBar;
|
||||
bShowMarkers = InArgs._ShowMarkers;
|
||||
bShowLocalPlayer = InArgs._ShowLocalPlayer;
|
||||
bAllowInput = InArgs._AllowInput;
|
||||
OnClicked = InArgs._OnClicked;
|
||||
|
||||
// The first zoom-to-fit and the follow-the-player both happen in Tick, so this widget has to have one.
|
||||
SetCanTick(true);
|
||||
|
||||
if (Layer.IsNone() && Definition.IsValid())
|
||||
{
|
||||
if (const FWorldMapLayer* Default = Definition->ResolveDefaultLayer())
|
||||
{
|
||||
Layer = Default->Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SWorldMap::SetDefinition(UWorldMapDefinition* InDefinition)
|
||||
{
|
||||
if (Definition.Get() == InDefinition)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Definition.Reset(InDefinition);
|
||||
// The brushes hold the old definition's textures alive; drop them with it, or switching worlds leaks a
|
||||
// map's worth of texture for as long as the widget lives.
|
||||
LayerBrushes.Reset();
|
||||
LoadedTextures.Reset();
|
||||
Layer = NAME_None;
|
||||
if (Definition.IsValid())
|
||||
{
|
||||
if (const FWorldMapLayer* Default = Definition->ResolveDefaultLayer())
|
||||
{
|
||||
Layer = Default->Id;
|
||||
}
|
||||
}
|
||||
bNeedsFit = true;
|
||||
}
|
||||
|
||||
void SWorldMap::SetMarkerSource(UWorldMapSubsystem* InSource)
|
||||
{
|
||||
MarkerSource = InSource;
|
||||
}
|
||||
|
||||
void SWorldMap::SetLayer(FName InLayer)
|
||||
{
|
||||
if (Definition.IsValid() && Definition->FindLayer(InLayer))
|
||||
{
|
||||
Layer = InLayer;
|
||||
}
|
||||
}
|
||||
|
||||
void SWorldMap::NextLayer()
|
||||
{
|
||||
if (Definition.IsValid())
|
||||
{
|
||||
SetLayer(Definition->NextLayerId(Layer));
|
||||
}
|
||||
}
|
||||
|
||||
const FWorldMapProjection& SWorldMap::GetProjection() const
|
||||
{
|
||||
static const FWorldMapProjection Empty;
|
||||
return Definition.IsValid() ? Definition->Projection : Empty;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// The view
|
||||
|
||||
double SWorldMap::FitMetresPerPixel(const FVector2D& LocalSizePx) const
|
||||
{
|
||||
const FWorldMapProjection& Projection = GetProjection();
|
||||
if (!Projection.IsValid() || LocalSizePx.X <= 0.0 || LocalSizePx.Y <= 0.0)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
// The larger of the two, so the whole world fits inside the widget rather than filling it.
|
||||
return FMath::Max(Projection.WidthM / LocalSizePx.X, Projection.HeightM / LocalSizePx.Y);
|
||||
}
|
||||
|
||||
SWorldMap::FView SWorldMap::ComputeView(const FVector2D& LocalSizePx) const
|
||||
{
|
||||
FView View;
|
||||
const FWorldMapProjection& Projection = GetProjection();
|
||||
if (!Projection.IsValid() || LocalSizePx.X <= 0.0 || LocalSizePx.Y <= 0.0 || MetresPerPixel <= 0.0)
|
||||
{
|
||||
return View;
|
||||
}
|
||||
View.SizePx = LocalSizePx;
|
||||
View.Centre = ViewCentre;
|
||||
// One metres-per-pixel for both axes, converted into each axis's own normalised span. This is what keeps
|
||||
// the ground unstretched on a map whose art is 2:1 inside a widget that is not.
|
||||
View.SpanU = LocalSizePx.X * MetresPerPixel / Projection.WidthM;
|
||||
View.SpanV = LocalSizePx.Y * MetresPerPixel / Projection.HeightM;
|
||||
View.bValid = true;
|
||||
return View;
|
||||
}
|
||||
|
||||
FVector2D SWorldMap::LocalToNormalised(const FView& View, const FVector2D& LocalPx) const
|
||||
{
|
||||
return FVector2D(
|
||||
View.Centre.X + (LocalPx.X / View.SizePx.X - 0.5) * View.SpanU,
|
||||
View.Centre.Y + (LocalPx.Y / View.SizePx.Y - 0.5) * View.SpanV);
|
||||
}
|
||||
|
||||
FVector2D SWorldMap::NormalisedToLocal(const FView& View, const FVector2D& Normalised) const
|
||||
{
|
||||
const FWorldMapProjection& Projection = GetProjection();
|
||||
// Shortest way round in U, so a place just the other side of the seam is drawn just off this side of the
|
||||
// screen and not most of a world away.
|
||||
const double DeltaU = Projection.ShortestDeltaU(View.Centre.X, Normalised.X);
|
||||
return FVector2D(
|
||||
(0.5 + DeltaU / View.SpanU) * View.SizePx.X,
|
||||
(0.5 + (Normalised.Y - View.Centre.Y) / View.SpanV) * View.SizePx.Y);
|
||||
}
|
||||
|
||||
void SWorldMap::ClampCentre(const FVector2D& LocalSizePx)
|
||||
{
|
||||
const FWorldMapProjection& Projection = GetProjection();
|
||||
if (!Projection.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
const FView View = ComputeView(LocalSizePx);
|
||||
if (!View.bValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ViewCentre.X = Projection.bWrapsX
|
||||
? Projection.WrapU(ViewCentre.X)
|
||||
: FMath::Clamp(ViewCentre.X, FMath::Min(0.5, View.SpanU * 0.5), FMath::Max(0.5, 1.0 - View.SpanU * 0.5));
|
||||
|
||||
// V never wraps: a cylinder has no route over its poles, and a map that scrolled past them would put the
|
||||
// arctic next to the antarctic.
|
||||
ViewCentre.Y = View.SpanV >= 1.0
|
||||
? 0.5
|
||||
: FMath::Clamp(ViewCentre.Y, View.SpanV * 0.5, 1.0 - View.SpanV * 0.5);
|
||||
}
|
||||
|
||||
void SWorldMap::SetMetresPerPixel(double InMetresPerPixel)
|
||||
{
|
||||
MetresPerPixel = FMath::Max(InMetresPerPixel, 0.25);
|
||||
bNeedsFit = false;
|
||||
}
|
||||
|
||||
void SWorldMap::ZoomToFit()
|
||||
{
|
||||
const FVector2D Size = GetTickSpaceGeometry().GetLocalSize();
|
||||
if (Size.X > 0.0 && Size.Y > 0.0)
|
||||
{
|
||||
MetresPerPixel = FitMetresPerPixel(Size);
|
||||
}
|
||||
ViewCentre = FVector2D(0.5, 0.5);
|
||||
bNeedsFit = false;
|
||||
}
|
||||
|
||||
void SWorldMap::FocusOnWorld(const FVector2D& WorldCm)
|
||||
{
|
||||
ViewCentre = GetProjection().WrapNormalised(GetProjection().WorldToNormalised(WorldCm));
|
||||
ClampCentre(GetTickSpaceGeometry().GetLocalSize());
|
||||
}
|
||||
|
||||
FVector2D SWorldMap::GetViewCentreWorldCm() const
|
||||
{
|
||||
return GetProjection().NormalisedToWorldCm(ViewCentre);
|
||||
}
|
||||
|
||||
bool SWorldMap::FocusOnLocalPlayer()
|
||||
{
|
||||
const UWorldMapSubsystem* Source = MarkerSource.Get();
|
||||
if (!Source)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
FWorldMapMarker Player;
|
||||
if (!Source->GetLocalPlayerMarker(Player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
FocusOnWorld(FVector2D(Player.WorldLocation.X, Player.WorldLocation.Y));
|
||||
// Set after FocusOnWorld, not before: FocusOnWorld is also what a "go here" jump calls, and that one must
|
||||
// not quietly re-arm the follow.
|
||||
bFollowLocalPlayer = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SWorldMap::Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime)
|
||||
{
|
||||
SLeafWidget::Tick(AllottedGeometry, InCurrentTime, InDeltaTime);
|
||||
|
||||
const FVector2D Size = AllottedGeometry.GetLocalSize();
|
||||
if (Size.X <= 0.0 || Size.Y <= 0.0 || !GetProjection().IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The first fit has to wait for a size, which a widget does not have until it has been laid out once.
|
||||
if (bNeedsFit || MetresPerPixel <= 0.0)
|
||||
{
|
||||
MetresPerPixel = FitMetresPerPixel(Size);
|
||||
ViewCentre = FVector2D(0.5, 0.5);
|
||||
bNeedsFit = false;
|
||||
}
|
||||
|
||||
// Never zoom out past the whole world; the widget can be resized under us, so this is checked every tick
|
||||
// rather than only when the zoom changes.
|
||||
MetresPerPixel = FMath::Min(MetresPerPixel, FitMetresPerPixel(Size));
|
||||
|
||||
if (bFollowLocalPlayer && !bDragging)
|
||||
{
|
||||
if (const UWorldMapSubsystem* Source = MarkerSource.Get())
|
||||
{
|
||||
FWorldMapMarker Player;
|
||||
if (Source->GetLocalPlayerMarker(Player))
|
||||
{
|
||||
ViewCentre = GetProjection().WrapNormalised(
|
||||
GetProjection().WorldToNormalised(FVector2D(Player.WorldLocation.X, Player.WorldLocation.Y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClampCentre(Size);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// Painting
|
||||
|
||||
const FSlateBrush* SWorldMap::BrushForCurrentLayer() const
|
||||
{
|
||||
if (!Definition.IsValid())
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
const FWorldMapLayer* Found = Definition->FindLayer(Layer);
|
||||
if (!Found)
|
||||
{
|
||||
Found = Definition->ResolveDefaultLayer();
|
||||
}
|
||||
if (!Found)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (const TSharedPtr<FSlateBrush>* Cached = LayerBrushes.Find(Found->Id))
|
||||
{
|
||||
return Cached->Get();
|
||||
}
|
||||
|
||||
// Soft, so a map that is never opened costs nothing; loaded here, the once, when it first is.
|
||||
UTexture2D* Texture = Found->Texture.LoadSynchronous();
|
||||
if (!Texture)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
LoadedTextures.Add(TStrongObjectPtr<UTexture2D>(Texture));
|
||||
|
||||
TSharedRef<FSlateBrush> Brush = MakeShared<FSlateBrush>();
|
||||
Brush->SetResourceObject(Texture);
|
||||
Brush->ImageSize = FVector2f(static_cast<float>(Texture->GetSizeX()), static_cast<float>(Texture->GetSizeY()));
|
||||
Brush->DrawAs = ESlateBrushDrawType::Image;
|
||||
Brush->Tiling = ESlateBrushTileType::NoTile;
|
||||
LayerBrushes.Add(Found->Id, Brush);
|
||||
return &Brush.Get();
|
||||
}
|
||||
|
||||
int32 SWorldMap::PaintMap(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const
|
||||
{
|
||||
const FSlateBrush* Brush = BrushForCurrentLayer();
|
||||
if (!Brush)
|
||||
{
|
||||
return LayerId;
|
||||
}
|
||||
const FWorldMapProjection& Projection = GetProjection();
|
||||
|
||||
const double U0 = View.Centre.X - View.SpanU * 0.5;
|
||||
const double V0 = View.Centre.Y - View.SpanV * 0.5;
|
||||
|
||||
// Vertical is simple: clip the view to the art and letterbox whatever is left over.
|
||||
const double VisibleV0 = FMath::Max(V0, 0.0);
|
||||
const double VisibleV1 = FMath::Min(V0 + View.SpanV, 1.0);
|
||||
if (VisibleV1 <= VisibleV0)
|
||||
{
|
||||
return LayerId;
|
||||
}
|
||||
const double ScreenY0 = (VisibleV0 - V0) / View.SpanV * View.SizePx.Y;
|
||||
const double ScreenY1 = (VisibleV1 - V0) / View.SpanV * View.SizePx.Y;
|
||||
|
||||
// Horizontal is where the cylinder shows. A view that straddles the seam is two draws of two different
|
||||
// parts of the same image, so the map is continuous rather than stopping at the edge of the art.
|
||||
const int32 FirstCopy = Projection.bWrapsX ? FMath::FloorToInt32(U0) : 0;
|
||||
const int32 LastCopy = Projection.bWrapsX ? FMath::FloorToInt32(U0 + View.SpanU) : 0;
|
||||
|
||||
for (int32 Copy = FirstCopy; Copy <= LastCopy; ++Copy)
|
||||
{
|
||||
const double CopyU0 = static_cast<double>(Copy);
|
||||
const double SegU0 = FMath::Max(U0, CopyU0);
|
||||
const double SegU1 = FMath::Min(U0 + View.SpanU, CopyU0 + 1.0);
|
||||
if (SegU1 <= SegU0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const double ScreenX0 = (SegU0 - U0) / View.SpanU * View.SizePx.X;
|
||||
const double ScreenX1 = (SegU1 - U0) / View.SpanU * View.SizePx.X;
|
||||
|
||||
FSlateBrush Segment = *Brush;
|
||||
Segment.SetUVRegion(FBox2f(
|
||||
FVector2f(static_cast<float>(SegU0 - CopyU0), static_cast<float>(VisibleV0)),
|
||||
FVector2f(static_cast<float>(SegU1 - CopyU0), static_cast<float>(VisibleV1))));
|
||||
|
||||
FSlateDrawElement::MakeBox(
|
||||
Out, LayerId,
|
||||
Geometry.ToPaintGeometry(
|
||||
FVector2f(static_cast<float>(ScreenX1 - ScreenX0), static_cast<float>(ScreenY1 - ScreenY0)),
|
||||
LayoutAt(FVector2f(static_cast<float>(ScreenX0), static_cast<float>(ScreenY0)))),
|
||||
&Segment, ESlateDrawEffect::None, FLinearColor::White);
|
||||
}
|
||||
|
||||
return LayerId + 1;
|
||||
}
|
||||
|
||||
int32 SWorldMap::PaintGrid(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const
|
||||
{
|
||||
const FWorldMapProjection& Projection = GetProjection();
|
||||
|
||||
// A grid line about every 120 px, snapped to a round number of metres. Done in continuous world space so
|
||||
// it crosses the seam without a special case.
|
||||
const double StepM = NiceStep(120.0 * MetresPerPixel);
|
||||
const FVector2D TopLeftM = Projection.NormalisedToWorldM(LocalToNormalised(View, FVector2D::ZeroVector));
|
||||
const FVector2D BottomRightM = Projection.NormalisedToWorldM(LocalToNormalised(View, View.SizePx));
|
||||
|
||||
const int32 MaxLines = 200; // a guard: a tiny step and a big view is a lot of draw calls for a faint grid
|
||||
|
||||
int32 Drawn = 0;
|
||||
for (double X = FMath::CeilToDouble(TopLeftM.X / StepM) * StepM; X <= BottomRightM.X && Drawn < MaxLines; X += StepM, ++Drawn)
|
||||
{
|
||||
const float Px = static_cast<float>((X - TopLeftM.X) / (BottomRightM.X - TopLeftM.X) * View.SizePx.X);
|
||||
const TArray<FVector2f> Points = { FVector2f(Px, 0.f), FVector2f(Px, static_cast<float>(View.SizePx.Y)) };
|
||||
FSlateDrawElement::MakeLines(Out, LayerId, Geometry.ToPaintGeometry(), Points, ESlateDrawEffect::None, GridInk, false, 1.f);
|
||||
}
|
||||
|
||||
Drawn = 0;
|
||||
for (double Y = FMath::CeilToDouble(TopLeftM.Y / StepM) * StepM; Y <= BottomRightM.Y && Drawn < MaxLines; Y += StepM, ++Drawn)
|
||||
{
|
||||
const float Py = static_cast<float>((Y - TopLeftM.Y) / (BottomRightM.Y - TopLeftM.Y) * View.SizePx.Y);
|
||||
const TArray<FVector2f> Points = { FVector2f(0.f, Py), FVector2f(static_cast<float>(View.SizePx.X), Py) };
|
||||
FSlateDrawElement::MakeLines(Out, LayerId, Geometry.ToPaintGeometry(), Points, ESlateDrawEffect::None, GridInk, false, 1.f);
|
||||
}
|
||||
|
||||
return LayerId + 1;
|
||||
}
|
||||
|
||||
int32 SWorldMap::PaintOneMarker(const FGeometry& Geometry, const FView& View, const FWorldMapMarker& Marker,
|
||||
FSlateWindowElementList& Out, int32 LayerId) const
|
||||
{
|
||||
const FWorldMapProjection& Projection = GetProjection();
|
||||
const FVector2D Normalised = Projection.WorldToNormalised(FVector2D(Marker.WorldLocation.X, Marker.WorldLocation.Y));
|
||||
const FVector2D At = NormalisedToLocal(View, Normalised);
|
||||
|
||||
// Off screen by more than its own size: nothing to draw. Cheap, and it is what makes a few thousand
|
||||
// registered markers cost nothing when you are looking at one valley.
|
||||
const double Slack = Marker.SizePx * 2.0;
|
||||
if (At.X < -Slack || At.Y < -Slack || At.X > View.SizePx.X + Slack || At.Y > View.SizePx.Y + Slack)
|
||||
{
|
||||
return LayerId;
|
||||
}
|
||||
|
||||
const float Size = Marker.SizePx;
|
||||
const float Half = Size * 0.5f;
|
||||
const FVector2f Centre = ToF(At);
|
||||
|
||||
switch (Marker.Shape)
|
||||
{
|
||||
case EWorldMapMarkerShape::Dot:
|
||||
{
|
||||
// A dark plate under the dot, so a pale marker is still visible on snow and a dark one on deep water.
|
||||
FSlateDrawElement::MakeBox(Out, LayerId,
|
||||
Geometry.ToPaintGeometry(FVector2f(Size + 2.f, Size + 2.f), LayoutAt(Centre - FVector2f(Half + 1.f))),
|
||||
&WhiteBrush(), ESlateDrawEffect::None, Shadow);
|
||||
FSlateDrawElement::MakeBox(Out, LayerId + 1,
|
||||
Geometry.ToPaintGeometry(FVector2f(Size, Size), LayoutAt(Centre - FVector2f(Half))),
|
||||
&WhiteBrush(), ESlateDrawEffect::None, Marker.Colour);
|
||||
break;
|
||||
}
|
||||
case EWorldMapMarkerShape::Ring:
|
||||
{
|
||||
TArray<FVector2f> Points;
|
||||
constexpr int32 Segments = 20;
|
||||
Points.Reserve(Segments + 1);
|
||||
for (int32 i = 0; i <= Segments; ++i)
|
||||
{
|
||||
const float Angle = 2.f * PI * i / Segments;
|
||||
Points.Add(Centre + FVector2f(FMath::Cos(Angle), FMath::Sin(Angle)) * Half);
|
||||
}
|
||||
FSlateDrawElement::MakeLines(Out, LayerId, Geometry.ToPaintGeometry(), Points, ESlateDrawEffect::None, Shadow, true, 3.f);
|
||||
FSlateDrawElement::MakeLines(Out, LayerId + 1, Geometry.ToPaintGeometry(), Points, ESlateDrawEffect::None, Marker.Colour, true, 1.5f);
|
||||
break;
|
||||
}
|
||||
case EWorldMapMarkerShape::Cross:
|
||||
{
|
||||
const TArray<FVector2f> Across = { Centre - FVector2f(Half, 0.f), Centre + FVector2f(Half, 0.f) };
|
||||
const TArray<FVector2f> Down = { Centre - FVector2f(0.f, Half), Centre + FVector2f(0.f, Half) };
|
||||
FSlateDrawElement::MakeLines(Out, LayerId, Geometry.ToPaintGeometry(), Across, ESlateDrawEffect::None, Shadow, true, 3.5f);
|
||||
FSlateDrawElement::MakeLines(Out, LayerId, Geometry.ToPaintGeometry(), Down, ESlateDrawEffect::None, Shadow, true, 3.5f);
|
||||
FSlateDrawElement::MakeLines(Out, LayerId + 1, Geometry.ToPaintGeometry(), Across, ESlateDrawEffect::None, Marker.Colour, true, 1.5f);
|
||||
FSlateDrawElement::MakeLines(Out, LayerId + 1, Geometry.ToPaintGeometry(), Down, ESlateDrawEffect::None, Marker.Colour, true, 1.5f);
|
||||
break;
|
||||
}
|
||||
case EWorldMapMarkerShape::Arrow:
|
||||
{
|
||||
// World +X is right on the map and world +Y is down, so a yaw is a screen angle unchanged. That is only
|
||||
// true because the projection does not rotate the world; see FWorldMapProjection on the axes.
|
||||
const float Angle = FMath::DegreesToRadians(Marker.HeadingDegrees);
|
||||
auto Point = [&](float Distance, float Offset)
|
||||
{
|
||||
const float A = Angle + Offset;
|
||||
return Centre + FVector2f(FMath::Cos(A), FMath::Sin(A)) * Distance;
|
||||
};
|
||||
const TArray<FVector2f> Triangle = {
|
||||
Point(Size * 0.85f, 0.f),
|
||||
Point(Size * 0.60f, 2.4f),
|
||||
Point(Size * 0.25f, PI),
|
||||
Point(Size * 0.60f, -2.4f),
|
||||
Point(Size * 0.85f, 0.f),
|
||||
};
|
||||
FSlateDrawElement::MakeLines(Out, LayerId, Geometry.ToPaintGeometry(), Triangle, ESlateDrawEffect::None, Shadow, true, 4.f);
|
||||
FSlateDrawElement::MakeLines(Out, LayerId + 1, Geometry.ToPaintGeometry(), Triangle, ESlateDrawEffect::None, Marker.Colour, true, 2.f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Marker.Label.IsEmpty())
|
||||
{
|
||||
const FString Text = Marker.Label.ToString();
|
||||
const FVector2f TextAt = Centre + FVector2f(Half + 4.f, -Half - 2.f);
|
||||
FSlateDrawElement::MakeText(Out, LayerId + 1,
|
||||
Geometry.ToPaintGeometry(LayoutAt(TextAt + FVector2f(1.f, 1.f))),
|
||||
Text, LabelFont(), ESlateDrawEffect::None, Shadow);
|
||||
FSlateDrawElement::MakeText(Out, LayerId + 2,
|
||||
Geometry.ToPaintGeometry(LayoutAt(TextAt)),
|
||||
Text, LabelFont(), ESlateDrawEffect::None, Marker.Colour);
|
||||
}
|
||||
|
||||
return LayerId + 3;
|
||||
}
|
||||
|
||||
int32 SWorldMap::PaintMarkers(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const
|
||||
{
|
||||
const UWorldMapSubsystem* Source = MarkerSource.Get();
|
||||
if (!Source)
|
||||
{
|
||||
return LayerId;
|
||||
}
|
||||
|
||||
int32 Next = LayerId;
|
||||
if (bShowMarkers)
|
||||
{
|
||||
for (const TPair<FGuid, FWorldMapMarker>& Pair : Source->GetMarkers())
|
||||
{
|
||||
Next = FMath::Max(Next, PaintOneMarker(Geometry, View, Pair.Value, Out, LayerId));
|
||||
}
|
||||
}
|
||||
|
||||
// The body last, so it is never hidden under a waypoint that happens to be on top of it.
|
||||
if (bShowLocalPlayer)
|
||||
{
|
||||
FWorldMapMarker Player;
|
||||
if (Source->GetLocalPlayerMarker(Player))
|
||||
{
|
||||
// A halo under the arrow. Zoomed out to the whole world the arrow is a dozen pixels on 71 km of
|
||||
// map and takes real hunting to find, which makes "where am I" - the one question a player map
|
||||
// exists to answer - the hardest thing on it. The ring is findable at a glance and costs nothing
|
||||
// close in, where it simply surrounds the arrow.
|
||||
FWorldMapMarker Halo = Player;
|
||||
Halo.Shape = EWorldMapMarkerShape::Ring;
|
||||
Halo.SizePx = Player.SizePx * 2.4f;
|
||||
Halo.Colour = Player.Colour.CopyWithNewOpacity(0.5f);
|
||||
Halo.Label = FText::GetEmpty();
|
||||
Next = FMath::Max(Next, PaintOneMarker(Geometry, View, Halo, Out, Next));
|
||||
|
||||
Next = FMath::Max(Next, PaintOneMarker(Geometry, View, Player, Out, Next));
|
||||
}
|
||||
}
|
||||
return Next;
|
||||
}
|
||||
|
||||
int32 SWorldMap::PaintScaleBar(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const
|
||||
{
|
||||
const double TargetM = 140.0 * MetresPerPixel;
|
||||
const double StepM = NiceStep(TargetM);
|
||||
const float BarPx = static_cast<float>(StepM / MetresPerPixel);
|
||||
if (BarPx < 8.f || BarPx > View.SizePx.X)
|
||||
{
|
||||
return LayerId;
|
||||
}
|
||||
|
||||
const float Left = 12.f;
|
||||
const float Bottom = static_cast<float>(View.SizePx.Y) - 16.f;
|
||||
|
||||
const TArray<FVector2f> Bar = {
|
||||
FVector2f(Left, Bottom - 4.f), FVector2f(Left, Bottom),
|
||||
FVector2f(Left + BarPx, Bottom), FVector2f(Left + BarPx, Bottom - 4.f),
|
||||
};
|
||||
FSlateDrawElement::MakeLines(Out, LayerId, Geometry.ToPaintGeometry(), Bar, ESlateDrawEffect::None, Shadow, true, 3.5f);
|
||||
FSlateDrawElement::MakeLines(Out, LayerId + 1, Geometry.ToPaintGeometry(), Bar, ESlateDrawEffect::None, Ink, true, 1.5f);
|
||||
|
||||
const FString Text = FormatDistance(StepM);
|
||||
const FVector2f TextAt(Left, Bottom - 4.f - 13.f);
|
||||
FSlateDrawElement::MakeText(Out, LayerId + 1, Geometry.ToPaintGeometry(LayoutAt(TextAt + FVector2f(1.f, 1.f))),
|
||||
Text, SmallFont(), ESlateDrawEffect::None, Shadow);
|
||||
FSlateDrawElement::MakeText(Out, LayerId + 2, Geometry.ToPaintGeometry(LayoutAt(TextAt)),
|
||||
Text, SmallFont(), ESlateDrawEffect::None, Ink);
|
||||
|
||||
return LayerId + 3;
|
||||
}
|
||||
|
||||
int32 SWorldMap::PaintReadout(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const
|
||||
{
|
||||
if (!HoverLocal.IsSet())
|
||||
{
|
||||
return LayerId;
|
||||
}
|
||||
const FWorldMapProjection& Projection = GetProjection();
|
||||
const FVector2D Normalised = Projection.WrapNormalised(LocalToNormalised(View, HoverLocal.GetValue()));
|
||||
// Outside the art vertically is off the world, not a place - say nothing rather than a made-up latitude.
|
||||
if (Normalised.Y < 0.0 || Normalised.Y > 1.0)
|
||||
{
|
||||
return LayerId;
|
||||
}
|
||||
const FVector2D WorldM = Projection.NormalisedToWorldM(Normalised);
|
||||
|
||||
const FString Text = FString::Printf(TEXT("X %s Y %s %s"),
|
||||
*FString::Printf(TEXT("%.0f m"), WorldM.X),
|
||||
*FString::Printf(TEXT("%.0f m"), WorldM.Y),
|
||||
*Layer.ToString());
|
||||
|
||||
const FVector2D TextSize = MeasureText(Text, SmallFont());
|
||||
const FVector2f At(static_cast<float>(View.SizePx.X - TextSize.X - 12.0), static_cast<float>(View.SizePx.Y - 16.0 - TextSize.Y * 0.5));
|
||||
|
||||
FSlateDrawElement::MakeBox(Out, LayerId,
|
||||
Geometry.ToPaintGeometry(ToF(TextSize + FVector2D(8.0, 4.0)), LayoutAt(At - FVector2f(4.f, 2.f))),
|
||||
&WhiteBrush(), ESlateDrawEffect::None, Shadow);
|
||||
FSlateDrawElement::MakeText(Out, LayerId + 1, Geometry.ToPaintGeometry(LayoutAt(At)),
|
||||
Text, SmallFont(), ESlateDrawEffect::None, Ink);
|
||||
|
||||
return LayerId + 2;
|
||||
}
|
||||
|
||||
int32 SWorldMap::PaintEmptyState(const FGeometry& Geometry, FSlateWindowElementList& Out, int32 LayerId) const
|
||||
{
|
||||
// Say which of the several nothings this is. A blank panel is the one outcome that cannot be debugged.
|
||||
FString Text;
|
||||
if (!Definition.IsValid())
|
||||
{
|
||||
Text = TEXT("No world map for this level.\nRun Scripts/Authoring/build_world_map.sh to build one.");
|
||||
}
|
||||
else if (!Definition->Projection.IsValid())
|
||||
{
|
||||
Text = TEXT("The map definition has no projection: it does not know how big the world is.");
|
||||
}
|
||||
else if (Definition->Layers.Num() == 0)
|
||||
{
|
||||
Text = TEXT("The map definition has no layers.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Text = FString::Printf(TEXT("Layer '%s' has no texture."), *Layer.ToString());
|
||||
}
|
||||
|
||||
const FVector2D Size = Geometry.GetLocalSize();
|
||||
const FVector2D TextSize = MeasureText(Text, SmallFont());
|
||||
const FVector2f At(static_cast<float>((Size.X - TextSize.X) * 0.5), static_cast<float>((Size.Y - TextSize.Y) * 0.5));
|
||||
FSlateDrawElement::MakeText(Out, LayerId, Geometry.ToPaintGeometry(LayoutAt(At)),
|
||||
Text, SmallFont(), ESlateDrawEffect::None, FLinearColor(0.65f, 0.67f, 0.70f));
|
||||
return LayerId + 1;
|
||||
}
|
||||
|
||||
int32 SWorldMap::OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect,
|
||||
FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
|
||||
{
|
||||
// The backdrop is always drawn: it is what the letterbox above and below a 2:1 map in a 16:9 panel is made
|
||||
// of, and it is what a missing layer shows through.
|
||||
FSlateDrawElement::MakeBox(OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry(),
|
||||
&WhiteBrush(), ESlateDrawEffect::None, Backdrop);
|
||||
int32 Next = LayerId + 1;
|
||||
|
||||
const FView View = ComputeView(AllottedGeometry.GetLocalSize());
|
||||
if (!View.bValid || !BrushForCurrentLayer())
|
||||
{
|
||||
return PaintEmptyState(AllottedGeometry, OutDrawElements, Next);
|
||||
}
|
||||
|
||||
Next = PaintMap(AllottedGeometry, View, OutDrawElements, Next);
|
||||
if (bShowGrid)
|
||||
{
|
||||
Next = PaintGrid(AllottedGeometry, View, OutDrawElements, Next);
|
||||
}
|
||||
Next = PaintMarkers(AllottedGeometry, View, OutDrawElements, Next);
|
||||
if (bShowScaleBar)
|
||||
{
|
||||
Next = PaintScaleBar(AllottedGeometry, View, OutDrawElements, Next);
|
||||
Next = PaintReadout(AllottedGeometry, View, OutDrawElements, Next);
|
||||
}
|
||||
return Next;
|
||||
}
|
||||
|
||||
FVector2D SWorldMap::ComputeDesiredSize(float) const
|
||||
{
|
||||
return FVector2D(640.0, 360.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// Input
|
||||
|
||||
FReply SWorldMap::OnMouseButtonDown(const FGeometry& Geometry, const FPointerEvent& Event)
|
||||
{
|
||||
if (!bAllowInput)
|
||||
{
|
||||
return FReply::Unhandled();
|
||||
}
|
||||
const bool bLeft = Event.GetEffectingButton() == EKeys::LeftMouseButton;
|
||||
const bool bRight = Event.GetEffectingButton() == EKeys::RightMouseButton;
|
||||
if (!bLeft && !bRight)
|
||||
{
|
||||
return FReply::Unhandled();
|
||||
}
|
||||
|
||||
bDragging = true;
|
||||
bDraggedFarEnoughToNotBeAClick = false;
|
||||
DragLastLocal = Geometry.AbsoluteToLocal(Event.GetScreenSpacePosition());
|
||||
PressLocal = DragLastLocal;
|
||||
// Capture, so a drag that leaves the widget keeps panning instead of stopping at the edge.
|
||||
return FReply::Handled().CaptureMouse(SharedThis(this));
|
||||
}
|
||||
|
||||
FReply SWorldMap::OnMouseButtonUp(const FGeometry& Geometry, const FPointerEvent& Event)
|
||||
{
|
||||
if (!bDragging)
|
||||
{
|
||||
return FReply::Unhandled();
|
||||
}
|
||||
bDragging = false;
|
||||
|
||||
FReply Reply = FReply::Handled().ReleaseMouseCapture();
|
||||
|
||||
// A press that never moved is a click on a place. A press that panned is not, or every pan would also
|
||||
// teleport whatever is bound to a click.
|
||||
if (!bDraggedFarEnoughToNotBeAClick && Event.GetEffectingButton() == EKeys::LeftMouseButton && OnClicked.IsBound())
|
||||
{
|
||||
const FView View = ComputeView(Geometry.GetLocalSize());
|
||||
if (View.bValid)
|
||||
{
|
||||
const FVector2D Local = Geometry.AbsoluteToLocal(Event.GetScreenSpacePosition());
|
||||
const FVector2D Normalised = GetProjection().WrapNormalised(LocalToNormalised(View, Local));
|
||||
if (Normalised.Y >= 0.0 && Normalised.Y <= 1.0)
|
||||
{
|
||||
OnClicked.Execute(GetProjection().NormalisedToWorldCm(Normalised));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Reply;
|
||||
}
|
||||
|
||||
FReply SWorldMap::OnMouseMove(const FGeometry& Geometry, const FPointerEvent& Event)
|
||||
{
|
||||
const FVector2D Local = Geometry.AbsoluteToLocal(Event.GetScreenSpacePosition());
|
||||
HoverLocal = Local;
|
||||
|
||||
if (!bDragging || !bAllowInput)
|
||||
{
|
||||
return FReply::Unhandled();
|
||||
}
|
||||
|
||||
const FVector2D DeltaPx = Local - DragLastLocal;
|
||||
DragLastLocal = Local;
|
||||
if (FVector2D::Distance(Local, PressLocal) > 3.0)
|
||||
{
|
||||
bDraggedFarEnoughToNotBeAClick = true;
|
||||
// Dragging is asking to look somewhere else, which is incompatible with being dragged along by the
|
||||
// player. Let go of the follow rather than fighting it every frame.
|
||||
bFollowLocalPlayer = false;
|
||||
}
|
||||
|
||||
const FView View = ComputeView(Geometry.GetLocalSize());
|
||||
if (View.bValid)
|
||||
{
|
||||
ViewCentre.X -= DeltaPx.X / View.SizePx.X * View.SpanU;
|
||||
ViewCentre.Y -= DeltaPx.Y / View.SizePx.Y * View.SpanV;
|
||||
ClampCentre(Geometry.GetLocalSize());
|
||||
}
|
||||
return FReply::Handled();
|
||||
}
|
||||
|
||||
FReply SWorldMap::OnMouseWheel(const FGeometry& Geometry, const FPointerEvent& Event)
|
||||
{
|
||||
if (!bAllowInput)
|
||||
{
|
||||
return FReply::Unhandled();
|
||||
}
|
||||
const FVector2D Size = Geometry.GetLocalSize();
|
||||
FView View = ComputeView(Size);
|
||||
if (!View.bValid)
|
||||
{
|
||||
return FReply::Unhandled();
|
||||
}
|
||||
|
||||
// Zoom about the cursor: the place under the pointer is the place that stays put, which is the only zoom
|
||||
// that feels like a map rather than like a slideshow.
|
||||
const FVector2D Local = Geometry.AbsoluteToLocal(Event.GetScreenSpacePosition());
|
||||
const FVector2D Anchor = LocalToNormalised(View, Local);
|
||||
|
||||
const double Factor = FMath::Pow(0.85, Event.GetWheelDelta());
|
||||
MetresPerPixel = FMath::Clamp(MetresPerPixel * Factor, 0.25, FitMetresPerPixel(Size));
|
||||
bNeedsFit = false;
|
||||
|
||||
View = ComputeView(Size);
|
||||
const FVector2D AfterAnchor = LocalToNormalised(View, Local);
|
||||
ViewCentre += Anchor - AfterAnchor;
|
||||
ClampCentre(Size);
|
||||
|
||||
return FReply::Handled();
|
||||
}
|
||||
|
||||
FReply SWorldMap::OnMouseButtonDoubleClick(const FGeometry& Geometry, const FPointerEvent& Event)
|
||||
{
|
||||
if (!bAllowInput)
|
||||
{
|
||||
return FReply::Unhandled();
|
||||
}
|
||||
// Left is "take me back to me", right is "show me everything" - the two places anyone wants to get to in
|
||||
// one gesture. Left is the one with no other way to reach it: panning drops the follow, and before this
|
||||
// the only way to resume it was to close the map and open it again.
|
||||
if (Event.GetEffectingButton() == EKeys::LeftMouseButton)
|
||||
{
|
||||
return FocusOnLocalPlayer() ? FReply::Handled() : FReply::Unhandled();
|
||||
}
|
||||
if (Event.GetEffectingButton() == EKeys::RightMouseButton)
|
||||
{
|
||||
ZoomToFit();
|
||||
return FReply::Handled();
|
||||
}
|
||||
return FReply::Unhandled();
|
||||
}
|
||||
|
||||
void SWorldMap::OnMouseLeave(const FPointerEvent& Event)
|
||||
{
|
||||
HoverLocal.Reset();
|
||||
SLeafWidget::OnMouseLeave(Event);
|
||||
}
|
||||
|
||||
FCursorReply SWorldMap::OnCursorQuery(const FGeometry& Geometry, const FPointerEvent& Event) const
|
||||
{
|
||||
if (!bAllowInput)
|
||||
{
|
||||
return FCursorReply::Unhandled();
|
||||
}
|
||||
return FCursorReply::Cursor(bDragging ? EMouseCursor::GrabHandClosed : EMouseCursor::Crosshairs);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Styling/SlateBrush.h"
|
||||
#include "UObject/StrongObjectPtr.h"
|
||||
#include "Widgets/SLeafWidget.h"
|
||||
#include "World/WorldMapProjection.h"
|
||||
|
||||
class UTexture2D;
|
||||
class UWorldMapDefinition;
|
||||
class UWorldMapSubsystem;
|
||||
|
||||
/** A place on the ground was clicked, in world centimetres. XY only; the map is a plan and knows no Z. */
|
||||
DECLARE_DELEGATE_OneParam(FOnWorldMapClicked, FVector2D /* WorldCm */);
|
||||
|
||||
/**
|
||||
* The world map. All of it: the drawing, the panning, the zooming, the markers and the scale bar.
|
||||
*
|
||||
* It is a Slate widget rather than a UUserWidget because it has two hosts that have nothing else in common -
|
||||
* UWorldMapWidget puts it in UMG for the game, and the editor's World Map tab hosts it directly - and a Slate
|
||||
* widget is the one shape both can take. The editor tab in particular has no UWorld, so anything that needed
|
||||
* one to exist could not be shared.
|
||||
*
|
||||
* It draws a rectangle of a picture, and the whole trick is that the picture and the world are the same
|
||||
* rectangle (see FWorldMapProjection). There is no scene capture and no render target anywhere near this.
|
||||
*/
|
||||
class SALTY_API SWorldMap : public SLeafWidget
|
||||
{
|
||||
public:
|
||||
SLATE_BEGIN_ARGS(SWorldMap)
|
||||
: _Definition(nullptr)
|
||||
, _Layer(NAME_None)
|
||||
, _ShowGrid(true)
|
||||
, _ShowScaleBar(true)
|
||||
, _ShowMarkers(true)
|
||||
, _ShowLocalPlayer(true)
|
||||
, _AllowInput(true)
|
||||
{}
|
||||
/** What to draw. Null draws the empty state and says so rather than drawing nothing. */
|
||||
SLATE_ARGUMENT(UWorldMapDefinition*, Definition)
|
||||
/** Which layer; NAME_None takes the definition's default. */
|
||||
SLATE_ARGUMENT(FName, Layer)
|
||||
SLATE_ARGUMENT(bool, ShowGrid)
|
||||
SLATE_ARGUMENT(bool, ShowScaleBar)
|
||||
SLATE_ARGUMENT(bool, ShowMarkers)
|
||||
SLATE_ARGUMENT(bool, ShowLocalPlayer)
|
||||
/** False makes it a picture: no pan, no zoom, no click. For a minimap. */
|
||||
SLATE_ARGUMENT(bool, AllowInput)
|
||||
/** Where registered markers and the local player come from. Absent in the editor tab, which has neither. */
|
||||
SLATE_ARGUMENT(TWeakObjectPtr<UWorldMapSubsystem>, MarkerSource)
|
||||
SLATE_EVENT(FOnWorldMapClicked, OnClicked)
|
||||
SLATE_END_ARGS()
|
||||
|
||||
void Construct(const FArguments& InArgs);
|
||||
|
||||
void SetDefinition(UWorldMapDefinition* InDefinition);
|
||||
UWorldMapDefinition* GetDefinition() const { return Definition.Get(); }
|
||||
|
||||
void SetMarkerSource(UWorldMapSubsystem* InSource);
|
||||
|
||||
/** An unknown id is ignored rather than blanking the map, so a stale saved setting is survivable. */
|
||||
void SetLayer(FName InLayer);
|
||||
FName GetLayer() const { return Layer; }
|
||||
void NextLayer();
|
||||
|
||||
void SetShowGrid(bool bValue) { bShowGrid = bValue; }
|
||||
void SetShowScaleBar(bool bValue) { bShowScaleBar = bValue; }
|
||||
void SetShowMarkers(bool bValue) { bShowMarkers = bValue; }
|
||||
void SetShowLocalPlayer(bool bValue) { bShowLocalPlayer = bValue; }
|
||||
void SetAllowInput(bool bValue) { bAllowInput = bValue; }
|
||||
|
||||
/** Centre the view on a place, in world centimetres. Does not change the zoom. */
|
||||
void FocusOnWorld(const FVector2D& WorldCm);
|
||||
FVector2D GetViewCentreWorldCm() const;
|
||||
|
||||
/**
|
||||
* Keep the view centred on the local player as it moves. Panning by hand turns this off, because a map that
|
||||
* snaps back the moment you let go of it cannot be read.
|
||||
*/
|
||||
void SetFollowLocalPlayer(bool bValue) { bFollowLocalPlayer = bValue; }
|
||||
bool IsFollowingLocalPlayer() const { return bFollowLocalPlayer; }
|
||||
|
||||
/**
|
||||
* Go back to the player and start following again. The way out of the corner panning puts you in: having
|
||||
* dropped the follow to look somewhere else, there was otherwise no way back to it but closing the map.
|
||||
* Does nothing when there is no local body, which is the honest answer rather than jumping to the origin.
|
||||
*/
|
||||
bool FocusOnLocalPlayer();
|
||||
|
||||
/** Ground metres to one screen pixel. Smaller is closer in. */
|
||||
void SetMetresPerPixel(double InMetresPerPixel);
|
||||
double GetMetresPerPixel() const { return MetresPerPixel; }
|
||||
|
||||
/** Zoom back out until the whole world fits, and centre it. */
|
||||
void ZoomToFit();
|
||||
|
||||
// SWidget
|
||||
/** The first fit needs a size, which a widget has only after it has been laid out; following the player
|
||||
* needs to happen whether or not anything else changed. Both live here rather than in OnPaint, which is
|
||||
* const and has no business moving the view. */
|
||||
virtual void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) override;
|
||||
virtual int32 OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect,
|
||||
FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override;
|
||||
virtual FVector2D ComputeDesiredSize(float LayoutScaleMultiplier) const override;
|
||||
virtual FReply OnMouseButtonDown(const FGeometry& Geometry, const FPointerEvent& Event) override;
|
||||
virtual FReply OnMouseButtonUp(const FGeometry& Geometry, const FPointerEvent& Event) override;
|
||||
virtual FReply OnMouseMove(const FGeometry& Geometry, const FPointerEvent& Event) override;
|
||||
virtual FReply OnMouseWheel(const FGeometry& Geometry, const FPointerEvent& Event) override;
|
||||
virtual FReply OnMouseButtonDoubleClick(const FGeometry& Geometry, const FPointerEvent& Event) override;
|
||||
virtual void OnMouseLeave(const FPointerEvent& Event) override;
|
||||
virtual FCursorReply OnCursorQuery(const FGeometry& Geometry, const FPointerEvent& Event) const override;
|
||||
|
||||
private:
|
||||
/** The view, in normalised map coordinates, derived from the zoom and the widget's own size. */
|
||||
struct FView
|
||||
{
|
||||
FVector2D Centre = FVector2D(0.5, 0.5);
|
||||
double SpanU = 1.0;
|
||||
double SpanV = 1.0;
|
||||
FVector2D SizePx = FVector2D(1.0, 1.0);
|
||||
bool bValid = false;
|
||||
};
|
||||
|
||||
FView ComputeView(const FVector2D& LocalSizePx) const;
|
||||
FVector2D LocalToNormalised(const FView& View, const FVector2D& LocalPx) const;
|
||||
FVector2D NormalisedToLocal(const FView& View, const FVector2D& Normalised) const;
|
||||
|
||||
/** Fold the centre back into what can actually be seen. X wraps on a whole cylinder; Y never does. */
|
||||
void ClampCentre(const FVector2D& LocalSizePx);
|
||||
|
||||
/** Widest the map may be zoomed out at this size: the whole thing, fitted. */
|
||||
double FitMetresPerPixel(const FVector2D& LocalSizePx) const;
|
||||
|
||||
const FSlateBrush* BrushForCurrentLayer() const;
|
||||
|
||||
int32 PaintMap(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const;
|
||||
int32 PaintGrid(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const;
|
||||
int32 PaintMarkers(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const;
|
||||
int32 PaintOneMarker(const FGeometry& Geometry, const FView& View, const struct FWorldMapMarker& Marker,
|
||||
FSlateWindowElementList& Out, int32 LayerId) const;
|
||||
int32 PaintScaleBar(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const;
|
||||
int32 PaintReadout(const FGeometry& Geometry, const FView& View, FSlateWindowElementList& Out, int32 LayerId) const;
|
||||
int32 PaintEmptyState(const FGeometry& Geometry, FSlateWindowElementList& Out, int32 LayerId) const;
|
||||
|
||||
const FWorldMapProjection& GetProjection() const;
|
||||
|
||||
TStrongObjectPtr<UWorldMapDefinition> Definition;
|
||||
TWeakObjectPtr<UWorldMapSubsystem> MarkerSource;
|
||||
FOnWorldMapClicked OnClicked;
|
||||
|
||||
FName Layer;
|
||||
|
||||
/** One brush per layer, built on first use and holding the texture's only hard reference. */
|
||||
mutable TMap<FName, TSharedPtr<FSlateBrush>> LayerBrushes;
|
||||
mutable TArray<TStrongObjectPtr<UTexture2D>> LoadedTextures;
|
||||
|
||||
/** Normalised. X may be anywhere on a wrapping map and is folded when it is used. */
|
||||
FVector2D ViewCentre = FVector2D(0.5, 0.5);
|
||||
double MetresPerPixel = 0.0; // 0 means "not fitted yet"; the first paint fits it to the widget
|
||||
mutable bool bNeedsFit = true;
|
||||
|
||||
bool bShowGrid = true;
|
||||
bool bShowScaleBar = true;
|
||||
bool bShowMarkers = true;
|
||||
bool bShowLocalPlayer = true;
|
||||
bool bAllowInput = true;
|
||||
bool bFollowLocalPlayer = false;
|
||||
|
||||
bool bDragging = false;
|
||||
bool bDraggedFarEnoughToNotBeAClick = false;
|
||||
FVector2D DragLastLocal = FVector2D::ZeroVector;
|
||||
FVector2D PressLocal = FVector2D::ZeroVector;
|
||||
|
||||
/** Where the pointer is, for the readout. Unset when it is not over the map. */
|
||||
mutable TOptional<FVector2D> HoverLocal;
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
#include "UI/WorldMapWidget.h"
|
||||
|
||||
#include "Engine/World.h"
|
||||
#include "UI/SWorldMap.h"
|
||||
#include "Widgets/SNullWidget.h"
|
||||
#include "World/WorldMapDefinition.h"
|
||||
#include "World/WorldMapSubsystem.h"
|
||||
|
||||
#define LOCTEXT_NAMESPACE "SaltyWorldMap"
|
||||
|
||||
UWorldMapDefinition* UWorldMapWidget::GetResolvedDefinition() const
|
||||
{
|
||||
if (Definition)
|
||||
{
|
||||
return Definition;
|
||||
}
|
||||
const UWorld* World = GetWorld();
|
||||
const UWorldMapSubsystem* Subsystem = World ? World->GetSubsystem<UWorldMapSubsystem>() : nullptr;
|
||||
// const_cast because the subsystem hands out a const view of shared content and SWorldMap holds a strong
|
||||
// pointer it cannot make const. Nothing here writes to the asset.
|
||||
return Subsystem ? const_cast<UWorldMapDefinition*>(Subsystem->GetDefinition()) : nullptr;
|
||||
}
|
||||
|
||||
TSharedRef<SWidget> UWorldMapWidget::RebuildWidget()
|
||||
{
|
||||
const UWorld* World = GetWorld();
|
||||
UWorldMapSubsystem* Subsystem = World ? World->GetSubsystem<UWorldMapSubsystem>() : nullptr;
|
||||
|
||||
MapWidget = SNew(SWorldMap)
|
||||
.Definition(GetResolvedDefinition())
|
||||
.Layer(Layer)
|
||||
.ShowGrid(bShowGrid)
|
||||
.ShowScaleBar(bShowScaleBar)
|
||||
.ShowMarkers(bShowMarkers)
|
||||
.ShowLocalPlayer(bShowLocalPlayer)
|
||||
.AllowInput(bAllowInput)
|
||||
.MarkerSource(Subsystem)
|
||||
.OnClicked(FOnWorldMapClicked::CreateUObject(this, &UWorldMapWidget::HandleClicked));
|
||||
|
||||
MapWidget->SetFollowLocalPlayer(bFollowLocalPlayer);
|
||||
return MapWidget.ToSharedRef();
|
||||
}
|
||||
|
||||
void UWorldMapWidget::SynchronizeProperties()
|
||||
{
|
||||
Super::SynchronizeProperties();
|
||||
if (!MapWidget.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
MapWidget->SetDefinition(GetResolvedDefinition());
|
||||
MapWidget->SetLayer(Layer);
|
||||
MapWidget->SetShowGrid(bShowGrid);
|
||||
MapWidget->SetShowScaleBar(bShowScaleBar);
|
||||
MapWidget->SetShowMarkers(bShowMarkers);
|
||||
MapWidget->SetShowLocalPlayer(bShowLocalPlayer);
|
||||
MapWidget->SetAllowInput(bAllowInput);
|
||||
MapWidget->SetFollowLocalPlayer(bFollowLocalPlayer);
|
||||
}
|
||||
|
||||
void UWorldMapWidget::ReleaseSlateResources(bool bReleaseChildren)
|
||||
{
|
||||
Super::ReleaseSlateResources(bReleaseChildren);
|
||||
MapWidget.Reset();
|
||||
}
|
||||
|
||||
#if WITH_EDITOR
|
||||
const FText UWorldMapWidget::GetPaletteCategory()
|
||||
{
|
||||
return LOCTEXT("PaletteCategory", "Salty");
|
||||
}
|
||||
#endif
|
||||
|
||||
void UWorldMapWidget::HandleClicked(FVector2D WorldCm)
|
||||
{
|
||||
OnMapClicked.Broadcast(WorldCm);
|
||||
}
|
||||
|
||||
void UWorldMapWidget::SetLayer(FName InLayer)
|
||||
{
|
||||
Layer = InLayer;
|
||||
if (MapWidget.IsValid())
|
||||
{
|
||||
MapWidget->SetLayer(InLayer);
|
||||
// Read it back: an id the definition does not have is ignored rather than obeyed, and the property
|
||||
// should say what is on screen rather than what was asked for.
|
||||
Layer = MapWidget->GetLayer();
|
||||
}
|
||||
}
|
||||
|
||||
void UWorldMapWidget::NextLayer()
|
||||
{
|
||||
if (MapWidget.IsValid())
|
||||
{
|
||||
MapWidget->NextLayer();
|
||||
Layer = MapWidget->GetLayer();
|
||||
}
|
||||
}
|
||||
|
||||
FName UWorldMapWidget::GetLayer() const
|
||||
{
|
||||
return MapWidget.IsValid() ? MapWidget->GetLayer() : Layer;
|
||||
}
|
||||
|
||||
TArray<FName> UWorldMapWidget::GetLayerIds() const
|
||||
{
|
||||
const UWorldMapDefinition* Resolved = GetResolvedDefinition();
|
||||
return Resolved ? Resolved->GetLayerIds() : TArray<FName>();
|
||||
}
|
||||
|
||||
void UWorldMapWidget::FocusOnWorldLocation(FVector WorldLocation)
|
||||
{
|
||||
if (MapWidget.IsValid())
|
||||
{
|
||||
MapWidget->SetFollowLocalPlayer(false);
|
||||
MapWidget->FocusOnWorld(FVector2D(WorldLocation.X, WorldLocation.Y));
|
||||
}
|
||||
}
|
||||
|
||||
void UWorldMapWidget::ZoomToFit()
|
||||
{
|
||||
if (MapWidget.IsValid())
|
||||
{
|
||||
MapWidget->ZoomToFit();
|
||||
}
|
||||
}
|
||||
|
||||
void UWorldMapWidget::SetFollowLocalPlayer(bool bValue)
|
||||
{
|
||||
bFollowLocalPlayer = bValue;
|
||||
if (MapWidget.IsValid())
|
||||
{
|
||||
MapWidget->SetFollowLocalPlayer(bValue);
|
||||
}
|
||||
}
|
||||
|
||||
bool UWorldMapWidget::FocusOnLocalPlayer()
|
||||
{
|
||||
if (!MapWidget.IsValid() || !MapWidget->FocusOnLocalPlayer())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Mirror it back, so the property says what the widget is actually doing.
|
||||
bFollowLocalPlayer = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
float UWorldMapWidget::GetMetresPerPixel() const
|
||||
{
|
||||
return MapWidget.IsValid() ? static_cast<float>(MapWidget->GetMetresPerPixel()) : 0.f;
|
||||
}
|
||||
|
||||
void UWorldMapWidget::SetMetresPerPixel(float InMetresPerPixel)
|
||||
{
|
||||
if (MapWidget.IsValid())
|
||||
{
|
||||
MapWidget->SetMetresPerPixel(InMetresPerPixel);
|
||||
}
|
||||
}
|
||||
|
||||
#undef LOCTEXT_NAMESPACE
|
||||
@@ -0,0 +1,116 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/Widget.h"
|
||||
#include "WorldMapWidget.generated.h"
|
||||
|
||||
class SWorldMap;
|
||||
class UWorldMapDefinition;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnWorldMapClickedDynamic, FVector2D, WorldCm);
|
||||
|
||||
/**
|
||||
* The world map, as a widget a Blueprint can drop into a screen.
|
||||
*
|
||||
* It is a UWidget wrapping SWorldMap rather than a UUserWidget, because the map has no parts to compose: it is
|
||||
* one thing that draws itself. The frame around it - the layer buttons, the close button, the title - is what
|
||||
* WBP_WorldMap is for, and that is a UUserWidget containing one of these. Which is also the C++/Blueprint line
|
||||
* the conventions ask for: C++ owns what the map is, the Blueprint owns what it looks like around the edges.
|
||||
*/
|
||||
UCLASS(meta = (DisplayName = "World Map"))
|
||||
class SALTY_API UWorldMapWidget : public UWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Which map to draw. Leave it unset and the widget asks the level's UWorldMapSubsystem, which is the
|
||||
* normal case: a screen should not have to know which world it was opened in. Set it to show a particular
|
||||
* map regardless - a world other than the one being played, or a designer preview.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
TObjectPtr<UWorldMapDefinition> Definition;
|
||||
|
||||
/** NAME_None opens on the definition's default layer. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
FName Layer;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map|Show")
|
||||
bool bShowGrid = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map|Show")
|
||||
bool bShowScaleBar = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map|Show")
|
||||
bool bShowMarkers = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map|Show")
|
||||
bool bShowLocalPlayer = true;
|
||||
|
||||
/** False makes it a picture: no pan, no zoom, no click. What a minimap wants. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map|Input")
|
||||
bool bAllowInput = true;
|
||||
|
||||
/** Keep the view on the player. Panning by hand turns it off; this is the starting state. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map|Input")
|
||||
bool bFollowLocalPlayer = false;
|
||||
|
||||
/** A place on the ground was clicked, in world centimetres. Not raised by a drag that happened to end. */
|
||||
UPROPERTY(BlueprintAssignable, Category = "World Map")
|
||||
FOnWorldMapClickedDynamic OnMapClicked;
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
void SetLayer(FName InLayer);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
void NextLayer();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "World Map")
|
||||
FName GetLayer() const;
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "World Map")
|
||||
TArray<FName> GetLayerIds() const;
|
||||
|
||||
/** Centre the map on a world location. Z is ignored. */
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
void FocusOnWorldLocation(FVector WorldLocation);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
void ZoomToFit();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
void SetFollowLocalPlayer(bool bValue);
|
||||
|
||||
/**
|
||||
* Centre on the local player and follow them again. False when there is no local body to follow. What a
|
||||
* "recentre" button on a screen calls; the map itself does it on a left double-click.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
bool FocusOnLocalPlayer();
|
||||
|
||||
/** Ground metres to one screen pixel, for a readout or a zoom slider. */
|
||||
UFUNCTION(BlueprintPure, Category = "World Map")
|
||||
float GetMetresPerPixel() const;
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
void SetMetresPerPixel(float InMetresPerPixel);
|
||||
|
||||
/** What the map is actually showing, resolved: the override if there is one, otherwise the level's. */
|
||||
UFUNCTION(BlueprintPure, Category = "World Map")
|
||||
UWorldMapDefinition* GetResolvedDefinition() const;
|
||||
|
||||
// UWidget
|
||||
virtual void SynchronizeProperties() override;
|
||||
virtual void ReleaseSlateResources(bool bReleaseChildren) override;
|
||||
#if WITH_EDITOR
|
||||
virtual const FText GetPaletteCategory() override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
virtual TSharedRef<SWidget> RebuildWidget() override;
|
||||
|
||||
private:
|
||||
void HandleClicked(FVector2D WorldCm);
|
||||
|
||||
TSharedPtr<SWorldMap> MapWidget;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "World/WorldMapDefinition.h"
|
||||
|
||||
// Direct-initialised from an explicit FName: copy-initialising from a string literal would need two
|
||||
// user-defined conversions (TCHAR* -> FName -> FPrimaryAssetType), which the language does not allow.
|
||||
const FPrimaryAssetType UWorldMapDefinition::AssetType(FName("WorldMap"));
|
||||
|
||||
const FWorldMapLayer* UWorldMapDefinition::FindLayer(FName Id) const
|
||||
{
|
||||
return Layers.FindByPredicate([Id](const FWorldMapLayer& Layer) { return Layer.Id == Id; });
|
||||
}
|
||||
|
||||
const FWorldMapLayer* UWorldMapDefinition::ResolveDefaultLayer() const
|
||||
{
|
||||
if (const FWorldMapLayer* Named = FindLayer(DefaultLayer))
|
||||
{
|
||||
return Named;
|
||||
}
|
||||
return Layers.Num() > 0 ? &Layers[0] : nullptr;
|
||||
}
|
||||
|
||||
FName UWorldMapDefinition::NextLayerId(FName Id) const
|
||||
{
|
||||
if (Layers.Num() == 0)
|
||||
{
|
||||
return NAME_None;
|
||||
}
|
||||
const int32 Index = Layers.IndexOfByPredicate([Id](const FWorldMapLayer& Layer) { return Layer.Id == Id; });
|
||||
// An unknown id lands on the first layer rather than on nothing, so a stale name in a saved setting
|
||||
// recovers on the next press instead of leaving the map blank.
|
||||
return Layers[Index == INDEX_NONE ? 0 : (Index + 1) % Layers.Num()].Id;
|
||||
}
|
||||
|
||||
TArray<FName> UWorldMapDefinition::GetLayerIds() const
|
||||
{
|
||||
TArray<FName> Ids;
|
||||
Ids.Reserve(Layers.Num());
|
||||
for (const FWorldMapLayer& Layer : Layers)
|
||||
{
|
||||
Ids.Add(Layer.Id);
|
||||
}
|
||||
return Ids;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "World/WorldMapProjection.h"
|
||||
#include "WorldMapDefinition.generated.h"
|
||||
|
||||
class UTexture2D;
|
||||
|
||||
// Every property here is EditAnywhere rather than the EditDefaultsOnly a data asset usually carries, because
|
||||
// this asset is *written by a script*: Python's set_editor_property refuses a property marked edit-on-default
|
||||
// when the object is an instance, and a UDataAsset is an instance. EditDefaultsOnly guards against editing a
|
||||
// placed actor's copy of something; there is no such copy here.
|
||||
|
||||
/**
|
||||
* One picture of the whole world, and what to call it. Every layer is a different render of the same cylinder,
|
||||
* so they share the definition's projection and need not share a resolution.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct SALTY_API FWorldMapLayer
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
/** How code and the console name this layer. Matches the id in RawContent/World/MapArt/layers.json. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
FName Id;
|
||||
|
||||
/** What a person sees on the button. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
FText DisplayName;
|
||||
|
||||
/** Soft, because a map that is never opened should not cost 11 MB of texture to be in the level. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
TSoftObjectPtr<UTexture2D> Texture;
|
||||
|
||||
/** Why this layer exists, for whoever opens the asset next. Never shown to a player. */
|
||||
UPROPERTY(EditAnywhere, Category = "World Map", meta = (MultiLine = true))
|
||||
FString Note;
|
||||
};
|
||||
|
||||
/**
|
||||
* The map of one level: where the ground is on the picture, and which pictures there are.
|
||||
*
|
||||
* Written by Scripts/Authoring/create_world_map.py out of RawContent/World/Region.json and MapArt/layers.json,
|
||||
* so the projection here and the geometry of the landscape come from the same numbers and cannot drift. It is
|
||||
* an asset rather than a config entry because it is content, and because the editor tab and the game both read
|
||||
* it without either of them knowing that RawContent exists.
|
||||
*/
|
||||
UCLASS(BlueprintType)
|
||||
class SALTY_API UWorldMapDefinition : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** The primary asset type, registered for scanning in Config/DefaultGame.ini. */
|
||||
static const FPrimaryAssetType AssetType;
|
||||
|
||||
virtual FPrimaryAssetId GetPrimaryAssetId() const override { return FPrimaryAssetId(AssetType, GetFName()); }
|
||||
|
||||
/** Where the ground is on the art. See FWorldMapProjection: for L_World this is the whole planet. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
FWorldMapProjection Projection;
|
||||
|
||||
/**
|
||||
* The level this is a map of. How UWorldMapSubsystem decides a definition belongs to the world it is in.
|
||||
*
|
||||
* A path rather than a TSoftObjectPtr<UWorld> because it is only ever compared, never loaded: resolving it
|
||||
* would pull a 98-landscape level in to answer "is this the world I am already standing in".
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map", meta = (AllowedClasses = "/Script/Engine.World"))
|
||||
FSoftObjectPath Level;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
TArray<FWorldMapLayer> Layers;
|
||||
|
||||
/** Which layer the map opens on. Falls back to the first layer when it names one that is not here. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
FName DefaultLayer;
|
||||
|
||||
/** What built this, so a map that looks wrong can be traced to the run that made it. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map", meta = (MultiLine = true))
|
||||
FString BuiltFrom;
|
||||
|
||||
const FWorldMapLayer* FindLayer(FName Id) const;
|
||||
|
||||
/** DefaultLayer if it exists, otherwise the first layer, otherwise nullptr. */
|
||||
const FWorldMapLayer* ResolveDefaultLayer() const;
|
||||
|
||||
/** The id after Id, wrapping; NAME_None when there are no layers. For a "next layer" key or button. */
|
||||
FName NextLayerId(FName Id) const;
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
TArray<FName> GetLayerIds() const;
|
||||
|
||||
/** True when this can actually be drawn: a projection with an extent and at least one layer. */
|
||||
UFUNCTION(BlueprintPure, Category = "World Map")
|
||||
bool IsUsable() const { return Projection.IsValid() && Layers.Num() > 0; }
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
#include "World/WorldMapSubsystem.h"
|
||||
|
||||
#include "Core/TelemetrySubsystem.h"
|
||||
#include "Engine/AssetManager.h"
|
||||
#include "Engine/Engine.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/World.h"
|
||||
#include "GameFramework/Pawn.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "Salty.h"
|
||||
#include "Telemetry/TelemetryEvent.h"
|
||||
#include "Telemetry/TelemetryEvents.h"
|
||||
#include "World/WorldMapDefinition.h"
|
||||
|
||||
bool UWorldMapSubsystem::ShouldCreateSubsystem(UObject* Outer) const
|
||||
{
|
||||
if (!Super::ShouldCreateSubsystem(Outer))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Game and PIE worlds only. An editor preview world or a thumbnail scene has no map and no one to show it to.
|
||||
const UWorld* World = Cast<UWorld>(Outer);
|
||||
return World && (World->IsGameWorld());
|
||||
}
|
||||
|
||||
void UWorldMapSubsystem::Deinitialize()
|
||||
{
|
||||
Markers.Reset();
|
||||
OnMarkersChanged.Clear();
|
||||
Definition = nullptr;
|
||||
bResolved = false;
|
||||
Super::Deinitialize();
|
||||
}
|
||||
|
||||
void UWorldMapSubsystem::ResolveDefinition() const
|
||||
{
|
||||
bResolved = true;
|
||||
Definition = nullptr;
|
||||
|
||||
const UWorld* World = GetWorld();
|
||||
if (!World)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// PIE renames the level package (/Game/Maps/UEDPIE_0_L_World), so the definition's own path would never
|
||||
// match what the world says it is. Strip it, or the map works in a cooked build and not in the editor.
|
||||
const FString LevelPackage = UWorld::RemovePIEPrefix(World->GetOutermost()->GetName());
|
||||
|
||||
// IsInitialized, not IsValid: the manager is always constructed now and IsValid is deprecated for saying
|
||||
// so. The guard stays because this can be reached before UEngine::InitializeObjectReferences has run.
|
||||
if (!UAssetManager::IsInitialized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
UAssetManager& Manager = UAssetManager::Get();
|
||||
|
||||
TArray<FPrimaryAssetId> Ids;
|
||||
Manager.GetPrimaryAssetIdList(UWorldMapDefinition::AssetType, Ids);
|
||||
if (Ids.Num() == 0)
|
||||
{
|
||||
UE_LOG(LogSalty, Verbose,
|
||||
TEXT("World map: no %s assets are registered. Add a PrimaryAssetTypesToScan entry in DefaultGame.ini "
|
||||
"and run Scripts/Authoring/build_world_map.sh."),
|
||||
*UWorldMapDefinition::AssetType.ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
for (const FPrimaryAssetId& Id : Ids)
|
||||
{
|
||||
const FSoftObjectPath Path = Manager.GetPrimaryAssetPath(Id);
|
||||
// Synchronous, because this happens once when a map is first opened and an asynchronous load would
|
||||
// mean a frame of blank map for no gain.
|
||||
const UWorldMapDefinition* Candidate = Cast<UWorldMapDefinition>(Path.TryLoad());
|
||||
if (Candidate && Candidate->Level.GetLongPackageName() == LevelPackage)
|
||||
{
|
||||
Definition = Candidate;
|
||||
UE_LOG(LogSalty, Log, TEXT("World map: %s covers %s - %s"),
|
||||
*Id.ToString(), *LevelPackage, *Candidate->Projection.ToString());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately not "fall back to the only one there is". L_Gym showing L_World's map would be a map that
|
||||
// lies about where you are, which is worse than no map.
|
||||
UE_LOG(LogSalty, Verbose, TEXT("World map: none of the %d definitions is a map of %s."), Ids.Num(), *LevelPackage);
|
||||
}
|
||||
|
||||
const UWorldMapDefinition* UWorldMapSubsystem::GetDefinition() const
|
||||
{
|
||||
if (!bResolved)
|
||||
{
|
||||
ResolveDefinition();
|
||||
}
|
||||
return Definition;
|
||||
}
|
||||
|
||||
FGuid UWorldMapSubsystem::AddMarker(const FWorldMapMarker& Marker)
|
||||
{
|
||||
const FGuid Handle = FGuid::NewGuid();
|
||||
Markers.Add(Handle, Marker);
|
||||
OnMarkersChanged.Broadcast();
|
||||
return Handle;
|
||||
}
|
||||
|
||||
bool UWorldMapSubsystem::UpdateMarker(const FGuid& Handle, const FWorldMapMarker& Marker)
|
||||
{
|
||||
if (FWorldMapMarker* Existing = Markers.Find(Handle))
|
||||
{
|
||||
*Existing = Marker;
|
||||
OnMarkersChanged.Broadcast();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UWorldMapSubsystem::RemoveMarker(const FGuid& Handle)
|
||||
{
|
||||
if (Markers.Remove(Handle) > 0)
|
||||
{
|
||||
OnMarkersChanged.Broadcast();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void UWorldMapSubsystem::NoteMapOpened(FName LayerId, float MetresPerPixel, const FString& OpenedBy)
|
||||
{
|
||||
const UWorld* World = GetWorld();
|
||||
const UGameInstance* GameInstance = World ? World->GetGameInstance() : nullptr;
|
||||
UTelemetrySubsystem* Telemetry = GameInstance ? GameInstance->GetSubsystem<UTelemetrySubsystem>() : nullptr;
|
||||
if (!Telemetry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Telemetry->Emit(TelemetryEvents::WorldMapOpened, FTelemetryPayload()
|
||||
.Set(TEXT("layer"), LayerId)
|
||||
.Set(TEXT("metres_per_pixel"), MetresPerPixel)
|
||||
.Set(TEXT("opened_by"), OpenedBy));
|
||||
}
|
||||
|
||||
bool UWorldMapSubsystem::GetLocalPlayerMarker(FWorldMapMarker& OutMarker) const
|
||||
{
|
||||
const UWorld* World = GetWorld();
|
||||
const APlayerController* Controller = World ? World->GetFirstPlayerController() : nullptr;
|
||||
if (!Controller)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The spectator counts: someone flying the level with no pawn is still somewhere, and that is exactly when
|
||||
// a map is most useful.
|
||||
const AActor* Body = Controller->GetPawnOrSpectator();
|
||||
if (!Body)
|
||||
{
|
||||
Body = Controller->GetViewTarget();
|
||||
}
|
||||
if (!Body)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
OutMarker = FWorldMapMarker();
|
||||
OutMarker.WorldLocation = Body->GetActorLocation();
|
||||
OutMarker.Shape = EWorldMapMarkerShape::Arrow;
|
||||
OutMarker.Colour = FLinearColor(1.f, 0.95f, 0.35f);
|
||||
OutMarker.SizePx = 13.f;
|
||||
OutMarker.HeadingDegrees = static_cast<float>(Controller->GetControlRotation().Yaw);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Subsystems/WorldSubsystem.h"
|
||||
#include "WorldMapSubsystem.generated.h"
|
||||
|
||||
class UWorldMapDefinition;
|
||||
|
||||
UENUM(BlueprintType)
|
||||
enum class EWorldMapMarkerShape : uint8
|
||||
{
|
||||
/** A filled disc. Anything that is just somewhere. */
|
||||
Dot,
|
||||
/** An unfilled ring, so it does not hide what is under it. Objectives, regions of interest. */
|
||||
Ring,
|
||||
/** A cross. Reads at one pixel where a dot does not, which is what a waypoint needs when zoomed out. */
|
||||
Cross,
|
||||
/** A triangle pointing along Heading. The body you are in, and anything else with a facing. */
|
||||
Arrow
|
||||
};
|
||||
|
||||
/**
|
||||
* Something to draw on the map at a place in the world. Purely a UI concern - nothing here is replicated and
|
||||
* nothing gameplay does depends on a marker existing.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct SALTY_API FWorldMapMarker
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
/** Centimetres, world space. Z is ignored; the map is a plan. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
FVector WorldLocation = FVector::ZeroVector;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
FText Label;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
FLinearColor Colour = FLinearColor::White;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
EWorldMapMarkerShape Shape = EWorldMapMarkerShape::Dot;
|
||||
|
||||
/** World yaw in degrees for Arrow: 0 points along world +X, which is to the right on the map. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
float HeadingDegrees = 0.f;
|
||||
|
||||
/** Drawn size in pixels, so a marker stays the same size however far the map is zoomed out. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
float SizePx = 7.f;
|
||||
};
|
||||
|
||||
/**
|
||||
* The map's registry: which definition this level has a map from, and what is on it.
|
||||
*
|
||||
* A world subsystem because a map belongs to a map. Markers are registered by whoever owns the thing being
|
||||
* marked - an actor in BeginPlay, unregistering in EndPlay - rather than found by the map, because a widget
|
||||
* that ran GetAllActorsOfClass to draw itself would be exactly the world search the conventions forbid.
|
||||
*
|
||||
* The local player's own body is deliberately NOT a registered marker: it moves every frame, and a registry
|
||||
* entry rewritten every frame is a registry being used as a variable. The widget asks for it instead.
|
||||
*/
|
||||
UCLASS()
|
||||
class SALTY_API UWorldMapSubsystem : public UWorldSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual bool ShouldCreateSubsystem(UObject* Outer) const override;
|
||||
virtual void Deinitialize() override;
|
||||
|
||||
/**
|
||||
* The definition whose Level is this world, loading it the first time it is asked for. Null when this level
|
||||
* has no map, which is the normal answer in L_Gym and is not an error.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
const UWorldMapDefinition* GetDefinition() const;
|
||||
|
||||
/** Adds a marker and returns its handle. Keep the handle; it is the only way to move or remove it. */
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
FGuid AddMarker(const FWorldMapMarker& Marker);
|
||||
|
||||
/** Replaces a marker wholesale. False when the handle is not one of ours. */
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
bool UpdateMarker(const FGuid& Handle, const FWorldMapMarker& Marker);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
bool RemoveMarker(const FGuid& Handle);
|
||||
|
||||
const TMap<FGuid, FWorldMapMarker>& GetMarkers() const { return Markers; }
|
||||
|
||||
/**
|
||||
* Where the local player is, as a marker, or false when there is no local body to speak of - a dedicated
|
||||
* server, or a client before possession. The heading is the control rotation rather than the body's, because
|
||||
* on a map a player expects the arrow to point where they are looking.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
bool GetLocalPlayerMarker(FWorldMapMarker& OutMarker) const;
|
||||
|
||||
/**
|
||||
* Emits world_map_opened. Every way of opening the map calls this - the console command today, a HUD key
|
||||
* when there is one - so the count is of maps opened and not of the ways of opening them.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
void NoteMapOpened(FName LayerId, float MetresPerPixel, const FString& OpenedBy);
|
||||
|
||||
/** Raised when a marker is added, moved or removed. The widget subscribes; it never polls. */
|
||||
DECLARE_MULTICAST_DELEGATE(FOnWorldMapMarkersChanged);
|
||||
FOnWorldMapMarkersChanged OnMarkersChanged;
|
||||
|
||||
private:
|
||||
/** Every WorldMap definition whose Level is this world. Searched once; the answer, including none, sticks. */
|
||||
void ResolveDefinition() const;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
mutable TObjectPtr<const UWorldMapDefinition> Definition;
|
||||
|
||||
mutable bool bResolved = false;
|
||||
|
||||
TMap<FGuid, FWorldMapMarker> Markers;
|
||||
};
|
||||
Reference in New Issue
Block a user