Tooling
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
#include "World/WorldMapDefinition.h"
|
||||
|
||||
// Direct-initialised from an explicit FName: copy-initialising from a string literal would need two
|
||||
// user-defined conversions (TCHAR* -> FName -> FPrimaryAssetType), which the language does not allow.
|
||||
const FPrimaryAssetType UWorldMapDefinition::AssetType(FName("WorldMap"));
|
||||
|
||||
const FWorldMapLayer* UWorldMapDefinition::FindLayer(FName Id) const
|
||||
{
|
||||
return Layers.FindByPredicate([Id](const FWorldMapLayer& Layer) { return Layer.Id == Id; });
|
||||
}
|
||||
|
||||
const FWorldMapLayer* UWorldMapDefinition::ResolveDefaultLayer() const
|
||||
{
|
||||
if (const FWorldMapLayer* Named = FindLayer(DefaultLayer))
|
||||
{
|
||||
return Named;
|
||||
}
|
||||
return Layers.Num() > 0 ? &Layers[0] : nullptr;
|
||||
}
|
||||
|
||||
FName UWorldMapDefinition::NextLayerId(FName Id) const
|
||||
{
|
||||
if (Layers.Num() == 0)
|
||||
{
|
||||
return NAME_None;
|
||||
}
|
||||
const int32 Index = Layers.IndexOfByPredicate([Id](const FWorldMapLayer& Layer) { return Layer.Id == Id; });
|
||||
// An unknown id lands on the first layer rather than on nothing, so a stale name in a saved setting
|
||||
// recovers on the next press instead of leaving the map blank.
|
||||
return Layers[Index == INDEX_NONE ? 0 : (Index + 1) % Layers.Num()].Id;
|
||||
}
|
||||
|
||||
TArray<FName> UWorldMapDefinition::GetLayerIds() const
|
||||
{
|
||||
TArray<FName> Ids;
|
||||
Ids.Reserve(Layers.Num());
|
||||
for (const FWorldMapLayer& Layer : Layers)
|
||||
{
|
||||
Ids.Add(Layer.Id);
|
||||
}
|
||||
return Ids;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "World/WorldMapProjection.h"
|
||||
#include "WorldMapDefinition.generated.h"
|
||||
|
||||
class UTexture2D;
|
||||
|
||||
// Every property here is EditAnywhere rather than the EditDefaultsOnly a data asset usually carries, because
|
||||
// this asset is *written by a script*: Python's set_editor_property refuses a property marked edit-on-default
|
||||
// when the object is an instance, and a UDataAsset is an instance. EditDefaultsOnly guards against editing a
|
||||
// placed actor's copy of something; there is no such copy here.
|
||||
|
||||
/**
|
||||
* One picture of the whole world, and what to call it. Every layer is a different render of the same cylinder,
|
||||
* so they share the definition's projection and need not share a resolution.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct SALTY_API FWorldMapLayer
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
/** How code and the console name this layer. Matches the id in RawContent/World/MapArt/layers.json. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
FName Id;
|
||||
|
||||
/** What a person sees on the button. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
FText DisplayName;
|
||||
|
||||
/** Soft, because a map that is never opened should not cost 11 MB of texture to be in the level. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
TSoftObjectPtr<UTexture2D> Texture;
|
||||
|
||||
/** Why this layer exists, for whoever opens the asset next. Never shown to a player. */
|
||||
UPROPERTY(EditAnywhere, Category = "World Map", meta = (MultiLine = true))
|
||||
FString Note;
|
||||
};
|
||||
|
||||
/**
|
||||
* The map of one level: where the ground is on the picture, and which pictures there are.
|
||||
*
|
||||
* Written by Scripts/Authoring/create_world_map.py out of RawContent/World/Region.json and MapArt/layers.json,
|
||||
* so the projection here and the geometry of the landscape come from the same numbers and cannot drift. It is
|
||||
* an asset rather than a config entry because it is content, and because the editor tab and the game both read
|
||||
* it without either of them knowing that RawContent exists.
|
||||
*/
|
||||
UCLASS(BlueprintType)
|
||||
class SALTY_API UWorldMapDefinition : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** The primary asset type, registered for scanning in Config/DefaultGame.ini. */
|
||||
static const FPrimaryAssetType AssetType;
|
||||
|
||||
virtual FPrimaryAssetId GetPrimaryAssetId() const override { return FPrimaryAssetId(AssetType, GetFName()); }
|
||||
|
||||
/** Where the ground is on the art. See FWorldMapProjection: for L_World this is the whole planet. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
FWorldMapProjection Projection;
|
||||
|
||||
/**
|
||||
* The level this is a map of. How UWorldMapSubsystem decides a definition belongs to the world it is in.
|
||||
*
|
||||
* A path rather than a TSoftObjectPtr<UWorld> because it is only ever compared, never loaded: resolving it
|
||||
* would pull a 98-landscape level in to answer "is this the world I am already standing in".
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map", meta = (AllowedClasses = "/Script/Engine.World"))
|
||||
FSoftObjectPath Level;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
TArray<FWorldMapLayer> Layers;
|
||||
|
||||
/** Which layer the map opens on. Falls back to the first layer when it names one that is not here. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map")
|
||||
FName DefaultLayer;
|
||||
|
||||
/** What built this, so a map that looks wrong can be traced to the run that made it. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Map", meta = (MultiLine = true))
|
||||
FString BuiltFrom;
|
||||
|
||||
const FWorldMapLayer* FindLayer(FName Id) const;
|
||||
|
||||
/** DefaultLayer if it exists, otherwise the first layer, otherwise nullptr. */
|
||||
const FWorldMapLayer* ResolveDefaultLayer() const;
|
||||
|
||||
/** The id after Id, wrapping; NAME_None when there are no layers. For a "next layer" key or button. */
|
||||
FName NextLayerId(FName Id) const;
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
TArray<FName> GetLayerIds() const;
|
||||
|
||||
/** True when this can actually be drawn: a projection with an extent and at least one layer. */
|
||||
UFUNCTION(BlueprintPure, Category = "World Map")
|
||||
bool IsUsable() const { return Projection.IsValid() && Layers.Num() > 0; }
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
#include "World/WorldMapSubsystem.h"
|
||||
|
||||
#include "Core/TelemetrySubsystem.h"
|
||||
#include "Engine/AssetManager.h"
|
||||
#include "Engine/Engine.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/World.h"
|
||||
#include "GameFramework/Pawn.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "Salty.h"
|
||||
#include "Telemetry/TelemetryEvent.h"
|
||||
#include "Telemetry/TelemetryEvents.h"
|
||||
#include "World/WorldMapDefinition.h"
|
||||
|
||||
bool UWorldMapSubsystem::ShouldCreateSubsystem(UObject* Outer) const
|
||||
{
|
||||
if (!Super::ShouldCreateSubsystem(Outer))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Game and PIE worlds only. An editor preview world or a thumbnail scene has no map and no one to show it to.
|
||||
const UWorld* World = Cast<UWorld>(Outer);
|
||||
return World && (World->IsGameWorld());
|
||||
}
|
||||
|
||||
void UWorldMapSubsystem::Deinitialize()
|
||||
{
|
||||
Markers.Reset();
|
||||
OnMarkersChanged.Clear();
|
||||
Definition = nullptr;
|
||||
bResolved = false;
|
||||
Super::Deinitialize();
|
||||
}
|
||||
|
||||
void UWorldMapSubsystem::ResolveDefinition() const
|
||||
{
|
||||
bResolved = true;
|
||||
Definition = nullptr;
|
||||
|
||||
const UWorld* World = GetWorld();
|
||||
if (!World)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// PIE renames the level package (/Game/Maps/UEDPIE_0_L_World), so the definition's own path would never
|
||||
// match what the world says it is. Strip it, or the map works in a cooked build and not in the editor.
|
||||
const FString LevelPackage = UWorld::RemovePIEPrefix(World->GetOutermost()->GetName());
|
||||
|
||||
// IsInitialized, not IsValid: the manager is always constructed now and IsValid is deprecated for saying
|
||||
// so. The guard stays because this can be reached before UEngine::InitializeObjectReferences has run.
|
||||
if (!UAssetManager::IsInitialized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
UAssetManager& Manager = UAssetManager::Get();
|
||||
|
||||
TArray<FPrimaryAssetId> Ids;
|
||||
Manager.GetPrimaryAssetIdList(UWorldMapDefinition::AssetType, Ids);
|
||||
if (Ids.Num() == 0)
|
||||
{
|
||||
UE_LOG(LogSalty, Verbose,
|
||||
TEXT("World map: no %s assets are registered. Add a PrimaryAssetTypesToScan entry in DefaultGame.ini "
|
||||
"and run Scripts/Authoring/build_world_map.sh."),
|
||||
*UWorldMapDefinition::AssetType.ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
for (const FPrimaryAssetId& Id : Ids)
|
||||
{
|
||||
const FSoftObjectPath Path = Manager.GetPrimaryAssetPath(Id);
|
||||
// Synchronous, because this happens once when a map is first opened and an asynchronous load would
|
||||
// mean a frame of blank map for no gain.
|
||||
const UWorldMapDefinition* Candidate = Cast<UWorldMapDefinition>(Path.TryLoad());
|
||||
if (Candidate && Candidate->Level.GetLongPackageName() == LevelPackage)
|
||||
{
|
||||
Definition = Candidate;
|
||||
UE_LOG(LogSalty, Log, TEXT("World map: %s covers %s - %s"),
|
||||
*Id.ToString(), *LevelPackage, *Candidate->Projection.ToString());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately not "fall back to the only one there is". L_Gym showing L_World's map would be a map that
|
||||
// lies about where you are, which is worse than no map.
|
||||
UE_LOG(LogSalty, Verbose, TEXT("World map: none of the %d definitions is a map of %s."), Ids.Num(), *LevelPackage);
|
||||
}
|
||||
|
||||
const UWorldMapDefinition* UWorldMapSubsystem::GetDefinition() const
|
||||
{
|
||||
if (!bResolved)
|
||||
{
|
||||
ResolveDefinition();
|
||||
}
|
||||
return Definition;
|
||||
}
|
||||
|
||||
FGuid UWorldMapSubsystem::AddMarker(const FWorldMapMarker& Marker)
|
||||
{
|
||||
const FGuid Handle = FGuid::NewGuid();
|
||||
Markers.Add(Handle, Marker);
|
||||
OnMarkersChanged.Broadcast();
|
||||
return Handle;
|
||||
}
|
||||
|
||||
bool UWorldMapSubsystem::UpdateMarker(const FGuid& Handle, const FWorldMapMarker& Marker)
|
||||
{
|
||||
if (FWorldMapMarker* Existing = Markers.Find(Handle))
|
||||
{
|
||||
*Existing = Marker;
|
||||
OnMarkersChanged.Broadcast();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UWorldMapSubsystem::RemoveMarker(const FGuid& Handle)
|
||||
{
|
||||
if (Markers.Remove(Handle) > 0)
|
||||
{
|
||||
OnMarkersChanged.Broadcast();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void UWorldMapSubsystem::NoteMapOpened(FName LayerId, float MetresPerPixel, const FString& OpenedBy)
|
||||
{
|
||||
const UWorld* World = GetWorld();
|
||||
const UGameInstance* GameInstance = World ? World->GetGameInstance() : nullptr;
|
||||
UTelemetrySubsystem* Telemetry = GameInstance ? GameInstance->GetSubsystem<UTelemetrySubsystem>() : nullptr;
|
||||
if (!Telemetry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Telemetry->Emit(TelemetryEvents::WorldMapOpened, FTelemetryPayload()
|
||||
.Set(TEXT("layer"), LayerId)
|
||||
.Set(TEXT("metres_per_pixel"), MetresPerPixel)
|
||||
.Set(TEXT("opened_by"), OpenedBy));
|
||||
}
|
||||
|
||||
bool UWorldMapSubsystem::GetLocalPlayerMarker(FWorldMapMarker& OutMarker) const
|
||||
{
|
||||
const UWorld* World = GetWorld();
|
||||
const APlayerController* Controller = World ? World->GetFirstPlayerController() : nullptr;
|
||||
if (!Controller)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The spectator counts: someone flying the level with no pawn is still somewhere, and that is exactly when
|
||||
// a map is most useful.
|
||||
const AActor* Body = Controller->GetPawnOrSpectator();
|
||||
if (!Body)
|
||||
{
|
||||
Body = Controller->GetViewTarget();
|
||||
}
|
||||
if (!Body)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
OutMarker = FWorldMapMarker();
|
||||
OutMarker.WorldLocation = Body->GetActorLocation();
|
||||
OutMarker.Shape = EWorldMapMarkerShape::Arrow;
|
||||
OutMarker.Colour = FLinearColor(1.f, 0.95f, 0.35f);
|
||||
OutMarker.SizePx = 13.f;
|
||||
OutMarker.HeadingDegrees = static_cast<float>(Controller->GetControlRotation().Yaw);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Subsystems/WorldSubsystem.h"
|
||||
#include "WorldMapSubsystem.generated.h"
|
||||
|
||||
class UWorldMapDefinition;
|
||||
|
||||
UENUM(BlueprintType)
|
||||
enum class EWorldMapMarkerShape : uint8
|
||||
{
|
||||
/** A filled disc. Anything that is just somewhere. */
|
||||
Dot,
|
||||
/** An unfilled ring, so it does not hide what is under it. Objectives, regions of interest. */
|
||||
Ring,
|
||||
/** A cross. Reads at one pixel where a dot does not, which is what a waypoint needs when zoomed out. */
|
||||
Cross,
|
||||
/** A triangle pointing along Heading. The body you are in, and anything else with a facing. */
|
||||
Arrow
|
||||
};
|
||||
|
||||
/**
|
||||
* Something to draw on the map at a place in the world. Purely a UI concern - nothing here is replicated and
|
||||
* nothing gameplay does depends on a marker existing.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct SALTY_API FWorldMapMarker
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
/** Centimetres, world space. Z is ignored; the map is a plan. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
FVector WorldLocation = FVector::ZeroVector;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
FText Label;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
FLinearColor Colour = FLinearColor::White;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
EWorldMapMarkerShape Shape = EWorldMapMarkerShape::Dot;
|
||||
|
||||
/** World yaw in degrees for Arrow: 0 points along world +X, which is to the right on the map. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
float HeadingDegrees = 0.f;
|
||||
|
||||
/** Drawn size in pixels, so a marker stays the same size however far the map is zoomed out. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
|
||||
float SizePx = 7.f;
|
||||
};
|
||||
|
||||
/**
|
||||
* The map's registry: which definition this level has a map from, and what is on it.
|
||||
*
|
||||
* A world subsystem because a map belongs to a map. Markers are registered by whoever owns the thing being
|
||||
* marked - an actor in BeginPlay, unregistering in EndPlay - rather than found by the map, because a widget
|
||||
* that ran GetAllActorsOfClass to draw itself would be exactly the world search the conventions forbid.
|
||||
*
|
||||
* The local player's own body is deliberately NOT a registered marker: it moves every frame, and a registry
|
||||
* entry rewritten every frame is a registry being used as a variable. The widget asks for it instead.
|
||||
*/
|
||||
UCLASS()
|
||||
class SALTY_API UWorldMapSubsystem : public UWorldSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual bool ShouldCreateSubsystem(UObject* Outer) const override;
|
||||
virtual void Deinitialize() override;
|
||||
|
||||
/**
|
||||
* The definition whose Level is this world, loading it the first time it is asked for. Null when this level
|
||||
* has no map, which is the normal answer in L_Gym and is not an error.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
const UWorldMapDefinition* GetDefinition() const;
|
||||
|
||||
/** Adds a marker and returns its handle. Keep the handle; it is the only way to move or remove it. */
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
FGuid AddMarker(const FWorldMapMarker& Marker);
|
||||
|
||||
/** Replaces a marker wholesale. False when the handle is not one of ours. */
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
bool UpdateMarker(const FGuid& Handle, const FWorldMapMarker& Marker);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
bool RemoveMarker(const FGuid& Handle);
|
||||
|
||||
const TMap<FGuid, FWorldMapMarker>& GetMarkers() const { return Markers; }
|
||||
|
||||
/**
|
||||
* Where the local player is, as a marker, or false when there is no local body to speak of - a dedicated
|
||||
* server, or a client before possession. The heading is the control rotation rather than the body's, because
|
||||
* on a map a player expects the arrow to point where they are looking.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
bool GetLocalPlayerMarker(FWorldMapMarker& OutMarker) const;
|
||||
|
||||
/**
|
||||
* Emits world_map_opened. Every way of opening the map calls this - the console command today, a HUD key
|
||||
* when there is one - so the count is of maps opened and not of the ways of opening them.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "World Map")
|
||||
void NoteMapOpened(FName LayerId, float MetresPerPixel, const FString& OpenedBy);
|
||||
|
||||
/** Raised when a marker is added, moved or removed. The widget subscribes; it never polls. */
|
||||
DECLARE_MULTICAST_DELEGATE(FOnWorldMapMarkersChanged);
|
||||
FOnWorldMapMarkersChanged OnMarkersChanged;
|
||||
|
||||
private:
|
||||
/** Every WorldMap definition whose Level is this world. Searched once; the answer, including none, sticks. */
|
||||
void ResolveDefinition() const;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
mutable TObjectPtr<const UWorldMapDefinition> Definition;
|
||||
|
||||
mutable bool bResolved = false;
|
||||
|
||||
TMap<FGuid, FWorldMapMarker> Markers;
|
||||
};
|
||||
Reference in New Issue
Block a user