Timeline polish + simulate/timeline lockstep + default base level

Timeline behavior:
- Natural end of playback now clamps to Duration and holds values there
  (no auto-rewind, no simulation reset) — symmetric between editor and
  gameplay. New Restart button does the rewind+replay sequence explicitly.
- Pause now freezes character movement too (new SetSimulationPaused on
  the PlayerController stops AI ticks without restoring saved transforms
  the way StopSimulation does).
- Simulate toolbar button is wired through the timeline: with at least one
  track, it toggles Play/Pause; without tracks it falls back to direct
  StartSimulation/StopSimulation. Label stays "Simulate"/"Stop" so it
  doesn't duplicate the timeline panel's own Play button.
- SetKeyTime re-sorts keys; clicking a key selects the animated actor
  exclusively (ClearSelection first) so splines release their handles.
- Seek now invalidates LastAppliedValues: a manual property edit between
  two seeks used to leak onto the next key because the cache made the
  next seek skip the write.
- Keyframe colors are saturated: vivid blue / orange / green / yellow
  instead of the washed-out pastels.

Scene load robustness:
- New LoadActorClassRobust chains TryLoadClass, LoadPackage+retry,
  LoadObject, StaticLoadClass — covers cold BP post-load races that made
  actors silently fail to spawn on the first PIE of a session.
- Failed class loads and null-spawn actors now log Error with class path
  and location; a Warning/Log recap lands at the end of every load.
- Actors that end up hidden or invalid after the pipeline get their own
  Warning/Error lines for post-mortem diagnosis.

Base level handling:
- New bIsDefault flag on FPS_Editor_BaseLevelEntry. The entry marked
  default is loaded automatically at editor startup (when no pending
  scenario is queued) and as fallback when a scenario saved without a
  base level ("Unfiled" folder) is opened.
- Removed the hardcoded "None" option from the base level popup — the
  project defines its own default entry instead (e.g. renamed to "None").
- Previous behavior of LoadBaseLevelAsSublevel("Unfiled") is gone; the
  bogus sublevel load used to leave the loading screen stuck.

Spline visuals:
- SetRenderCustomDepth is now off by default and only turned on while the
  spline is selected — project outline post-process materials no longer
  highlight every loaded spline.
- ImportFromCustomProperties forces SetHandlesVisible(false) and
  SetSelected(false) after setting points (both PS_Editor and
  PS_BehaviorEditor versions) so loaded splines start in the deselected
  visual state.

AI in editor:
- Spawned pawns now get their AI disabled synchronously right after
  SpawnActor (in addition to the existing next-tick fallback) so the BT
  can't drive the character for the 1-frame gap between spawn and timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 13:47:45 +02:00
parent 75f54e27c4
commit 4181801635
11 changed files with 422 additions and 110 deletions

View File

@@ -28,7 +28,9 @@ APS_BehaviorEditor_AISpline::APS_BehaviorEditor_AISpline()
SplineMeshComp->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
SplineMeshComp->bUseComplexAsSimpleCollision = true;
SplineMeshComp->SetCastShadow(false);
SplineMeshComp->SetRenderCustomDepth(true);
// Custom depth off by default; SetSelected enables it while selected so the outline PP
// only catches the currently-selected spline.
SplineMeshComp->SetRenderCustomDepth(false);
SplineMeshComp->SetCustomDepthStencilValue(2);
// Handle parent
@@ -409,6 +411,8 @@ void APS_BehaviorEditor_AISpline::SetSelected(bool bSelected)
{
UMaterialInstanceDynamic* Mat = bSelected ? SplineMatSelected : SplineMat;
if (Mat) SplineMeshComp->SetMaterial(0, Mat);
// Only participate in the custom-depth pass while selected (see SplineActor).
SplineMeshComp->SetRenderCustomDepth(bSelected);
SplineMeshComp->SetCustomDepthStencilValue(bSelected ? 1 : 2);
}
if (CenterCubeMat)
@@ -528,4 +532,10 @@ void APS_BehaviorEditor_AISpline::ImportFromCustomProperties(const TMap<FString,
Priority = FCString::Atoi(**PriStr);
UpdateSplineMaterials();
// Force deselected state on load: handles default to visible on creation and
// UpdateVisualization uses handle visibility to pick the "selected" material.
SetHandlesVisible(false);
SetSelected(false);
UpdateVisualization();
}

View File

@@ -66,6 +66,7 @@ void APS_Editor_PlayerController::BeginPlay()
}
// Check if PROSERVE set a pending base level + scenario via the subsystem
bool bHandledStartupLevel = false;
if (UGameInstance* GI = GetGameInstance())
{
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
@@ -88,6 +89,7 @@ void APS_Editor_PlayerController::BeginPlay()
}
}
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel, ResolvedMapPath, ResolvedExtras);
bHandledStartupLevel = true;
if (!Sub->PendingScenarioName.IsEmpty() && SpawnManager && SceneSerializer)
{
@@ -101,6 +103,23 @@ void APS_Editor_PlayerController::BeginPlay()
}
}
// No pending base level from the subsystem — fall back to the catalog's default
// (entry with bIsDefault=true) so the editor always starts with some ground/lighting
// instead of a bare persistent level.
if (!bHandledStartupLevel)
{
for (const FPS_Editor_BaseLevelEntry& Entry : Master->BaseLevels)
{
if (Entry.bIsDefault && !Entry.DisplayName.IsEmpty() && !Entry.MapPath.IsEmpty())
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Loading catalog default base level at startup — DisplayName='%s', MapPath='%s'"),
*Entry.DisplayName, *Entry.MapPath);
LoadBaseLevelAsSublevel(Entry.DisplayName, Entry.MapPath, Entry.AdditionalSublevels);
break;
}
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: MasterCatalog loaded (%d catalogs, %d entries). BaseLevel: '%s'"),
Master->Catalogs.Num(), SpawnManager->GetResolvedEntries().Num(), *CurrentBaseLevel);
}
@@ -259,6 +278,37 @@ void APS_Editor_PlayerController::StartSimulation()
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Simulation started (%d actors saved)"), SimulationSavedTransforms.Num());
}
void APS_Editor_PlayerController::SetSimulationPaused(bool bPaused)
{
// Toggle AI tick + BT on every spawned pawn without touching bSimulating or the saved
// transforms — the timeline's Pause uses this so characters freeze instead of snapping
// back to their pre-play positions.
if (!SpawnManager) return;
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
{
APawn* P = Cast<APawn>(Weak.Get());
if (!P) continue;
P->SetActorTickEnabled(!bPaused);
if (AAIController* AIC = Cast<AAIController>(P->GetController()))
{
AIC->SetActorTickEnabled(!bPaused);
if (UBrainComponent* Brain = AIC->GetBrainComponent())
{
if (bPaused)
{
Brain->StopLogic(TEXT("PS_Editor_Pause"));
}
else
{
Brain->RestartLogic();
}
}
}
}
}
void APS_Editor_PlayerController::StopSimulation()
{
if (!bSimulating) return;

View File

@@ -74,6 +74,14 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
bool IsSimulating() const { return bSimulating; }
/**
* Toggle AI execution on all spawned pawns without changing the simulation's
* saved-transforms state. Used by the timeline's Pause so the characters freeze
* in place instead of snapping back to their pre-play positions.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
void SetSimulationPaused(bool bPaused);
/** Load a base level as sublevel for visual context. Unloads previous sublevel.
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
* @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse").

View File

@@ -13,11 +13,39 @@
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Misc/PackageName.h"
#include "UObject/UnrealType.h"
#include "UObject/Package.h"
#include "Components/ActorComponent.h"
#include "Engine/World.h"
#include "GameFramework/PlayerController.h"
namespace
{
/** Same robust class loader as in SceneSerializer — kept local to avoid an extra export. */
static UClass* LoadActorClassRobust_SceneLoader(const FString& ClassPath)
{
if (ClassPath.IsEmpty()) return nullptr;
FSoftClassPath SoftPath(ClassPath);
if (UClass* C = SoftPath.TryLoadClass<AActor>()) return C;
const FString PackageName = FPackageName::ObjectPathToPackageName(ClassPath);
if (!PackageName.IsEmpty())
{
if (UPackage* Package = LoadPackage(nullptr, *PackageName, LOAD_None))
{
if (UClass* C = SoftPath.TryLoadClass<AActor>()) return C;
if (UClass* C = FindObject<UClass>(nullptr, *ClassPath)) return C;
}
}
if (UClass* C = LoadObject<UClass>(nullptr, *ClassPath)) return C;
if (UClass* C = StaticLoadClass(AActor::StaticClass(), nullptr, *ClassPath)) return C;
return nullptr;
}
}
bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors, bool bStripEditorComponents, EPS_Editor_SceneLoadFilter Filter)
{
OutActors.Empty();
@@ -147,22 +175,13 @@ AActor* UPS_Editor_SceneLoader::SpawnActorFromData(UWorld* World, const FPS_Edit
if (Filter == EPS_Editor_SceneLoadFilter::PreloadOnly && !ActorData.bPreload) return nullptr;
if (Filter == EPS_Editor_SceneLoadFilter::ExcludePreload && ActorData.bPreload) return nullptr;
// Load class (supports both Blueprint and C++ classes). TryLoadClass forces the full
// Blueprint load path (package + generated class + post-load) which LoadObject<UClass>
// can skip on a cold first call, leaving the class null intermittently.
FSoftClassPath SoftPath(ActorData.ClassPath);
UClass* LoadedClass = SoftPath.TryLoadClass<AActor>();
// Robust class load: tries TryLoadClass, then force-loads the package and retries,
// then falls back to LoadObject / StaticLoadClass. Covers cold-start races where a BP
// package isn't fully post-loaded yet.
UClass* LoadedClass = LoadActorClassRobust_SceneLoader(ActorData.ClassPath);
if (!LoadedClass)
{
LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass)
{
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor SceneLoader: Failed to load class: %s"), *ActorData.ClassPath);
UE_LOG(LogTemp, Error, TEXT("PS_Editor SceneLoader: Failed to load class after all fallbacks: %s"), *ActorData.ClassPath);
return nullptr;
}
@@ -172,7 +191,8 @@ AActor* UPS_Editor_SceneLoader::SpawnActorFromData(UWorld* World, const FPS_Edit
AActor* SpawnedActor = World->SpawnActor<AActor>(LoadedClass, ActorData.Location, ActorData.Rotation, SpawnParams);
if (!SpawnedActor)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor SceneLoader: Failed to spawn: %s"), *ActorData.ClassPath);
UE_LOG(LogTemp, Error, TEXT("PS_Editor SceneLoader: SpawnActor returned null for %s at %s"),
*ActorData.ClassPath, *ActorData.Location.ToString());
return nullptr;
}

View File

@@ -15,11 +15,47 @@
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Misc/PackageName.h"
#include "HAL/PlatformFileManager.h"
#include "UObject/UnrealType.h"
#include "UObject/Package.h"
#include "Components/ActorComponent.h"
#include "Engine/World.h"
namespace
{
/**
* Robust class load for Blueprint actor classes. Layers several strategies so we cover
* cold-start cases where the package isn't in memory yet and async post-load phases
* that leave TryLoadClass returning null briefly.
*/
static UClass* LoadActorClassRobust(const FString& ClassPath)
{
if (ClassPath.IsEmpty()) return nullptr;
// 1. SoftClassPath::TryLoadClass forces the full BP load chain (package + generated class + post-load)
FSoftClassPath SoftPath(ClassPath);
if (UClass* C = SoftPath.TryLoadClass<AActor>()) return C;
// 2. Force-load the containing package synchronously, then retry
const FString PackageName = FPackageName::ObjectPathToPackageName(ClassPath);
if (!PackageName.IsEmpty())
{
UPackage* Package = LoadPackage(nullptr, *PackageName, LOAD_None);
if (Package)
{
if (UClass* C = SoftPath.TryLoadClass<AActor>()) return C;
if (UClass* C = FindObject<UClass>(nullptr, *ClassPath)) return C;
}
}
// 3. LoadObject / StaticLoadClass as last-resort fallbacks
if (UClass* C = LoadObject<UClass>(nullptr, *ClassPath)) return C;
if (UClass* C = StaticLoadClass(AActor::StaticClass(), nullptr, *ClassPath)) return C;
return nullptr;
}
}
bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
{
if (!SpawnManager || SceneName.IsEmpty())
@@ -185,31 +221,27 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
// Re-spawn actors from saved data
int32 SpawnedCount = 0;
int32 FailedClassCount = 0;
int32 FailedSpawnCount = 0;
UPS_Editor_SceneLoader::bIsCurrentlyLoading = true;
for (const FPS_Editor_ActorData& ActorData : SceneData.Actors)
{
// TryLoadClass synchronously loads the BP package AND resolves the generated class,
// handling redirectors. Plain LoadObject<UClass> can return null on a cold first load
// of a Blueprint whose package hasn't been through post-load yet — that's the source
// of the "it works on the second try" bug.
FSoftClassPath SoftPath(ActorData.ClassPath);
UClass* LoadedClass = SoftPath.TryLoadClass<AActor>();
UClass* LoadedClass = LoadActorClassRobust(ActorData.ClassPath);
if (!LoadedClass)
{
// Fallback for paths that aren't a "Class'...'" soft ref (e.g. legacy data)
LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass)
{
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class: %s"), *ActorData.ClassPath);
++FailedClassCount;
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to load class after all fallbacks: %s"), *ActorData.ClassPath);
continue;
}
AActor* SpawnedActor = SpawnManager->SpawnActorDirect(LoadedClass, ActorData.Location, ActorData.Rotation, ActorData.Scale);
if (!SpawnedActor)
{
++FailedSpawnCount;
UE_LOG(LogTemp, Error, TEXT("PS_Editor: SpawnActor returned null for class %s at %s"),
*ActorData.ClassPath, *ActorData.Location.ToString());
continue;
}
if (SpawnedActor)
{
// Restore stable actor ID from the JSON (needed so timeline tracks can find the actor)
@@ -298,32 +330,67 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
}
// Stop AI on spawned pawns (editor mode — AI only runs during Simulate)
// Apply immediately if the AIController is already assigned (typical path, since
// SpawnActor completes the full PossessedBy chain synchronously), and keep the
// deferred call as a safety net for any case where auto-possession takes longer.
// Without the immediate call, the BT has a 1-frame window to move the pawn.
if (APawn* P = Cast<APawn>(SpawnedActor))
{
TWeakObjectPtr<APawn> WeakP = P;
P->GetWorldTimerManager().SetTimerForNextTick([WeakP]()
auto DisableAI = [](APawn* Pawn)
{
if (APawn* Pawn = WeakP.Get())
if (!Pawn) return;
Pawn->SetActorTickEnabled(false);
if (AAIController* AIC = Cast<AAIController>(Pawn->GetController()))
{
if (AAIController* AIC = Cast<AAIController>(Pawn->GetController()))
AIC->SetActorTickEnabled(false);
if (UBrainComponent* Brain = AIC->GetBrainComponent())
{
AIC->SetActorTickEnabled(false);
if (UBrainComponent* Brain = AIC->GetBrainComponent())
{
Brain->StopLogic(TEXT("PS_Editor"));
}
Pawn->SetActorTickEnabled(false);
Brain->StopLogic(TEXT("PS_Editor"));
}
}
};
DisableAI(P);
TWeakObjectPtr<APawn> WeakP = P;
P->GetWorldTimerManager().SetTimerForNextTick([WeakP, DisableAI]()
{
DisableAI(WeakP.Get());
});
}
// Post-spawn diagnostics: if the actor got hidden or destroyed by its own BP logic
// we want that visible in the log so the "character not loading first time" bug
// can be traced to a specific actor.
if (!IsValid(SpawnedActor))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Actor %s became invalid after load pipeline"), *ActorData.ClassPath);
}
else if (SpawnedActor->IsHidden())
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Actor %s (%s) spawned HIDDEN after load"), *SpawnedActor->GetName(), *ActorData.ClassPath);
}
else
{
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Spawned %s (%s) at %s"), *SpawnedActor->GetName(), *ActorData.ClassPath, *ActorData.Location.ToString());
}
SpawnedCount++;
}
}
UPS_Editor_SceneLoader::bIsCurrentlyLoading = false;
const int32 TotalExpected = SceneData.Actors.Num();
if (FailedClassCount > 0 || FailedSpawnCount > 0)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Scene load incomplete — %d/%d actors spawned, %d class loads failed, %d spawns failed"),
SpawnedCount, TotalExpected, FailedClassCount, FailedSpawnCount);
}
else
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene load complete — %d/%d actors spawned"), SpawnedCount, TotalExpected);
}
// Push timeline data into the world subsystem (editor mode: no auto-play)
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter()))
{

View File

@@ -85,6 +85,15 @@ struct FPS_Editor_BaseLevelEntry
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
TArray<TObjectPtr<UPS_Editor_SpawnCatalog>> Catalogs;
/**
* If true, this entry is used as a fallback when loading a scenario that was saved
* without a base level. Exactly one entry should be marked as default — if several
* are, the first one wins; if none is, scenarios without a base level keep whatever
* sublevel is currently loaded.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
bool bIsDefault = false;
};
/**

View File

@@ -187,29 +187,35 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
IPS_Editor_EditableInterface::Execute_OnEditorModeChanged(SpawnedActor, true);
}
// Stop AI logic on spawned pawns in editor mode (deferred — AIController may not be assigned yet)
// Stop AI logic on spawned pawns in editor mode. Apply synchronously (AIController is
// usually already possessing by the time SpawnActor returns, so waiting a frame lets the
// BT kick off and move the pawn) and keep the deferred fallback for the occasional case
// where auto-possession happens later.
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (!EditorPC->IsSimulating())
{
if (APawn* SpawnedPawn = Cast<APawn>(SpawnedActor))
{
TWeakObjectPtr<APawn> WeakPawn = SpawnedPawn;
SpawnedPawn->GetWorldTimerManager().SetTimerForNextTick([WeakPawn]()
auto DisableAI = [](APawn* Pawn)
{
if (APawn* P = WeakPawn.Get())
if (!Pawn) return;
Pawn->SetActorTickEnabled(false);
if (AAIController* AIC = Cast<AAIController>(Pawn->GetController()))
{
if (AAIController* AIC = Cast<AAIController>(P->GetController()))
AIC->SetActorTickEnabled(false);
if (UBrainComponent* Brain = AIC->GetBrainComponent())
{
AIC->SetActorTickEnabled(false);
if (UBrainComponent* Brain = AIC->GetBrainComponent())
{
Brain->StopLogic(TEXT("PS_Editor"));
}
// Disable AI perception
P->SetActorTickEnabled(false);
Brain->StopLogic(TEXT("PS_Editor"));
}
}
};
DisableAI(SpawnedPawn);
TWeakObjectPtr<APawn> WeakPawn = SpawnedPawn;
SpawnedPawn->GetWorldTimerManager().SetTimerForNextTick([WeakPawn, DisableAI]()
{
DisableAI(WeakPawn.Get());
});
}
}

View File

@@ -30,7 +30,10 @@ APS_Editor_SplineActor::APS_Editor_SplineActor()
SplineMeshComp->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
SplineMeshComp->bUseComplexAsSimpleCollision = true;
SplineMeshComp->SetCastShadow(false);
SplineMeshComp->SetRenderCustomDepth(true);
// Custom depth is OFF by default and only enabled while the spline is selected —
// otherwise any project-level outline PP material picks up every loaded spline as if
// it were selected. SetSelected() flips this on/off.
SplineMeshComp->SetRenderCustomDepth(false);
SplineMeshComp->SetCustomDepthStencilValue(2);
// Handle parent
@@ -427,7 +430,9 @@ void APS_Editor_SplineActor::SetSelected(bool bSelected)
{
SplineMeshComp->SetMaterial(0, Mat);
}
// Restore stencil 2 for occluded PP effect when deselected (selection sets it to 1)
// Only participate in the custom-depth pass while selected, so any project outline PP
// only highlights the selected spline (not every spline in the scene).
SplineMeshComp->SetRenderCustomDepth(bSelected);
SplineMeshComp->SetCustomDepthStencilValue(bSelected ? 1 : 2);
}
@@ -580,4 +585,12 @@ void APS_Editor_SplineActor::ImportFromCustomProperties(const TMap<FString, FStr
SplineTag = *TagStr;
}
UpdateSplineMaterials();
// After load, force the deselected visual state: handles default to visible on creation,
// and UpdateVisualization reads handle visibility to pick "selected vs. normal" material
// — so without this the line would render with the selected color until the user clicks
// the spline once.
SetHandlesVisible(false);
SetSelected(false);
UpdateVisualization();
}

View File

@@ -54,28 +54,17 @@ void UPS_Editor_TimelineSubsystem::Tick(float DeltaTime)
}
CurrentTime += DeltaTime;
bool bReachedEnd = false;
if (CurrentTime >= Data.Duration)
{
// Natural end: clamp to the last frame and stop advancing. Values are left at
// whatever the last keyframe produced (no rewind), and simulation keeps running so
// the gameplay / editor scenario can continue unimpeded. Use Restart() to rewind
// and replay from the beginning.
CurrentTime = Data.Duration;
bIsPlaying = false;
bReachedEnd = true;
}
ApplyValuesAtTime(CurrentTime);
// Natural end of timeline: stop simulation as well (same contract as Pause button)
if (bReachedEnd)
{
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (EditorPC->IsSimulating())
{
EditorPC->StopSimulation();
}
}
}
OnTimelineChanged.Broadcast();
}
@@ -105,15 +94,20 @@ void UPS_Editor_TimelineSubsystem::Play()
}
bIsPlaying = true;
// Timeline and simulation are inherently linked: when we start the timeline in the runtime
// editor, make sure AI is enabled too. In pure gameplay, FindEditorPC returns null and we
// skip this (AI is already running through the normal game flow).
// Timeline and simulation are inherently linked: start simulation if it isn't running,
// or un-pause it if it was frozen by a previous Pause (keeping the actor transforms in
// place). In pure gameplay, FindEditorPC returns null so we skip — AI is driven by the
// normal game flow anyway.
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (!EditorPC->IsSimulating())
{
EditorPC->StartSimulation();
}
else
{
EditorPC->SetSimulationPaused(false);
}
}
OnTimelineChanged.Broadcast();
@@ -121,14 +115,14 @@ void UPS_Editor_TimelineSubsystem::Play()
void UPS_Editor_TimelineSubsystem::Pause()
{
// Pause stops timeline advance AND freezes character movement (AI tick off, BT stopped)
// while keeping the pawns where they are — no transform restore, that's what Stop is for.
bIsPlaying = false;
// Pause also suspends the simulation so everything freezes coherently
// (positions are NOT restored — Stop is the way to reset).
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (EditorPC->IsSimulating())
{
EditorPC->StopSimulation();
EditorPC->SetSimulationPaused(true);
}
}
OnTimelineChanged.Broadcast();
@@ -138,9 +132,11 @@ void UPS_Editor_TimelineSubsystem::Stop()
{
bIsPlaying = false;
CurrentTime = 0.f;
LastAppliedValues.Empty();
LastAppliedValues.SetNum(Data.Tracks.Num());
// Stop simulation first (this restores saved transforms) so we're back to the pre-play state,
// then apply the values at t=0 to match the timeline start.
// Full reset: stop simulation (restores pre-play transforms) and apply t=0 values so
// the scene visually matches the timeline start.
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (EditorPC->IsSimulating())
@@ -153,9 +149,24 @@ void UPS_Editor_TimelineSubsystem::Stop()
OnTimelineChanged.Broadcast();
}
void UPS_Editor_TimelineSubsystem::Restart()
{
// Rewind + replay from the start. Done in two steps so simulation transforms are restored
// by Stop() before Play() re-enables AI, giving a clean "from the beginning" rerun.
Stop();
Play();
}
void UPS_Editor_TimelineSubsystem::Seek(float Time)
{
CurrentTime = FMath::Clamp(Time, 0.f, Data.Duration);
// Invalidate the per-track value cache: the user may have edited properties manually
// between two seeks, so the actor's actual value can have drifted from what was last
// applied. Without this, a seek to a key whose stored value matches LastAppliedValues
// silently skips the write and leaves the manual edit visible — making every key
// "look like" it has the edited value until we seek to one with a different stored value.
LastAppliedValues.Empty();
LastAppliedValues.SetNum(Data.Tracks.Num());
ApplyValuesAtTime(CurrentTime);
OnTimelineChanged.Broadcast();
}
@@ -216,6 +227,9 @@ bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, co
const FGuid ActorId = GetOrCreateActorId(Actor);
if (!ActorId.IsValid()) return false;
UE_LOG(LogTemp, Log, TEXT("PS_Editor Timeline: AddOrUpdateKey — Actor=%s ActorId=%s Prop=%s CurrentTime=%.3f"),
*Actor->GetName(), *ActorId.ToString(), *PropertyPath.ToString(), CurrentTime);
// Resolve property to read current value
UObject* Target = nullptr;
FProperty* Prop = nullptr;

View File

@@ -49,14 +49,18 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Play();
/** Pause playback, keeping CurrentTime. */
/** Pause playback, keeping CurrentTime. Simulation keeps running. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Pause();
/** Pause playback and reset CurrentTime to 0. */
/** Pause playback, rewind to CurrentTime=0, stop simulation and apply t=0 values. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Stop();
/** Rewind to t=0 and immediately replay from the start (re-enables simulation). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Restart();
/** Jump to a specific time (seconds, clamped to [0, Duration]). Applies values immediately. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Seek(float Time);

View File

@@ -33,6 +33,10 @@
#include "PS_Editor_TimelineSubsystem.h"
#include "Engine/World.h"
// Forward declaration — the real definition lives further down with the timeline UI block,
// but the toolbar Simulate button + NativeTick both need to look up the subsystem too.
static UPS_Editor_TimelineSubsystem* GetTimelineSubsystemFromWidget(UPS_Editor_MainWidget* Widget);
void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
{
SelectionManager = InSelectionManager;
@@ -905,18 +909,39 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ToolTipText(FText::FromString(TEXT("Start/stop simulation. If a timeline exists, also plays/pauses it in sync.")))
.OnClicked_Lambda([this]() -> FReply
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return FReply::Handled();
if (EditorPC->IsSimulating())
UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this);
// If there's a timeline with at least one track, route through the timeline
// so simulate + animation are always in lockstep (Simulate is effectively
// the Play/Pause button of the timeline).
if (TS && TS->GetNumTracks() > 0)
{
EditorPC->StopSimulation();
if (TS->IsPlaying())
{
TS->Pause();
}
else
{
TS->Play();
}
}
else
{
EditorPC->StartSimulation();
// No animation to sync with — just toggle simulation directly.
if (EditorPC->IsSimulating())
{
EditorPC->StopSimulation();
}
else
{
EditorPC->StartSimulation();
}
}
return FReply::Handled();
})
@@ -977,16 +1002,29 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
// Sync BaseLevel sublevel only if scenario specifies a REAL base level.
// Scenes without a base level land in the "Unfiled" folder — GetSceneLevelName
// returns that as a placeholder but it isn't a loadable map, so skip.
// Same base-level sync logic as the scene-list click path:
// fall back to the MasterCatalog's default entry when the scenario
// has no base level; otherwise keep the current sublevel.
FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(FileName);
const bool bHasValidBaseLevel = !SceneLevelName.IsEmpty()
bool bHasValidBaseLevel = !SceneLevelName.IsEmpty()
&& SceneLevelName != TEXT("Unfiled")
&& BaseLevelPathMap.Contains(SceneLevelName);
if (bHasValidBaseLevel)
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
if (!bHasValidBaseLevel && EditorPC->MasterCatalogAsset)
{
for (const FPS_Editor_BaseLevelEntry& Entry : EditorPC->MasterCatalogAsset->BaseLevels)
{
if (Entry.bIsDefault && !Entry.DisplayName.IsEmpty())
{
SceneLevelName = Entry.DisplayName;
bHasValidBaseLevel = BaseLevelPathMap.Contains(SceneLevelName);
break;
}
}
}
if (bHasValidBaseLevel)
{
if (EditorPC->CurrentBaseLevel != SceneLevelName)
{
@@ -1010,9 +1048,9 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
break;
}
}
}
}
}
}
SceneSerializer->LoadScene(FileName, SpawnManager.Get());
bSceneDirty = false;
@@ -1119,10 +1157,12 @@ void UPS_Editor_MainWidget::NativeConstruct()
Super::NativeConstruct();
PopulateSpawnCatalog();
// Populate BaseLevel options from MasterCatalog
// Populate BaseLevel options from MasterCatalog.
// No hardcoded "None" entry: if the user wants an "empty" fallback they mark one of
// their catalog entries as the default (bIsDefault) — it appears in the popup like
// any other base level.
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
BaseLevelOptions.Add(MakeShared<FString>(TEXT("None")));
if (UPS_Editor_MasterCatalog* Master = EditorPC->MasterCatalogAsset)
{
for (const FPS_Editor_BaseLevelEntry& Entry : Master->BaseLevels)
@@ -1251,17 +1291,41 @@ void UPS_Editor_MainWidget::PopulateSceneList()
{
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
// Sync the BaseLevel sublevel only if scenario specifies a REAL base level.
// "Unfiled" is the folder for scenes saved without a base level — we keep the
// current sublevel as-is in that case rather than trying to load "Unfiled".
// Sync the BaseLevel sublevel to match the scenario being loaded.
// "Unfiled" is the folder for scenes saved without a base level — in that case
// we fall back to the MasterCatalog entry marked bIsDefault, or if none is
// defined we keep whatever sublevel is currently loaded.
FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(Name);
const bool bHasValidBaseLevel = !SceneLevelName.IsEmpty()
bool bHasValidBaseLevel = !SceneLevelName.IsEmpty()
&& SceneLevelName != TEXT("Unfiled")
&& BaseLevelPathMap.Contains(SceneLevelName);
if (bHasValidBaseLevel)
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
// No base level in the scenario file — try to resolve the default from the catalog.
if (!bHasValidBaseLevel && EditorPC->MasterCatalogAsset)
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scenario '%s' has no base level, searching for default in MasterCatalog (%d entries)"),
*Name, EditorPC->MasterCatalogAsset->BaseLevels.Num());
for (const FPS_Editor_BaseLevelEntry& Entry : EditorPC->MasterCatalogAsset->BaseLevels)
{
UE_LOG(LogTemp, Log, TEXT(" BaseLevel entry: DisplayName='%s', MapPath='%s', bIsDefault=%d"),
*Entry.DisplayName, *Entry.MapPath, Entry.bIsDefault ? 1 : 0);
if (Entry.bIsDefault && !Entry.DisplayName.IsEmpty())
{
SceneLevelName = Entry.DisplayName;
bHasValidBaseLevel = BaseLevelPathMap.Contains(SceneLevelName);
UE_LOG(LogTemp, Log, TEXT(" -> Picked default '%s' (in PathMap=%d)"),
*SceneLevelName, bHasValidBaseLevel ? 1 : 0);
break;
}
}
}
if (bHasValidBaseLevel)
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Loading scenario '%s' with base level '%s' (current='%s')"),
*Name, *SceneLevelName, *EditorPC->CurrentBaseLevel);
if (EditorPC->CurrentBaseLevel != SceneLevelName)
{
FString SceneMapPath = SceneLevelName;
@@ -1283,6 +1347,8 @@ void UPS_Editor_MainWidget::PopulateSceneList()
}
}
}
// If still no valid base level and no default in the catalog, keep the
// current sublevel as-is — don't unload it (avoids empty scenes).
}
SceneSerializer->LoadScene(Name, SpawnManager.Get());
@@ -1457,7 +1523,16 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
}
}
Mgr->SpawnFromCatalog(EntryIndex);
// Spawn and immediately select the new actor exclusively — deselects
// whatever was active before, so the gizmo and property panel switch to it.
if (AActor* Spawned = Mgr->SpawnFromCatalog(EntryIndex))
{
if (UPS_Editor_SelectionManager* Sel = SelectionManager.Get())
{
Sel->ClearSelection();
Sel->SelectActor(Spawned);
}
}
bSceneDirty = true;
return FReply::Handled();
})
@@ -1491,12 +1566,19 @@ void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDelt
}
}
// Update simulate button text
// Update simulate button text / color. Keep the label "Simulate" / "Stop" regardless of
// the timeline state so it doesn't clash with the dedicated Play button in the timeline
// panel — a second "Play" on the toolbar was confusing. The button still drives the
// timeline under the hood (see BuildToolbar lambda).
if (SimulateButtonText.IsValid())
{
if (APS_Editor_PlayerController* SimPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (SimPC->IsSimulating())
UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this);
const bool bTimelinePlaying = TS && TS->GetNumTracks() > 0 && TS->IsPlaying();
const bool bActive = SimPC->IsSimulating() || bTimelinePlaying;
if (bActive)
{
SimulateButtonText->SetText(FText::FromString(TEXT("Stop")));
SimulateButtonText->SetColorAndOpacity(FLinearColor(1.0f, 0.3f, 0.2f));
@@ -3105,6 +3187,26 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTimelinePanel()
+ SHorizontalBox::Slot().AutoWidth().Padding(4.0f, 0.0f, 0.0f, 0.0f).VAlign(VAlign_Center)
[
SNew(SButton)
.ToolTipText(FText::FromString(TEXT("Rewind to t=0 and replay from the start")))
.OnClicked_Lambda([this]() -> FReply
{
if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this))
{
TS->Restart();
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Restart")))
.Font(Bold)
.ColorAndOpacity(FLinearColor(0.6f, 0.8f, 1.0f))
]
]
+ SHorizontalBox::Slot().AutoWidth().Padding(4.0f, 0.0f, 0.0f, 0.0f).VAlign(VAlign_Center)
[
SNew(SButton)
.ToolTipText(FText::FromString(TEXT("Stop: rewind + restore actor transforms")))
.OnClicked_Lambda([this]() -> FReply
{
if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this))
@@ -3300,7 +3402,10 @@ void UPS_Editor_MainWidget::RebuildTimelineTracksList()
for (int32 KeyIdx = 0; KeyIdx < Track.Keys.Num(); ++KeyIdx)
{
const FPS_Editor_TimelineKey& Key = Track.Keys[KeyIdx];
const FLinearColor BaseDotColor = Key.bInterpolate ? FLinearColor(0.4f, 0.8f, 1.0f) : FLinearColor(1.0f, 0.8f, 0.3f);
// Saturated base colors so the dots pop against the dark track.
const FLinearColor BaseDotColor = Key.bInterpolate
? FLinearColor(0.0f, 0.6f, 1.0f) // vivid blue = interpolated
: FLinearColor(1.0f, 0.5f, 0.0f); // vivid orange = stepped
const int32 CapturedKey = KeyIdx;
const int32 CapturedTrack = TrackIdx;
const bool bKeyInterp = Key.bInterpolate;
@@ -3325,14 +3430,14 @@ void UPS_Editor_MainWidget::RebuildTimelineTracksList()
.VisualTimeRef(VisualTime)
.DotColor_Lambda([this, CapturedTrack, CapturedKey, BaseDotColor]() -> FSlateColor
{
// Priority 1: selected by the user (click) — white
// Priority 1: selected by the user (click) — bright yellow
if (SelectedTimelineTrackIdx == CapturedTrack && SelectedTimelineKeyIdx == CapturedKey)
{
return FSlateColor(FLinearColor(1.0f, 1.0f, 1.0f));
return FSlateColor(FLinearColor(1.0f, 1.0f, 0.0f));
}
// Priority 2: "active" — the last key whose time is <= CurrentTime for this track.
// That's the key currently providing the base value during playback/scrub. Green.
// That's the key currently providing the base value during playback/scrub.
if (UPS_Editor_TimelineSubsystem* TS2 = GetTimelineSubsystemFromWidget(const_cast<UPS_Editor_MainWidget*>(this)))
{
if (TS2->GetTracks().IsValidIndex(CapturedTrack))
@@ -3348,13 +3453,13 @@ void UPS_Editor_MainWidget::RebuildTimelineTracksList()
}
if (ActiveIdx == CapturedKey)
{
return FSlateColor(FLinearColor(0.3f, 1.0f, 0.3f));
return FSlateColor(FLinearColor(0.0f, 1.0f, 0.0f)); // pure green
}
}
}
}
// Priority 3: base color (blue for interp, orange for step)
// Priority 3: base color (vivid blue for interp, vivid orange for step)
return FSlateColor(BaseDotColor);
})
.ToolTipText(FText::FromString(FString::Printf(TEXT("t=%.2fs %s — click to select/seek, drag to move, Del to delete"), Key.Time, bKeyInterp ? TEXT("(interp)") : TEXT("(step)"))))
@@ -3370,11 +3475,17 @@ void UPS_Editor_MainWidget::RebuildTimelineTracksList()
SelectedTimelineTrackIdx = CapturedTrack;
SelectedTimelineKeyIdx = CapturedKey;
// Select the animated actor so the property panel shows matching values
if (AActor* TrackActor = TS2->FindActorById(Tr.ActorId))
// Select the animated actor exclusively — ClearSelection first so any
// previously-selected actor (e.g. a spline still in overlay-visible state)
// is properly released and its handles/highlight go away.
AActor* TrackActor = TS2->FindActorById(Tr.ActorId);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Key click — track=%d key=%d ActorId=%s resolved=%s"),
CapturedTrack, CapturedKey, *Tr.ActorId.ToString(), TrackActor ? *TrackActor->GetName() : TEXT("null"));
if (TrackActor)
{
if (UPS_Editor_SelectionManager* Sel = SelectionManager.Get())
{
Sel->ClearSelection();
Sel->SelectActor(TrackActor);
}
}