Mandatory actors auto-spawn + navmesh surface overlay (custom material)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 16:14:27 +02:00
parent b9ec67f9ff
commit 8f7476d2db
15 changed files with 602 additions and 9 deletions

View File

@@ -1167,8 +1167,35 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager(); UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
if (!SM || !SM->IsAnythingSelected()) return; if (!SM || !SM->IsAnythingSelected()) return;
// Filter out mandatory actors (UPS_Editor_SpawnableComponent::bIsMandatory) — they're
// pinned to the scene by design and the delete is silently refused for them. We pre-filter
// here so the confirmation dialog count matches what will actually be deleted, and we
// skip the dialog entirely if nothing is deletable.
UPS_Editor_SpawnManager* SpawnManager = EditorPC->GetSpawnManager();
TArray<TWeakObjectPtr<AActor>> DeletableActors;
int32 SkippedMandatory = 0;
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
{
AActor* Actor = Weak.Get();
if (!Actor) continue;
if (SpawnManager && !SpawnManager->IsActorDeletable(Actor))
{
++SkippedMandatory;
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: '%s' is a mandatory actor — delete refused."),
*Actor->GetName());
continue;
}
DeletableActors.Add(Weak);
}
if (DeletableActors.Num() == 0)
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Delete aborted — selection contained only mandatory actor(s) (%d)."), SkippedMandatory);
return;
}
// Show in-game confirmation dialog (runtime compatible) // Show in-game confirmation dialog (runtime compatible)
const int32 Count = SM->GetSelectedActors().Num(); const int32 Count = DeletableActors.Num();
const FText Message = FText::Format( const FText Message = FText::Format(
NSLOCTEXT("PS_Editor", "DeleteConfirm", "Delete {0} selected object(s)?"), NSLOCTEXT("PS_Editor", "DeleteConfirm", "Delete {0} selected object(s)?"),
FText::AsNumber(Count)); FText::AsNumber(Count));
@@ -1181,15 +1208,15 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
// Weak refs to avoid dangling pointers in the callback // Weak refs to avoid dangling pointers in the callback
TWeakObjectPtr<APS_Editor_PlayerController> WeakPC = EditorPC; TWeakObjectPtr<APS_Editor_PlayerController> WeakPC = EditorPC;
Dialog->Show(Message, FPS_Editor_OnConfirmResult::CreateLambda([WeakPC](bool bConfirmed) Dialog->Show(Message, FPS_Editor_OnConfirmResult::CreateLambda([WeakPC, DeletableActors](bool bConfirmed)
{ {
if (!bConfirmed || !WeakPC.IsValid()) return; if (!bConfirmed || !WeakPC.IsValid()) return;
UPS_Editor_SelectionManager* SM = WeakPC->GetSelectionManager(); UPS_Editor_SelectionManager* SM = WeakPC->GetSelectionManager();
if (!SM || !SM->IsAnythingSelected()) return; if (!SM) return;
TSharedPtr<FPS_Editor_DeleteAction> Action = MakeShared<FPS_Editor_DeleteAction>(); TSharedPtr<FPS_Editor_DeleteAction> Action = MakeShared<FPS_Editor_DeleteAction>();
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors()) for (const TWeakObjectPtr<AActor>& Weak : DeletableActors)
{ {
if (AActor* Actor = Weak.Get()) if (AActor* Actor = Weak.Get())
{ {

View File

@@ -10,6 +10,8 @@
#include "PS_Editor_SplineEditable.h" #include "PS_Editor_SplineEditable.h"
#include "PS_Editor_PointPlaceable.h" #include "PS_Editor_PointPlaceable.h"
#include "PS_Editor_EditableInterface.h" #include "PS_Editor_EditableInterface.h"
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_TimelineSubsystem.h"
#include "Engine/LevelStreamingDynamic.h" #include "Engine/LevelStreamingDynamic.h"
#include "GameFramework/Character.h" #include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h" #include "GameFramework/CharacterMovementComponent.h"
@@ -29,6 +31,44 @@
#include "GameFramework/GameStateBase.h" #include "GameFramework/GameStateBase.h"
#include "GameFramework/PlayerState.h" #include "GameFramework/PlayerState.h"
namespace
{
/** Resolve an editable property path ("Comp.Prop" or "Prop") on Actor to a UObject target +
* FProperty pointer. Mirrors PS_Editor_MainWidget::FindPropertyTarget + class lookup so the
* PlayerController can snapshot/restore values without depending on the widget. */
static bool ResolveEditableProp(AActor* Actor, const FName& Path, UObject*& OutTarget, FProperty*& OutProp)
{
OutTarget = nullptr;
OutProp = nullptr;
if (!Actor) return false;
FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName);
if (CompName.IsEmpty())
{
OutTarget = Actor;
}
else
{
TArray<UActorComponent*> Components;
Actor->GetComponents(Components);
for (UActorComponent* Comp : Components)
{
if (Comp && Comp->GetName() == CompName)
{
OutTarget = Comp;
break;
}
}
}
if (!OutTarget) return false;
OutProp = OutTarget->GetClass()->FindPropertyByName(*PropName);
return OutProp != nullptr;
}
}
APS_Editor_PlayerController::APS_Editor_PlayerController() APS_Editor_PlayerController::APS_Editor_PlayerController()
{ {
bShowMouseCursor = true; bShowMouseCursor = true;
@@ -275,6 +315,43 @@ void APS_Editor_PlayerController::StartSimulation()
} }
} }
// Snapshot editable property values so we can restore them on Stop. Game logic may mutate
// editable properties during simulate (e.g. WalkSpeed driven by gameplay code) — restoring
// on Stop matches the convention used for transforms. Skip properties already tracked by the
// timeline: the timeline's RestoreBaseline owns those, double-restoring would conflict.
SimulationSavedEditableProps.Empty();
UPS_Editor_TimelineSubsystem* TimelineSS = GetWorld() ? GetWorld()->GetSubsystem<UPS_Editor_TimelineSubsystem>() : nullptr;
if (SpawnManager)
{
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
{
AActor* Actor = Weak.Get();
if (!Actor) continue;
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
if (!EditComp || EditComp->EditableProperties.Num() == 0) continue;
TMap<FName, FString> ActorMap;
for (const FPS_Editor_EditablePropertyEntry& Entry : EditComp->EditableProperties)
{
if (TimelineSS && TimelineSS->IsPropertyTracked(Actor, Entry.Path))
{
continue; // timeline owns this one
}
UObject* Target = nullptr;
FProperty* Prop = nullptr;
if (!ResolveEditableProp(Actor, Entry.Path, Target, Prop)) continue;
FString Exported;
Prop->ExportText_InContainer(0, Exported, Target, Target, nullptr, PPF_None);
ActorMap.Add(Entry.Path, Exported);
}
if (ActorMap.Num() > 0)
{
SimulationSavedEditableProps.Add(Actor, MoveTemp(ActorMap));
}
}
}
// Clear selection // Clear selection
if (SelectionManager) if (SelectionManager)
{ {
@@ -522,7 +599,49 @@ void APS_Editor_PlayerController::StopSimulation()
} }
SimulationSavedTransforms.Empty(); SimulationSavedTransforms.Empty();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Simulation stopped, transforms restored")); // Restore editable properties that game logic may have mutated during simulate. The timeline's
// own RestoreBaseline already covers the animated ones (we skipped them at snapshot time), so
// this loop only touches properties NOT owned by the timeline — no double-write. Mirrors the
// canonical UI write path (ImportText + MarkRenderStateDirty + RepNotify + OnEditorPropertyChanged)
// so actors reinit consistently regardless of where the value change originated.
int32 RestoredCount = 0;
for (auto& ActorPair : SimulationSavedEditableProps)
{
AActor* Actor = ActorPair.Key.Get();
if (!Actor) continue;
const bool bImplementsInterface = Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass());
for (auto& PropPair : ActorPair.Value)
{
UObject* Target = nullptr;
FProperty* Prop = nullptr;
if (!ResolveEditableProp(Actor, PropPair.Key, Target, Prop)) continue;
Prop->ImportText_InContainer(*PropPair.Value, Target, Target, PPF_None);
if (UActorComponent* Comp = Cast<UActorComponent>(Target))
{
Comp->MarkRenderStateDirty();
}
// RepNotify mirror — ImportText bypasses it, same as the UI write path.
if (!Prop->RepNotifyFunc.IsNone())
{
if (UFunction* RepFunc = Target->FindFunction(Prop->RepNotifyFunc))
{
Target->ProcessEvent(RepFunc, nullptr);
}
}
if (bImplementsInterface)
{
IPS_Editor_EditableInterface::Execute_OnEditorPropertyChanged(Actor, PropPair.Key.ToString());
}
++RestoredCount;
}
}
SimulationSavedEditableProps.Empty();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Simulation stopped, transforms restored (%d editable properties also restored)"), RestoredCount);
} }
void APS_Editor_PlayerController::BeginPointPlacement(TSubclassOf<AActor> InActorClass) void APS_Editor_PlayerController::BeginPointPlacement(TSubclassOf<AActor> InActorClass)
@@ -844,6 +963,16 @@ void APS_Editor_PlayerController::DisableSublevelPostProcessMaterials()
} }
} }
// Auto-spawn mandatory actors now that the baseLevel is loaded and visible. This covers:
// - Initial editor startup (default baseLevel loads at BeginPlay)
// - User switching baseLevel via the UI
// Idempotent — already-present mandatory instances are skipped. The catalog was rebuilt
// just before LoadLevelInstance so its bIsMandatory cache is fresh.
if (SpawnManager)
{
SpawnManager->EnsureMandatoryActorsPresent();
}
// Delay 2s then fade in from black over 2 seconds // Delay 2s then fade in from black over 2 seconds
if (bWaitingForSublevelFadeIn) if (bWaitingForSublevelFadeIn)
{ {

View File

@@ -180,6 +180,12 @@ private:
/** Saved transforms for all spawned actors before simulation, to restore on stop. */ /** Saved transforms for all spawned actors before simulation, to restore on stop. */
TMap<TWeakObjectPtr<AActor>, FTransform> SimulationSavedTransforms; TMap<TWeakObjectPtr<AActor>, FTransform> SimulationSavedTransforms;
/** Saved exported-text values for editable properties (UPS_Editor_EditableComponent::EditableProperties)
* that aren't already managed by the timeline. Game logic may mutate these during simulate;
* StopSimulation restores them so the editor returns to the pre-Play state for ALL editable
* properties, animated or not. Map: Actor -> { PropertyPath -> ExportedTextValue }. */
TMap<TWeakObjectPtr<AActor>, TMap<FName, FString>> SimulationSavedEditableProps;
/** Timer handle for the deferred StopSimulation used by RequestStopSimulation. */ /** Timer handle for the deferred StopSimulation used by RequestStopSimulation. */
FTimerHandle PendingStopSimulationHandle; FTimerHandle PendingStopSimulationHandle;

View File

@@ -454,6 +454,12 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
{ {
PS_Editor_NavUtils::SetDynamicNavigationEnabled(EditorPC->GetWorld(), SpawnManager->bUseDynamicNavigation); PS_Editor_NavUtils::SetDynamicNavigationEnabled(EditorPC->GetWorld(), SpawnManager->bUseDynamicNavigation);
} }
// Re-add any mandatory actors missing from the loaded scene. Handles legacy saves
// that predate the bIsMandatory flag — the mandatory actor is auto-spawned at the
// baseLevel's MandatorySpawnAnchor. If all mandatory actors were already in the JSON,
// this is a no-op.
SpawnManager->EnsureMandatoryActorsPresent();
} }
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene loaded: %s (%d actors)"), *SceneName, SpawnedCount); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene loaded: %s (%d actors)"), *SceneName, SpawnedCount);
@@ -512,6 +518,14 @@ void UPS_Editor_SceneSerializer::ClearScene(UPS_Editor_SpawnManager* SpawnManage
} }
} }
} }
// NOTE: Mandatory-actor auto-spawn is intentionally NOT done here. ClearScene is called
// from three contexts: (1) "New Scenario" button (we want mandatory spawn — handled in the
// widget after ClearScene returns), (2) LoadScene before re-spawning saved actors (we
// don't want it — the JSON itself contains the mandatory actor, and there's a final
// EnsureMandatoryActorsPresent at the end of LoadScene that handles legacy saves), and
// (3) BaseLevel switch (we don't want it — the new baseLevel isn't loaded yet, the
// MandatoryAnchor isn't in the world; the post-OnLevelShown hook handles that).
} }
FString UPS_Editor_SceneSerializer::GetSceneFilePath(const FString& SceneName) const FString UPS_Editor_SceneSerializer::GetSceneFilePath(const FString& SceneName) const

View File

@@ -0,0 +1,38 @@
#include "PS_Editor_MandatoryAnchor.h"
#include "Components/SceneComponent.h"
#include "Components/ArrowComponent.h"
#include "Components/BillboardComponent.h"
#include "UObject/ConstructorHelpers.h"
APS_Editor_MandatoryAnchor::APS_Editor_MandatoryAnchor()
{
PrimaryActorTick.bCanEverTick = false;
USceneComponent* Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
RootComponent = Root;
#if WITH_EDITORONLY_DATA
SpriteComponent = CreateDefaultSubobject<UBillboardComponent>(TEXT("Sprite"));
SpriteComponent->SetupAttachment(Root);
SpriteComponent->SetHiddenInGame(true);
SpriteComponent->bIsScreenSizeScaled = true;
SpriteComponent->ScreenSize = 0.0015f;
// Pivot icon — clearly distinct from APS_Editor_CameraStart's note icon.
static ConstructorHelpers::FObjectFinder<UTexture2D> IconFinder(TEXT("/Engine/EditorResources/S_Pivot"));
if (IconFinder.Succeeded())
{
SpriteComponent->SetSprite(IconFinder.Object);
}
ArrowComponent = CreateDefaultSubobject<UArrowComponent>(TEXT("Arrow"));
ArrowComponent->SetupAttachment(Root);
ArrowComponent->ArrowColor = FColor(255, 200, 60); // orange so it pops in the viewport
ArrowComponent->ArrowSize = 1.5f;
ArrowComponent->bIsScreenSizeScaled = true;
ArrowComponent->SetHiddenInGame(true);
#endif
// The anchor is purely a marker — no rendering in PIE / packaged
SetActorHiddenInGame(true);
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_Editor_MandatoryAnchor.generated.h"
/**
* Standalone marker actor that defines where mandatory actors
* (UPS_Editor_SpawnableComponent::bIsMandatory) auto-spawn when a new scenario is created
* or when a legacy save is missing one.
*
* Usage:
* - Place ONE instance of this actor in each baseLevel
* - Position it freely — independent from APS_Editor_CameraStart
* - Save the level
*
* The actor is hidden in game / PIE; it has a visible orange arrow + sprite in the level
* editor for placement. SpawnManager::EnsureMandatoryActorsPresent reads its world transform.
*/
UCLASS(BlueprintType, Blueprintable, meta = (DisplayName = "PS Editor Mandatory Anchor"))
class PS_EDITOR_API APS_Editor_MandatoryAnchor : public AActor
{
GENERATED_BODY()
public:
APS_Editor_MandatoryAnchor();
#if WITH_EDITORONLY_DATA
protected:
/** Editor-only billboard sprite so the actor is easy to pick in the level. */
UPROPERTY(VisibleAnywhere, Category = "PS_Editor")
TObjectPtr<class UBillboardComponent> SpriteComponent;
/** Editor-only forward arrow visualizing the anchor's orientation. */
UPROPERTY(VisibleAnywhere, Category = "PS_Editor")
TObjectPtr<class UArrowComponent> ArrowComponent;
#endif
};

View File

@@ -7,6 +7,10 @@
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "Kismet/GameplayStatics.h" #include "Kismet/GameplayStatics.h"
#include "DrawDebugHelpers.h" #include "DrawDebugHelpers.h"
#include "ProceduralMeshComponent.h"
#include "Materials/MaterialInterface.h"
#include "UObject/ConstructorHelpers.h"
#include "UObject/SoftObjectPath.h"
namespace PS_Editor_NavUtils namespace PS_Editor_NavUtils
{ {
@@ -118,10 +122,100 @@ namespace PS_Editor_NavUtils
static TArray<FCachedNavMeshGeo> GCachedNavMeshes; static TArray<FCachedNavMeshGeo> GCachedNavMeshes;
static float GTimeSinceRefresh = 9999.f; // force initial refresh static float GTimeSinceRefresh = 9999.f; // force initial refresh
// Lift the cached navmesh geometry slightly so the wireframe + translucent surface don't
// z-fight with the floor mesh underneath (the navmesh sits exactly on the walkable surface
// per the cell height, which causes shimmering with translucent materials especially).
// 2cm is enough to suppress visual artifacts on typical level geometry without making the
// overlay look detached from the floor.
static constexpr float NavMeshDrawZOffset = 2.0f;
// ---- Surface visualization (procedural mesh + user-supplied translucent material) ----
//
// We spawn a single persistent actor with one UProceduralMeshComponent that holds the
// triangulated navmesh. Its material is loaded once from a hardcoded soft path:
// /PS_Editor/M_PS_Editor_NavMeshSurface
// Users create that material in the plugin Content folder with their own settings
// (translucent, two-sided, color, etc). If the asset is missing, we log a one-shot warning
// and the surface stays hidden — the wireframe still draws so the feature degrades
// gracefully rather than crashing.
static const TCHAR* GNavMeshSurfaceMaterialPath = TEXT("/PS_Editor/M_PS_Editor_NavMeshSurface.M_PS_Editor_NavMeshSurface");
static TWeakObjectPtr<AActor> GSurfaceActor;
static TWeakObjectPtr<UProceduralMeshComponent> GSurfaceMesh;
static TWeakObjectPtr<UMaterialInterface> GSurfaceMaterial;
static bool GSurfaceMaterialMissingWarned = false;
static UMaterialInterface* LoadSurfaceMaterial()
{
if (GSurfaceMaterial.IsValid()) return GSurfaceMaterial.Get();
// Always retry — LoadObject is cheap when the asset is already loaded, and we want
// the load to succeed even if the material was created AFTER the first attempt
// (Live Coding hot-reload, or user just created the asset). Suppressing retries
// breaks "create the material, hit P, see it work" without restarting the editor.
UMaterialInterface* Mat = LoadObject<UMaterialInterface>(nullptr, GNavMeshSurfaceMaterialPath);
if (Mat)
{
GSurfaceMaterial = Mat;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: NavMesh surface material loaded from %s"), GNavMeshSurfaceMaterialPath);
GSurfaceMaterialMissingWarned = false;
return Mat;
}
if (!GSurfaceMaterialMissingWarned)
{
GSurfaceMaterialMissingWarned = true;
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: NavMesh surface material not found at %s. ")
TEXT("Create a translucent material at that path to enable surface visualization. ")
TEXT("Falling back to wireframe-only."), GNavMeshSurfaceMaterialPath);
}
return nullptr;
}
static UProceduralMeshComponent* EnsureSurfaceComponent(UWorld* World)
{
if (!World) return nullptr;
if (GSurfaceMesh.IsValid()) return GSurfaceMesh.Get();
// Spawn a minimal actor and add a UProceduralMeshComponent at runtime. The actor is
// transient — never saved with the scenario, never selectable in the editor.
FActorSpawnParameters Params;
Params.ObjectFlags = RF_Transient;
Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AActor* Actor = World->SpawnActor<AActor>(AActor::StaticClass(), FTransform::Identity, Params);
if (!Actor) return nullptr;
#if WITH_EDITOR
Actor->SetActorLabel(TEXT("PS_Editor_NavMeshSurfaceViz"));
#endif
UProceduralMeshComponent* Mesh = NewObject<UProceduralMeshComponent>(Actor, TEXT("NavMeshSurface"));
Mesh->SetMobility(EComponentMobility::Movable);
Mesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
Mesh->bUseAsyncCooking = false;
Mesh->SetCastShadow(false);
Mesh->SetGenerateOverlapEvents(false);
Mesh->RegisterComponent();
Actor->SetRootComponent(Mesh);
GSurfaceActor = Actor;
GSurfaceMesh = Mesh;
return Mesh;
}
static void DestroySurfaceActor()
{
if (AActor* Actor = GSurfaceActor.Get())
{
Actor->Destroy();
}
GSurfaceActor.Reset();
GSurfaceMesh.Reset();
}
void ClearNavMeshDebugCache() void ClearNavMeshDebugCache()
{ {
GCachedNavMeshes.Empty(); GCachedNavMeshes.Empty();
GTimeSinceRefresh = 9999.f; GTimeSinceRefresh = 9999.f;
DestroySurfaceActor();
} }
void DrawNavMeshDebug(UWorld* World, float DeltaTime, float RefreshIntervalSeconds) void DrawNavMeshDebug(UWorld* World, float DeltaTime, float RefreshIntervalSeconds)
@@ -146,14 +240,21 @@ namespace PS_Editor_NavUtils
// Iterate every tile and pull its debug geometry. UE 5.5 dropped the // Iterate every tile and pull its debug geometry. UE 5.5 dropped the
// single-call GetDebugGeometry; only the per-tile variant remains. // single-call GetDebugGeometry; only the per-tile variant remains.
const int32 NumTiles = Recast->GetNavMeshTilesCount(); // As of 5.5 the int32-tile-index overload is deprecated — use the
for (int32 TileIdx = 0; TileIdx < NumTiles; ++TileIdx) // FNavTileRef overload via GetAllNavMeshTiles.
TArray<FNavTileRef> TileRefs;
Recast->GetAllNavMeshTiles(TileRefs);
for (const FNavTileRef& TileRef : TileRefs)
{ {
FRecastDebugGeometry Geo; FRecastDebugGeometry Geo;
Recast->GetDebugGeometryForTile(Geo, TileIdx); Recast->GetDebugGeometryForTile(Geo, TileRef);
const int32 BaseIdx = Cached.Vertices.Num(); const int32 BaseIdx = Cached.Vertices.Num();
Cached.Vertices.Append(Geo.MeshVerts); Cached.Vertices.Reserve(BaseIdx + Geo.MeshVerts.Num());
for (const FVector& V : Geo.MeshVerts)
{
Cached.Vertices.Add(V + FVector(0.f, 0.f, NavMeshDrawZOffset));
}
for (int32 AreaIdx = 0; AreaIdx < RECAST_MAX_AREAS; ++AreaIdx) for (int32 AreaIdx = 0; AreaIdx < RECAST_MAX_AREAS; ++AreaIdx)
{ {
const TArray<int32>& AreaIndices = Geo.AreaIndices[AreaIdx]; const TArray<int32>& AreaIndices = Geo.AreaIndices[AreaIdx];
@@ -169,6 +270,55 @@ namespace PS_Editor_NavUtils
GCachedNavMeshes.Add(MoveTemp(Cached)); GCachedNavMeshes.Add(MoveTemp(Cached));
} }
} }
// Cache rebuilt — also refresh the procedural surface mesh so the translucent
// overlay tracks live navmesh changes. We merge every navmesh's geometry into a
// single section to keep the proc mesh component lightweight (one section, one
// material, one draw call).
if (UProceduralMeshComponent* Surface = EnsureSurfaceComponent(World))
{
TArray<FVector> Vertices;
TArray<int32> Triangles;
for (const FCachedNavMeshGeo& Cached : GCachedNavMeshes)
{
const int32 BaseIdx = Vertices.Num();
Vertices.Append(Cached.Vertices);
Triangles.Reserve(Triangles.Num() + Cached.TriIndices.Num());
for (int32 Idx : Cached.TriIndices)
{
Triangles.Add(BaseIdx + Idx);
}
}
// Empty arrays for normals/UVs/colors/tangents — the unlit translucent material
// the user provides doesn't need them. ProcMesh treats empty arrays as "skip
// this stream", which is fine for an unlit overlay.
static const TArray<FVector> EmptyNormals;
static const TArray<FVector2D> EmptyUVs;
static const TArray<FColor> EmptyColors;
static const TArray<FProcMeshTangent> EmptyTangents;
if (Vertices.Num() > 0 && Triangles.Num() > 0)
{
Surface->CreateMeshSection(/*SectionIndex=*/ 0, Vertices, Triangles,
EmptyNormals, EmptyUVs, EmptyColors, EmptyTangents,
/*bCreateCollision=*/ false);
UMaterialInterface* Mat = LoadSurfaceMaterial();
if (Mat)
{
Surface->SetMaterial(0, Mat);
}
Surface->SetVisibility(true);
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: NavMesh surface refreshed — %d verts, %d tris, material=%s"),
Vertices.Num(), Triangles.Num() / 3,
Mat ? *Mat->GetName() : TEXT("<none, wireframe-only>"));
}
else
{
Surface->ClearAllMeshSections();
}
}
} }
// Per-frame draw of cached geometry. Lifetime=0 + bPersistent=false means each call // Per-frame draw of cached geometry. Lifetime=0 + bPersistent=false means each call

View File

@@ -6,8 +6,11 @@
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_TimelineSubsystem.h" #include "PS_Editor_TimelineSubsystem.h"
#include "PS_Editor_GroundSnap.h" #include "PS_Editor_GroundSnap.h"
#include "PS_Editor_NavUtils.h"
#include "PS_Editor_MandatoryAnchor.h"
#include "AIController.h" #include "AIController.h"
#include "BrainComponent.h" #include "BrainComponent.h"
#include "EngineUtils.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "GameFramework/Character.h" #include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h" #include "GameFramework/CharacterMovementComponent.h"
@@ -91,6 +94,7 @@ void UPS_Editor_SpawnManager::AddCatalog(UPS_Editor_SpawnCatalog* InCatalog)
Resolved.Category = SpawnComp ? SpawnComp->Category : TEXT("Default"); Resolved.Category = SpawnComp ? SpawnComp->Category : TEXT("Default");
Resolved.Thumbnail = SpawnComp ? SpawnComp->Thumbnail : nullptr; Resolved.Thumbnail = SpawnComp ? SpawnComp->Thumbnail : nullptr;
Resolved.bIsMandatory = SpawnComp ? SpawnComp->bIsMandatory : false;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolved '%s' - SpawnComp=%s, Thumbnail=%s"), UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolved '%s' - SpawnComp=%s, Thumbnail=%s"),
*Resolved.DisplayName, *Resolved.DisplayName,
@@ -469,6 +473,112 @@ void UPS_Editor_SpawnManager::NotifyEditorModeChanged(bool bIsEditing)
} }
} }
bool UPS_Editor_SpawnManager::IsActorDeletable(const AActor* Actor) const
{
if (!Actor) return true;
const UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>();
return !(SpawnComp && SpawnComp->bIsMandatory);
}
void UPS_Editor_SpawnManager::EnsureMandatoryActorsPresent()
{
APlayerController* PC = OwnerPC.Get();
UWorld* World = PC ? PC->GetWorld() : nullptr;
if (!World) return;
// Diagnostic: count how many catalog entries are flagged mandatory. If zero, the user
// likely hasn't restarted PIE since marking the BP — Live Coding doesn't re-resolve the
// catalog, so cached bIsMandatory values stay stale. Surface this clearly in the log.
int32 MandatoryCount = 0;
for (const FPS_Editor_ResolvedEntry& E : ResolvedEntries)
{
if (E.bIsMandatory) ++MandatoryCount;
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: EnsureMandatoryActorsPresent — scanning %d catalog entries (%d marked mandatory)"),
ResolvedEntries.Num(), MandatoryCount);
if (MandatoryCount == 0)
{
// Nothing to do. Don't bother locating an anchor.
return;
}
// 1. Locate the standalone APS_Editor_MandatoryAnchor in the loaded baseLevel. We take
// the first one found. If absent, fall back to world origin (with a warning) so
// mandatory actors still land somewhere reachable.
FTransform AnchorXf = FTransform::Identity;
bool bAnchorFound = false;
for (TActorIterator<APS_Editor_MandatoryAnchor> It(World); It; ++It)
{
if (APS_Editor_MandatoryAnchor* Anchor = *It)
{
AnchorXf = Anchor->GetActorTransform();
bAnchorFound = true;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: MandatoryAnchor found at %s"), *AnchorXf.GetLocation().ToString());
break;
}
}
if (!bAnchorFound)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: EnsureMandatoryActorsPresent — no APS_Editor_MandatoryAnchor placed in the baseLevel. Mandatory actors will spawn at world origin."));
}
// 2. For each mandatory class in the catalog: skip if any tracked actor of that class
// already exists, otherwise spawn one at the anchor transform. SpawnActorDirect routes
// through the standard tracking path so the new actor lands in SpawnedActors and gets
// a stable timeline ActorId.
int32 SpawnedNow = 0;
int32 SkippedExisting = 0;
for (const FPS_Editor_ResolvedEntry& Entry : ResolvedEntries)
{
if (!Entry.bIsMandatory || !Entry.ActorClass) continue;
// Already present?
bool bAlreadyPresent = false;
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
AActor* Existing = Weak.Get();
if (Existing && Existing->IsA(Entry.ActorClass))
{
bAlreadyPresent = true;
break;
}
}
if (bAlreadyPresent)
{
++SkippedExisting;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Mandatory '%s' already present, skipping."), *Entry.DisplayName);
continue;
}
AActor* NewActor = SpawnActorDirect(Entry.ActorClass, AnchorXf.GetLocation(),
AnchorXf.Rotator(), AnchorXf.GetScale3D());
if (NewActor)
{
++SpawnedNow;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Mandatory actor auto-spawned: %s at %s"),
*Entry.DisplayName, *AnchorXf.GetLocation().ToString());
// Fire OnPropertiesLoaded so the actor's BP can run its init logic. For loaded
// scenes, the SceneLoader/Serializer already fires this after applying JSON
// properties; mandatory auto-spawn has no properties to apply, but the actor
// still needs the same init hook (e.g. setting up visuals based on defaults).
// Without this call, BP code that relies on OnPropertiesLoaded silently doesn't
// run for auto-spawned actors → the actor stays in its raw post-BeginPlay state.
if (NewActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnPropertiesLoaded(NewActor);
}
}
else
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to spawn mandatory actor %s"), *Entry.DisplayName);
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: EnsureMandatoryActorsPresent — done. Spawned=%d, AlreadyPresent=%d"),
SpawnedNow, SkippedExisting);
}
void UPS_Editor_SpawnManager::TickEditorMode(float DeltaTime) void UPS_Editor_SpawnManager::TickEditorMode(float DeltaTime)
{ {
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors) for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
@@ -480,6 +590,24 @@ void UPS_Editor_SpawnManager::TickEditorMode(float DeltaTime)
IPS_Editor_EditableInterface::Execute_OnEditorTick(Actor, DeltaTime); IPS_Editor_EditableInterface::Execute_OnEditorTick(Actor, DeltaTime);
} }
} }
// Custom navmesh visualization (P key / Settings checkbox). Bypasses UE's nav debug
// renderer (which is gated behind UE_ALLOW_NAVMESH_DEBUG_DRAWING_IN_GAME and unusable
// on binary engine builds at runtime). Triangulates each ARecastNavMesh and emits
// DrawDebugLine calls per edge — works in PIE / Standalone / packaged. The proc-mesh
// surface overlay is also (re)built inside DrawNavMeshDebug; on the on→off transition
// we explicitly clear the cache so the surface actor is destroyed (DrawDebugLine entries
// auto-expire after 1 frame, but the proc-mesh actor needs an explicit teardown).
if (bShowNavMesh)
{
PS_Editor_NavUtils::DrawNavMeshDebug(GetWorld(), DeltaTime);
bWasShowingNavMesh = true;
}
else if (bWasShowingNavMesh)
{
PS_Editor_NavUtils::ClearNavMeshDebugCache();
bWasShowingNavMesh = false;
}
} }
TArray<FString> UPS_Editor_SpawnManager::GetCategories() const TArray<FString> UPS_Editor_SpawnManager::GetCategories() const

View File

@@ -24,6 +24,10 @@ struct FPS_Editor_ResolvedEntry
UPROPERTY() UPROPERTY()
TObjectPtr<UTexture2D> Thumbnail; TObjectPtr<UTexture2D> Thumbnail;
/** Cached from UPS_Editor_SpawnableComponent::bIsMandatory at catalog resolution time.
* Mandatory entries are auto-spawned on new-scene/load and hidden from the catalog UI. */
bool bIsMandatory = false;
}; };
/** /**
@@ -75,6 +79,22 @@ public:
/** Notify all spawned actors that implement IPS_Editor_EditableInterface of editor mode change. */ /** Notify all spawned actors that implement IPS_Editor_EditableInterface of editor mode change. */
void NotifyEditorModeChanged(bool bIsEditing); void NotifyEditorModeChanged(bool bIsEditing);
/**
* Walk the catalog, and for every class flagged bIsMandatory that has no instance currently
* tracked in SpawnedActors, spawn one at the world's APS_Editor_CameraStart::MandatorySpawnAnchor
* (or world origin if none found). Idempotent — calling repeatedly is safe; missing instances
* are added, existing ones are left untouched.
*
* Called from SceneSerializer::ClearScene (new scene) and SceneLoader (after JSON load) so
* mandatory actors are guaranteed present whether the user starts fresh or opens a legacy
* save that predates the mandatory flag.
*/
void EnsureMandatoryActorsPresent();
/** Returns false if Actor has a UPS_Editor_SpawnableComponent with bIsMandatory==true.
* Used by the delete code path (Pawn::HandleDelete) to skip mandatory actors silently. */
bool IsActorDeletable(const AActor* Actor) const;
// ---- Per-scenario settings (persisted in JSON) ---- // ---- Per-scenario settings (persisted in JSON) ----
/** If true, switch the level's RecastNavMesh to Dynamic runtime generation when this /** If true, switch the level's RecastNavMesh to Dynamic runtime generation when this
@@ -88,6 +108,11 @@ public:
* visualization preference, never saved with the scenario. */ * visualization preference, never saved with the scenario. */
bool bShowNavMesh = false; bool bShowNavMesh = false;
/** Tracks the previous tick's bShowNavMesh state so TickEditorMode can detect the
* on→off transition and tear down the proc-mesh surface actor that DrawNavMeshDebug
* spawned. Without this, the surface would stay visible after the user toggled it off. */
bool bWasShowingNavMesh = false;
/** Tick all spawned actors that implement IPS_Editor_EditableInterface. */ /** Tick all spawned actors that implement IPS_Editor_EditableInterface. */
void TickEditorMode(float DeltaTime); void TickEditorMode(float DeltaTime);

View File

@@ -54,6 +54,15 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
bool bPreload = false; bool bPreload = false;
/** If true, this actor class is mandatory for any scenario:
* - Auto-spawned on "New Scene" (one instance, at the MandatorySpawnAnchor of the
* baseLevel's APS_Editor_CameraStart, falling back to world origin)
* - On scene load, if missing, auto-added (handles legacy saves cleanly)
* - Cannot be deleted by the user (delete is silently refused with a warning log)
* - Hidden from the catalog (you can't add a 2nd instance) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
bool bIsMandatory = false;
// ---- Runtime-only: saved movement/rotation settings before editor override ---- // ---- Runtime-only: saved movement/rotation settings before editor override ----
// Set when the editor disables AI/rotation overrides, restored on StartSimulation. // Set when the editor disables AI/rotation overrides, restored on StartSimulation.
bool bSavedMovementState = false; bool bSavedMovementState = false;

View File

@@ -374,6 +374,14 @@ static int32 FindTrack(const FPS_Editor_TimelineData& Data, const FGuid& ActorId
return INDEX_NONE; return INDEX_NONE;
} }
bool UPS_Editor_TimelineSubsystem::IsPropertyTracked(AActor* Actor, const FName& PropertyPath) const
{
if (!Actor) return false;
const FGuid ActorId = FindActorId(Actor);
if (!ActorId.IsValid()) return false;
return FindTrack(Data, ActorId, PropertyPath) != INDEX_NONE;
}
bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, const FName& PropertyPath) bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, const FName& PropertyPath)
{ {
if (!Actor) return false; if (!Actor) return false;

View File

@@ -88,6 +88,11 @@ public:
const TArray<FPS_Editor_TimelineTrack>& GetTracks() const { return Data.Tracks; } const TArray<FPS_Editor_TimelineTrack>& GetTracks() const { return Data.Tracks; }
/** Returns true if the timeline holds a track for (Actor, PropertyPath). Used by the
* PlayerController's editable-property snapshot/restore so we don't double-restore
* properties already handled by the timeline's RestoreBaseline. */
bool IsPropertyTracked(AActor* Actor, const FName& PropertyPath) const;
/** /**
* Add a key at CurrentTime for (Actor, PropertyPath), reading the actor's current value. * Add a key at CurrentTime for (Actor, PropertyPath), reading the actor's current value.
* Creates the track if it doesn't exist. If a key already exists at this time, updates its value. * Creates the track if it doesn't exist. If a key already exists at this time, updates its value.

View File

@@ -1081,6 +1081,10 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (SceneSerializer.IsValid() && SpawnManager.IsValid()) if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{ {
SceneSerializer->ClearScene(SpawnManager.Get()); SceneSerializer->ClearScene(SpawnManager.Get());
// Re-spawn mandatory actors immediately after the wipe — this is
// the "New Scenario" path, where the user expects a fresh-but-not-empty
// scene (mandatory class instances at the MandatoryAnchor).
SpawnManager->EnsureMandatoryActorsPresent();
bSceneDirty = false; bSceneDirty = false;
LastKnownSpawnedCount = 0; LastKnownSpawnedCount = 0;
} }
@@ -1673,6 +1677,10 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
{ {
const FPS_Editor_ResolvedEntry& Entry = Entries[i]; const FPS_Editor_ResolvedEntry& Entry = Entries[i];
// Mandatory classes are auto-spawned and singletons — hide them from the catalog so
// the user can't add a 2nd instance.
if (Entry.bIsMandatory) continue;
// Category header // Category header
if (Entry.Category != CurrentCategory) if (Entry.Category != CurrentCategory)
{ {

View File

@@ -924,6 +924,10 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildSceneButtons()
if (SceneSerializer.IsValid() && SpawnManager.IsValid()) if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{ {
SceneSerializer->ClearScene(SpawnManager.Get()); SceneSerializer->ClearScene(SpawnManager.Get());
// Re-spawn mandatory actors immediately after the wipe — this is
// the "New Scenario" path, where the user expects a fresh-but-not-empty
// scene (mandatory class instances at the MandatoryAnchor).
SpawnManager->EnsureMandatoryActorsPresent();
bSceneDirty = false; bSceneDirty = false;
LastKnownSpawnedCount = 0; LastKnownSpawnedCount = 0;
} }
@@ -1507,6 +1511,10 @@ void UPS_Editor_MainWidget_Legacy::PopulateSpawnCatalog()
{ {
const FPS_Editor_ResolvedEntry& Entry = Entries[i]; const FPS_Editor_ResolvedEntry& Entry = Entries[i];
// Mandatory classes are auto-spawned and singletons — hide them from the catalog so
// the user can't add a 2nd instance.
if (Entry.bIsMandatory) continue;
// Category header // Category header
if (Entry.Category != CurrentCategory) if (Entry.Category != CurrentCategory)
{ {