diff --git a/Unreal/Plugins/PS_BehaviorEditor/Source/PS_BehaviorEditor/PS_BehaviorEditor_AISpline.cpp b/Unreal/Plugins/PS_BehaviorEditor/Source/PS_BehaviorEditor/PS_BehaviorEditor_AISpline.cpp index 0867066..2c2f76d 100644 --- a/Unreal/Plugins/PS_BehaviorEditor/Source/PS_BehaviorEditor/PS_BehaviorEditor_AISpline.cpp +++ b/Unreal/Plugins/PS_BehaviorEditor/Source/PS_BehaviorEditor/PS_BehaviorEditor_AISpline.cpp @@ -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 TMapGetSubsystem()) @@ -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& Weak : SpawnManager->GetSpawnedActors()) + { + APawn* P = Cast(Weak.Get()); + if (!P) continue; + + P->SetActorTickEnabled(!bPaused); + if (AAIController* AIC = Cast(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; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h index ec1be6e..b329376 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h @@ -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"). diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.cpp index 539103a..55052f7 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.cpp @@ -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()) return C; + + const FString PackageName = FPackageName::ObjectPathToPackageName(ClassPath); + if (!PackageName.IsEmpty()) + { + if (UPackage* Package = LoadPackage(nullptr, *PackageName, LOAD_None)) + { + if (UClass* C = SoftPath.TryLoadClass()) return C; + if (UClass* C = FindObject(nullptr, *ClassPath)) return C; + } + } + + if (UClass* C = LoadObject(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& 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 - // can skip on a cold first call, leaving the class null intermittently. - FSoftClassPath SoftPath(ActorData.ClassPath); - UClass* LoadedClass = SoftPath.TryLoadClass(); + // 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(nullptr, *ActorData.ClassPath); - } - if (!LoadedClass) - { - LoadedClass = FindObject(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(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; } diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp index 2b8be6e..60c192c 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp @@ -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()) 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()) return C; + if (UClass* C = FindObject(nullptr, *ClassPath)) return C; + } + } + + // 3. LoadObject / StaticLoadClass as last-resort fallbacks + if (UClass* C = LoadObject(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 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(); + UClass* LoadedClass = LoadActorClassRobust(ActorData.ClassPath); if (!LoadedClass) { - // Fallback for paths that aren't a "Class'...'" soft ref (e.g. legacy data) - LoadedClass = LoadObject(nullptr, *ActorData.ClassPath); - } - if (!LoadedClass) - { - LoadedClass = FindObject(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(SpawnedActor)) { - TWeakObjectPtr 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(Pawn->GetController())) { - if (AAIController* AIC = Cast(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 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(GetOuter())) { diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnCatalog.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnCatalog.h index ecda2f5..adf52b3 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnCatalog.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnCatalog.h @@ -85,6 +85,15 @@ struct FPS_Editor_BaseLevelEntry */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor") TArray> 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; }; /** diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp index fe8b37b..7715a16 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp @@ -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(PC)) { if (!EditorPC->IsSimulating()) { if (APawn* SpawnedPawn = Cast(SpawnedActor)) { - TWeakObjectPtr 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(Pawn->GetController())) { - if (AAIController* AIC = Cast(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 WeakPawn = SpawnedPawn; + SpawnedPawn->GetWorldTimerManager().SetTimerForNextTick([WeakPawn, DisableAI]() + { + DisableAI(WeakPawn.Get()); }); } } diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spline/PS_Editor_SplineActor.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spline/PS_Editor_SplineActor.cpp index ed81903..1053205 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spline/PS_Editor_SplineActor.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spline/PS_Editor_SplineActor.cpp @@ -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= 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; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h index c9d4a09..7c6912a 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h @@ -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); diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp index 06932cb..370ee4e 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp @@ -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 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(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 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(GetOwningPlayer())) { - if (APS_Editor_PlayerController* EditorPC = Cast(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 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(GetOwningPlayer())) { - BaseLevelOptions.Add(MakeShared(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(GetOwningPlayer())) { - if (APS_Editor_PlayerController* EditorPC = Cast(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(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 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(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); } }