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 0000000..3e8d2c3 Binary files /dev/null and b/Unreal/Plugins/PS_Editor/Content/M_PS_Editor_NavMeshSurface.uasset differ diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp index d8f4849..de99730 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp @@ -1167,8 +1167,35 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value) UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager(); 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) {