This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+173
View File
@@ -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());
}));
}
+2 -1
View File
@@ -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" });
+828
View File
@@ -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);
}
+176
View File
@@ -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;
};
+161
View File
@@ -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
+116
View File
@@ -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;
};
+42
View File
@@ -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;
}
+98
View File
@@ -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; }
};
+170
View File
@@ -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;
}
+121
View File
@@ -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;
};