diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp index 7329f71..e495d52 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp @@ -16,6 +16,8 @@ #include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineEditable.h" #include "PS_Editor_ChildMovable.h" +#include "PS_Editor_HUD.h" +#include "PS_Editor_MainWidget.h" #include "Components/SplineComponent.h" #include "DrawDebugHelpers.h" #include "PS_Editor_ConfirmDialog.h" @@ -1026,6 +1028,18 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value) APS_Editor_PlayerController* EditorPC = Cast(GetController()); if (!EditorPC) return; + // 1st priority: if a timeline key is selected in the HUD, delete it and stop. + if (APS_Editor_HUD* EditorHUD = Cast(EditorPC->GetHUD())) + { + if (UPS_Editor_MainWidget* MW = EditorHUD->GetMainWidget()) + { + if (MW->TryDeleteSelectedTimelineKey()) + { + return; + } + } + } + // In SplineEditing mode with a point selected: delete that point if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement && ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid()) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp index aaa7e82..e4dc40f 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp @@ -531,6 +531,17 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa else { UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load sublevel '%s' (path: '%s')"), *LevelName, *LevelToLoad); + + // Safety net: the async load failed, OnLevelShown will never fire, so we must + // release the loading screen and the fade-to-black here or the UI gets stuck. + ShowLoadingScreen(false); + bWaitingForSublevelFadeIn = false; + bPendingFadeToBlack = false; + if (PlayerCameraManager) + { + // Fade back in immediately so the user isn't staring at a black screen + PlayerCameraManager->StartCameraFade(1.0f, 0.0f, 0.0f, FLinearColor::Black, false, false); + } } } diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/PS_Editor.Build.cs b/Unreal/Plugins/PS_Editor/Source/PS_Editor/PS_Editor.Build.cs index 99ecafd..acb2b44 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/PS_Editor.Build.cs +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/PS_Editor.Build.cs @@ -18,6 +18,7 @@ public class PS_Editor : ModuleRules PublicIncludePaths.Add(Path.Combine(SourceDir, "Spawn")); PublicIncludePaths.Add(Path.Combine(SourceDir, "Serialization")); PublicIncludePaths.Add(Path.Combine(SourceDir, "Spline")); + PublicIncludePaths.Add(Path.Combine(SourceDir, "Timeline")); PublicDependencyModuleNames.AddRange(new string[] { diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneData.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneData.h index 491d34f..8a1b9a6 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneData.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneData.h @@ -1,6 +1,7 @@ #pragma once #include "CoreMinimal.h" +#include "PS_Editor_TimelineData.h" #include "PS_Editor_SceneData.generated.h" /** Serialized data for a single spawned actor. */ @@ -13,6 +14,10 @@ struct FPS_Editor_ActorData UPROPERTY() FString ClassPath; + /** Stable identifier used by the timeline and any lookup system. Generated at save time if missing. */ + UPROPERTY() + FGuid ActorId; + UPROPERTY() FVector Location = FVector::ZeroVector; @@ -38,7 +43,7 @@ struct FPS_Editor_SceneData GENERATED_BODY() UPROPERTY() - int32 Version = 3; + int32 Version = 4; UPROPERTY() FString SceneName; @@ -52,4 +57,8 @@ struct FPS_Editor_SceneData UPROPERTY() TArray Actors; + + /** Global timeline data (animation tracks + duration). Empty on legacy scenes. */ + UPROPERTY() + FPS_Editor_TimelineData Timeline; }; 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 59868e3..539103a 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 @@ -7,11 +7,16 @@ #include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineEditable.h" #include "PS_Editor_PointPlaceable.h" +#include "PS_Editor_TimelineSubsystem.h" +#include "PS_Editor_SpawnManager.h" +#include "PS_Editor_PlayerController.h" #include "JsonObjectConverter.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" #include "UObject/UnrealType.h" #include "Components/ActorComponent.h" +#include "Engine/World.h" +#include "GameFramework/PlayerController.h" bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray& OutActors, bool bStripEditorComponents, EPS_Editor_SceneLoadFilter Filter) { @@ -95,6 +100,8 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S return false; } + UPS_Editor_TimelineSubsystem* TimelineSS = World->GetSubsystem(); + int32 SpawnedCount = 0; bIsCurrentlyLoading = true; @@ -103,12 +110,32 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S AActor* SpawnedActor = SpawnActorFromData(World, ActorData, Filter); if (SpawnedActor) { + // Register the stable ID so timeline tracks can find this actor + if (TimelineSS && ActorData.ActorId.IsValid()) + { + TimelineSS->RegisterActorId(SpawnedActor, ActorData.ActorId); + } + else if (TimelineSS) + { + TimelineSS->GetOrCreateActorId(SpawnedActor); + } + OutActors.Add(SpawnedActor); SpawnedCount++; } } bIsCurrentlyLoading = false; + // Push timeline data into the subsystem, and auto-play if we're in gameplay (not the runtime editor) + if (TimelineSS) + { + TimelineSS->SetData(SceneData.Timeline); + if (!IsInEditorMode() && SceneData.Timeline.Tracks.Num() > 0) + { + TimelineSS->Play(); + } + } + UE_LOG(LogTemp, Log, TEXT("PS_Editor SceneLoader: Loaded %d actors from scene '%s'"), SpawnedCount, *SceneData.SceneName); return SpawnedCount > 0; @@ -120,8 +147,15 @@ 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) - UClass* LoadedClass = LoadObject(nullptr, *ActorData.ClassPath); + // 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(); + if (!LoadedClass) + { + LoadedClass = LoadObject(nullptr, *ActorData.ClassPath); + } if (!LoadedClass) { LoadedClass = FindObject(nullptr, *ActorData.ClassPath); 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 b3c4721..2b8be6e 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 @@ -11,12 +11,14 @@ #include "AIController.h" #include "BrainComponent.h" #include "PS_Editor_SplineEditable.h" +#include "PS_Editor_TimelineSubsystem.h" #include "JsonObjectConverter.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" #include "HAL/PlatformFileManager.h" #include "UObject/UnrealType.h" #include "Components/ActorComponent.h" +#include "Engine/World.h" bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager) { @@ -31,11 +33,19 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_ SceneData.Timestamp = FDateTime::Now().ToString(); // Use the current base level from PlayerController - if (APS_Editor_PlayerController* EditorPC = Cast(GetOuter())) + APS_Editor_PlayerController* EditorPC = Cast(GetOuter()); + if (EditorPC) { SceneData.LevelName = EditorPC->CurrentBaseLevel; } + // Resolve the timeline subsystem (used to assign / query stable actor IDs) + UPS_Editor_TimelineSubsystem* TimelineSS = nullptr; + if (EditorPC && EditorPC->GetWorld()) + { + TimelineSS = EditorPC->GetWorld()->GetSubsystem(); + } + const TArray>& SpawnedActors = SpawnManager->GetSpawnedActors(); for (const TWeakObjectPtr& Weak : SpawnedActors) { @@ -47,6 +57,7 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_ FPS_Editor_ActorData ActorData; ActorData.ClassPath = Actor->GetClass()->GetPathName(); + ActorData.ActorId = TimelineSS ? TimelineSS->GetOrCreateActorId(Actor) : FGuid::NewGuid(); ActorData.Location = Actor->GetActorLocation(); ActorData.Rotation = Actor->GetActorRotation(); ActorData.Scale = Actor->GetActorScale3D(); @@ -111,6 +122,12 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_ SceneData.Actors.Add(ActorData); } + // Pull the global timeline data (if any) from the world subsystem + if (TimelineSS) + { + SceneData.Timeline = TimelineSS->GetData(); + } + // Convert to JSON FString JsonString; if (!FJsonObjectConverter::UStructToJsonObjectString(SceneData, JsonString, 0, 0, 0, nullptr, true)) @@ -171,7 +188,17 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_ UPS_Editor_SceneLoader::bIsCurrentlyLoading = true; for (const FPS_Editor_ActorData& ActorData : SceneData.Actors) { - UClass* LoadedClass = LoadObject(nullptr, *ActorData.ClassPath); + // 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(); + 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); @@ -185,6 +212,18 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_ AActor* SpawnedActor = SpawnManager->SpawnActorDirect(LoadedClass, ActorData.Location, ActorData.Rotation, ActorData.Scale); if (SpawnedActor) { + // Restore stable actor ID from the JSON (needed so timeline tracks can find the actor) + if (ActorData.ActorId.IsValid()) + { + if (UWorld* World = SpawnedActor->GetWorld()) + { + if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem()) + { + TS->RegisterActorId(SpawnedActor, ActorData.ActorId); + } + } + } + // Restore preload tag if (ActorData.bPreload) { @@ -285,6 +324,18 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_ UPS_Editor_SceneLoader::bIsCurrentlyLoading = false; + // Push timeline data into the world subsystem (editor mode: no auto-play) + if (APS_Editor_PlayerController* EditorPC = Cast(GetOuter())) + { + if (UWorld* World = EditorPC->GetWorld()) + { + if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem()) + { + TS->SetData(SceneData.Timeline); + } + } + } + UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene loaded: %s (%d actors)"), *SceneName, SpawnedCount); return true; } @@ -329,6 +380,18 @@ void UPS_Editor_SceneSerializer::ClearScene(UPS_Editor_SpawnManager* SpawnManage { SpawnManager->DestroyAllSpawnedActors(); } + + // Flush any timeline data tied to the previous scenario + if (APS_Editor_PlayerController* EditorPC = Cast(GetOuter())) + { + if (UWorld* World = EditorPC->GetWorld()) + { + if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem()) + { + TS->Clear(); + } + } + } } FString UPS_Editor_SceneSerializer::GetSceneFilePath(const FString& SceneName) const diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableComponent.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableComponent.cpp index 8b52adc..da27199 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableComponent.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableComponent.cpp @@ -226,6 +226,18 @@ bool UPS_Editor_EditableComponent::RequiresRespawn(const FName& PropertyPath) co return false; } +bool UPS_Editor_EditableComponent::IsAnimatable(const FName& PropertyPath) const +{ + for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties) + { + if (Entry.Path == PropertyPath) + { + return Entry.bAnimatable; + } + } + return false; +} + FString UPS_Editor_EditableComponent::GetDisplayName(const FName& Path) const { for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableComponent.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableComponent.h index 3dc29d7..3661d2e 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableComponent.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableComponent.h @@ -35,6 +35,16 @@ struct FPS_Editor_EditablePropertyEntry */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor") bool bNeedsRespawn = false; + + /** + * If true, this property can be animated on the global timeline. + * Adds a "+" button in the runtime editor property panel to add keyframes. + * For the animation to replicate to clients, the underlying UPROPERTY must be marked + * Replicated (ideally ReplicatedUsing=OnRep_X) — the server-side timeline writes the value + * and standard UE replication pushes it to clients. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor") + bool bAnimatable = false; }; /** @@ -75,6 +85,9 @@ public: /** Returns true if the given property path requires a full respawn. */ bool RequiresRespawn(const FName& PropertyPath) const; + /** Returns true if the given property path is marked animatable on this component. */ + bool IsAnimatable(const FName& PropertyPath) const; + /** Lists available component names on the owning actor. */ UFUNCTION() TArray GetAvailableComponentNames() const; 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 7427df8..fe8b37b 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 @@ -4,11 +4,24 @@ #include "PS_Editor_EditableInterface.h" #include "PS_Editor_UndoManager.h" #include "PS_Editor_PlayerController.h" +#include "PS_Editor_TimelineSubsystem.h" #include "AIController.h" #include "BrainComponent.h" #include "GameFramework/PlayerController.h" #include "Engine/World.h" +namespace +{ + static void RegisterSpawnedActorId(UWorld* World, AActor* Actor) + { + if (!World || !Actor) return; + if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem()) + { + TS->GetOrCreateActorId(Actor); + } + } +} + void UPS_Editor_SpawnManager::Initialize(APlayerController* InOwnerPC) { OwnerPC = InOwnerPC; @@ -140,8 +153,9 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector } } - // Track spawned actor + // Track spawned actor + assign stable ID via the timeline subsystem SpawnedActors.Add(SpawnedActor); + RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor); // Auto-snap to ground if configured UPS_Editor_SpawnableComponent* SpawnComp = SpawnedActor->FindComponentByClass(); @@ -260,6 +274,7 @@ AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Lo SpawnedActor->SetActorScale3D(Scale); SpawnedActors.Add(SpawnedActor); + RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor); return SpawnedActor; } @@ -283,6 +298,10 @@ void UPS_Editor_SpawnManager::TrackSpawnedActor(AActor* Actor) if (Actor) { SpawnedActors.Add(Actor); + if (APlayerController* PC = OwnerPC.Get()) + { + RegisterSpawnedActorId(PC->GetWorld(), Actor); + } } } @@ -309,6 +328,9 @@ AActor* UPS_Editor_SpawnManager::RespawnActorWithProperties(AActor* OldActor, co UClass* ActorClass = OldActor->GetClass(); const FTransform OldTransform = OldActor->GetActorTransform(); + // Preserve the stable ID across respawn (if any), then drop the old actor's registration + UPS_Editor_TimelineSubsystem* TimelineSS = PC->GetWorld() ? PC->GetWorld()->GetSubsystem() : nullptr; + const FGuid PreservedId = TimelineSS ? TimelineSS->FindActorId(OldActor) : FGuid(); // 1. Remove old actor from tracking and destroy SpawnedActors.RemoveAll([OldActor](const TWeakObjectPtr& Weak) @@ -383,6 +405,18 @@ AActor* UPS_Editor_SpawnManager::RespawnActorWithProperties(AActor* OldActor, co } SpawnedActors.Add(NewActor); + // Restore preserved ID (or generate a fresh one if it was missing) + if (TimelineSS) + { + if (PreservedId.IsValid()) + { + TimelineSS->RegisterActorId(NewActor, PreservedId); + } + else + { + TimelineSS->GetOrCreateActorId(NewActor); + } + } UE_LOG(LogTemp, Log, TEXT("PS_Editor: Respawned %s with %d properties"), *ActorClass->GetName(), PropertyValues.Num()); return NewActor; } diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineData.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineData.h new file mode 100644 index 0000000..29ff92b --- /dev/null +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineData.h @@ -0,0 +1,66 @@ +#pragma once + +#include "CoreMinimal.h" +#include "PS_Editor_TimelineData.generated.h" + +/** + * A single key on a timeline track. + * Value is stored as exported text (same format as FProperty::ExportText_InContainer), + * so any property type can be serialized via the existing reflection path. + */ +USTRUCT(BlueprintType) +struct FPS_Editor_TimelineKey +{ + GENERATED_BODY() + + /** Time in seconds, from the start of the timeline. */ + UPROPERTY() + float Time = 0.f; + + /** Exported text value (FProperty::ExportText_InContainer format). */ + UPROPERTY() + FString Value; + + /** If true, linearly interpolate from THIS key's value towards the next key's value. + * Ignored for non-numeric property types (stepped behaviour always). */ + UPROPERTY() + bool bInterpolate = true; +}; + +/** + * A single animation track: ties one actor to one property, with an ordered list of keys. + */ +USTRUCT(BlueprintType) +struct FPS_Editor_TimelineTrack +{ + GENERATED_BODY() + + /** Stable GUID referencing the target actor. Resolved at runtime via the SpawnManager map. */ + UPROPERTY() + FGuid ActorId; + + /** Property path: "ComponentName.PropertyName" or just "PropertyName" for actor-level. */ + UPROPERTY() + FName PropertyPath; + + /** Keys sorted by Time (ascending). */ + UPROPERTY() + TArray Keys; +}; + +/** + * Global timeline data attached to a scenario. Serialised with the scene JSON. + */ +USTRUCT(BlueprintType) +struct FPS_Editor_TimelineData +{ + GENERATED_BODY() + + /** Total timeline duration, in seconds. User-editable in the UI. */ + UPROPERTY() + float Duration = 10.f; + + /** All tracks for all animated (actor, property) pairs. */ + UPROPERTY() + TArray Tracks; +}; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineInterpolator.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineInterpolator.cpp new file mode 100644 index 0000000..d8ac77d --- /dev/null +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineInterpolator.cpp @@ -0,0 +1,139 @@ +#include "PS_Editor_TimelineInterpolator.h" +#include "UObject/UnrealType.h" + +bool FPS_Editor_TimelineInterpolator::IsNumericProperty(const FProperty* Prop) +{ + if (!Prop) return false; + + if (CastField(Prop)) return true; + if (CastField(Prop)) return true; + if (CastField(Prop)) return true; + if (CastField(Prop)) return true; + + if (const FStructProperty* Struct = CastField(Prop)) + { + if (Struct->Struct == TBaseStructure::Get()) return true; + if (Struct->Struct == TBaseStructure::Get()) return true; + if (Struct->Struct == TBaseStructure::Get()) return true; + if (Struct->Struct == TBaseStructure::Get()) return true; + if (Struct->Struct == TBaseStructure::Get()) return true; + } + return false; +} + +namespace +{ + /** Parse "(X=1.0,Y=2.0,Z=3.0)" style exported vector. Returns FVector::ZeroVector on parse failure. */ + static bool ParseVectorText(const FString& Text, FVector& Out) + { + return Out.InitFromString(Text); + } + static bool ParseRotatorText(const FString& Text, FRotator& Out) + { + return Out.InitFromString(Text); + } + static bool ParseLinearColorText(const FString& Text, FLinearColor& Out) + { + return Out.InitFromString(Text); + } +} + +FString FPS_Editor_TimelineInterpolator::InterpolateValue(const FProperty* Prop, const FString& A, const FString& B, float Alpha, bool bInterpolate) +{ + // Non-numeric or stepped → always return the "previous" value + if (!bInterpolate || !IsNumericProperty(Prop)) + { + return A; + } + + const float Clamped = FMath::Clamp(Alpha, 0.f, 1.f); + + // Float + if (CastField(Prop)) + { + const float Va = FCString::Atof(*A); + const float Vb = FCString::Atof(*B); + return FString::SanitizeFloat(FMath::Lerp(Va, Vb, Clamped)); + } + + // Double + if (CastField(Prop)) + { + const double Va = FCString::Atod(*A); + const double Vb = FCString::Atod(*B); + return FString::SanitizeFloat(FMath::Lerp(Va, Vb, (double)Clamped)); + } + + // Int32 / Int64 — keep integer output (round to nearest) + if (CastField(Prop)) + { + const int32 Va = FCString::Atoi(*A); + const int32 Vb = FCString::Atoi(*B); + const int32 Result = FMath::RoundToInt(FMath::Lerp((float)Va, (float)Vb, Clamped)); + return FString::FromInt(Result); + } + if (CastField(Prop)) + { + const int64 Va = FCString::Atoi64(*A); + const int64 Vb = FCString::Atoi64(*B); + const int64 Result = (int64)FMath::RoundToDouble(FMath::Lerp((double)Va, (double)Vb, (double)Clamped)); + return FString::Printf(TEXT("%lld"), Result); + } + + // Struct types + if (const FStructProperty* Struct = CastField(Prop)) + { + if (Struct->Struct == TBaseStructure::Get()) + { + FVector Va = FVector::ZeroVector, Vb = FVector::ZeroVector; + ParseVectorText(A, Va); + ParseVectorText(B, Vb); + const FVector Result = FMath::Lerp(Va, Vb, Clamped); + return FString::Printf(TEXT("(X=%f,Y=%f,Z=%f)"), Result.X, Result.Y, Result.Z); + } + if (Struct->Struct == TBaseStructure::Get()) + { + FVector2D Va = FVector2D::ZeroVector, Vb = FVector2D::ZeroVector; + Va.InitFromString(A); + Vb.InitFromString(B); + const FVector2D Result = FMath::Lerp(Va, Vb, Clamped); + return FString::Printf(TEXT("(X=%f,Y=%f)"), Result.X, Result.Y); + } + if (Struct->Struct == TBaseStructure::Get()) + { + FRotator Ra = FRotator::ZeroRotator, Rb = FRotator::ZeroRotator; + ParseRotatorText(A, Ra); + ParseRotatorText(B, Rb); + // Use quaternion slerp to get the shortest path + const FQuat Qa = Ra.Quaternion(); + const FQuat Qb = Rb.Quaternion(); + const FRotator Result = FQuat::Slerp(Qa, Qb, Clamped).Rotator(); + return FString::Printf(TEXT("(Pitch=%f,Yaw=%f,Roll=%f)"), Result.Pitch, Result.Yaw, Result.Roll); + } + if (Struct->Struct == TBaseStructure::Get()) + { + FLinearColor Ca(ForceInitToZero), Cb(ForceInitToZero); + ParseLinearColorText(A, Ca); + ParseLinearColorText(B, Cb); + const FLinearColor Result = FMath::Lerp(Ca, Cb, Clamped); + return FString::Printf(TEXT("(R=%f,G=%f,B=%f,A=%f)"), Result.R, Result.G, Result.B, Result.A); + } + if (Struct->Struct == TBaseStructure::Get()) + { + // FColor exports as "(R=..,G=..,B=..,A=..)" with integer components + FColor Ca = FColor::Black, Cb = FColor::Black; + Ca.InitFromString(A); + Cb.InitFromString(B); + const FColor Result( + (uint8)FMath::RoundToInt(FMath::Lerp((float)Ca.R, (float)Cb.R, Clamped)), + (uint8)FMath::RoundToInt(FMath::Lerp((float)Ca.G, (float)Cb.G, Clamped)), + (uint8)FMath::RoundToInt(FMath::Lerp((float)Ca.B, (float)Cb.B, Clamped)), + (uint8)FMath::RoundToInt(FMath::Lerp((float)Ca.A, (float)Cb.A, Clamped)) + ); + return FString::Printf(TEXT("(R=%d,G=%d,B=%d,A=%d)"), Result.R, Result.G, Result.B, Result.A); + } + } + + // Fallback: step + return A; +} diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineInterpolator.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineInterpolator.h new file mode 100644 index 0000000..f5d172d --- /dev/null +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineInterpolator.h @@ -0,0 +1,33 @@ +#pragma once + +#include "CoreMinimal.h" + +class FProperty; + +/** + * Helpers to parse exported-text property values, interpolate between them, and export the result. + * Handles the types exposed by UPS_Editor_EditableComponent: + * - Numeric scalars (float, double, int) + * - FVector, FRotator, FLinearColor (linear component lerp; rotator uses FQuat::Slerp) + * - Bool / Enum / FString / unsupported types: stepped behaviour (returns the A value) + * + * The buffer used during lerp is allocated/initialised/destroyed exactly like UE does internally, + * so no hidden state survives across calls. + */ +class PS_EDITOR_API FPS_Editor_TimelineInterpolator +{ +public: + /** Returns true if Prop is a numeric type that the interpolator can lerp (float/double/int, FVector, FRotator, FLinearColor). */ + static bool IsNumericProperty(const FProperty* Prop); + + /** + * Interpolate between the two text-exported values A and B. + * @param Prop Property being animated (used to pick the parser and interpolation code). + * @param A Exported text value of the "previous" key. + * @param B Exported text value of the "next" key. + * @param Alpha 0..1 interpolation factor. + * @param bInterpolate If false or if Prop is non-numeric, always returns A (stepped). + * @return Text-exported interpolated value suitable for FProperty::ImportText_InContainer. + */ + static FString InterpolateValue(const FProperty* Prop, const FString& A, const FString& B, float Alpha, bool bInterpolate); +}; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp new file mode 100644 index 0000000..09b886d --- /dev/null +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp @@ -0,0 +1,513 @@ +#include "PS_Editor_TimelineSubsystem.h" +#include "PS_Editor_TimelineInterpolator.h" +#include "PS_Editor_EditableComponent.h" +#include "PS_Editor_EditableInterface.h" +#include "PS_Editor_PlayerController.h" +#include "Engine/World.h" +#include "Components/ActorComponent.h" +#include "GameFramework/Actor.h" +#include "GameFramework/PlayerController.h" +#include "UObject/UnrealType.h" + +namespace +{ + /** Find the (first) PS_Editor player controller in the world, if any. Null in pure gameplay. */ + static APS_Editor_PlayerController* FindEditorPC(UWorld* World) + { + if (!World) return nullptr; + for (FConstPlayerControllerIterator It = World->GetPlayerControllerIterator(); It; ++It) + { + if (APS_Editor_PlayerController* PC = Cast(It->Get())) + { + return PC; + } + } + return nullptr; + } +} + +void UPS_Editor_TimelineSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + CurrentTime = 0.f; + bIsPlaying = false; + LastAppliedValues.Empty(); +} + +void UPS_Editor_TimelineSubsystem::Deinitialize() +{ + bIsPlaying = false; + LastAppliedValues.Empty(); + Super::Deinitialize(); +} + +// ---- Tickable ---- + +void UPS_Editor_TimelineSubsystem::Tick(float DeltaTime) +{ + if (!bIsPlaying) return; + + // Only the server drives the animation; clients receive values via replication. + if (UWorld* World = GetWorld()) + { + if (World->GetNetMode() == NM_Client) return; + } + + CurrentTime += DeltaTime; + bool bReachedEnd = false; + if (CurrentTime >= Data.Duration) + { + 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(); +} + +TStatId UPS_Editor_TimelineSubsystem::GetStatId() const +{ + RETURN_QUICK_DECLARE_CYCLE_STAT(UPS_Editor_TimelineSubsystem, STATGROUP_Tickables); +} + +bool UPS_Editor_TimelineSubsystem::IsTickable() const +{ + if (!bIsPlaying) return false; + if (UWorld* World = GetWorld()) + { + return World->IsGameWorld(); + } + return false; +} + +// ---- Playback controls ---- + +void UPS_Editor_TimelineSubsystem::Play() +{ + if (Data.Tracks.Num() == 0) return; + if (CurrentTime >= Data.Duration) + { + CurrentTime = 0.f; + } + 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). + if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld())) + { + if (!EditorPC->IsSimulating()) + { + EditorPC->StartSimulation(); + } + } + + OnTimelineChanged.Broadcast(); +} + +void UPS_Editor_TimelineSubsystem::Pause() +{ + 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(); + } + } + OnTimelineChanged.Broadcast(); +} + +void UPS_Editor_TimelineSubsystem::Stop() +{ + bIsPlaying = false; + CurrentTime = 0.f; + + // 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. + if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld())) + { + if (EditorPC->IsSimulating()) + { + EditorPC->StopSimulation(); + } + } + + ApplyValuesAtTime(CurrentTime); + OnTimelineChanged.Broadcast(); +} + +void UPS_Editor_TimelineSubsystem::Seek(float Time) +{ + CurrentTime = FMath::Clamp(Time, 0.f, Data.Duration); + ApplyValuesAtTime(CurrentTime); + OnTimelineChanged.Broadcast(); +} + +void UPS_Editor_TimelineSubsystem::SetDuration(float NewDuration) +{ + Data.Duration = FMath::Max(0.1f, NewDuration); + if (CurrentTime > Data.Duration) + { + CurrentTime = Data.Duration; + } + OnTimelineChanged.Broadcast(); +} + +// ---- Data ---- + +void UPS_Editor_TimelineSubsystem::SetData(const FPS_Editor_TimelineData& NewData) +{ + Data = NewData; + if (Data.Duration <= 0.f) + { + Data.Duration = 10.f; + } + CurrentTime = 0.f; + bIsPlaying = false; + LastAppliedValues.Empty(); + LastAppliedValues.SetNum(Data.Tracks.Num()); + OnTimelineChanged.Broadcast(); +} + +void UPS_Editor_TimelineSubsystem::Clear() +{ + Data = FPS_Editor_TimelineData(); + CurrentTime = 0.f; + bIsPlaying = false; + LastAppliedValues.Empty(); + OnTimelineChanged.Broadcast(); +} + +// ---- Tracks / keys ---- + +static int32 FindTrack(const FPS_Editor_TimelineData& Data, const FGuid& ActorId, const FName& PropertyPath) +{ + for (int32 i = 0; i < Data.Tracks.Num(); ++i) + { + if (Data.Tracks[i].ActorId == ActorId && Data.Tracks[i].PropertyPath == PropertyPath) + { + return i; + } + } + return INDEX_NONE; +} + +bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, const FName& PropertyPath) +{ + if (!Actor) return false; + + const FGuid ActorId = GetOrCreateActorId(Actor); + if (!ActorId.IsValid()) return false; + + // Resolve property to read current value + UObject* Target = nullptr; + FProperty* Prop = nullptr; + ResolveTarget(Actor, PropertyPath, Target, Prop); + if (!Target || !Prop) return false; + + FString CurrentValue; + Prop->ExportText_InContainer(0, CurrentValue, Target, Target, nullptr, PPF_None); + + // Locate or create the track + int32 TrackIndex = FindTrack(Data, ActorId, PropertyPath); + if (TrackIndex == INDEX_NONE) + { + FPS_Editor_TimelineTrack NewTrack; + NewTrack.ActorId = ActorId; + NewTrack.PropertyPath = PropertyPath; + TrackIndex = Data.Tracks.Add(NewTrack); + LastAppliedValues.SetNum(Data.Tracks.Num()); + } + FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex]; + + // If a key already exists at ~CurrentTime, update it; else insert sorted + const float Epsilon = 0.0001f; + int32 InsertAt = 0; + for (; InsertAt < Track.Keys.Num(); ++InsertAt) + { + if (FMath::IsNearlyEqual(Track.Keys[InsertAt].Time, CurrentTime, Epsilon)) + { + Track.Keys[InsertAt].Value = CurrentValue; + OnTimelineChanged.Broadcast(); + return true; + } + if (Track.Keys[InsertAt].Time > CurrentTime) + { + break; + } + } + + FPS_Editor_TimelineKey NewKey; + NewKey.Time = CurrentTime; + NewKey.Value = CurrentValue; + NewKey.bInterpolate = true; + Track.Keys.Insert(NewKey, InsertAt); + + OnTimelineChanged.Broadcast(); + return true; +} + +void UPS_Editor_TimelineSubsystem::RemoveTrack(int32 TrackIndex) +{ + if (!Data.Tracks.IsValidIndex(TrackIndex)) return; + Data.Tracks.RemoveAt(TrackIndex); + LastAppliedValues.SetNum(Data.Tracks.Num()); + OnTimelineChanged.Broadcast(); +} + +void UPS_Editor_TimelineSubsystem::RemoveKey(int32 TrackIndex, int32 KeyIndex) +{ + if (!Data.Tracks.IsValidIndex(TrackIndex)) return; + FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex]; + if (!Track.Keys.IsValidIndex(KeyIndex)) return; + Track.Keys.RemoveAt(KeyIndex); + + // If the track is empty, drop it + if (Track.Keys.Num() == 0) + { + Data.Tracks.RemoveAt(TrackIndex); + LastAppliedValues.SetNum(Data.Tracks.Num()); + } + OnTimelineChanged.Broadcast(); +} + +void UPS_Editor_TimelineSubsystem::SetKeyInterpolate(int32 TrackIndex, int32 KeyIndex, bool bInterpolate) +{ + if (!Data.Tracks.IsValidIndex(TrackIndex)) return; + FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex]; + if (!Track.Keys.IsValidIndex(KeyIndex)) return; + Track.Keys[KeyIndex].bInterpolate = bInterpolate; + OnTimelineChanged.Broadcast(); +} + +void UPS_Editor_TimelineSubsystem::SetKeyTime(int32 TrackIndex, int32 KeyIndex, float NewTime) +{ + if (!Data.Tracks.IsValidIndex(TrackIndex)) return; + FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex]; + if (!Track.Keys.IsValidIndex(KeyIndex)) return; + + Track.Keys[KeyIndex].Time = FMath::Clamp(NewTime, 0.f, Data.Duration); + // Re-sort keys by time so downstream logic (FindSurroundingKeys, etc.) stays correct. + Track.Keys.Sort([](const FPS_Editor_TimelineKey& A, const FPS_Editor_TimelineKey& B) + { + return A.Time < B.Time; + }); + OnTimelineChanged.Broadcast(); +} + +// ---- Value application ---- + +void UPS_Editor_TimelineSubsystem::ApplyValuesAtTime(float Time) +{ + // Only the server drives the animation; clients receive via replication. + if (UWorld* World = GetWorld()) + { + if (World->GetNetMode() == NM_Client) return; + } + + if (LastAppliedValues.Num() != Data.Tracks.Num()) + { + LastAppliedValues.SetNum(Data.Tracks.Num()); + } + + for (int32 i = 0; i < Data.Tracks.Num(); ++i) + { + ApplyTrackAtTime(i, Time); + } +} + +bool UPS_Editor_TimelineSubsystem::FindSurroundingKeys(const FPS_Editor_TimelineTrack& Track, float Time, int32& OutPrev, int32& OutNext) +{ + if (Track.Keys.Num() == 0) return false; + + OutPrev = 0; + OutNext = 0; + for (int32 i = 0; i < Track.Keys.Num(); ++i) + { + if (Track.Keys[i].Time <= Time) + { + OutPrev = i; + } + if (Track.Keys[i].Time >= Time) + { + OutNext = i; + break; + } + OutNext = i; // fallback: past last key + } + return true; +} + +void UPS_Editor_TimelineSubsystem::ApplyTrackAtTime(int32 TrackIndex, float Time) +{ + if (!Data.Tracks.IsValidIndex(TrackIndex)) return; + const FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex]; + if (Track.Keys.Num() == 0) return; + + AActor* Actor = FindActorById(Track.ActorId); + if (!Actor) return; + + UObject* Target = nullptr; + FProperty* Prop = nullptr; + ResolveTarget(Actor, Track.PropertyPath, Target, Prop); + if (!Target || !Prop) return; + + // Bracket the time + int32 PrevIdx = 0, NextIdx = 0; + if (!FindSurroundingKeys(Track, Time, PrevIdx, NextIdx)) return; + + const FPS_Editor_TimelineKey& Prev = Track.Keys[PrevIdx]; + const FPS_Editor_TimelineKey& Next = Track.Keys[NextIdx]; + + FString ValueText; + if (PrevIdx == NextIdx || Next.Time <= Prev.Time) + { + ValueText = Prev.Value; + } + else + { + const float Alpha = (Time - Prev.Time) / (Next.Time - Prev.Time); + ValueText = FPS_Editor_TimelineInterpolator::InterpolateValue(Prop, Prev.Value, Next.Value, Alpha, Prev.bInterpolate); + } + + // Skip the write (and the OnEditorPropertyChanged event) if the value hasn't moved + if (LastAppliedValues.IsValidIndex(TrackIndex) && LastAppliedValues[TrackIndex] == ValueText) + { + return; + } + + // Apply via the same reflection path as the UI + Prop->ImportText_InContainer(*ValueText, Target, Target, PPF_None); + + if (UActorComponent* AsComp = Cast(Target)) + { + AsComp->MarkRenderStateDirty(); + } + + // Force net update + auto-invoke RepNotify on the server + Actor->ForceNetUpdate(); + if (!Prop->RepNotifyFunc.IsNone()) + { + if (UFunction* RepFunc = Target->FindFunction(Prop->RepNotifyFunc)) + { + Target->ProcessEvent(RepFunc, nullptr); + } + } + + // Notify the actor so it can react (bNeedsReinit etc.) + if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) + { + IPS_Editor_EditableInterface::Execute_OnEditorPropertyChanged(Actor, Track.PropertyPath.ToString()); + } + + if (LastAppliedValues.IsValidIndex(TrackIndex)) + { + LastAppliedValues[TrackIndex] = ValueText; + } +} + +// ---- Stable actor IDs ---- + +FGuid UPS_Editor_TimelineSubsystem::GetOrCreateActorId(AActor* Actor) +{ + if (!Actor) return FGuid(); + + const TWeakObjectPtr Weak(Actor); + if (const FGuid* Existing = ActorIds.Find(Weak)) + { + if (Existing->IsValid()) + { + return *Existing; + } + } + const FGuid NewId = FGuid::NewGuid(); + ActorIds.Add(Weak, NewId); + return NewId; +} + +FGuid UPS_Editor_TimelineSubsystem::FindActorId(AActor* Actor) const +{ + if (!Actor) return FGuid(); + if (const FGuid* Existing = ActorIds.Find(TWeakObjectPtr(Actor))) + { + return *Existing; + } + return FGuid(); +} + +AActor* UPS_Editor_TimelineSubsystem::FindActorById(const FGuid& Id) const +{ + if (!Id.IsValid()) return nullptr; + for (const TPair, FGuid>& Pair : ActorIds) + { + if (Pair.Value == Id) + { + return Pair.Key.Get(); + } + } + return nullptr; +} + +void UPS_Editor_TimelineSubsystem::RegisterActorId(AActor* Actor, const FGuid& Id) +{ + if (!Actor || !Id.IsValid()) return; + ActorIds.Add(TWeakObjectPtr(Actor), Id); +} + +// ---- Helpers ---- + +void UPS_Editor_TimelineSubsystem::ResolveTarget(AActor* Actor, const FName& PropertyPath, UObject*& OutTarget, FProperty*& OutProperty) +{ + OutTarget = nullptr; + OutProperty = nullptr; + if (!Actor) return; + + FString CompName, PropName; + UPS_Editor_EditableComponent::ParsePropertyPath(PropertyPath, CompName, PropName); + + if (CompName.IsEmpty()) + { + OutTarget = Actor; + } + else + { + TArray Components; + Actor->GetComponents(Components); + for (UActorComponent* Comp : Components) + { + if (!Comp) continue; + const FString InstanceName = Comp->GetName(); + const FString ClassName = Comp->GetClass()->GetName(); + FString CleanClassName = ClassName; + CleanClassName.RemoveFromEnd(TEXT("_C")); + + if (InstanceName == CompName || ClassName == CompName || CleanClassName == CompName) + { + OutTarget = Comp; + break; + } + } + } + + if (OutTarget) + { + OutProperty = FindFProperty(OutTarget->GetClass(), *PropName); + } +} 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 new file mode 100644 index 0000000..c9d4a09 --- /dev/null +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h @@ -0,0 +1,159 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/WorldSubsystem.h" +#include "Tickable.h" +#include "PS_Editor_TimelineData.h" +#include "PS_Editor_TimelineSubsystem.generated.h" + +class AActor; +class UPS_Editor_SpawnManager; + +DECLARE_MULTICAST_DELEGATE(FPS_Editor_OnTimelineChanged); + +/** + * Global animation timeline for the PS_Editor runtime. + * + * Responsibilities: + * - Own the timeline data (duration + tracks) for the current scenario. + * - Tick automatically (via FTickableGameObject) while playing, advancing CurrentTime and applying values. + * - Apply values to actors by looking up their stable FGuid via the SpawnManager (server-side only). + * - Expose Play / Pause / Stop / Seek API to both C++ and Blueprint. + * + * Execution model: + * - Values are applied ONLY on the server. Clients receive updates through normal UE replication + * (UPROPERTY must be marked Replicated, ideally with RepNotify). + * - In gameplay, the SceneLoader auto-plays the timeline after loading a scenario with data. + * - In the runtime editor, the user drives playback via the MainWidget controls. + */ +UCLASS() +class PS_EDITOR_API UPS_Editor_TimelineSubsystem : public UWorldSubsystem, public FTickableGameObject +{ + GENERATED_BODY() + +public: + // ---- UWorldSubsystem ---- + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + // ---- FTickableGameObject ---- + virtual void Tick(float DeltaTime) override; + virtual TStatId GetStatId() const override; + virtual bool IsTickable() const override; + virtual bool IsTickableInEditor() const override { return false; } + virtual ETickableTickType GetTickableTickType() const override { return ETickableTickType::Conditional; } + + // ---- Playback controls ---- + + /** Start / resume playback from CurrentTime. */ + UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline") + void Play(); + + /** Pause playback, keeping CurrentTime. */ + UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline") + void Pause(); + + /** Pause playback and reset CurrentTime to 0. */ + UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline") + void Stop(); + + /** Jump to a specific time (seconds, clamped to [0, Duration]). Applies values immediately. */ + UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline") + void Seek(float Time); + + /** True while Tick is advancing CurrentTime. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline") + bool IsPlaying() const { return bIsPlaying; } + + /** Get current playback time (seconds). */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline") + float GetCurrentTime() const { return CurrentTime; } + + /** Get the configured duration (seconds). */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline") + float GetDuration() const { return Data.Duration; } + + /** Change the timeline duration. Values <= 0 are clamped to 0.1. */ + UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline") + void SetDuration(float NewDuration); + + // ---- Tracks / keys ---- + + /** Number of tracks. */ + int32 GetNumTracks() const { return Data.Tracks.Num(); } + + const TArray& GetTracks() const { return Data.Tracks; } + + /** + * Add a key at CurrentTime for (Actor, PropertyPath), reading the actor's current value. + * Creates the track if it doesn't exist. If a key already exists at this time, updates its value. + * @return True on success. + */ + bool AddOrUpdateKeyAtCurrentTime(AActor* Actor, const FName& PropertyPath); + + /** Remove a track by index. */ + void RemoveTrack(int32 TrackIndex); + + /** Remove a single key. */ + void RemoveKey(int32 TrackIndex, int32 KeyIndex); + + /** Toggle the interpolate flag on a key. */ + void SetKeyInterpolate(int32 TrackIndex, int32 KeyIndex, bool bInterpolate); + + /** + * Move a key to a different time. The track is re-sorted after the update, so the caller + * cannot assume the key keeps the same KeyIndex afterwards. Clamps to [0, Duration]. + */ + void SetKeyTime(int32 TrackIndex, int32 KeyIndex, float NewTime); + + // ---- Data accessors (save/load integration) ---- + + const FPS_Editor_TimelineData& GetData() const { return Data; } + + /** Replace the entire timeline data (used by scene loader). Resets CurrentTime to 0, pauses. */ + void SetData(const FPS_Editor_TimelineData& NewData); + + /** Clear all tracks and reset state. */ + void Clear(); + + // ---- Stable actor IDs (used by tracks to reference actors across save/load) ---- + + /** Get the stable ID for this actor, generating one if it has no entry yet. */ + FGuid GetOrCreateActorId(AActor* Actor); + + /** Returns an invalid GUID if this actor has no registered ID. */ + FGuid FindActorId(AActor* Actor) const; + + /** Find the actor associated with a stable ID, or nullptr if none / stale. */ + AActor* FindActorById(const FGuid& Id) const; + + /** Register a specific ID for an actor (e.g. when loading a scene). Overwrites any existing ID. */ + void RegisterActorId(AActor* Actor, const FGuid& Id); + + // ---- UI refresh signal ---- + FPS_Editor_OnTimelineChanged OnTimelineChanged; + + /** Apply interpolated values of all tracks at a given time (no tick advance). */ + void ApplyValuesAtTime(float Time); + +private: + FPS_Editor_TimelineData Data; + + float CurrentTime = 0.f; + bool bIsPlaying = false; + + /** Cache of last applied exported-text value per track, used to avoid re-firing OnEditorPropertyChanged every frame when the value hasn't actually changed. */ + TArray LastAppliedValues; + + /** Map of stable IDs for every actor that participates in the timeline / scene registry. */ + TMap, FGuid> ActorIds; + + /** Find prev / next keys bracketing the requested time. Returns false if the track is empty. */ + static bool FindSurroundingKeys(const FPS_Editor_TimelineTrack& Track, float Time, int32& OutPrev, int32& OutNext); + + /** Apply a single track at the given time. */ + void ApplyTrackAtTime(int32 TrackIndex, float Time); + + /** Resolve the UObject target (actor or component) and the FProperty pointer. Nullable. */ + static void ResolveTarget(AActor* Actor, const FName& PropertyPath, UObject*& OutTarget, FProperty*& OutProperty); +}; 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 e8ad867..06932cb 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 @@ -23,11 +23,15 @@ #include "Widgets/Input/SComboBox.h" #include "Engine/Texture2D.h" #include "Widgets/Input/SEditableTextBox.h" +#include "Widgets/Input/SSlider.h" +#include "Widgets/Layout/SConstraintCanvas.h" #include "Widgets/SOverlay.h" #include "UObject/UnrealType.h" #include "Kismet/GameplayStatics.h" #include "DesktopPlatformModule.h" #include "PS_Editor_GameInstanceSubsystem.h" +#include "PS_Editor_TimelineSubsystem.h" +#include "Engine/World.h" void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager) { @@ -171,6 +175,14 @@ TSharedRef UPS_Editor_MainWidget::RebuildWidget() BuildOutliner() ] ] + // Timeline panel (hidden by default; toggled via toolbar button) + + SOverlay::Slot() + .HAlign(HAlign_Fill) + .VAlign(VAlign_Bottom) + .Padding(16.0f, 0.0f, 16.0f, 60.0f) + [ + BuildTimelinePanel() + ] // Bottom notification / help bar + SOverlay::Slot() .HAlign(HAlign_Center) @@ -918,6 +930,24 @@ TSharedRef UPS_Editor_MainWidget::BuildSceneButtons() + SHorizontalBox::Slot() .AutoWidth() .Padding(4.0f, 0.0f, 0.0f, 0.0f) + [ + SNew(SButton) + .ToolTipText(FText::FromString(TEXT("Show/hide the timeline panel"))) + .OnClicked_Lambda([this]() -> FReply + { + ToggleTimelineVisible(); + return FReply::Handled(); + }) + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("Timeline"))) + .Font(FCoreStyle::GetDefaultFontStyle("Bold", 10)) + .ColorAndOpacity(FLinearColor(0.6f, 0.8f, 1.0f)) + ] + ] + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(4.0f, 0.0f, 0.0f, 0.0f) [ SNew(SButton) .OnClicked_Lambda([this]() -> FReply @@ -947,9 +977,14 @@ TSharedRef UPS_Editor_MainWidget::BuildSceneButtons() if (SceneSerializer.IsValid() && SpawnManager.IsValid()) { - // Sync BaseLevel sublevel only if scenario specifies one + // 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. FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(FileName); - if (!SceneLevelName.IsEmpty()) + const bool bHasValidBaseLevel = !SceneLevelName.IsEmpty() + && SceneLevelName != TEXT("Unfiled") + && BaseLevelPathMap.Contains(SceneLevelName); + if (bHasValidBaseLevel) { if (APS_Editor_PlayerController* EditorPC = Cast(GetOwningPlayer())) { @@ -1142,6 +1177,42 @@ void UPS_Editor_MainWidget::NativeConstruct() { ShowLoadingScreen(false); } + + // Bind to the timeline subsystem change delegate to refresh the tracks UI when data mutates + if (UWorld* World = GetWorld()) + { + if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem()) + { + TimelineChangedHandle = TS->OnTimelineChanged.AddLambda([this]() + { + RebuildTimelineTracksList(); + }); + + // Seed duration field + if (TimelineDurationBox.IsValid()) + { + TimelineDurationBox->SetText(FText::FromString(FString::SanitizeFloat(TS->GetDuration()))); + } + } + } + + RebuildTimelineTracksList(); +} + +void UPS_Editor_MainWidget::NativeDestruct() +{ + if (TimelineChangedHandle.IsValid()) + { + if (UWorld* World = GetWorld()) + { + if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem()) + { + TS->OnTimelineChanged.Remove(TimelineChangedHandle); + } + } + TimelineChangedHandle.Reset(); + } + Super::NativeDestruct(); } void UPS_Editor_MainWidget::PopulateSceneList() @@ -1180,9 +1251,14 @@ void UPS_Editor_MainWidget::PopulateSceneList() { if (SceneSerializer.IsValid() && SpawnManager.IsValid()) { - // Sync the BaseLevel sublevel only if scenario specifies one + // 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". FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(Name); - if (!SceneLevelName.IsEmpty()) + const bool bHasValidBaseLevel = !SceneLevelName.IsEmpty() + && SceneLevelName != TEXT("Unfiled") + && BaseLevelPathMap.Contains(SceneLevelName); + if (bHasValidBaseLevel) { if (APS_Editor_PlayerController* EditorPC = Cast(GetOwningPlayer())) { @@ -1435,6 +1511,7 @@ void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDelt RefreshPropertiesFromSelection(); RefreshOutliner(); + RefreshTimelineUI(); } void UPS_Editor_MainWidget::RefreshPropertiesFromSelection() @@ -2083,8 +2160,40 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties() } // end else (enum/struct/default type detection) - DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 2.0f) - [ ValueWidget ]; + const bool bAnimatable = EditComp->IsAnimatable(Entry.Path); + + if (bAnimatable) + { + const int32 CapturedRowIndex = RowIndex; + DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 2.0f) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().FillWidth(1.0f) + [ ValueWidget ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(4.0f, 0.0f, 0.0f, 0.0f) + [ + SNew(SButton) + .ToolTipText(FText::FromString(TEXT("Add a keyframe at the current timeline time"))) + .ContentPadding(FMargin(4.0f, 0.0f)) + .OnClicked_Lambda([this, CapturedRowIndex]() -> FReply + { + AddKeyForRow(CapturedRowIndex); + return FReply::Handled(); + }) + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("+"))) + .Font(FCoreStyle::GetDefaultFontStyle("Bold", 10)) + .ColorAndOpacity(FLinearColor(0.6f, 0.8f, 1.0f)) + ] + ] + ]; + } + else + { + DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 2.0f) + [ ValueWidget ]; + } DynamicPropertyRows.Add(Row); } @@ -2704,3 +2813,761 @@ void UPS_Editor_MainWidget::ExecuteClose(bool bSave) // Delegate cleanup + transition to PlayerController EditorPC->CloseEditor(); } + +// ============================================================================ +// Timeline UI +// ============================================================================ + +static UPS_Editor_TimelineSubsystem* GetTimelineSubsystemFromWidget(UPS_Editor_MainWidget* Widget) +{ + if (!Widget) return nullptr; + UWorld* World = Widget->GetWorld(); + return World ? World->GetSubsystem() : nullptr; +} + +/** + * A single keyframe dot on the timeline track strip. Supports: + * - left click (no drag): fires OnClickedAction + * - left drag: updates a shared VisualTimeRef each frame so the parent canvas + * re-anchors the dot live; fires OnDragFinished with the final time on release + * + * Deletion is handled externally via the global Delete key, targeting whichever + * key MainWidget has marked as selected (set by OnClickedAction). + * + * The widget must live inside a parent whose cached geometry spans the timeline's + * full width — the horizontal cursor position within that parent maps linearly to time. + */ +class SPS_Editor_TimelineKeyDot : public SCompoundWidget +{ +public: + DECLARE_DELEGATE(FOnSimpleAction); + DECLARE_DELEGATE_OneParam(FOnTimeChanged, float); + + SLATE_BEGIN_ARGS(SPS_Editor_TimelineKeyDot) + : _Duration(10.f) + , _InitialTime(0.f) + , _DotColor(FLinearColor::White) + {} + SLATE_ARGUMENT(float, Duration) + SLATE_ARGUMENT(float, InitialTime) + SLATE_ATTRIBUTE(FSlateColor, DotColor) + SLATE_ARGUMENT(TSharedPtr, VisualTimeRef) + SLATE_EVENT(FOnSimpleAction, OnClickedAction) + SLATE_EVENT(FOnTimeChanged, OnDragFinished) + SLATE_ATTRIBUTE(FText, ToolTipText) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs) + { + Duration = FMath::Max(InArgs._Duration, 0.0001f); + VisualTimeRef = InArgs._VisualTimeRef; + if (VisualTimeRef.IsValid()) + { + *VisualTimeRef = InArgs._InitialTime; + } + OnClickedAction = InArgs._OnClickedAction; + OnDragFinished = InArgs._OnDragFinished; + + ChildSlot + [ + SNew(SBox).WidthOverride(10.f).HeightOverride(10.f) + [ + SNew(SBorder) + .BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush")) + .BorderBackgroundColor(InArgs._DotColor) + .ToolTipText(InArgs._ToolTipText) + ] + ]; + } + + virtual FReply OnMouseButtonDown(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override + { + if (MouseEvent.GetEffectingButton() == EKeys::LeftMouseButton) + { + MouseDownScreenPos = MouseEvent.GetScreenSpacePosition(); + bDragging = false; + return FReply::Handled().CaptureMouse(SharedThis(this)); + } + return FReply::Unhandled(); + } + + virtual FReply OnMouseMove(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override + { + if (HasMouseCapture()) + { + const float DistMoved = (MouseEvent.GetScreenSpacePosition() - MouseDownScreenPos).Size(); + if (!bDragging && DistMoved > 3.f) + { + bDragging = true; + } + if (bDragging && VisualTimeRef.IsValid()) + { + *VisualTimeRef = ComputeTimeAtCursor(MouseEvent.GetScreenSpacePosition()); + } + return FReply::Handled(); + } + return FReply::Unhandled(); + } + + virtual FReply OnMouseButtonUp(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override + { + if (HasMouseCapture()) + { + if (!bDragging) + { + OnClickedAction.ExecuteIfBound(); + } + else if (VisualTimeRef.IsValid()) + { + OnDragFinished.ExecuteIfBound(*VisualTimeRef); + } + bDragging = false; + return FReply::Handled().ReleaseMouseCapture(); + } + return FReply::Unhandled(); + } + +private: + float ComputeTimeAtCursor(const FVector2D& ScreenPos) const + { + TSharedPtr Parent = GetParentWidget(); + if (!Parent.IsValid()) return 0.f; + const FGeometry ParentGeo = Parent->GetCachedGeometry(); + const FVector2D LocalInParent = ParentGeo.AbsoluteToLocal(ScreenPos); + const float Norm = FMath::Clamp(LocalInParent.X / FMath::Max(ParentGeo.GetLocalSize().X, 1.f), 0.f, 1.f); + return Norm * Duration; + } + + float Duration = 10.f; + TSharedPtr VisualTimeRef; + FVector2D MouseDownScreenPos = FVector2D::ZeroVector; + bool bDragging = false; + + FOnSimpleAction OnClickedAction; + FOnTimeChanged OnDragFinished; +}; + +/** + * Horizontal scrub bar that drives the timeline cursor. Replaces SSlider because + * SSlider insets the handle by (HandleSize/2) on each side, which breaks pixel-perfect + * alignment with the keyframe dots (which use pure percentage anchors). + * + * Layout is the same as the key strips below: full-width track + a single handle anchored + * at (CurrentTime / Duration). Click or drag anywhere on the bar to seek. + */ +class SPS_Editor_TimelineScrubBar : public SCompoundWidget +{ +public: + DECLARE_DELEGATE_OneParam(FOnTimeSeek, float); + + SLATE_BEGIN_ARGS(SPS_Editor_TimelineScrubBar) {} + SLATE_ATTRIBUTE(float, Duration) + SLATE_ATTRIBUTE(float, CurrentTime) + SLATE_EVENT(FOnTimeSeek, OnSeek) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs) + { + DurationAttr = InArgs._Duration; + CurrentTimeAttr = InArgs._CurrentTime; + OnSeek = InArgs._OnSeek; + + ChildSlot + [ + SNew(SBox).HeightOverride(18.f) + [ + SNew(SOverlay) + // Track background (thin horizontal bar) + + SOverlay::Slot().HAlign(HAlign_Fill).VAlign(VAlign_Center) + [ + SNew(SBox).HeightOverride(4.f) + [ + SNew(SBorder) + .BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush")) + .BorderBackgroundColor(FLinearColor(0.25f, 0.25f, 0.25f, 1.0f)) + .Padding(FMargin(0.f)) + ] + ] + // Handle — anchored via percentage just like the keyframes below + + SOverlay::Slot().HAlign(HAlign_Fill).VAlign(VAlign_Fill) + [ + SNew(SConstraintCanvas) + + SConstraintCanvas::Slot() + .Anchors_Lambda([this]() -> FAnchors + { + const float Dur = FMath::Max(DurationAttr.Get(10.f), 0.0001f); + const float Now = CurrentTimeAttr.Get(0.f); + const float N = FMath::Clamp(Now / Dur, 0.f, 1.f); + return FAnchors(N, 0.5f, N, 0.5f); + }) + .Alignment(FVector2D(0.5f, 0.5f)) + .AutoSize(true) + [ + SNew(SBox).WidthOverride(12.f).HeightOverride(16.f) + [ + SNew(SBorder) + .BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush")) + .BorderBackgroundColor(FLinearColor(0.95f, 0.95f, 0.95f)) + ] + ] + ] + ] + ]; + } + + virtual FReply OnMouseButtonDown(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override + { + if (MouseEvent.GetEffectingButton() == EKeys::LeftMouseButton) + { + DoSeek(MyGeometry, MouseEvent.GetScreenSpacePosition()); + return FReply::Handled().CaptureMouse(SharedThis(this)); + } + return FReply::Unhandled(); + } + + virtual FReply OnMouseMove(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override + { + if (HasMouseCapture()) + { + DoSeek(MyGeometry, MouseEvent.GetScreenSpacePosition()); + return FReply::Handled(); + } + return FReply::Unhandled(); + } + + virtual FReply OnMouseButtonUp(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) override + { + if (HasMouseCapture()) + { + return FReply::Handled().ReleaseMouseCapture(); + } + return FReply::Unhandled(); + } + +private: + void DoSeek(const FGeometry& Geo, const FVector2D& ScreenPos) + { + const FVector2D LocalPos = Geo.AbsoluteToLocal(ScreenPos); + const float Norm = FMath::Clamp(LocalPos.X / FMath::Max(Geo.GetLocalSize().X, 1.f), 0.f, 1.f); + const float Dur = FMath::Max(DurationAttr.Get(10.f), 0.0001f); + if (OnSeek.IsBound()) + { + OnSeek.Execute(Norm * Dur); + } + } + + TAttribute DurationAttr; + TAttribute CurrentTimeAttr; + FOnTimeSeek OnSeek; +}; + +TSharedRef UPS_Editor_MainWidget::BuildTimelinePanel() +{ + const FSlateFontInfo Small = FCoreStyle::GetDefaultFontStyle("Regular", 9); + const FSlateFontInfo Bold = FCoreStyle::GetDefaultFontStyle("Bold", 10); + + return SAssignNew(TimelinePanel, SBorder) + .Visibility(EVisibility::Collapsed) + .BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush")) + .BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.95f)) + .Padding(FMargin(12.0f, 8.0f)) + [ + SNew(SVerticalBox) + // Header: Play / Pause / Stop + current time + duration + + SVerticalBox::Slot().AutoHeight().Padding(0.0f, 0.0f, 0.0f, 6.0f) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("Timeline"))) + .Font(FCoreStyle::GetDefaultFontStyle("Bold", 12)) + .ColorAndOpacity(FLinearColor(0.6f, 0.8f, 1.0f)) + ] + + SHorizontalBox::Slot().AutoWidth().Padding(12.0f, 0.0f, 0.0f, 0.0f).VAlign(VAlign_Center) + [ + SNew(SButton) + .OnClicked_Lambda([this]() -> FReply + { + if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this)) + { + if (TS->IsPlaying()) TS->Pause(); else TS->Play(); + } + return FReply::Handled(); + }) + [ + SAssignNew(TimelinePlayButtonText, STextBlock) + .Text(FText::FromString(TEXT("Play"))) + .Font(Bold) + .ColorAndOpacity(FLinearColor(0.2f, 0.9f, 0.2f)) + ] + ] + + SHorizontalBox::Slot().AutoWidth().Padding(4.0f, 0.0f, 0.0f, 0.0f).VAlign(VAlign_Center) + [ + SNew(SButton) + .OnClicked_Lambda([this]() -> FReply + { + if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this)) + { + TS->Stop(); + } + return FReply::Handled(); + }) + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("Stop"))) + .Font(Bold) + .ColorAndOpacity(FLinearColor(1.0f, 0.4f, 0.2f)) + ] + ] + + SHorizontalBox::Slot().AutoWidth().Padding(12.0f, 0.0f, 0.0f, 0.0f).VAlign(VAlign_Center) + [ + SAssignNew(TimelineTimeLabel, STextBlock) + .Text(FText::FromString(TEXT("0.00 / 10.00 s"))) + .Font(Small) + .ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f)) + ] + + SHorizontalBox::Slot().FillWidth(1.0f) + [ + SNullWidget::NullWidget + ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(8.0f, 0.0f, 4.0f, 0.0f) + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("Duration:"))) + .Font(Small) + .ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f)) + ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center) + [ + SNew(SBox).MinDesiredWidth(60.0f) + [ + SAssignNew(TimelineDurationBox, SEditableTextBox) + .Font(Small) + .HintText(FText::FromString(TEXT("10"))) + .OnTextCommitted_Lambda([this](const FText& Text, ETextCommit::Type) + { + if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this)) + { + float Val = FCString::Atof(*Text.ToString()); + if (Val > 0.0f) + { + TS->SetDuration(Val); + } + } + }) + ] + ] + ] + // Scrub slider — aligned with the per-track key strips below so that + // the slider handle and the key dots share the same time axis. + + SVerticalBox::Slot().AutoHeight().Padding(0.0f, 0.0f, 0.0f, 6.0f) + [ + SNew(SHorizontalBox) + // Spacer matching the per-track X button width (+ its padding) + + SHorizontalBox::Slot().AutoWidth() + [ + SNew(SBox).WidthOverride(24.0f) + ] + // Spacer matching the per-track label width + + SHorizontalBox::Slot().AutoWidth().Padding(0.0f, 0.0f, 12.0f, 0.0f) + [ + SNew(SBox).MinDesiredWidth(180.0f) + ] + // The scrub bar fills the exact same area as the key strips below. Using a + // custom SConstraintCanvas-based widget (same math as the key anchors) to avoid + // the handle-inset that SSlider applies by default. + + SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center) + [ + SNew(SPS_Editor_TimelineScrubBar) + .Duration_Lambda([this]() + { + if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this)) + { + return TS->GetDuration(); + } + return 10.f; + }) + .CurrentTime_Lambda([this]() + { + if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this)) + { + return TS->GetCurrentTime(); + } + return 0.f; + }) + .OnSeek_Lambda([this](float NewTime) + { + if (UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this)) + { + TS->Seek(NewTime); + } + }) + ] + // Spacer matching the per-track Interp button width + + SHorizontalBox::Slot().AutoWidth().Padding(6.0f, 0.0f, 0.0f, 0.0f) + [ + SNew(SBox).WidthOverride(50.0f) + ] + ] + // Track list (scrollable) + + SVerticalBox::Slot().AutoHeight().MaxHeight(220.0f) + [ + SNew(SScrollBox) + .ConsumeMouseWheel(EConsumeMouseWheel::Always) + + SScrollBox::Slot() + [ + SAssignNew(TimelineTracksContainer, SVerticalBox) + ] + ] + ]; +} + +void UPS_Editor_MainWidget::ToggleTimelineVisible() +{ + bTimelineVisible = !bTimelineVisible; + if (TimelinePanel.IsValid()) + { + TimelinePanel->SetVisibility(bTimelineVisible ? EVisibility::Visible : EVisibility::Collapsed); + } + if (bTimelineVisible) + { + RebuildTimelineTracksList(); + } +} + +void UPS_Editor_MainWidget::RebuildTimelineTracksList() +{ + if (!TimelineTracksContainer.IsValid()) return; + + // Validate the stored key selection against the current data. Any mutation that reshuffles + // keys (SetKeyTime, RemoveKey, clear, ...) triggers a rebuild; if the indices no longer + // address a real key, we drop the selection. Otherwise we keep it so a Seek-induced rebuild + // doesn't blow away the selection the user just made. + if (SelectedTimelineTrackIdx >= 0) + { + UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this); + if (!TS + || !TS->GetTracks().IsValidIndex(SelectedTimelineTrackIdx) + || !TS->GetTracks()[SelectedTimelineTrackIdx].Keys.IsValidIndex(SelectedTimelineKeyIdx)) + { + SelectedTimelineTrackIdx = -1; + SelectedTimelineKeyIdx = -1; + } + } + + TimelineTracksContainer->ClearChildren(); + + UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this); + if (!TS) return; + + const FSlateFontInfo Small = FCoreStyle::GetDefaultFontStyle("Regular", 9); + const float Duration = FMath::Max(TS->GetDuration(), 0.0001f); + + const TArray& Tracks = TS->GetTracks(); + if (Tracks.Num() == 0) + { + TimelineTracksContainer->AddSlot().AutoHeight().Padding(0.0f, 8.0f) + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("No animated properties yet. Click + next to an animatable property to add a keyframe."))) + .Font(FCoreStyle::GetDefaultFontStyle("Italic", 9)) + .ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f)) + ]; + return; + } + + for (int32 TrackIdx = 0; TrackIdx < Tracks.Num(); ++TrackIdx) + { + const FPS_Editor_TimelineTrack& Track = Tracks[TrackIdx]; + + // Resolve actor display name for the label (fallback to GUID if not found) + FString ActorName = TEXT("(missing)"); + if (AActor* Actor = TS->FindActorById(Track.ActorId)) + { + ActorName = Actor->GetName(); + if (UPS_Editor_SpawnableComponent* Sp = Actor->FindComponentByClass()) + { + if (!Sp->DisplayName.IsEmpty()) ActorName = Sp->DisplayName; + } + } + const FString Label = FString::Printf(TEXT("%s . %s"), *ActorName, *Track.PropertyPath.ToString()); + + // Build the keyframe strip with absolute-percentage positioning so each dot's + // horizontal center sits exactly at (Time / Duration) of the strip width — + // the slider handle uses the same math, so visual alignment is pixel-perfect. + TSharedRef KeysCanvas = SNew(SConstraintCanvas); + 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); + const int32 CapturedKey = KeyIdx; + const int32 CapturedTrack = TrackIdx; + const bool bKeyInterp = Key.bInterpolate; + + // Shared live time value: updated by the widget during drag, read by the canvas anchor + // lambda each frame so the dot moves under the cursor without a full UI rebuild. + TSharedPtr VisualTime = MakeShared(Key.Time); + + KeysCanvas->AddSlot() + .Anchors_Lambda([VisualTime, Duration]() + { + const float N = FMath::Clamp(*VisualTime / Duration, 0.f, 1.f); + return FAnchors(N, 0.5f, N, 0.5f); + }) + .Alignment(FVector2D(0.5f, 0.5f)) + .AutoSize(true) + .Offset(FMargin(0.f, 0.f, 0.f, 0.f)) + [ + SNew(SPS_Editor_TimelineKeyDot) + .Duration(Duration) + .InitialTime(Key.Time) + .VisualTimeRef(VisualTime) + .DotColor_Lambda([this, CapturedTrack, CapturedKey, BaseDotColor]() -> FSlateColor + { + // Priority 1: selected by the user (click) — white + if (SelectedTimelineTrackIdx == CapturedTrack && SelectedTimelineKeyIdx == CapturedKey) + { + return FSlateColor(FLinearColor(1.0f, 1.0f, 1.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. + if (UPS_Editor_TimelineSubsystem* TS2 = GetTimelineSubsystemFromWidget(const_cast(this))) + { + if (TS2->GetTracks().IsValidIndex(CapturedTrack)) + { + const FPS_Editor_TimelineTrack& Tr = TS2->GetTracks()[CapturedTrack]; + if (Tr.Keys.IsValidIndex(CapturedKey)) + { + const float Now = TS2->GetCurrentTime(); + int32 ActiveIdx = INDEX_NONE; + for (int32 k = 0; k < Tr.Keys.Num(); ++k) + { + if (Tr.Keys[k].Time <= Now + 0.0001f) ActiveIdx = k; else break; + } + if (ActiveIdx == CapturedKey) + { + return FSlateColor(FLinearColor(0.3f, 1.0f, 0.3f)); + } + } + } + } + + // Priority 3: base color (blue for interp, 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)")))) + .OnClickedAction_Lambda([this, CapturedTrack, CapturedKey]() + { + UPS_Editor_TimelineSubsystem* TS2 = GetTimelineSubsystemFromWidget(this); + if (!TS2) return; + if (!TS2->GetTracks().IsValidIndex(CapturedTrack)) return; + const FPS_Editor_TimelineTrack& Tr = TS2->GetTracks()[CapturedTrack]; + if (!Tr.Keys.IsValidIndex(CapturedKey)) return; + + // Mark this key as the selected one so Del can target it + SelectedTimelineTrackIdx = CapturedTrack; + SelectedTimelineKeyIdx = CapturedKey; + + // Select the animated actor so the property panel shows matching values + if (AActor* TrackActor = TS2->FindActorById(Tr.ActorId)) + { + if (UPS_Editor_SelectionManager* Sel = SelectionManager.Get()) + { + Sel->SelectActor(TrackActor); + } + } + + // Seek to the key time — ApplyValuesAtTime writes the key value onto the actor + TS2->Seek(Tr.Keys[CapturedKey].Time); + }) + .OnDragFinished_Lambda([this, CapturedTrack, CapturedKey](float NewTime) + { + if (UPS_Editor_TimelineSubsystem* TS2 = GetTimelineSubsystemFromWidget(this)) + { + // SetKeyTime re-sorts the keys, so indices may shift afterwards; + // clear the key selection to avoid pointing at the wrong one. + SelectedTimelineTrackIdx = -1; + SelectedTimelineKeyIdx = -1; + TS2->SetKeyTime(CapturedTrack, CapturedKey, NewTime); + } + }) + ]; + } + + TimelineTracksContainer->AddSlot().AutoHeight().Padding(0.0f, 2.0f) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center) + [ + SNew(SBox).WidthOverride(24.0f) + [ + SNew(SButton) + .ToolTipText(FText::FromString(TEXT("Remove this track"))) + .ContentPadding(FMargin(2.0f, 0.0f)) + .OnClicked_Lambda([this, TrackIdx]() -> FReply + { + if (UPS_Editor_TimelineSubsystem* TS2 = GetTimelineSubsystemFromWidget(this)) + { + TS2->RemoveTrack(TrackIdx); + } + return FReply::Handled(); + }) + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("X"))) + .Font(FCoreStyle::GetDefaultFontStyle("Bold", 9)) + .ColorAndOpacity(FLinearColor(1.0f, 0.3f, 0.3f)) + ] + ] + ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 12.0f, 0.0f) + [ + SNew(SBox).MinDesiredWidth(180.0f) + [ + SNew(STextBlock) + .Text(FText::FromString(Label)) + .Font(Small) + .ColorAndOpacity(FLinearColor(0.85f, 0.85f, 0.85f)) + ] + ] + // Key strip: horizontal bar with small buttons positioned by Time/Duration + + SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center) + [ + SNew(SBox).HeightOverride(18.0f) + [ + SNew(SOverlay) + + SOverlay::Slot() + .HAlign(HAlign_Fill).VAlign(VAlign_Center) + [ + SNew(SBorder) + .BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush")) + .BorderBackgroundColor(FLinearColor(0.15f, 0.15f, 0.15f, 1.0f)) + .Padding(FMargin(0.0f, 2.0f)) + ] + + SOverlay::Slot() + .HAlign(HAlign_Fill).VAlign(VAlign_Fill) + [ + KeysCanvas + ] + ] + ] + + SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(6.0f, 0.0f, 0.0f, 0.0f) + [ + SNew(SBox).WidthOverride(50.0f) + [ + SNew(SButton) + .ToolTipText(FText::FromString(TEXT("Toggle interpolation on the key at (or before) the current time"))) + .ContentPadding(FMargin(2.0f, 0.0f)) + .OnClicked_Lambda([this, TrackIdx]() -> FReply + { + if (UPS_Editor_TimelineSubsystem* TS2 = GetTimelineSubsystemFromWidget(this)) + { + if (TS2->GetTracks().IsValidIndex(TrackIdx)) + { + const FPS_Editor_TimelineTrack& Tr = TS2->GetTracks()[TrackIdx]; + if (Tr.Keys.Num() > 0) + { + // Pick the key AT or BEFORE the current time (cursor-oriented) + const float Now = TS2->GetCurrentTime(); + int32 PickedKey = 0; + for (int32 k = 0; k < Tr.Keys.Num(); ++k) + { + if (Tr.Keys[k].Time <= Now) PickedKey = k; else break; + } + TS2->SetKeyInterpolate(TrackIdx, PickedKey, !Tr.Keys[PickedKey].bInterpolate); + } + } + } + return FReply::Handled(); + }) + [ + SNew(STextBlock) + .Text(FText::FromString(TEXT("Interp"))) + .Font(Small) + .ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f)) + ] + ] + ] + ]; + } +} + +void UPS_Editor_MainWidget::RefreshTimelineUI() +{ + if (!TimelinePanel.IsValid() || !bTimelineVisible) return; + + UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this); + if (!TS) return; + + const float Duration = FMath::Max(TS->GetDuration(), 0.0001f); + const float Time = TS->GetCurrentTime(); + + // Auto-deselect a keyframe when the timeline cursor moves away from it + // (slider scrub, playback, or clicking a different key). The selection is tied to + // "cursor parked on this key", so if the cursor leaves, so does the selection. + if (SelectedTimelineTrackIdx >= 0 + && TS->GetTracks().IsValidIndex(SelectedTimelineTrackIdx) + && TS->GetTracks()[SelectedTimelineTrackIdx].Keys.IsValidIndex(SelectedTimelineKeyIdx)) + { + const float SelectedKeyTime = TS->GetTracks()[SelectedTimelineTrackIdx].Keys[SelectedTimelineKeyIdx].Time; + if (!FMath::IsNearlyEqual(Time, SelectedKeyTime, 0.05f)) + { + SelectedTimelineTrackIdx = -1; + SelectedTimelineKeyIdx = -1; + } + } + + // The scrub bar handle position is driven by CurrentTimeAttr (TAttribute) — no manual SetValue needed. + if (TimelineTimeLabel.IsValid()) + { + TimelineTimeLabel->SetText(FText::FromString(FString::Printf(TEXT("%.2f / %.2f s"), Time, Duration))); + } + if (TimelinePlayButtonText.IsValid()) + { + if (TS->IsPlaying()) + { + TimelinePlayButtonText->SetText(FText::FromString(TEXT("Pause"))); + TimelinePlayButtonText->SetColorAndOpacity(FLinearColor(1.0f, 0.8f, 0.2f)); + } + else + { + TimelinePlayButtonText->SetText(FText::FromString(TEXT("Play"))); + TimelinePlayButtonText->SetColorAndOpacity(FLinearColor(0.2f, 0.9f, 0.2f)); + } + } +} + +void UPS_Editor_MainWidget::AddKeyForRow(int32 RowIndex) +{ + if (!DynamicPropertyRows.IsValidIndex(RowIndex)) return; + + UPS_Editor_SelectionManager* SM = SelectionManager.Get(); + if (!SM || !SM->IsAnythingSelected()) return; + + AActor* Actor = SM->GetSelectedActors()[0].Get(); + if (!Actor) return; + + UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this); + if (!TS) return; + + const FPS_Editor_PropertyRowWidgets& Row = DynamicPropertyRows[RowIndex]; + TS->AddOrUpdateKeyAtCurrentTime(Actor, FName(*Row.Key)); + bSceneDirty = true; +} + +bool UPS_Editor_MainWidget::TryDeleteSelectedTimelineKey() +{ + if (SelectedTimelineTrackIdx < 0 || SelectedTimelineKeyIdx < 0) return false; + UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this); + if (!TS) return false; + if (!TS->GetTracks().IsValidIndex(SelectedTimelineTrackIdx)) return false; + if (!TS->GetTracks()[SelectedTimelineTrackIdx].Keys.IsValidIndex(SelectedTimelineKeyIdx)) return false; + + TS->RemoveKey(SelectedTimelineTrackIdx, SelectedTimelineKeyIdx); + SelectedTimelineTrackIdx = -1; + SelectedTimelineKeyIdx = -1; + bSceneDirty = true; + return true; +} diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h index 70f6440..042efa3 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h @@ -50,6 +50,7 @@ public: protected: virtual TSharedRef RebuildWidget() override; virtual void NativeConstruct() override; + virtual void NativeDestruct() override; virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override; private: @@ -122,6 +123,19 @@ private: // Simulate button TSharedPtr SimulateButtonText; + // Timeline panel + TSharedPtr TimelinePanel; + TSharedPtr TimelineSlider; + TSharedPtr TimelineTimeLabel; + TSharedPtr TimelinePlayButtonText; + TSharedPtr TimelineDurationBox; + TSharedPtr TimelineTracksContainer; + bool bTimelineVisible = false; + FDelegateHandle TimelineChangedHandle; + /** Currently selected timeline key (updated on click). -1 means no selection. */ + int32 SelectedTimelineTrackIdx = -1; + int32 SelectedTimelineKeyIdx = -1; + // Close confirmation TSharedPtr CloseConfirmOverlay; bool bSceneDirty = false; @@ -140,6 +154,7 @@ public: TSharedRef BuildSpawnCatalog(); TSharedRef BuildSceneButtons(); TSharedRef BuildOutliner(); + TSharedRef BuildTimelinePanel(); void RefreshOutliner(); void PopulateSceneList(); void PopulateSpawnCatalog(); @@ -148,6 +163,14 @@ public: void RebuildDynamicProperties(); void RefreshDynamicPropertyValues(); void ApplyPropertyFromUI(int32 RowIndex); + void AddKeyForRow(int32 RowIndex); + void ToggleTimelineVisible(); + void RebuildTimelineTracksList(); + void RefreshTimelineUI(); + + /** Try to delete the currently selected timeline key. Returns true if one was deleted. + * Called by the Pawn's global Delete action before it falls back to actor deletion. */ + bool TryDeleteSelectedTimelineKey(); static UObject* FindPropertyTarget(AActor* Actor, const FString& ComponentName); FString FloatToString(float Value) const;