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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user