#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(V.X), static_cast(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 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* 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(Texture)); TSharedRef Brush = MakeShared(); Brush->SetResourceObject(Texture); Brush->ImageSize = FVector2f(static_cast(Texture->GetSizeX()), static_cast(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(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(SegU0 - CopyU0), static_cast(VisibleV0)), FVector2f(static_cast(SegU1 - CopyU0), static_cast(VisibleV1)))); FSlateDrawElement::MakeBox( Out, LayerId, Geometry.ToPaintGeometry( FVector2f(static_cast(ScreenX1 - ScreenX0), static_cast(ScreenY1 - ScreenY0)), LayoutAt(FVector2f(static_cast(ScreenX0), static_cast(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((X - TopLeftM.X) / (BottomRightM.X - TopLeftM.X) * View.SizePx.X); const TArray Points = { FVector2f(Px, 0.f), FVector2f(Px, static_cast(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((Y - TopLeftM.Y) / (BottomRightM.Y - TopLeftM.Y) * View.SizePx.Y); const TArray Points = { FVector2f(0.f, Py), FVector2f(static_cast(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 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 Across = { Centre - FVector2f(Half, 0.f), Centre + FVector2f(Half, 0.f) }; const TArray 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 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& 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(StepM / MetresPerPixel); if (BarPx < 8.f || BarPx > View.SizePx.X) { return LayerId; } const float Left = 12.f; const float Bottom = static_cast(View.SizePx.Y) - 16.f; const TArray 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(View.SizePx.X - TextSize.X - 12.0), static_cast(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((Size.X - TextSize.X) * 0.5), static_cast((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); }