From 8f7476d2dbdc39f478af3ea56d39bcc87147b889 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Thu, 30 Apr 2026 16:14:27 +0200 Subject: [PATCH] Mandatory actors auto-spawn + navmesh surface overlay (custom material) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Content/M_PS_Editor_NavMeshSurface.uasset | Bin 0 -> 12006 bytes .../PS_Editor/GameMode/PS_Editor_Pawn.cpp | 35 +++- .../GameMode/PS_Editor_PlayerController.cpp | 131 ++++++++++++++- .../GameMode/PS_Editor_PlayerController.h | 6 + .../PS_Editor_SceneSerializer.cpp | 14 ++ .../Spawn/PS_Editor_MandatoryAnchor.cpp | 38 +++++ .../Spawn/PS_Editor_MandatoryAnchor.h | 38 +++++ .../PS_Editor/Spawn/PS_Editor_NavUtils.cpp | 158 +++++++++++++++++- .../Spawn/PS_Editor_SpawnManager.cpp | 128 ++++++++++++++ .../PS_Editor/Spawn/PS_Editor_SpawnManager.h | 25 +++ .../Spawn/PS_Editor_SpawnableComponent.h | 9 + .../Timeline/PS_Editor_TimelineSubsystem.cpp | 8 + .../Timeline/PS_Editor_TimelineSubsystem.h | 5 + .../UI/Widgets/PS_Editor_MainWidget.cpp | 8 + .../Widgets/PS_Editor_MainWidget_Legacy.cpp | 8 + 15 files changed, 602 insertions(+), 9 deletions(-) create mode 100644 Unreal/Plugins/PS_Editor/Content/M_PS_Editor_NavMeshSurface.uasset create mode 100644 Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_MandatoryAnchor.cpp create mode 100644 Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_MandatoryAnchor.h diff --git a/Unreal/Plugins/PS_Editor/Content/M_PS_Editor_NavMeshSurface.uasset b/Unreal/Plugins/PS_Editor/Content/M_PS_Editor_NavMeshSurface.uasset new file mode 100644 index 0000000000000000000000000000000000000000..3e8d2c3e18ed7faa669087f2fefef769774cfa24 GIT binary patch literal 12006 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 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> DeletableActors; + int32 SkippedMandatory = 0; + for (const TWeakObjectPtr& 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) - const int32 Count = SM->GetSelectedActors().Num(); + const int32 Count = DeletableActors.Num(); const FText Message = FText::Format( NSLOCTEXT("PS_Editor", "DeleteConfirm", "Delete {0} selected object(s)?"), FText::AsNumber(Count)); @@ -1181,15 +1208,15 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value) // Weak refs to avoid dangling pointers in the callback TWeakObjectPtr 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; UPS_Editor_SelectionManager* SM = WeakPC->GetSelectionManager(); - if (!SM || !SM->IsAnythingSelected()) return; + if (!SM) return; TSharedPtr Action = MakeShared(); - for (const TWeakObjectPtr& Weak : SM->GetSelectedActors()) + for (const TWeakObjectPtr& Weak : DeletableActors) { if (AActor* Actor = Weak.Get()) { diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp index 8f78735..48f4e5b 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp @@ -10,6 +10,8 @@ #include "PS_Editor_SplineEditable.h" #include "PS_Editor_PointPlaceable.h" #include "PS_Editor_EditableInterface.h" +#include "PS_Editor_EditableComponent.h" +#include "PS_Editor_TimelineSubsystem.h" #include "Engine/LevelStreamingDynamic.h" #include "GameFramework/Character.h" #include "GameFramework/CharacterMovementComponent.h" @@ -29,6 +31,44 @@ #include "GameFramework/GameStateBase.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 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() { 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() : nullptr; + if (SpawnManager) + { + for (const TWeakObjectPtr& Weak : SpawnManager->GetSpawnedActors()) + { + AActor* Actor = Weak.Get(); + if (!Actor) continue; + UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass(); + if (!EditComp || EditComp->EditableProperties.Num() == 0) continue; + + TMap 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 if (SelectionManager) { @@ -522,7 +599,49 @@ void APS_Editor_PlayerController::StopSimulation() } 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(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 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 if (bWaitingForSublevelFadeIn) { diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h index b0ed328..24217d1 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h @@ -180,6 +180,12 @@ private: /** Saved transforms for all spawned actors before simulation, to restore on stop. */ TMap, 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, TMap> SimulationSavedEditableProps; + /** Timer handle for the deferred StopSimulation used by RequestStopSimulation. */ FTimerHandle PendingStopSimulationHandle; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp index 2550d88..1ee64c5 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp @@ -454,6 +454,12 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_ { 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); @@ -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 diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_MandatoryAnchor.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_MandatoryAnchor.cpp new file mode 100644 index 0000000..6ebd9f5 --- /dev/null +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_MandatoryAnchor.cpp @@ -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(TEXT("Root")); + RootComponent = Root; + +#if WITH_EDITORONLY_DATA + SpriteComponent = CreateDefaultSubobject(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 IconFinder(TEXT("/Engine/EditorResources/S_Pivot")); + if (IconFinder.Succeeded()) + { + SpriteComponent->SetSprite(IconFinder.Object); + } + + ArrowComponent = CreateDefaultSubobject(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); +} diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_MandatoryAnchor.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_MandatoryAnchor.h new file mode 100644 index 0000000..54f1c0e --- /dev/null +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_MandatoryAnchor.h @@ -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 SpriteComponent; + + /** Editor-only forward arrow visualizing the anchor's orientation. */ + UPROPERTY(VisibleAnywhere, Category = "PS_Editor") + TObjectPtr ArrowComponent; +#endif +}; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_NavUtils.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_NavUtils.cpp index 2fc2a10..ad07ba8 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_NavUtils.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_NavUtils.cpp @@ -7,6 +7,10 @@ #include "GameFramework/PlayerController.h" #include "Kismet/GameplayStatics.h" #include "DrawDebugHelpers.h" +#include "ProceduralMeshComponent.h" +#include "Materials/MaterialInterface.h" +#include "UObject/ConstructorHelpers.h" +#include "UObject/SoftObjectPath.h" namespace PS_Editor_NavUtils { @@ -118,10 +122,100 @@ namespace PS_Editor_NavUtils static TArray GCachedNavMeshes; 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 GSurfaceActor; + static TWeakObjectPtr GSurfaceMesh; + static TWeakObjectPtr 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(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::StaticClass(), FTransform::Identity, Params); + if (!Actor) return nullptr; +#if WITH_EDITOR + Actor->SetActorLabel(TEXT("PS_Editor_NavMeshSurfaceViz")); +#endif + + UProceduralMeshComponent* Mesh = NewObject(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() { GCachedNavMeshes.Empty(); GTimeSinceRefresh = 9999.f; + DestroySurfaceActor(); } 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 // single-call GetDebugGeometry; only the per-tile variant remains. - const int32 NumTiles = Recast->GetNavMeshTilesCount(); - for (int32 TileIdx = 0; TileIdx < NumTiles; ++TileIdx) + // As of 5.5 the int32-tile-index overload is deprecated — use the + // FNavTileRef overload via GetAllNavMeshTiles. + TArray TileRefs; + Recast->GetAllNavMeshTiles(TileRefs); + for (const FNavTileRef& TileRef : TileRefs) { FRecastDebugGeometry Geo; - Recast->GetDebugGeometryForTile(Geo, TileIdx); + Recast->GetDebugGeometryForTile(Geo, TileRef); 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) { const TArray& AreaIndices = Geo.AreaIndices[AreaIdx]; @@ -169,6 +270,55 @@ namespace PS_Editor_NavUtils 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 Vertices; + TArray 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 EmptyNormals; + static const TArray EmptyUVs; + static const TArray EmptyColors; + static const TArray 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("")); + } + else + { + Surface->ClearAllMeshSections(); + } + } } // Per-frame draw of cached geometry. Lifetime=0 + bPersistent=false means each call diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp index 9764ead..c06af5d 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp @@ -6,8 +6,11 @@ #include "PS_Editor_PlayerController.h" #include "PS_Editor_TimelineSubsystem.h" #include "PS_Editor_GroundSnap.h" +#include "PS_Editor_NavUtils.h" +#include "PS_Editor_MandatoryAnchor.h" #include "AIController.h" #include "BrainComponent.h" +#include "EngineUtils.h" #include "GameFramework/PlayerController.h" #include "GameFramework/Character.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.Thumbnail = SpawnComp ? SpawnComp->Thumbnail : nullptr; + Resolved.bIsMandatory = SpawnComp ? SpawnComp->bIsMandatory : false; UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolved '%s' - SpawnComp=%s, Thumbnail=%s"), *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(); + 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 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& 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) { for (const TWeakObjectPtr& Weak : SpawnedActors) @@ -480,6 +590,24 @@ void UPS_Editor_SpawnManager::TickEditorMode(float 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 UPS_Editor_SpawnManager::GetCategories() const diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.h index efcf7fd..413bd7d 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.h @@ -24,6 +24,10 @@ struct FPS_Editor_ResolvedEntry UPROPERTY() TObjectPtr 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. */ 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) ---- /** 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. */ 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. */ void TickEditorMode(float DeltaTime); diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnableComponent.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnableComponent.h index a8e9798..d6d92e5 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnableComponent.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnableComponent.h @@ -54,6 +54,15 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable") 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 ---- // Set when the editor disables AI/rotation overrides, restored on StartSimulation. bool bSavedMovementState = false; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp index 3e22b81..e987234 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp @@ -374,6 +374,14 @@ static int32 FindTrack(const FPS_Editor_TimelineData& Data, const FGuid& ActorId 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) { if (!Actor) return false; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h index 24b45c4..e618e4b 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h @@ -88,6 +88,11 @@ public: const TArray& 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. * Creates the track if it doesn't exist. If a key already exists at this time, updates its value. diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp index b494856..0ef8800 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp @@ -1081,6 +1081,10 @@ TSharedRef UPS_Editor_MainWidget::BuildSceneButtons() if (SceneSerializer.IsValid() && SpawnManager.IsValid()) { 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; LastKnownSpawnedCount = 0; } @@ -1673,6 +1677,10 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog() { 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 if (Entry.Category != CurrentCategory) { diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp index bb49af1..7f35c16 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp @@ -924,6 +924,10 @@ TSharedRef UPS_Editor_MainWidget_Legacy::BuildSceneButtons() if (SceneSerializer.IsValid() && SpawnManager.IsValid()) { 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; LastKnownSpawnedCount = 0; } @@ -1507,6 +1511,10 @@ void UPS_Editor_MainWidget_Legacy::PopulateSpawnCatalog() { 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 if (Entry.Category != CurrentCategory) {