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;
};
@@ -12,4 +12,8 @@ namespace TelemetryEvents
inline const FName SessionStarted(TEXT("session_started"));
inline const FName SessionEnded(TEXT("session_ended"));
inline const FName CheatUsed(TEXT("cheat_used"));
// World map (off the ladder, with the world). One event: opening it is the act worth counting, and which
// layer and how far zoomed in say what a person actually wanted from it.
inline const FName WorldMapOpened(TEXT("world_map_opened"));
}
@@ -0,0 +1,168 @@
#include "Misc/AutomationTest.h"
#include "World/WorldMapProjection.h"
#if WITH_DEV_AUTOMATION_TESTS
namespace
{
// L_World as RawContent/World/Region.json describes it: 14 x 7 tiles of 2550 quads at 200 cm, straddling
// the origin, the whole cylinder. The numbers are duplicated here on purpose - a test that read the
// manifest would pass when both it and the manifest were wrong together.
FWorldMapProjection MakeWorldProjection()
{
FWorldMapProjection Projection;
Projection.WidthM = 71400.0;
Projection.HeightM = 35700.0;
Projection.CentreM = FVector2D::ZeroVector;
Projection.bWrapsX = true;
Projection.ElevationMinM = -1024.0;
Projection.ElevationMaxM = 6144.0;
Projection.SeaLevelM = 0.0;
return Projection;
}
constexpr double Tolerance = 1e-9;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionCornersTest, "Salty.Core.WorldMap.Projection.CornersAndCentre",
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
bool FWorldMapProjectionCornersTest::RunTest(const FString& Parameters)
{
const FWorldMapProjection Projection = MakeWorldProjection();
// The world straddles the origin, so the centre of the art is the origin and the corners are half the
// extent away. In centimetres, because that is the unit every call site is in.
const FVector2D Centre = Projection.WorldToNormalised(FVector2D(0.0, 0.0));
TestNearlyEqual(TEXT("world origin is the middle of the map in U"), Centre.X, 0.5, Tolerance);
TestNearlyEqual(TEXT("world origin is the middle of the map in V"), Centre.Y, 0.5, Tolerance);
const FVector2D TopLeft = Projection.WorldToNormalised(FVector2D(-3570000.0, -1785000.0));
TestNearlyEqual(TEXT("west edge is U 0"), TopLeft.X, 0.0, Tolerance);
TestNearlyEqual(TEXT("north edge is V 0"), TopLeft.Y, 0.0, Tolerance);
const FVector2D BottomRight = Projection.WorldToNormalised(FVector2D(3570000.0, 1785000.0));
TestNearlyEqual(TEXT("east edge is U 1"), BottomRight.X, 1.0, Tolerance);
TestNearlyEqual(TEXT("south edge is V 1"), BottomRight.Y, 1.0, Tolerance);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionRoundTripTest, "Salty.Core.WorldMap.Projection.RoundTrips",
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
bool FWorldMapProjectionRoundTripTest::RunTest(const FString& Parameters)
{
const FWorldMapProjection Projection = MakeWorldProjection();
// Arbitrary places, including outside the map, which is legal: a marker off the edge is off the edge and
// the transform is not the thing that decides what to do about it.
const TArray<FVector2D> Places = {
FVector2D(0.0, 0.0),
FVector2D(1234567.0, -890123.0),
FVector2D(-3569999.0, 1784999.0),
FVector2D(9000000.0, 4000000.0),
};
for (const FVector2D& Place : Places)
{
const FVector2D Back = Projection.NormalisedToWorldCm(Projection.WorldToNormalised(Place));
TestNearlyEqual(*FString::Printf(TEXT("X round trips at (%.0f, %.0f)"), Place.X, Place.Y), Back.X, Place.X, 1e-6);
TestNearlyEqual(*FString::Printf(TEXT("Y round trips at (%.0f, %.0f)"), Place.X, Place.Y), Back.Y, Place.Y, 1e-6);
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionWrapTest, "Salty.Core.WorldMap.Projection.WrapsWestwardPastTheSeam",
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
bool FWorldMapProjectionWrapTest::RunTest(const FString& Parameters)
{
const FWorldMapProjection Projection = MakeWorldProjection();
// The case that motivated WrapU: panning west off the seam. FMath::Fmod would leave these negative and the
// map would sample nothing at all.
TestNearlyEqual(TEXT("just west of the seam comes back to just east of it"), Projection.WrapU(-0.02), 0.98, Tolerance);
TestNearlyEqual(TEXT("a whole turn west is the seam"), Projection.WrapU(-1.0), 0.0, Tolerance);
TestNearlyEqual(TEXT("two and a bit turns east"), Projection.WrapU(2.25), 0.25, Tolerance);
TestNearlyEqual(TEXT("inside the map is untouched"), Projection.WrapU(0.5), 0.5, Tolerance);
// Half open: 1.0 is the same meridian as 0.0 and must not be a second copy of it, or the seam column is
// drawn twice at some zoom levels and shimmers.
TestNearlyEqual(TEXT("U 1 is U 0"), Projection.WrapU(1.0), 0.0, Tolerance);
// And a map that is a window rather than a whole cylinder has edges, which stay edges.
FWorldMapProjection Window = MakeWorldProjection();
Window.bWrapsX = false;
TestNearlyEqual(TEXT("a window does not wrap"), Window.WrapU(-0.02), -0.02, Tolerance);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionShortestDeltaTest, "Salty.Core.WorldMap.Projection.ShortestWayRound",
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
bool FWorldMapProjectionShortestDeltaTest::RunTest(const FString& Parameters)
{
const FWorldMapProjection Projection = MakeWorldProjection();
// Either side of the seam is close, not almost a whole world apart. This is what keeps a marker at U 0.99
// drawn next to a view centred on U 0.01 rather than off the far edge.
TestNearlyEqual(TEXT("across the seam eastward"), Projection.ShortestDeltaU(0.99, 0.01), 0.02, Tolerance);
TestNearlyEqual(TEXT("across the seam westward"), Projection.ShortestDeltaU(0.01, 0.99), -0.02, Tolerance);
TestNearlyEqual(TEXT("ordinary distance is unchanged"), Projection.ShortestDeltaU(0.20, 0.35), 0.15, Tolerance);
FWorldMapProjection Window = MakeWorldProjection();
Window.bWrapsX = false;
TestNearlyEqual(TEXT("a window goes the long way because there is no short way"),
Window.ShortestDeltaU(0.99, 0.01), -0.98, Tolerance);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionDistanceTest, "Salty.Core.WorldMap.Projection.DistanceInMetres",
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
bool FWorldMapProjectionDistanceTest::RunTest(const FString& Parameters)
{
const FWorldMapProjection Projection = MakeWorldProjection();
// A kilometre east of the origin is a kilometre, in the unit a player would be told.
TestNearlyEqual(TEXT("a kilometre east"),
Projection.DistanceM(FVector2D(0.0, 0.0), FVector2D(100000.0, 0.0)), 1000.0, 1e-6);
// Two places a hundred metres either side of the seam are two hundred metres apart, not 71.2 km.
// The map is 71 400 m wide and straddles the origin, so its edges are at +-35 700 m: a hundred metres
// inside each of them is +-35 600 m, which is +-3 560 000 cm.
const double JustWest = -3560000.0;
const double JustEast = 3560000.0;
TestNearlyEqual(TEXT("either side of the seam is 200 m, not most of a world"),
Projection.DistanceM(FVector2D(JustWest, 0.0), FVector2D(JustEast, 0.0)), 200.0, 1e-6);
// And on a map that does not wrap those same two places really are most of a world apart.
FWorldMapProjection Window = MakeWorldProjection();
Window.bWrapsX = false;
TestNearlyEqual(TEXT("without a seam to cross it is the long way"),
Window.DistanceM(FVector2D(JustWest, 0.0), FVector2D(JustEast, 0.0)), 71200.0, 1e-6);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionInvalidTest, "Salty.Core.WorldMap.Projection.UnsetIsInvalidAndSafe",
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
bool FWorldMapProjectionInvalidTest::RunTest(const FString& Parameters)
{
// A definition asset that was never filled in is the normal state of a new one, and asking it where a place
// is must not divide by zero - the widget checks IsValid and draws nothing, rather than NaNs.
const FWorldMapProjection Empty;
TestFalse(TEXT("a default projection is not valid"), Empty.IsValid());
TestEqual(TEXT("and maps everything to the origin rather than to NaN"),
Empty.WorldToNormalised(FVector2D(1234.0, 5678.0)), FVector2D::ZeroVector);
TestEqual(TEXT("both ways"), Empty.NormalisedToWorldCm(FVector2D(0.5, 0.5)), FVector2D::ZeroVector);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,65 @@
#include "World/WorldMapProjection.h"
FVector2D FWorldMapProjection::WorldToNormalised(const FVector2D& WorldCm) const
{
if (!IsValid())
{
return FVector2D::ZeroVector;
}
const FVector2D FromCentreM = WorldCm / 100.0 - CentreM;
return FVector2D(FromCentreM.X / WidthM + 0.5, FromCentreM.Y / HeightM + 0.5);
}
FVector2D FWorldMapProjection::NormalisedToWorldCm(const FVector2D& Normalised) const
{
if (!IsValid())
{
return FVector2D::ZeroVector;
}
const FVector2D FromCentreM((Normalised.X - 0.5) * WidthM, (Normalised.Y - 0.5) * HeightM);
return (CentreM + FromCentreM) * 100.0;
}
double FWorldMapProjection::WrapU(double U) const
{
if (!bWrapsX)
{
return U;
}
// Not FMath::Fmod: it keeps the sign of the dividend, so -0.02 stays -0.02 and a pan west off the seam
// samples outside the art. FMath::Wrap is inclusive of its maximum, which would make 1.0 a second copy of
// the seam column. Floor is the one that gives a half-open [0, 1).
const double Wrapped = U - FMath::Floor(U);
// Floor of a tiny negative can round to exactly 1.0 in double; fold it back so the range stays half-open.
return Wrapped >= 1.0 ? 0.0 : Wrapped;
}
double FWorldMapProjection::ShortestDeltaU(double FromU, double ToU) const
{
double Delta = ToU - FromU;
if (bWrapsX)
{
Delta -= FMath::RoundToDouble(Delta);
}
return Delta;
}
double FWorldMapProjection::DistanceM(const FVector2D& FromWorldCm, const FVector2D& ToWorldCm) const
{
if (!IsValid())
{
return 0.0;
}
const FVector2D From = WorldToNormalised(FromWorldCm);
const FVector2D To = WorldToNormalised(ToWorldCm);
const double DeltaXM = ShortestDeltaU(From.X, To.X) * WidthM;
const double DeltaYM = (To.Y - From.Y) * HeightM;
return FMath::Sqrt(DeltaXM * DeltaXM + DeltaYM * DeltaYM);
}
FString FWorldMapProjection::ToString() const
{
return FString::Printf(TEXT("%.2f x %.2f km centred on (%.0f, %.0f) m, elevation %.0f..%.0f m%s"),
WidthM / 1000.0, HeightM / 1000.0, CentreM.X, CentreM.Y,
ElevationMinM, ElevationMaxM, bWrapsX ? TEXT(", wraps in X") : TEXT(""));
}
@@ -0,0 +1,98 @@
#pragma once
#include "CoreMinimal.h"
#include "WorldMapProjection.generated.h"
// Where a place in the world is on a picture of the world, and back. // pure
//
// This is the whole of what a map view needs to know about the ground, and it is four numbers, because
// L_World is a *whole planet map imported with no crop* (RawContent/World/Region.json's window is the full
// 8192 x 4096 export) laid out as a grid of landscapes straddling the world origin. So the map art and the
// world are the same rectangle at two scales, and the transform between them is one multiply and one add per
// axis - no render capture, no per-tile bookkeeping, no stitching.
//
// The axes are the region manifest's: the source image's X runs along world X and its Y along world Y, which
// is what Scripts/Authoring/generate_region_tiles.py samples by and therefore what the ground actually is.
// On screen that means the map's horizontal axis is world X and *up on the map is world -Y*. That is not the
// compass convention and it is deliberately not corrected here: the alternative is a map that disagrees with
// the coordinates every other tool in the project prints.
//
// Normalised coordinates are 0..1 across the art, which is why they are the currency rather than pixels: every
// layer is a different render of the same cylinder and they are not all the same resolution.
USTRUCT(BlueprintType)
struct SALTYCORE_API FWorldMapProjection
{
GENERATED_BODY()
// The ground the map covers, in metres. 71 400 x 35 700 for L_World.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double WidthM = 0.0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double HeightM = 0.0;
// Where the centre of the map sits in the world, in metres. The region grid straddles the origin, so this
// is 0,0 today; it is a field rather than an assumption because a window cut from somewhere else would not.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
FVector2D CentreM = FVector2D::ZeroVector;
// True when the art is a whole cylinder, so its left and right edges are the same meridian. Panning then
// crosses the seam instead of stopping at it, and the distance between two points may go the short way
// round. False for a window cut out of one, where the edges are edges.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
bool bWrapsX = false;
// The height contract, copied from the same manifest, so a readout can say what a place is in metres
// without the map owning a second opinion about elevation. Not used by the transform.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double ElevationMinM = 0.0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double ElevationMaxM = 0.0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double SeaLevelM = 0.0;
bool IsValid() const { return WidthM > 0.0 && HeightM > 0.0; }
FVector2D ExtentM() const { return FVector2D(WidthM, HeightM); }
/** Metres of ground across the whole map, per axis. The two differ on any map that is not square. */
double MetresPerUnitX() const { return WidthM; }
double MetresPerUnitY() const { return HeightM; }
/** World XY in centimetres to 0..1 across the art. Outside the map the result is simply outside 0..1. */
FVector2D WorldToNormalised(const FVector2D& WorldCm) const;
/** The same for a full world location; Z is ignored. */
FVector2D WorldToNormalised(const FVector& WorldCm) const { return WorldToNormalised(FVector2D(WorldCm.X, WorldCm.Y)); }
/** 0..1 across the art back to world XY in centimetres. */
FVector2D NormalisedToWorldCm(const FVector2D& Normalised) const;
/** Metres rather than centimetres, for anything a person reads. */
FVector2D NormalisedToWorldM(const FVector2D& Normalised) const { return NormalisedToWorldCm(Normalised) / 100.0; }
/**
* U folded into [0, 1) on a wrapping map, left alone on one that does not wrap. Every pan and every marker
* goes through this rather than through fmod at the call site: FMath::Fmod keeps the sign of its argument,
* so a westward pan past the seam lands at -0.02 and samples nothing.
*/
double WrapU(double U) const;
/** WrapU on the U of a pair, V untouched - V is latitude and a cylinder does not wrap over its poles. */
FVector2D WrapNormalised(const FVector2D& Normalised) const { return FVector2D(WrapU(Normalised.X), Normalised.Y); }
/**
* Signed shortest distance from one U to another, in normalised units. On a wrapping map two points either
* side of the seam are close, and this is what says so: the result is in [-0.5, 0.5]. Without it a marker
* at U 0.99 and a view centred on U 0.01 are 0.98 apart and the marker is drawn off the far edge.
*/
double ShortestDeltaU(double FromU, double ToU) const;
/** How far apart two places are on the ground, in metres, taking the short way round a wrapping map. */
double DistanceM(const FVector2D& FromWorldCm, const FVector2D& ToWorldCm) const;
/** A line for a log or a tooltip. Not for a player: this is metres and axis names, not a place name. */
FString ToString() const;
};
@@ -2,12 +2,15 @@
#include "SaltyEditor.h"
#include "Landscape.h"
#include "LandscapeInfo.h"
#include "LandscapeComponent.h"
#include "LandscapeImportHelper.h"
#include "LandscapeLayerInfoObject.h"
#include "LandscapeSubsystem.h"
#include "Engine/World.h"
#include "Materials/MaterialInterface.h"
#include "RenderingThread.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "UObject/Package.h"
ALandscape* ULandscapeAuthoringLibrary::CreateLandscapeFromHeightmap(UWorld* World, const FString& HeightmapFile,
const TArray<FLandscapeAuthoringWeightmap>& Weightmaps, UMaterialInterface* Material,
@@ -80,6 +83,22 @@ ALandscape* ULandscapeAuthoringLibrary::CreateLandscapeFromHeightmap(UWorld* Wor
FLandscapeImportHelper::TransformWeightmapImportData(Info.LayerData, Expanded, WeightDescriptor.ImportResolutions[0], RequiredResolution, ELandscapeImportTransformType::ExpandCentered);
Info.LayerData = MoveTemp(Expanded);
}
// An empty layer would be dropped without a word further down: ALandscapeProxy::Import only calls
// SetAlphaData when `MaterialLayerInfo.LayerData.Num() > 0`, so no samples here would mean a layer that
// is silently never painted. It should not be reachable - FLandscapeImportHelper's reader calls
// SetNumZeroed(TotalPixels) before it decodes anything, so a descriptor that resolved at all comes back
// full-sized - which is exactly why it is worth a check rather than a comment: the day it is reachable
// is the day a whole world comes out shaded as the material's first layer with nothing logged anywhere.
if (Info.LayerData.Num() == 0)
{
UE_LOG(LogSaltyEditor, Error, TEXT("CreateLandscapeFromHeightmap: layer %s read no samples from %s; the engine would have dropped it silently"),
*Info.LayerName.ToString(), *Weightmap.File);
return nullptr;
}
UE_LOG(LogSaltyEditor, Log, TEXT("CreateLandscapeFromHeightmap: layer %s <- %s, %d samples at %ux%u"),
*Info.LayerName.ToString(), *Weightmap.File, Info.LayerData.Num(),
WeightDescriptor.ImportResolutions[0].Width, WeightDescriptor.ImportResolutions[0].Height);
LayerInfos.Add(MoveTemp(Info));
}
@@ -146,7 +165,80 @@ ALandscape* ULandscapeAuthoringLibrary::CreateLandscapeFromHeightmap(UWorld* Wor
UE_LOG(LogSaltyEditor, Warning, TEXT("CreateLandscapeFromHeightmap: the edit-layer update did not finish; render heightmaps may be stale"));
}
// Did the weights actually survive the import and the merge? A component with no weightmap allocations is a
// landscape the material will shade entirely from its preview weights, which looks like a material problem
// and is not one. Checked rather than assumed, because every failure mode above this line is silent.
{
TArray<ULandscapeComponent*> Components;
Landscape->GetComponents<ULandscapeComponent>(Components);
if (Components.Num() > 0 && Components[0] != nullptr)
{
const int32 Allocations = Components[0]->GetWeightmapLayerAllocations().Num();
const int32 Textures = Components[0]->GetWeightmapTextures().Num();
if (Allocations == 0 && LayerInfos.Num() > 0)
{
UE_LOG(LogSaltyEditor, Error, TEXT("CreateLandscapeFromHeightmap: %d layers were imported but component 0 has no weightmap allocations; the ground will render as the material's first layer everywhere"),
LayerInfos.Num());
}
else
{
UE_LOG(LogSaltyEditor, Log, TEXT("CreateLandscapeFromHeightmap: component 0 carries %d weightmap allocation(s) across %d texture(s)"),
Allocations, Textures);
}
}
}
UE_LOG(LogSaltyEditor, Log, TEXT("CreateLandscapeFromHeightmap: %ux%u vertices, %dx%d components of %d quads, scale (%g, %g, %g), %d paint layers"),
RequiredResolution.Width, RequiredResolution.Height, ComponentCount.X, ComponentCount.Y, QuadsPerComponent, Scale.X, Scale.Y, Scale.Z, LayerInfos.Num());
return Landscape;
}
ULandscapeLayerInfoObject* ULandscapeAuthoringLibrary::CreateLayerInfo(const FString& PackagePath,
const FString& AssetName, FName LayerName)
{
if (PackagePath.IsEmpty() || AssetName.IsEmpty() || LayerName.IsNone())
{
UE_LOG(LogSaltyEditor, Error, TEXT("CreateLayerInfo: needs a package path, an asset name and a layer name"));
return nullptr;
}
const FString PackageName = PackagePath / AssetName;
// Rerunnable: an existing layer info is returned as it is. Replacing one would break every landscape that
// already paints with it, and the thing a caller wants here is "make sure this exists".
if (ULandscapeLayerInfoObject* Existing = LoadObject<ULandscapeLayerInfoObject>(nullptr, *(PackageName + TEXT(".") + AssetName)))
{
if (Existing->GetLayerName() != LayerName)
{
UE_LOG(LogSaltyEditor, Warning, TEXT("CreateLayerInfo: %s already exists and its LayerName is %s, not %s; left alone"),
*PackageName, *Existing->GetLayerName().ToString(), *LayerName.ToString());
}
return Existing;
}
UPackage* Package = CreatePackage(*PackageName);
if (!Package)
{
UE_LOG(LogSaltyEditor, Error, TEXT("CreateLayerInfo: could not create package %s"), *PackageName);
return nullptr;
}
ULandscapeLayerInfoObject* Info = NewObject<ULandscapeLayerInfoObject>(
Package, *AssetName, RF_Public | RF_Standalone | RF_Transactional);
if (!Info)
{
UE_LOG(LogSaltyEditor, Error, TEXT("CreateLayerInfo: could not create %s"), *PackageName);
return nullptr;
}
// The whole reason this function exists: LayerName is VisibleAnywhere, so Python cannot set it and a
// duplicated layer info keeps the name it was copied from. Through the setter, not the member: the member
// is deprecated and goes private next release, and bInModify false because the object was made a moment
// ago and has nothing to record an undo against.
Info->SetLayerName(LayerName, /*bInModify=*/false);
Info->MarkPackageDirty();
FAssetRegistryModule::AssetCreated(Info);
UE_LOG(LogSaltyEditor, Log, TEXT("CreateLayerInfo: %s with LayerName %s"), *PackageName, *LayerName.ToString());
return Info;
}
@@ -40,4 +40,15 @@ public:
static ALandscape* CreateLandscapeFromHeightmap(UWorld* World, const FString& HeightmapFile,
const TArray<FLandscapeAuthoringWeightmap>& Weightmaps, UMaterialInterface* Material,
FVector Location, FVector Scale, int32 WorldPartitionGridSize = 4);
// Creates a ULandscapeLayerInfoObject asset carrying LayerName, which is the name the landscape material
// blends by and the name a weightmap is imported against.
//
// The second thing the engine exposes no Python path to, and for the same reason as the first. There is no
// LayerInfo factory in the bindings, and ULandscapeLayerInfoObject::LayerName is VisibleAnywhere - so the
// obvious workaround, duplicating an existing layer info, produces an asset that silently keeps the name it
// was copied from and paints that substance wherever the new layer should be. Returns null and logs why on
// failure; an existing asset at the path is returned rather than replaced, so this is safe to rerun.
UFUNCTION(BlueprintCallable, Category = "Salty|Authoring")
static ULandscapeLayerInfoObject* CreateLayerInfo(const FString& PackagePath, const FString& AssetName, FName LayerName);
};
+12 -1
View File
@@ -2,6 +2,7 @@ using UnrealBuildTool;
// Editor-only tools. Arrived with the first one (Docs/Spec/Architecture.md, Modules): the landscape authoring
// library that Scripts/Authoring/create_world.py calls, because the engine exposes no landscape creation to Python.
// The World Map tab joined it: the same SWorldMap the game draws, in a dockable tab.
public class SaltyEditor : ModuleRules
{
public SaltyEditor(ReadOnlyTargetRules Target) : base(Target)
@@ -18,7 +19,17 @@ public class SaltyEditor : ModuleRules
"UnrealEd",
"Landscape",
"LandscapeEditor", // FLandscapeImportHelper reads the heightmap and weightmap files
"RenderCore" // FlushRenderingCommands while the edit-layer merge is driven by hand
"RenderCore", // FlushRenderingCommands while the edit-layer merge is driven by hand
// The World Map tab. It depends on the game module because it hosts the game's own map widget
// rather than a second copy of it; an editor module may depend on a runtime one, never the reverse.
"Salty",
"SaltyCore",
"Slate",
"SlateCore",
"InputCore",
"AssetRegistry", // finding every UWorldMapDefinition in the project
"WorkspaceMenuStructure" // putting the tab in the Window menu
});
PublicIncludePaths.Add(ModuleDirectory);
+35 -2
View File
@@ -1,6 +1,39 @@
#include "SaltyEditor.h"
#include "Modules/ModuleManager.h"
IMPLEMENT_MODULE(FDefaultModuleImpl, SaltyEditor);
#include "Framework/Application/SlateApplication.h"
#include "Modules/ModuleManager.h"
#include "WorldMap/WorldMapTab.h"
// The editor module now has a startup of its own: the World Map tab registers here. Before it there was
// nothing to start and FDefaultModuleImpl was enough.
class FSaltyEditorModule : public IModuleInterface
{
public:
virtual void StartupModule() override
{
// A commandlet - Scripts/Authoring runs several through -run=pythonscript - loads editor modules with
// no Slate at all, and registering a tab spawner there would crash on the way to building a level.
if (IsRunningCommandlet() || !FSlateApplication::IsInitialized())
{
return;
}
SaltyWorldMapTab::Register();
bRegisteredTab = true;
}
virtual void ShutdownModule() override
{
if (bRegisteredTab && FSlateApplication::IsInitialized())
{
SaltyWorldMapTab::Unregister();
}
bRegisteredTab = false;
}
private:
bool bRegisteredTab = false;
};
IMPLEMENT_MODULE(FSaltyEditorModule, SaltyEditor);
DEFINE_LOG_CATEGORY(LogSaltyEditor);
+310
View File
@@ -0,0 +1,310 @@
#include "WorldMap/WorldMapTab.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "Editor.h"
#include "Framework/Docking/TabManager.h"
#include "LevelEditorViewport.h"
#include "SaltyEditor.h"
#include "UI/SWorldMap.h"
#include "Widgets/Docking/SDockTab.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Input/SComboBox.h"
#include "Styling/AppStyle.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SSeparator.h"
#include "Widgets/Text/STextBlock.h"
#include "WorkspaceMenuStructure.h"
#include "WorkspaceMenuStructureModule.h"
#include "World/WorldMapDefinition.h"
#define LOCTEXT_NAMESPACE "SaltyWorldMapTab"
namespace SaltyWorldMapTab
{
const FName TabId("SaltyWorldMap");
}
void SWorldMapPanel::Construct(const FArguments& InArgs)
{
FindDefinitions();
ChildSlot
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(4.f)
[
MakeToolbar()
]
]
+ SVerticalBox::Slot()
.FillHeight(1.f)
[
SAssignNew(Map, SWorldMap)
.Definition(Selected.Get())
.ShowGrid(true)
.ShowScaleBar(true)
// No subsystem in an editor world, so neither of these has anything to draw. Said explicitly
// rather than left to default, because "where is my player arrow" is otherwise a puzzle.
.ShowMarkers(false)
.ShowLocalPlayer(false)
.OnClicked(FOnWorldMapClicked::CreateSP(this, &SWorldMapPanel::HandleMapClicked))
]
];
}
void SWorldMapPanel::FindDefinitions()
{
Definitions.Reset();
const FAssetRegistryModule& Registry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry");
FARFilter Filter;
Filter.ClassPaths.Add(UWorldMapDefinition::StaticClass()->GetClassPathName());
Filter.bRecursiveClasses = true;
TArray<FAssetData> Found;
Registry.Get().GetAssets(Filter, Found);
for (const FAssetData& Asset : Found)
{
if (UWorldMapDefinition* Definition = Cast<UWorldMapDefinition>(Asset.GetAsset()))
{
Definitions.Add(Definition);
}
}
// A stable order, so the combo does not shuffle between sessions.
Definitions.Sort([](const TWeakObjectPtr<UWorldMapDefinition>& A, const TWeakObjectPtr<UWorldMapDefinition>& B)
{
return A.IsValid() && B.IsValid() && A->GetName() < B->GetName();
});
if (!Selected.IsValid() && Definitions.Num() > 0)
{
Selected = Definitions[0];
}
LayerOptions.Reset();
if (const UWorldMapDefinition* Definition = Selected.Get())
{
for (const FName& Id : Definition->GetLayerIds())
{
LayerOptions.Add(MakeShared<FName>(Id));
}
}
}
void SWorldMapPanel::SelectDefinition(TWeakObjectPtr<UWorldMapDefinition> Choice)
{
Selected = Choice;
LayerOptions.Reset();
if (const UWorldMapDefinition* Definition = Selected.Get())
{
for (const FName& Id : Definition->GetLayerIds())
{
LayerOptions.Add(MakeShared<FName>(Id));
}
}
if (Map.IsValid())
{
Map->SetDefinition(Selected.Get());
Map->ZoomToFit();
}
if (LayerCombo.IsValid())
{
LayerCombo->RefreshOptions();
}
}
void SWorldMapPanel::HandleMapClicked(FVector2D WorldCm)
{
// Move every perspective viewport, because "the active one" is whichever was clicked last and that was
// this tab. Keep the altitude: a click on a map is "look over there", not "drop to sea level".
int32 Moved = 0;
for (FLevelEditorViewportClient* Client : GEditor->GetLevelViewportClients())
{
if (!Client || !Client->IsPerspective())
{
continue;
}
FVector Location = Client->GetViewLocation();
Location.X = WorldCm.X;
Location.Y = WorldCm.Y;
Client->SetViewLocation(Location);
Client->Invalidate();
++Moved;
}
const FVector2D WorldM = WorldCm / 100.0;
Status = Moved > 0
? FText::Format(LOCTEXT("Moved", "Viewport moved to X {0} m, Y {1} m"),
FText::AsNumber(FMath::RoundToInt(WorldM.X)), FText::AsNumber(FMath::RoundToInt(WorldM.Y)))
: LOCTEXT("NoViewport", "No perspective viewport to move.");
UE_LOG(LogSaltyEditor, Log, TEXT("World map: clicked X %.0f m, Y %.0f m; moved %d viewport(s)"),
WorldM.X, WorldM.Y, Moved);
}
FText SWorldMapPanel::DefinitionLabel() const
{
const UWorldMapDefinition* Definition = Selected.Get();
return Definition ? FText::FromString(Definition->GetName()) : LOCTEXT("NoDefinition", "No map found");
}
FText SWorldMapPanel::LayerLabel() const
{
return Map.IsValid() ? FText::FromName(Map->GetLayer()) : FText::GetEmpty();
}
FText SWorldMapPanel::StatusLabel() const
{
if (!Status.IsEmpty())
{
return Status;
}
const UWorldMapDefinition* Definition = Selected.Get();
return Definition
? FText::FromString(Definition->Projection.ToString())
: LOCTEXT("BuildHint", "Run Scripts/Authoring/build_world_map.sh to build one.");
}
TSharedRef<SWidget> SWorldMapPanel::MakeToolbar()
{
return SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center)
[
SNew(SComboBox<TWeakObjectPtr<UWorldMapDefinition>>)
.OptionsSource(&Definitions)
.OnGenerateWidget_Lambda([](TWeakObjectPtr<UWorldMapDefinition> Item)
{
return SNew(STextBlock).Text(FText::FromString(Item.IsValid() ? Item->GetName() : TEXT("(missing)")));
})
.OnSelectionChanged_Lambda([this](TWeakObjectPtr<UWorldMapDefinition> Item, ESelectInfo::Type)
{
if (Item.IsValid())
{
SelectDefinition(Item);
}
})
[
SNew(STextBlock).Text(this, &SWorldMapPanel::DefinitionLabel)
]
]
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center)
[
SAssignNew(LayerCombo, SComboBox<TSharedPtr<FName>>)
.OptionsSource(&LayerOptions)
.OnGenerateWidget_Lambda([](TSharedPtr<FName> Item)
{
return SNew(STextBlock).Text(FText::FromName(Item.IsValid() ? *Item : NAME_None));
})
.OnSelectionChanged_Lambda([this](TSharedPtr<FName> Item, ESelectInfo::Type)
{
if (Item.IsValid() && Map.IsValid())
{
Map->SetLayer(*Item);
}
})
[
SNew(STextBlock).Text(this, &SWorldMapPanel::LayerLabel)
]
]
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsChecked(ECheckBoxState::Checked)
.OnCheckStateChanged_Lambda([this](ECheckBoxState State)
{
if (Map.IsValid())
{
Map->SetShowGrid(State == ECheckBoxState::Checked);
}
})
[
SNew(STextBlock).Text(LOCTEXT("Grid", "Grid"))
]
]
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center)
[
SNew(SButton)
.Text(LOCTEXT("Fit", "Fit"))
.ToolTipText(LOCTEXT("FitTip", "Zoom out until the whole world fits. Right-double-click the map does the same."))
.OnClicked_Lambda([this]()
{
if (Map.IsValid())
{
Map->ZoomToFit();
}
return FReply::Handled();
})
]
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 6, 0).VAlign(VAlign_Center)
[
SNew(SButton)
.Text(LOCTEXT("Refresh", "Refresh"))
.ToolTipText(LOCTEXT("RefreshTip", "Look for map definitions again, after a rebuild."))
.OnClicked_Lambda([this]()
{
FindDefinitions();
if (Map.IsValid())
{
Map->SetDefinition(Selected.Get());
}
if (LayerCombo.IsValid())
{
LayerCombo->RefreshOptions();
}
return FReply::Handled();
})
]
+ SHorizontalBox::Slot().FillWidth(1.f).VAlign(VAlign_Center).Padding(6, 0, 0, 0)
[
SNew(STextBlock)
.Text(this, &SWorldMapPanel::StatusLabel)
.ColorAndOpacity(FSlateColor::UseSubduedForeground())
];
}
// ---------------------------------------------------------------------------------------------------------
namespace SaltyWorldMapTab
{
static TSharedRef<SDockTab> SpawnTab(const FSpawnTabArgs&)
{
return SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
[
SNew(SWorldMapPanel)
];
}
void Register()
{
FGlobalTabmanager::Get()->RegisterNomadTabSpawner(TabId, FOnSpawnTab::CreateStatic(&SpawnTab))
.SetDisplayName(LOCTEXT("TabTitle", "World Map"))
.SetTooltipText(LOCTEXT("TabTooltip", "The world's map. Click it to move the viewport camera."))
.SetGroup(WorkspaceMenu::GetMenuStructure().GetLevelEditorCategory())
.SetIcon(FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.WorldBrowser"));
}
void Unregister()
{
FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(TabId);
}
}
#undef LOCTEXT_NAMESPACE
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include "CoreMinimal.h"
#include "Widgets/SCompoundWidget.h"
class SWorldMap;
class UWorldMapDefinition;
template <typename T> class SComboBox;
/**
* The World Map tab: the same SWorldMap the game draws, with a strip of controls above it and a click that
* takes the viewport camera somewhere.
*
* The map widget is shared rather than reimplemented, which is the point of it being Slate. What differs here
* is only what an editor has and a game does not: a choice of which world's map to look at, and a viewport to
* send somewhere. There are no markers, because markers come from a UWorldMapSubsystem and an editor world
* has none - that is not a gap, it is that nobody is playing.
*/
class SWorldMapPanel : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SWorldMapPanel) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
private:
/** Every UWorldMapDefinition in the project, by asset registry. Editor-only, which is why it may do this. */
void FindDefinitions();
void SelectDefinition(TWeakObjectPtr<UWorldMapDefinition> Choice);
void HandleMapClicked(FVector2D WorldCm);
TSharedRef<SWidget> MakeToolbar();
FText DefinitionLabel() const;
FText LayerLabel() const;
FText StatusLabel() const;
TArray<TWeakObjectPtr<UWorldMapDefinition>> Definitions;
TWeakObjectPtr<UWorldMapDefinition> Selected;
TArray<TSharedPtr<FName>> LayerOptions;
TSharedPtr<SWorldMap> Map;
TSharedPtr<SComboBox<TSharedPtr<FName>>> LayerCombo;
/** What the last click did, so a click that could not move a camera says why rather than doing nothing. */
FText Status;
};
/** Registers and unregisters the tab with the global tab manager. Called by the module. */
namespace SaltyWorldMapTab
{
extern const FName TabId;
void Register();
void Unregister();
}