829 lines
29 KiB
C++
829 lines
29 KiB
C++
#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);
|
|
}
|