Per-scenario dynamic nav opt-in + Settings popup + timeline restore fixes

Dynamic navigation toggle (per scenario):
- New PS_Editor_NavUtils helpers wrap UNavigationSystemV1 / RecastNavMesh
  protected APIs (RuntimeGeneration field + bEnableDrawing) via the C++
  using-trick so we can switch the level's nav mode and toggle visualization
  without engine modifications.
- FPS_Editor_SceneData gets bUseDynamicNavigation persisted in JSON.
- SpawnManager carries the live state plus a session-only bShowNavMesh
  preference (never saved).
- SceneSerializer reads/writes the flag and applies it on load (editor mode).
- SceneLoader applies it on the gameplay path before spawning actors so
  scenarios that need runtime nav rebuild get it both in editor and at
  runtime via the project's GameMode + SceneLoader::LoadScene flow.

UI:
- New "Settings" toolbar button in both Legacy and UMG widgets opens a popup
  with two checkboxes:
    * Use Dynamic Navigation - saved per scenario, live toggle
    * Show NavMesh (P) - session only, mimics editor's P key (green nav
      visualization)
- Pawn binds P to IA_ToggleNavMesh -> SpawnManager.bShowNavMesh, in sync
  with the popup checkbox.

Timeline restore correctness:
- AddOrUpdateKeyAtCurrentTime, RemoveKey, SetKeyTime, SetKeyInterpolate now
  invalidate LastAppliedValues[TrackIndex] so a key edit isn't masked by a
  stale per-track cache entry from a previous apply.
- RestoreBaseline now prefers a key at t=0 (within epsilon) over the
  captured pre-animation baseline. Updating a t=0 key + Stop now restores
  the new value instead of snapping back to the original. Tracks without a
  t=0 key still fall back to the pre-animation baseline.

Restart simulation rewind:
- Restart() used to call Stop() + Play() but Play immediately cancelled the
  pending RequestStopSimulation timer set by Stop, so transforms were never
  restored - characters stayed where they were.
- RequestStopSimulation now takes bImmediate; when true, OnEditorBeforeSimulateStop
  still fires (so BP behaviour-reset hooks run) but StopSimulation runs
  synchronously, restoring transforms before Play() recaptures them.
- Restart inlines the reset (RestoreBaseline + immediate stop + Play) so
  rewinding actually rewinds.

Build:
- Re-add NavigationSystem to PS_Editor.Build.cs (needed for ARecastNavMesh
  + ANavigationData access in NavUtils).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 10:40:18 +02:00
parent 49cb480e15
commit 2a53d12e68
16 changed files with 653 additions and 18 deletions

View File

@@ -13,6 +13,8 @@
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "PS_Editor_SelectionManager.h" #include "PS_Editor_SelectionManager.h"
#include "PS_Editor_GroundSnap.h" #include "PS_Editor_GroundSnap.h"
#include "PS_Editor_NavUtils.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_Gizmo.h" #include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
@@ -254,6 +256,11 @@ void APS_Editor_Pawn::CreateInputActions()
IA_Focus = NewObject<UInputAction>(this, TEXT("IA_Focus")); IA_Focus = NewObject<UInputAction>(this, TEXT("IA_Focus"));
IA_Focus->ValueType = EInputActionValueType::Boolean; IA_Focus->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Focus, EKeys::F); EditorMappingContext->MapKey(IA_Focus, EKeys::F);
// Toggle navmesh visualization (mimics UE editor's P key)
IA_ToggleNavMesh = NewObject<UInputAction>(this, TEXT("IA_ToggleNavMesh"));
IA_ToggleNavMesh->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_ToggleNavMesh, EKeys::P);
} }
void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -301,6 +308,7 @@ void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComp
EIC->BindAction(IA_Confirm, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleConfirm); EIC->BindAction(IA_Confirm, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleConfirm);
EIC->BindAction(IA_Cancel, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleCancel); EIC->BindAction(IA_Cancel, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleCancel);
EIC->BindAction(IA_Focus, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleFocus); EIC->BindAction(IA_Focus, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleFocus);
EIC->BindAction(IA_ToggleNavMesh, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleToggleNavMesh);
} }
// ---- Input Handlers ---- // ---- Input Handlers ----
@@ -1460,6 +1468,20 @@ void APS_Editor_Pawn::HandleFocus(const FInputActionValue& Value)
OrbitDistance = TargetDistance; OrbitDistance = TargetDistance;
} }
void APS_Editor_Pawn::HandleToggleNavMesh(const FInputActionValue& Value)
{
// Mimics UE editor's P key — toggles the runtime navmesh visualization. We route
// through SpawnManager's session-only bShowNavMesh flag so the Settings popup
// checkbox stays in sync.
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return;
UPS_Editor_SpawnManager* SM = EditorPC->GetSpawnManager();
if (!SM) return;
SM->bShowNavMesh = !SM->bShowNavMesh;
PS_Editor_NavUtils::SetNavMeshVisible(GetWorld(), SM->bShowNavMesh);
}
// ---- Helpers ---- // ---- Helpers ----
void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode) void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode)

View File

@@ -212,6 +212,9 @@ private:
UPROPERTY() UPROPERTY()
TObjectPtr<UInputAction> IA_Focus; TObjectPtr<UInputAction> IA_Focus;
UPROPERTY()
TObjectPtr<UInputAction> IA_ToggleNavMesh;
// ---- Spline point / ChildMovable drag state ---- // ---- Spline point / ChildMovable drag state ----
TWeakObjectPtr<AActor> DraggedSplineActor; TWeakObjectPtr<AActor> DraggedSplineActor;
TArray<FVector> SplinePointDragInitialState; TArray<FVector> SplinePointDragInitialState;
@@ -260,6 +263,7 @@ private:
void HandleConfirm(const FInputActionValue& Value); void HandleConfirm(const FInputActionValue& Value);
void HandleCancel(const FInputActionValue& Value); void HandleCancel(const FInputActionValue& Value);
void HandleFocus(const FInputActionValue& Value); void HandleFocus(const FInputActionValue& Value);
void HandleToggleNavMesh(const FInputActionValue& Value);
// ---- Focus animation state (F key, frame selection) ---- // ---- Focus animation state (F key, frame selection) ----
bool bIsFocusing = false; bool bIsFocusing = false;

View File

@@ -389,18 +389,23 @@ void APS_Editor_PlayerController::SetSimulationPaused(bool bPaused)
} }
} }
void APS_Editor_PlayerController::RequestStopSimulation() void APS_Editor_PlayerController::RequestStopSimulation(bool bImmediate)
{ {
if (!bSimulating) return; if (!bSimulating) return;
// If a request is already scheduled, do nothing (debounce double-clicks on Stop). // If a request is already scheduled, do nothing (debounce double-clicks on Stop).
if (UWorld* World = GetWorld()) // Skip the debounce when bImmediate (Restart wants to flush right now).
if (!bImmediate)
{ {
if (PendingStopSimulationHandle.IsValid()) return; if (UWorld* World = GetWorld())
{
if (PendingStopSimulationHandle.IsValid()) return;
}
} }
// Fire OnEditorBeforeSimulateStop immediately on every actor so BP handlers can react // Fire OnEditorBeforeSimulateStop immediately on every actor so BP handlers can react
// while the BT is still live. Then schedule the real cut for ~500ms later. // while the BT is still live. Then schedule the real cut (deferred) or run it now
// (immediate, used by timeline Restart).
if (SpawnManager) if (SpawnManager)
{ {
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors()) for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
@@ -413,6 +418,21 @@ void APS_Editor_PlayerController::RequestStopSimulation()
} }
} }
if (bImmediate)
{
// Cancel any pending deferred stop so it doesn't fire again later.
if (UWorld* World = GetWorld())
{
if (PendingStopSimulationHandle.IsValid())
{
World->GetTimerManager().ClearTimer(PendingStopSimulationHandle);
PendingStopSimulationHandle.Invalidate();
}
}
StopSimulation();
return;
}
if (UWorld* World = GetWorld()) if (UWorld* World = GetWorld())
{ {
TWeakObjectPtr<APS_Editor_PlayerController> WeakThis(this); TWeakObjectPtr<APS_Editor_PlayerController> WeakThis(this);

View File

@@ -76,9 +76,14 @@ public:
/** Request a deferred stop: fires OnEditorBeforeSimulateStop immediately, then schedules /** Request a deferred stop: fires OnEditorBeforeSimulateStop immediately, then schedules
* the real StopSimulation after a grace period (500ms by default) so BT has time to * the real StopSimulation after a grace period (500ms by default) so BT has time to
* react to whatever the BP handler does in OnEditorBeforeSimulateStop. * react to whatever the BP handler does in OnEditorBeforeSimulateStop.
* Timeline Stop and the Simulate toolbar button both go through this. */ * Timeline Stop and the Simulate toolbar button both go through this.
*
* @param bImmediate If true, fire OnEditorBeforeSimulateStop and run StopSimulation
* synchronously in the same call (no grace period). Used by
* timeline Restart, where we want transforms restored before the
* subsequent Play(). Defaults to false (deferred). */
UFUNCTION(BlueprintCallable, Category = "PS_Editor") UFUNCTION(BlueprintCallable, Category = "PS_Editor")
void RequestStopSimulation(); void RequestStopSimulation(bool bImmediate = false);
/** True while simulation is running. */ /** True while simulation is running. */
UFUNCTION(BlueprintCallable, Category = "PS_Editor") UFUNCTION(BlueprintCallable, Category = "PS_Editor")

View File

@@ -33,7 +33,8 @@ public class PS_Editor : ModuleRules
"JsonUtilities", "JsonUtilities",
"ProceduralMeshComponent", "ProceduralMeshComponent",
"DesktopPlatform", "DesktopPlatform",
"AIModule" "AIModule",
"NavigationSystem"
}); });
PrivateDependencyModuleNames.AddRange(new string[] PrivateDependencyModuleNames.AddRange(new string[]

View File

@@ -61,4 +61,11 @@ struct FPS_Editor_SceneData
/** Global timeline data (animation tracks + duration). Empty on legacy scenes. */ /** Global timeline data (animation tracks + duration). Empty on legacy scenes. */
UPROPERTY() UPROPERTY()
FPS_Editor_TimelineData Timeline; FPS_Editor_TimelineData Timeline;
/** If true, the level's nav mesh is switched to Dynamic runtime generation when this
* scenario loads — both in the runtime editor (so spawn / move actors update nav live)
* and in gameplay (for scenarios that need runtime-built navigation). False keeps the
* cooked Static nav, which has zero runtime cost. Default: false. */
UPROPERTY()
bool bUseDynamicNavigation = false;
}; };

View File

@@ -10,6 +10,7 @@
#include "PS_Editor_TimelineSubsystem.h" #include "PS_Editor_TimelineSubsystem.h"
#include "PS_Editor_SpawnManager.h" #include "PS_Editor_SpawnManager.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_NavUtils.h"
#include "AIController.h" #include "AIController.h"
#include "BrainComponent.h" #include "BrainComponent.h"
#include "JsonObjectConverter.h" #include "JsonObjectConverter.h"
@@ -130,6 +131,12 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S
return false; return false;
} }
// Apply the scenario's dynamic-navigation flag BEFORE spawning actors. If the scenario
// needs runtime nav generation, switching the level's RecastNavMesh to Dynamic now
// means the actors we're about to spawn will dirty + rebuild tiles around them. Cooked
// Static nav is preserved on disk for scenarios that don't need this.
PS_Editor_NavUtils::SetDynamicNavigationEnabled(World, SceneData.bUseDynamicNavigation);
UPS_Editor_TimelineSubsystem* TimelineSS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>(); UPS_Editor_TimelineSubsystem* TimelineSS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>();
int32 SpawnedCount = 0; int32 SpawnedCount = 0;

View File

@@ -14,6 +14,7 @@
#include "GameFramework/CharacterMovementComponent.h" #include "GameFramework/CharacterMovementComponent.h"
#include "PS_Editor_SplineEditable.h" #include "PS_Editor_SplineEditable.h"
#include "PS_Editor_TimelineSubsystem.h" #include "PS_Editor_TimelineSubsystem.h"
#include "PS_Editor_NavUtils.h"
#include "JsonObjectConverter.h" #include "JsonObjectConverter.h"
#include "Misc/FileHelper.h" #include "Misc/FileHelper.h"
#include "Misc/Paths.h" #include "Misc/Paths.h"
@@ -166,6 +167,12 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
SceneData.Timeline = TimelineSS->GetData(); SceneData.Timeline = TimelineSS->GetData();
} }
// Per-scenario settings — read from the SpawnManager's session state.
if (SpawnManager)
{
SceneData.bUseDynamicNavigation = SpawnManager->bUseDynamicNavigation;
}
// Convert to JSON // Convert to JSON
FString JsonString; FString JsonString;
if (!FJsonObjectConverter::UStructToJsonObjectString(SceneData, JsonString, 0, 0, 0, nullptr, true)) if (!FJsonObjectConverter::UStructToJsonObjectString(SceneData, JsonString, 0, 0, 0, nullptr, true))
@@ -438,6 +445,17 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
} }
} }
// Per-scenario settings — push into the SpawnManager's session state and apply
// the dynamic-navigation switch to the level's RecastNavMesh actors right away.
if (SpawnManager)
{
SpawnManager->bUseDynamicNavigation = SceneData.bUseDynamicNavigation;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter()))
{
PS_Editor_NavUtils::SetDynamicNavigationEnabled(EditorPC->GetWorld(), SpawnManager->bUseDynamicNavigation);
}
}
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);
return true; return true;
} }

View File

@@ -0,0 +1,61 @@
#include "PS_Editor_NavUtils.h"
#include "NavigationSystem.h"
#include "NavigationData.h"
#include "NavMesh/RecastNavMesh.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
namespace PS_Editor_NavUtils
{
// ANavigationData::RuntimeGeneration and bEnableDrawing are both protected. Local
// using-trick subclass to re-publish them — the C++ access check uses the static
// type, so a static_cast through this exposing class lets us read/write them
// without modifying the engine.
struct FNavDataAccessor : public ANavigationData
{
using ANavigationData::RuntimeGeneration;
using ANavigationData::bEnableDrawing;
};
void SetRuntimeGenerationMode(UWorld* World, ERuntimeGenerationType Mode)
{
if (!World) return;
TArray<AActor*> NavMeshActors;
UGameplayStatics::GetAllActorsOfClass(World, ANavigationData::StaticClass(), NavMeshActors);
for (AActor* A : NavMeshActors)
{
if (ANavigationData* NavData = Cast<ANavigationData>(A))
{
FNavDataAccessor* Accessor = static_cast<FNavDataAccessor*>(NavData);
if (Accessor->RuntimeGeneration != Mode)
{
Accessor->RuntimeGeneration = Mode;
}
}
}
}
void SetDynamicNavigationEnabled(UWorld* World, bool bDynamic)
{
SetRuntimeGenerationMode(World,
bDynamic ? ERuntimeGenerationType::Dynamic : ERuntimeGenerationType::Static);
}
void SetNavMeshVisible(UWorld* World, bool bVisible)
{
if (!World) return;
TArray<AActor*> NavMeshActors;
UGameplayStatics::GetAllActorsOfClass(World, ANavigationData::StaticClass(), NavMeshActors);
for (AActor* A : NavMeshActors)
{
if (ANavigationData* NavData = Cast<ANavigationData>(A))
{
FNavDataAccessor* Accessor = static_cast<FNavDataAccessor*>(NavData);
Accessor->bEnableDrawing = bVisible;
NavData->MarkComponentsRenderStateDirty();
}
}
}
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include "CoreMinimal.h"
class UWorld;
enum class ERuntimeGenerationType : uint8;
/**
* Centralised helpers for switching the level's RecastNavMesh runtime-generation mode and
* toggling the runtime navmesh visualization. Used by:
* - The runtime editor "Settings" popup (live toggle of dynamic nav + show navmesh)
* - SceneLoader (gameplay path: applies the scenario's bUseDynamicNavigation flag)
* - Scenario save/load (persists bUseDynamicNavigation in JSON)
*/
namespace PS_Editor_NavUtils
{
/**
* Switch every RecastNavMesh in the world to the requested runtime-generation mode.
* Static = baked nav only, no runtime updates (fastest gameplay).
* DynamicModifiersOnly = only NavModifierVolumes affect nav at runtime.
* Dynamic = all collision-affecting actors update the nav mesh on spawn / move.
*
* Idempotent — calling with the same mode the navmesh already has is a no-op.
*/
PS_EDITOR_API void SetRuntimeGenerationMode(UWorld* World, ERuntimeGenerationType Mode);
/** Convenience: switch to Dynamic if bDynamic, Static otherwise. */
PS_EDITOR_API void SetDynamicNavigationEnabled(UWorld* World, bool bDynamic);
/**
* Toggle the runtime navmesh visualization (mimics the editor's P key — green nav lines
* + tile bounds rendered on top of the world). Affects all RecastNavMesh actors in the
* world. Free in editor PIE/Standalone; in shipping packaged builds, debug rendering may
* be stripped depending on build flags.
*/
PS_EDITOR_API void SetNavMeshVisible(UWorld* World, bool bVisible);
}

View File

@@ -75,6 +75,19 @@ 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);
// ---- Per-scenario settings (persisted in JSON) ----
/** If true, switch the level's RecastNavMesh to Dynamic runtime generation when this
* scenario is loaded — both in editor and gameplay. False = keep cooked Static nav.
* Persisted in the scenario JSON; default false. */
bool bUseDynamicNavigation = false;
// ---- Session-only UI preferences (NOT persisted) ----
/** If true, the navmesh is rendered at runtime (mimics the editor's P key). Personal
* visualization preference, never saved with the scenario. */
bool bShowNavMesh = 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

@@ -194,20 +194,16 @@ void UPS_Editor_TimelineSubsystem::CaptureBaselinesForAllMissingTracks()
void UPS_Editor_TimelineSubsystem::RestoreBaseline() void UPS_Editor_TimelineSubsystem::RestoreBaseline()
{ {
if (PrePlayBaseline.Num() == 0) return;
// Server-only write path (same guard as ApplyValuesAtTime). // Server-only write path (same guard as ApplyValuesAtTime).
if (UWorld* World = GetWorld()) if (UWorld* World = GetWorld())
{ {
if (World->GetNetMode() == NM_Client) return; if (World->GetNetMode() == NM_Client) return;
} }
const float ZeroEpsilon = 0.0001f;
for (const FPS_Editor_TimelineTrack& Track : Data.Tracks) for (const FPS_Editor_TimelineTrack& Track : Data.Tracks)
{ {
const TPair<FGuid, FName> Key(Track.ActorId, Track.PropertyPath);
const FString* BaselineValue = PrePlayBaseline.Find(Key);
if (!BaselineValue) continue;
AActor* Actor = FindActorById(Track.ActorId); AActor* Actor = FindActorById(Track.ActorId);
if (!Actor) continue; if (!Actor) continue;
UObject* Target = nullptr; UObject* Target = nullptr;
@@ -215,7 +211,39 @@ void UPS_Editor_TimelineSubsystem::RestoreBaseline()
ResolveTarget(Actor, Track.PropertyPath, Target, Prop); ResolveTarget(Actor, Track.PropertyPath, Target, Prop);
if (!Target || !Prop) continue; if (!Target || !Prop) continue;
Prop->ImportText_InContainer(**BaselineValue, Target, Target, PPF_None); // Pick the value to restore:
// - If the user placed a key at (or before) t=0 → use that key's value. This
// matches the natural mental model where a key at t=0 sets the timeline's
// starting state — and lets the user adjust it just by re-keying at t=0
// (otherwise Stop would keep snapping back to the captured pre-animation
// baseline forever, ignoring their edits).
// - Otherwise fall back to the captured pre-animation baseline so Stop still
// undoes whatever the timeline pushed (relevant for tracks whose first key
// is past t=0, e.g. a property that only changes mid-timeline).
const FString* RestoreValue = nullptr;
FString KeyZeroValue;
for (const FPS_Editor_TimelineKey& K : Track.Keys)
{
if (K.Time <= ZeroEpsilon)
{
KeyZeroValue = K.Value;
RestoreValue = &KeyZeroValue;
// Continue scanning in case there are several keys at t≈0 — the LAST one wins
// (matches sort order: later same-time entries override earlier ones).
}
else
{
break; // keys are sorted by time; no need to look past t>0
}
}
if (!RestoreValue)
{
const TPair<FGuid, FName> BaselineKey(Track.ActorId, Track.PropertyPath);
RestoreValue = PrePlayBaseline.Find(BaselineKey);
}
if (!RestoreValue) continue;
Prop->ImportText_InContainer(**RestoreValue, Target, Target, PPF_None);
if (UActorComponent* AsComp = Cast<UActorComponent>(Target)) if (UActorComponent* AsComp = Cast<UActorComponent>(Target))
{ {
AsComp->MarkRenderStateDirty(); AsComp->MarkRenderStateDirty();
@@ -234,15 +262,40 @@ void UPS_Editor_TimelineSubsystem::RestoreBaseline()
} }
} }
// Don't clear the map a subsequent Stop (or Restart) must still restore the same baseline. // Don't clear PrePlayBaseline — subsequent Stops without a t=0 key still need it.
// SetData/Clear are the only resetters. // SetData/Clear are the only resetters.
} }
void UPS_Editor_TimelineSubsystem::Restart() void UPS_Editor_TimelineSubsystem::Restart()
{ {
// Rewind + replay from the start. Done in two steps so simulation transforms are restored // Rewind + replay from the start.
// by Stop() before Play() re-enables AI, giving a clean "from the beginning" rerun. //
Stop(); // We can't just call Stop() then Play() because Stop() goes through
// PlayerController::RequestStopSimulation which DEFERS the actual StopSimulation by
// 500ms (BT grace period). The Play() call right after cancels that pending timer
// AND re-enters StartSimulation early (bSimulating still true), so transforms are
// never restored — characters stay at their current position instead of rewinding.
//
// Fix: do the full reset synchronously here. Fire OnEditorBeforeSimulateStop (via
// RequestStopSimulation(bImmediate=true) so BP behaviour-reset hooks still run),
// then immediately StopSimulation (which restores transforms + fires
// OnEditorSimulateStop), then Play() to capture the rewound transforms and restart
// AI from the clean state.
bIsPlaying = false;
CurrentTime = 0.f;
LastAppliedValues.Empty();
LastAppliedValues.SetNum(Data.Tracks.Num());
RestoreBaseline();
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (EditorPC->IsSimulating())
{
EditorPC->RequestStopSimulation(/*bImmediate=*/ true);
}
}
Play(); Play();
} }
@@ -364,6 +417,14 @@ bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, co
if (FMath::IsNearlyEqual(Track.Keys[InsertAt].Time, CurrentTime, Epsilon)) if (FMath::IsNearlyEqual(Track.Keys[InsertAt].Time, CurrentTime, Epsilon))
{ {
Track.Keys[InsertAt].Value = CurrentValue; Track.Keys[InsertAt].Value = CurrentValue;
// Invalidate the per-track applied-value cache so the next ApplyTrackAtTime
// re-writes the (possibly different) value instead of skipping based on the
// stale cache entry. Otherwise, updating a key with a value the cache happened
// to hold from a previous apply would silently revert the property to it.
if (LastAppliedValues.IsValidIndex(TrackIndex))
{
LastAppliedValues[TrackIndex].Empty();
}
OnTimelineChanged.Broadcast(); OnTimelineChanged.Broadcast();
return true; return true;
} }
@@ -404,6 +465,12 @@ void UPS_Editor_TimelineSubsystem::RemoveKey(int32 TrackIndex, int32 KeyIndex)
Data.Tracks.RemoveAt(TrackIndex); Data.Tracks.RemoveAt(TrackIndex);
LastAppliedValues.SetNum(Data.Tracks.Num()); LastAppliedValues.SetNum(Data.Tracks.Num());
} }
else if (LastAppliedValues.IsValidIndex(TrackIndex))
{
// Track still has keys but its surrounding keys for any time may have changed —
// invalidate the cache so the next apply re-writes the correct value.
LastAppliedValues[TrackIndex].Empty();
}
OnTimelineChanged.Broadcast(); OnTimelineChanged.Broadcast();
} }
@@ -413,6 +480,10 @@ void UPS_Editor_TimelineSubsystem::SetKeyInterpolate(int32 TrackIndex, int32 Key
FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex]; FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
if (!Track.Keys.IsValidIndex(KeyIndex)) return; if (!Track.Keys.IsValidIndex(KeyIndex)) return;
Track.Keys[KeyIndex].bInterpolate = bInterpolate; Track.Keys[KeyIndex].bInterpolate = bInterpolate;
if (LastAppliedValues.IsValidIndex(TrackIndex))
{
LastAppliedValues[TrackIndex].Empty();
}
OnTimelineChanged.Broadcast(); OnTimelineChanged.Broadcast();
} }
@@ -428,6 +499,10 @@ void UPS_Editor_TimelineSubsystem::SetKeyTime(int32 TrackIndex, int32 KeyIndex,
{ {
return A.Time < B.Time; return A.Time < B.Time;
}); });
if (LastAppliedValues.IsValidIndex(TrackIndex))
{
LastAppliedValues[TrackIndex].Empty();
}
OnTimelineChanged.Broadcast(); OnTimelineChanged.Broadcast();
} }

View File

@@ -1,6 +1,7 @@
#include "PS_Editor_MainWidget.h" #include "PS_Editor_MainWidget.h"
#include "PS_Editor_SelectionManager.h" #include "PS_Editor_SelectionManager.h"
#include "PS_Editor_SpawnManager.h" #include "PS_Editor_SpawnManager.h"
#include "PS_Editor_NavUtils.h"
#include "PS_Editor_SceneSerializer.h" #include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_SceneLoader.h" #include "PS_Editor_SceneLoader.h"
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
@@ -500,6 +501,110 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
] ]
] ]
] ]
// Settings popup (per-scenario nav option + session navmesh visu)
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SAssignNew(SettingsPopup, SBorder)
.Visibility(EVisibility::Collapsed)
.BorderImage(BrushGlassStrong.Get())
.Padding(FMargin(28.0f, 20.0f))
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 0.0f, 0.0f, 16.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Settings")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 14))
.ColorAndOpacity(FLinearColor::White)
]
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 4.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 8.0f, 0.0f)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() -> ECheckBoxState
{
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
{
return SM->bUseDynamicNavigation ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
}
return ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState State) { OnUseDynamicNavigationToggled(State); })
]
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Use Dynamic Navigation")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor::White)
]
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Rebuild navmesh at runtime when actors are spawned/moved (saved with scenario).")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
.AutoWrapText(true)
]
]
]
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 8.0f, 0.0f, 4.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 8.0f, 0.0f)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() -> ECheckBoxState
{
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
{
return SM->bShowNavMesh ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
}
return ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState State) { OnShowNavMeshToggled(State); })
]
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Show NavMesh (P)")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor::White)
]
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Render the navmesh in green (mimics editor's P key). Personal preference, never saved.")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
.AutoWrapText(true)
]
]
]
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 16.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ButtonStyle(StyleFlatButton.Get())
.OnClicked_Lambda([this]() -> FReply { CloseSettingsPopup(); return FReply::Handled(); })
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Close")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor::White)
]
]
]
]
// ============= COMMAND PALETTE (modal, Ctrl+K) ============= // ============= COMMAND PALETTE (modal, Ctrl+K) =============
+ SOverlay::Slot() + SOverlay::Slot()
@@ -987,6 +1092,26 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10)) .Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
] ]
] ]
// Settings button
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ButtonStyle(StyleFlatButton.Get())
.ToolTipText(FText::FromString(TEXT("Open scenario / session settings (dynamic nav, navmesh visu).")))
.OnClicked_Lambda([this]() -> FReply
{
OpenSettingsPopup();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Settings")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
]
]
+ SHorizontalBox::Slot() + SHorizontalBox::Slot()
.AutoWidth() .AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f) .Padding(4.0f, 0.0f, 0.0f, 0.0f)
@@ -3207,6 +3332,43 @@ void UPS_Editor_MainWidget::CancelBaseLevelPopup()
PendingBaseLevelSelection.Empty(); PendingBaseLevelSelection.Empty();
} }
void UPS_Editor_MainWidget::OpenSettingsPopup()
{
if (SettingsPopup.IsValid())
{
SettingsPopup->SetVisibility(EVisibility::Visible);
}
}
void UPS_Editor_MainWidget::CloseSettingsPopup()
{
if (SettingsPopup.IsValid())
{
SettingsPopup->SetVisibility(EVisibility::Collapsed);
}
}
void UPS_Editor_MainWidget::OnUseDynamicNavigationToggled(ECheckBoxState State)
{
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
if (!SM) return;
const bool bEnabled = (State == ECheckBoxState::Checked);
SM->bUseDynamicNavigation = bEnabled;
bSceneDirty = true;
PS_Editor_NavUtils::SetDynamicNavigationEnabled(GetWorld(), bEnabled);
}
void UPS_Editor_MainWidget::OnShowNavMeshToggled(ECheckBoxState State)
{
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
if (!SM) return;
const bool bVisible = (State == ECheckBoxState::Checked);
SM->bShowNavMesh = bVisible;
PS_Editor_NavUtils::SetNavMeshVisible(GetWorld(), bVisible);
}
void UPS_Editor_MainWidget::OnCloseClicked() void UPS_Editor_MainWidget::OnCloseClicked()
{ {
if (CloseConfirmOverlay.IsValid()) if (CloseConfirmOverlay.IsValid())

View File

@@ -134,6 +134,13 @@ private:
void ConfirmBaseLevelSelection(); void ConfirmBaseLevelSelection();
void CancelBaseLevelPopup(); void CancelBaseLevelPopup();
// Global Settings popup (per-scenario nav option + session-only navmesh visu).
TSharedPtr<SWidget> SettingsPopup;
void OpenSettingsPopup();
void CloseSettingsPopup();
void OnUseDynamicNavigationToggled(ECheckBoxState State);
void OnShowNavMeshToggled(ECheckBoxState State);
// Transform fields // Transform fields
TSharedPtr<SEditableTextBox> LocX, LocY, LocZ; TSharedPtr<SEditableTextBox> LocX, LocY, LocZ;
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ; TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;

View File

@@ -1,6 +1,7 @@
#include "PS_Editor_MainWidget_Legacy.h" #include "PS_Editor_MainWidget_Legacy.h"
#include "PS_Editor_SelectionManager.h" #include "PS_Editor_SelectionManager.h"
#include "PS_Editor_SpawnManager.h" #include "PS_Editor_SpawnManager.h"
#include "PS_Editor_NavUtils.h"
#include "PS_Editor_SceneSerializer.h" #include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_SceneLoader.h" #include "PS_Editor_SceneLoader.h"
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
@@ -448,6 +449,124 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::RebuildWidget()
] ]
] ]
] ]
]
// Settings popup (per-scenario nav option + session navmesh visu)
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SAssignNew(SettingsPopup, SBorder)
.Visibility(EVisibility::Collapsed)
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
.BorderBackgroundColor(FLinearColor(0.08f, 0.08f, 0.08f, 0.97f))
.Padding(FMargin(28.0f, 20.0f))
[
SNew(SVerticalBox)
// Header
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 0.0f, 0.0f, 16.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Settings")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 14))
.ColorAndOpacity(FLinearColor::White)
]
// Use Dynamic Navigation (saved per-scenario)
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 4.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 8.0f, 0.0f)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() -> ECheckBoxState
{
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
{
return SM->bUseDynamicNavigation ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
}
return ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState State)
{
OnUseDynamicNavigationToggled(State);
})
]
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Use Dynamic Navigation")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor::White)
]
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Rebuild navmesh at runtime when actors are spawned/moved (saved with scenario).")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
.AutoWrapText(true)
]
]
]
// Show NavMesh (session-only)
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 8.0f, 0.0f, 4.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 8.0f, 0.0f)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() -> ECheckBoxState
{
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
{
return SM->bShowNavMesh ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
}
return ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState State)
{
OnShowNavMeshToggled(State);
})
]
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Show NavMesh (P)")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor::White)
]
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Render the navmesh in green (mimics editor's P key). Personal preference, never saved.")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
.AutoWrapText(true)
]
]
]
// Close button
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 16.0f, 0.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
CloseSettingsPopup();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Close")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor::White)
]
]
]
]; ];
} }
@@ -816,6 +935,25 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildSceneButtons()
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10)) .Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
] ]
] ]
// Settings button — opens the global Settings popup
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ToolTipText(FText::FromString(TEXT("Open scenario / session settings (dynamic nav, navmesh visu).")))
.OnClicked_Lambda([this]() -> FReply
{
OpenSettingsPopup();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Settings")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
]
]
+ SHorizontalBox::Slot() + SHorizontalBox::Slot()
.AutoWidth() .AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f) .Padding(4.0f, 0.0f, 0.0f, 0.0f)
@@ -2868,6 +3006,55 @@ void UPS_Editor_MainWidget_Legacy::CancelBaseLevelPopup()
PendingBaseLevelSelection.Empty(); PendingBaseLevelSelection.Empty();
} }
void UPS_Editor_MainWidget_Legacy::OpenSettingsPopup()
{
if (SettingsPopup.IsValid())
{
SettingsPopup->SetVisibility(EVisibility::Visible);
}
}
void UPS_Editor_MainWidget_Legacy::CloseSettingsPopup()
{
if (SettingsPopup.IsValid())
{
SettingsPopup->SetVisibility(EVisibility::Collapsed);
}
}
void UPS_Editor_MainWidget_Legacy::OnUseDynamicNavigationToggled(ECheckBoxState State)
{
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
if (!SM) return;
const bool bEnabled = (State == ECheckBoxState::Checked);
SM->bUseDynamicNavigation = bEnabled;
bSceneDirty = true; // user changed a per-scenario setting
// Apply live to the level's RecastNavMesh actors.
PS_Editor_NavUtils::SetDynamicNavigationEnabled(GetWorld(), bEnabled);
}
void UPS_Editor_MainWidget_Legacy::OnShowNavMeshToggled(ECheckBoxState State)
{
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
if (!SM) return;
const bool bVisible = (State == ECheckBoxState::Checked);
SM->bShowNavMesh = bVisible;
PS_Editor_NavUtils::SetNavMeshVisible(GetWorld(), bVisible);
}
void UPS_Editor_MainWidget_Legacy::ToggleShowNavMesh()
{
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
if (!SM) return;
SM->bShowNavMesh = !SM->bShowNavMesh;
PS_Editor_NavUtils::SetNavMeshVisible(GetWorld(), SM->bShowNavMesh);
}
void UPS_Editor_MainWidget_Legacy::OnCloseClicked() void UPS_Editor_MainWidget_Legacy::OnCloseClicked()
{ {
if (CloseConfirmOverlay.IsValid()) if (CloseConfirmOverlay.IsValid())

View File

@@ -107,6 +107,15 @@ private:
void ConfirmBaseLevelSelection(); void ConfirmBaseLevelSelection();
void CancelBaseLevelPopup(); void CancelBaseLevelPopup();
// Global Settings popup (per-scenario nav option + session-only navmesh visu).
TSharedPtr<SWidget> SettingsPopup;
void OpenSettingsPopup();
void CloseSettingsPopup();
void OnUseDynamicNavigationToggled(ECheckBoxState State);
void OnShowNavMeshToggled(ECheckBoxState State);
/** Toggle the navmesh visualization (P key handler). */
void ToggleShowNavMesh();
// Transform fields // Transform fields
TSharedPtr<SEditableTextBox> LocX, LocY, LocZ; TSharedPtr<SEditableTextBox> LocX, LocY, LocZ;
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ; TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;