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->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
|
||||||
SplineMeshComp->bUseComplexAsSimpleCollision = true;
|
SplineMeshComp->bUseComplexAsSimpleCollision = true;
|
||||||
SplineMeshComp->SetCastShadow(false);
|
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);
|
SplineMeshComp->SetCustomDepthStencilValue(2);
|
||||||
|
|
||||||
// Handle parent
|
// Handle parent
|
||||||
@@ -409,6 +411,8 @@ void APS_BehaviorEditor_AISpline::SetSelected(bool bSelected)
|
|||||||
{
|
{
|
||||||
UMaterialInstanceDynamic* Mat = bSelected ? SplineMatSelected : SplineMat;
|
UMaterialInstanceDynamic* Mat = bSelected ? SplineMatSelected : SplineMat;
|
||||||
if (Mat) SplineMeshComp->SetMaterial(0, Mat);
|
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);
|
SplineMeshComp->SetCustomDepthStencilValue(bSelected ? 1 : 2);
|
||||||
}
|
}
|
||||||
if (CenterCubeMat)
|
if (CenterCubeMat)
|
||||||
@@ -528,4 +532,10 @@ void APS_BehaviorEditor_AISpline::ImportFromCustomProperties(const TMap<FString,
|
|||||||
Priority = FCString::Atoi(**PriStr);
|
Priority = FCString::Atoi(**PriStr);
|
||||||
|
|
||||||
UpdateSplineMaterials();
|
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
|
// Check if PROSERVE set a pending base level + scenario via the subsystem
|
||||||
|
bool bHandledStartupLevel = false;
|
||||||
if (UGameInstance* GI = GetGameInstance())
|
if (UGameInstance* GI = GetGameInstance())
|
||||||
{
|
{
|
||||||
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
|
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
|
||||||
@@ -88,6 +89,7 @@ void APS_Editor_PlayerController::BeginPlay()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel, ResolvedMapPath, ResolvedExtras);
|
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel, ResolvedMapPath, ResolvedExtras);
|
||||||
|
bHandledStartupLevel = true;
|
||||||
|
|
||||||
if (!Sub->PendingScenarioName.IsEmpty() && SpawnManager && SceneSerializer)
|
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'"),
|
UE_LOG(LogTemp, Log, TEXT("PS_Editor: MasterCatalog loaded (%d catalogs, %d entries). BaseLevel: '%s'"),
|
||||||
Master->Catalogs.Num(), SpawnManager->GetResolvedEntries().Num(), *CurrentBaseLevel);
|
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());
|
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()
|
void APS_Editor_PlayerController::StopSimulation()
|
||||||
{
|
{
|
||||||
if (!bSimulating) return;
|
if (!bSimulating) return;
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ public:
|
|||||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
bool IsSimulating() const { return bSimulating; }
|
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.
|
/** Load a base level as sublevel for visual context. Unloads previous sublevel.
|
||||||
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
|
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
|
||||||
* @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse").
|
* @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse").
|
||||||
|
|||||||
@@ -13,11 +13,39 @@
|
|||||||
#include "JsonObjectConverter.h"
|
#include "JsonObjectConverter.h"
|
||||||
#include "Misc/FileHelper.h"
|
#include "Misc/FileHelper.h"
|
||||||
#include "Misc/Paths.h"
|
#include "Misc/Paths.h"
|
||||||
|
#include "Misc/PackageName.h"
|
||||||
#include "UObject/UnrealType.h"
|
#include "UObject/UnrealType.h"
|
||||||
|
#include "UObject/Package.h"
|
||||||
#include "Components/ActorComponent.h"
|
#include "Components/ActorComponent.h"
|
||||||
#include "Engine/World.h"
|
#include "Engine/World.h"
|
||||||
#include "GameFramework/PlayerController.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)
|
bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors, bool bStripEditorComponents, EPS_Editor_SceneLoadFilter Filter)
|
||||||
{
|
{
|
||||||
OutActors.Empty();
|
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::PreloadOnly && !ActorData.bPreload) return nullptr;
|
||||||
if (Filter == EPS_Editor_SceneLoadFilter::ExcludePreload && 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
|
// Robust class load: tries TryLoadClass, then force-loads the package and retries,
|
||||||
// Blueprint load path (package + generated class + post-load) which LoadObject<UClass>
|
// then falls back to LoadObject / StaticLoadClass. Covers cold-start races where a BP
|
||||||
// can skip on a cold first call, leaving the class null intermittently.
|
// package isn't fully post-loaded yet.
|
||||||
FSoftClassPath SoftPath(ActorData.ClassPath);
|
UClass* LoadedClass = LoadActorClassRobust_SceneLoader(ActorData.ClassPath);
|
||||||
UClass* LoadedClass = SoftPath.TryLoadClass<AActor>();
|
|
||||||
if (!LoadedClass)
|
if (!LoadedClass)
|
||||||
{
|
{
|
||||||
LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
|
UE_LOG(LogTemp, Error, TEXT("PS_Editor SceneLoader: Failed to load class after all fallbacks: %s"), *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);
|
|
||||||
return nullptr;
|
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);
|
AActor* SpawnedActor = World->SpawnActor<AActor>(LoadedClass, ActorData.Location, ActorData.Rotation, SpawnParams);
|
||||||
if (!SpawnedActor)
|
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;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,47 @@
|
|||||||
#include "JsonObjectConverter.h"
|
#include "JsonObjectConverter.h"
|
||||||
#include "Misc/FileHelper.h"
|
#include "Misc/FileHelper.h"
|
||||||
#include "Misc/Paths.h"
|
#include "Misc/Paths.h"
|
||||||
|
#include "Misc/PackageName.h"
|
||||||
#include "HAL/PlatformFileManager.h"
|
#include "HAL/PlatformFileManager.h"
|
||||||
#include "UObject/UnrealType.h"
|
#include "UObject/UnrealType.h"
|
||||||
|
#include "UObject/Package.h"
|
||||||
#include "Components/ActorComponent.h"
|
#include "Components/ActorComponent.h"
|
||||||
#include "Engine/World.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)
|
bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
|
||||||
{
|
{
|
||||||
if (!SpawnManager || SceneName.IsEmpty())
|
if (!SpawnManager || SceneName.IsEmpty())
|
||||||
@@ -185,31 +221,27 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
|
|||||||
|
|
||||||
// Re-spawn actors from saved data
|
// Re-spawn actors from saved data
|
||||||
int32 SpawnedCount = 0;
|
int32 SpawnedCount = 0;
|
||||||
|
int32 FailedClassCount = 0;
|
||||||
|
int32 FailedSpawnCount = 0;
|
||||||
UPS_Editor_SceneLoader::bIsCurrentlyLoading = true;
|
UPS_Editor_SceneLoader::bIsCurrentlyLoading = true;
|
||||||
for (const FPS_Editor_ActorData& ActorData : SceneData.Actors)
|
for (const FPS_Editor_ActorData& ActorData : SceneData.Actors)
|
||||||
{
|
{
|
||||||
// TryLoadClass synchronously loads the BP package AND resolves the generated class,
|
UClass* LoadedClass = LoadActorClassRobust(ActorData.ClassPath);
|
||||||
// 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>();
|
|
||||||
if (!LoadedClass)
|
if (!LoadedClass)
|
||||||
{
|
{
|
||||||
// Fallback for paths that aren't a "Class'...'" soft ref (e.g. legacy data)
|
++FailedClassCount;
|
||||||
LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
|
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to load class after all fallbacks: %s"), *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);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
AActor* SpawnedActor = SpawnManager->SpawnActorDirect(LoadedClass, ActorData.Location, ActorData.Rotation, ActorData.Scale);
|
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)
|
if (SpawnedActor)
|
||||||
{
|
{
|
||||||
// Restore stable actor ID from the JSON (needed so timeline tracks can find the actor)
|
// 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)
|
// 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))
|
if (APawn* P = Cast<APawn>(SpawnedActor))
|
||||||
{
|
{
|
||||||
TWeakObjectPtr<APawn> WeakP = P;
|
auto DisableAI = [](APawn* Pawn)
|
||||||
P->GetWorldTimerManager().SetTimerForNextTick([WeakP]()
|
|
||||||
{
|
{
|
||||||
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);
|
Brain->StopLogic(TEXT("PS_Editor"));
|
||||||
if (UBrainComponent* Brain = AIC->GetBrainComponent())
|
|
||||||
{
|
|
||||||
Brain->StopLogic(TEXT("PS_Editor"));
|
|
||||||
}
|
|
||||||
Pawn->SetActorTickEnabled(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
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++;
|
SpawnedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UPS_Editor_SceneLoader::bIsCurrentlyLoading = false;
|
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)
|
// Push timeline data into the world subsystem (editor mode: no auto-play)
|
||||||
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter()))
|
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")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||||
TArray<TObjectPtr<UPS_Editor_SpawnCatalog>> Catalogs;
|
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);
|
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 (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
|
||||||
{
|
{
|
||||||
if (!EditorPC->IsSimulating())
|
if (!EditorPC->IsSimulating())
|
||||||
{
|
{
|
||||||
if (APawn* SpawnedPawn = Cast<APawn>(SpawnedActor))
|
if (APawn* SpawnedPawn = Cast<APawn>(SpawnedActor))
|
||||||
{
|
{
|
||||||
TWeakObjectPtr<APawn> WeakPawn = SpawnedPawn;
|
auto DisableAI = [](APawn* Pawn)
|
||||||
SpawnedPawn->GetWorldTimerManager().SetTimerForNextTick([WeakPawn]()
|
|
||||||
{
|
{
|
||||||
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);
|
Brain->StopLogic(TEXT("PS_Editor"));
|
||||||
if (UBrainComponent* Brain = AIC->GetBrainComponent())
|
|
||||||
{
|
|
||||||
Brain->StopLogic(TEXT("PS_Editor"));
|
|
||||||
}
|
|
||||||
// Disable AI perception
|
|
||||||
P->SetActorTickEnabled(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
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->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
|
||||||
SplineMeshComp->bUseComplexAsSimpleCollision = true;
|
SplineMeshComp->bUseComplexAsSimpleCollision = true;
|
||||||
SplineMeshComp->SetCastShadow(false);
|
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);
|
SplineMeshComp->SetCustomDepthStencilValue(2);
|
||||||
|
|
||||||
// Handle parent
|
// Handle parent
|
||||||
@@ -427,7 +430,9 @@ void APS_Editor_SplineActor::SetSelected(bool bSelected)
|
|||||||
{
|
{
|
||||||
SplineMeshComp->SetMaterial(0, Mat);
|
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);
|
SplineMeshComp->SetCustomDepthStencilValue(bSelected ? 1 : 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,4 +585,12 @@ void APS_Editor_SplineActor::ImportFromCustomProperties(const TMap<FString, FStr
|
|||||||
SplineTag = *TagStr;
|
SplineTag = *TagStr;
|
||||||
}
|
}
|
||||||
UpdateSplineMaterials();
|
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;
|
CurrentTime += DeltaTime;
|
||||||
bool bReachedEnd = false;
|
|
||||||
if (CurrentTime >= Data.Duration)
|
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;
|
CurrentTime = Data.Duration;
|
||||||
bIsPlaying = false;
|
bIsPlaying = false;
|
||||||
bReachedEnd = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyValuesAtTime(CurrentTime);
|
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();
|
OnTimelineChanged.Broadcast();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,15 +94,20 @@ void UPS_Editor_TimelineSubsystem::Play()
|
|||||||
}
|
}
|
||||||
bIsPlaying = true;
|
bIsPlaying = true;
|
||||||
|
|
||||||
// Timeline and simulation are inherently linked: when we start the timeline in the runtime
|
// Timeline and simulation are inherently linked: start simulation if it isn't running,
|
||||||
// editor, make sure AI is enabled too. In pure gameplay, FindEditorPC returns null and we
|
// or un-pause it if it was frozen by a previous Pause (keeping the actor transforms in
|
||||||
// skip this (AI is already running through the normal game flow).
|
// 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 (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
|
||||||
{
|
{
|
||||||
if (!EditorPC->IsSimulating())
|
if (!EditorPC->IsSimulating())
|
||||||
{
|
{
|
||||||
EditorPC->StartSimulation();
|
EditorPC->StartSimulation();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
EditorPC->SetSimulationPaused(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OnTimelineChanged.Broadcast();
|
OnTimelineChanged.Broadcast();
|
||||||
@@ -121,14 +115,14 @@ void UPS_Editor_TimelineSubsystem::Play()
|
|||||||
|
|
||||||
void UPS_Editor_TimelineSubsystem::Pause()
|
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;
|
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 (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
|
||||||
{
|
{
|
||||||
if (EditorPC->IsSimulating())
|
if (EditorPC->IsSimulating())
|
||||||
{
|
{
|
||||||
EditorPC->StopSimulation();
|
EditorPC->SetSimulationPaused(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OnTimelineChanged.Broadcast();
|
OnTimelineChanged.Broadcast();
|
||||||
@@ -138,9 +132,11 @@ void UPS_Editor_TimelineSubsystem::Stop()
|
|||||||
{
|
{
|
||||||
bIsPlaying = false;
|
bIsPlaying = false;
|
||||||
CurrentTime = 0.f;
|
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,
|
// Full reset: stop simulation (restores pre-play transforms) and apply t=0 values so
|
||||||
// then apply the values at t=0 to match the timeline start.
|
// the scene visually matches the timeline start.
|
||||||
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
|
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
|
||||||
{
|
{
|
||||||
if (EditorPC->IsSimulating())
|
if (EditorPC->IsSimulating())
|
||||||
@@ -153,9 +149,24 @@ void UPS_Editor_TimelineSubsystem::Stop()
|
|||||||
OnTimelineChanged.Broadcast();
|
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)
|
void UPS_Editor_TimelineSubsystem::Seek(float Time)
|
||||||
{
|
{
|
||||||
CurrentTime = FMath::Clamp(Time, 0.f, Data.Duration);
|
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);
|
ApplyValuesAtTime(CurrentTime);
|
||||||
OnTimelineChanged.Broadcast();
|
OnTimelineChanged.Broadcast();
|
||||||
}
|
}
|
||||||
@@ -216,6 +227,9 @@ bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, co
|
|||||||
const FGuid ActorId = GetOrCreateActorId(Actor);
|
const FGuid ActorId = GetOrCreateActorId(Actor);
|
||||||
if (!ActorId.IsValid()) return false;
|
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
|
// Resolve property to read current value
|
||||||
UObject* Target = nullptr;
|
UObject* Target = nullptr;
|
||||||
FProperty* Prop = nullptr;
|
FProperty* Prop = nullptr;
|
||||||
|
|||||||
@@ -49,14 +49,18 @@ public:
|
|||||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||||
void Play();
|
void Play();
|
||||||
|
|
||||||
/** Pause playback, keeping CurrentTime. */
|
/** Pause playback, keeping CurrentTime. Simulation keeps running. */
|
||||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||||
void Pause();
|
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")
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||||
void Stop();
|
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. */
|
/** Jump to a specific time (seconds, clamped to [0, Duration]). Applies values immediately. */
|
||||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||||
void Seek(float Time);
|
void Seek(float Time);
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
#include "PS_Editor_TimelineSubsystem.h"
|
#include "PS_Editor_TimelineSubsystem.h"
|
||||||
#include "Engine/World.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)
|
void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
|
||||||
{
|
{
|
||||||
SelectionManager = InSelectionManager;
|
SelectionManager = InSelectionManager;
|
||||||
@@ -905,18 +909,39 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
|
|||||||
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
|
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
|
||||||
[
|
[
|
||||||
SNew(SButton)
|
SNew(SButton)
|
||||||
|
.ToolTipText(FText::FromString(TEXT("Start/stop simulation. If a timeline exists, also plays/pauses it in sync.")))
|
||||||
.OnClicked_Lambda([this]() -> FReply
|
.OnClicked_Lambda([this]() -> FReply
|
||||||
{
|
{
|
||||||
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
|
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
|
||||||
if (!EditorPC) return FReply::Handled();
|
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
|
else
|
||||||
{
|
{
|
||||||
EditorPC->StartSimulation();
|
// No animation to sync with — just toggle simulation directly.
|
||||||
|
if (EditorPC->IsSimulating())
|
||||||
|
{
|
||||||
|
EditorPC->StopSimulation();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
EditorPC->StartSimulation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return FReply::Handled();
|
return FReply::Handled();
|
||||||
})
|
})
|
||||||
@@ -977,16 +1002,29 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
|
|||||||
|
|
||||||
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
|
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
|
||||||
{
|
{
|
||||||
// Sync BaseLevel sublevel only if scenario specifies a REAL base level.
|
// Same base-level sync logic as the scene-list click path:
|
||||||
// Scenes without a base level land in the "Unfiled" folder — GetSceneLevelName
|
// fall back to the MasterCatalog's default entry when the scenario
|
||||||
// returns that as a placeholder but it isn't a loadable map, so skip.
|
// has no base level; otherwise keep the current sublevel.
|
||||||
FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(FileName);
|
FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(FileName);
|
||||||
const bool bHasValidBaseLevel = !SceneLevelName.IsEmpty()
|
bool bHasValidBaseLevel = !SceneLevelName.IsEmpty()
|
||||||
&& SceneLevelName != TEXT("Unfiled")
|
&& SceneLevelName != TEXT("Unfiled")
|
||||||
&& BaseLevelPathMap.Contains(SceneLevelName);
|
&& 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)
|
if (EditorPC->CurrentBaseLevel != SceneLevelName)
|
||||||
{
|
{
|
||||||
@@ -1010,9 +1048,9 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
SceneSerializer->LoadScene(FileName, SpawnManager.Get());
|
SceneSerializer->LoadScene(FileName, SpawnManager.Get());
|
||||||
bSceneDirty = false;
|
bSceneDirty = false;
|
||||||
@@ -1119,10 +1157,12 @@ void UPS_Editor_MainWidget::NativeConstruct()
|
|||||||
Super::NativeConstruct();
|
Super::NativeConstruct();
|
||||||
PopulateSpawnCatalog();
|
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()))
|
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||||
{
|
{
|
||||||
BaseLevelOptions.Add(MakeShared<FString>(TEXT("None")));
|
|
||||||
if (UPS_Editor_MasterCatalog* Master = EditorPC->MasterCatalogAsset)
|
if (UPS_Editor_MasterCatalog* Master = EditorPC->MasterCatalogAsset)
|
||||||
{
|
{
|
||||||
for (const FPS_Editor_BaseLevelEntry& Entry : Master->BaseLevels)
|
for (const FPS_Editor_BaseLevelEntry& Entry : Master->BaseLevels)
|
||||||
@@ -1251,17 +1291,41 @@ void UPS_Editor_MainWidget::PopulateSceneList()
|
|||||||
{
|
{
|
||||||
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
|
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
|
||||||
{
|
{
|
||||||
// Sync the BaseLevel sublevel only if scenario specifies a REAL base level.
|
// Sync the BaseLevel sublevel to match the scenario being loaded.
|
||||||
// "Unfiled" is the folder for scenes saved without a base level — we keep the
|
// "Unfiled" is the folder for scenes saved without a base level — in that case
|
||||||
// current sublevel as-is in that case rather than trying to load "Unfiled".
|
// 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);
|
FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(Name);
|
||||||
const bool bHasValidBaseLevel = !SceneLevelName.IsEmpty()
|
bool bHasValidBaseLevel = !SceneLevelName.IsEmpty()
|
||||||
&& SceneLevelName != TEXT("Unfiled")
|
&& SceneLevelName != TEXT("Unfiled")
|
||||||
&& BaseLevelPathMap.Contains(SceneLevelName);
|
&& 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)
|
if (EditorPC->CurrentBaseLevel != SceneLevelName)
|
||||||
{
|
{
|
||||||
FString SceneMapPath = 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());
|
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;
|
bSceneDirty = true;
|
||||||
return FReply::Handled();
|
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 (SimulateButtonText.IsValid())
|
||||||
{
|
{
|
||||||
if (APS_Editor_PlayerController* SimPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
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->SetText(FText::FromString(TEXT("Stop")));
|
||||||
SimulateButtonText->SetColorAndOpacity(FLinearColor(1.0f, 0.3f, 0.2f));
|
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)
|
+ SHorizontalBox::Slot().AutoWidth().Padding(4.0f, 0.0f, 0.0f, 0.0f).VAlign(VAlign_Center)
|
||||||
[
|
[
|
||||||
SNew(SButton)
|
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
|
.OnClicked_Lambda([this]() -> FReply
|
||||||
{
|
{
|
||||||
if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this))
|
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)
|
for (int32 KeyIdx = 0; KeyIdx < Track.Keys.Num(); ++KeyIdx)
|
||||||
{
|
{
|
||||||
const FPS_Editor_TimelineKey& Key = Track.Keys[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 CapturedKey = KeyIdx;
|
||||||
const int32 CapturedTrack = TrackIdx;
|
const int32 CapturedTrack = TrackIdx;
|
||||||
const bool bKeyInterp = Key.bInterpolate;
|
const bool bKeyInterp = Key.bInterpolate;
|
||||||
@@ -3325,14 +3430,14 @@ void UPS_Editor_MainWidget::RebuildTimelineTracksList()
|
|||||||
.VisualTimeRef(VisualTime)
|
.VisualTimeRef(VisualTime)
|
||||||
.DotColor_Lambda([this, CapturedTrack, CapturedKey, BaseDotColor]() -> FSlateColor
|
.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)
|
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.
|
// 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 (UPS_Editor_TimelineSubsystem* TS2 = GetTimelineSubsystemFromWidget(const_cast<UPS_Editor_MainWidget*>(this)))
|
||||||
{
|
{
|
||||||
if (TS2->GetTracks().IsValidIndex(CapturedTrack))
|
if (TS2->GetTracks().IsValidIndex(CapturedTrack))
|
||||||
@@ -3348,13 +3453,13 @@ void UPS_Editor_MainWidget::RebuildTimelineTracksList()
|
|||||||
}
|
}
|
||||||
if (ActiveIdx == CapturedKey)
|
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);
|
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)"))))
|
.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;
|
SelectedTimelineTrackIdx = CapturedTrack;
|
||||||
SelectedTimelineKeyIdx = CapturedKey;
|
SelectedTimelineKeyIdx = CapturedKey;
|
||||||
|
|
||||||
// Select the animated actor so the property panel shows matching values
|
// Select the animated actor exclusively — ClearSelection first so any
|
||||||
if (AActor* TrackActor = TS2->FindActorById(Tr.ActorId))
|
// 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())
|
if (UPS_Editor_SelectionManager* Sel = SelectionManager.Get())
|
||||||
{
|
{
|
||||||
|
Sel->ClearSelection();
|
||||||
Sel->SelectActor(TrackActor);
|
Sel->SelectActor(TrackActor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user