Add global timeline for animating editable properties

New UWorldSubsystem + FTickableGameObject drives per-property keyframe
animation in both the runtime editor and real gameplay (server-side,
replicated to clients via standard UE property replication). Tightly
coupled with Simulate: Play/Pause/Stop also start/stop AI.

Highlights:
- bAnimatable flag on EditablePropertyEntry gates which properties get
  a "+" keyframe button in the runtime editor
- FGuid-based actor IDs in the timeline subsystem survive save/load
- Custom Slate widgets replace SSlider/SButton for pixel-perfect
  alignment between the scrub bar handle and keyframe dots
- Click a key = select actor + seek; drag moves it; Del removes it
- Active key highlighted green; selected key white; auto-deselect when
  the cursor leaves the key time
- Scenes loaded via the standalone SceneLoader auto-play in gameplay;
  SceneSerializer pushes timeline data into the subsystem on load
- SceneData v3 -> v4 (Timeline + per-actor ActorId); legacy scenes
  load cleanly with empty timeline + regenerated GUIDs
- "Unfiled" scenes no longer try to load a bogus sublevel (loading
  screen no longer gets stuck); LoadBaseLevelAsSublevel now has a
  safety net that hides the overlay if LoadLevelInstance fails
- TryLoadClass via FSoftClassPath fixes the intermittent "actors
  disappear on first load" bug (BP class cold-load race)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 11:28:21 +02:00
parent b08b17d762
commit 4b0a182013
16 changed files with 2003 additions and 12 deletions

View File

@@ -16,6 +16,8 @@
#include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineActor.h"
#include "PS_Editor_SplineEditable.h" #include "PS_Editor_SplineEditable.h"
#include "PS_Editor_ChildMovable.h" #include "PS_Editor_ChildMovable.h"
#include "PS_Editor_HUD.h"
#include "PS_Editor_MainWidget.h"
#include "Components/SplineComponent.h" #include "Components/SplineComponent.h"
#include "DrawDebugHelpers.h" #include "DrawDebugHelpers.h"
#include "PS_Editor_ConfirmDialog.h" #include "PS_Editor_ConfirmDialog.h"
@@ -1026,6 +1028,18 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()); APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return; if (!EditorPC) return;
// 1st priority: if a timeline key is selected in the HUD, delete it and stop.
if (APS_Editor_HUD* EditorHUD = Cast<APS_Editor_HUD>(EditorPC->GetHUD()))
{
if (UPS_Editor_MainWidget* MW = EditorHUD->GetMainWidget())
{
if (MW->TryDeleteSelectedTimelineKey())
{
return;
}
}
}
// In SplineEditing mode with a point selected: delete that point // In SplineEditing mode with a point selected: delete that point
if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement
&& ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid()) && ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())

View File

@@ -531,6 +531,17 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa
else else
{ {
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load sublevel '%s' (path: '%s')"), *LevelName, *LevelToLoad); 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);
}
} }
} }

View File

@@ -18,6 +18,7 @@ public class PS_Editor : ModuleRules
PublicIncludePaths.Add(Path.Combine(SourceDir, "Spawn")); PublicIncludePaths.Add(Path.Combine(SourceDir, "Spawn"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Serialization")); PublicIncludePaths.Add(Path.Combine(SourceDir, "Serialization"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Spline")); PublicIncludePaths.Add(Path.Combine(SourceDir, "Spline"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Timeline"));
PublicDependencyModuleNames.AddRange(new string[] PublicDependencyModuleNames.AddRange(new string[]
{ {

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "PS_Editor_TimelineData.h"
#include "PS_Editor_SceneData.generated.h" #include "PS_Editor_SceneData.generated.h"
/** Serialized data for a single spawned actor. */ /** Serialized data for a single spawned actor. */
@@ -13,6 +14,10 @@ struct FPS_Editor_ActorData
UPROPERTY() UPROPERTY()
FString ClassPath; FString ClassPath;
/** Stable identifier used by the timeline and any lookup system. Generated at save time if missing. */
UPROPERTY()
FGuid ActorId;
UPROPERTY() UPROPERTY()
FVector Location = FVector::ZeroVector; FVector Location = FVector::ZeroVector;
@@ -38,7 +43,7 @@ struct FPS_Editor_SceneData
GENERATED_BODY() GENERATED_BODY()
UPROPERTY() UPROPERTY()
int32 Version = 3; int32 Version = 4;
UPROPERTY() UPROPERTY()
FString SceneName; FString SceneName;
@@ -52,4 +57,8 @@ struct FPS_Editor_SceneData
UPROPERTY() UPROPERTY()
TArray<FPS_Editor_ActorData> Actors; TArray<FPS_Editor_ActorData> Actors;
/** Global timeline data (animation tracks + duration). Empty on legacy scenes. */
UPROPERTY()
FPS_Editor_TimelineData Timeline;
}; };

View File

@@ -7,11 +7,16 @@
#include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineActor.h"
#include "PS_Editor_SplineEditable.h" #include "PS_Editor_SplineEditable.h"
#include "PS_Editor_PointPlaceable.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 "JsonObjectConverter.h"
#include "Misc/FileHelper.h" #include "Misc/FileHelper.h"
#include "Misc/Paths.h" #include "Misc/Paths.h"
#include "UObject/UnrealType.h" #include "UObject/UnrealType.h"
#include "Components/ActorComponent.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<AActor*>& OutActors, bool bStripEditorComponents, EPS_Editor_SceneLoadFilter Filter) bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors, bool bStripEditorComponents, EPS_Editor_SceneLoadFilter Filter)
{ {
@@ -95,6 +100,8 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S
return false; return false;
} }
UPS_Editor_TimelineSubsystem* TimelineSS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>();
int32 SpawnedCount = 0; int32 SpawnedCount = 0;
bIsCurrentlyLoading = true; bIsCurrentlyLoading = true;
@@ -103,12 +110,32 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S
AActor* SpawnedActor = SpawnActorFromData(World, ActorData, Filter); AActor* SpawnedActor = SpawnActorFromData(World, ActorData, Filter);
if (SpawnedActor) 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); OutActors.Add(SpawnedActor);
SpawnedCount++; SpawnedCount++;
} }
} }
bIsCurrentlyLoading = false; 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'"), UE_LOG(LogTemp, Log, TEXT("PS_Editor SceneLoader: Loaded %d actors from scene '%s'"),
SpawnedCount, *SceneData.SceneName); SpawnedCount, *SceneData.SceneName);
return SpawnedCount > 0; 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::PreloadOnly && !ActorData.bPreload) return nullptr;
if (Filter == EPS_Editor_SceneLoadFilter::ExcludePreload && ActorData.bPreload) return nullptr; if (Filter == EPS_Editor_SceneLoadFilter::ExcludePreload && ActorData.bPreload) return nullptr;
// Load class (supports both Blueprint and C++ classes) // Load class (supports both Blueprint and C++ classes). TryLoadClass forces the full
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath); // Blueprint load path (package + generated class + post-load) which LoadObject<UClass>
// can skip on a cold first call, leaving the class null intermittently.
FSoftClassPath SoftPath(ActorData.ClassPath);
UClass* LoadedClass = SoftPath.TryLoadClass<AActor>();
if (!LoadedClass)
{
LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass) if (!LoadedClass)
{ {
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath); LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath);

View File

@@ -11,12 +11,14 @@
#include "AIController.h" #include "AIController.h"
#include "BrainComponent.h" #include "BrainComponent.h"
#include "PS_Editor_SplineEditable.h" #include "PS_Editor_SplineEditable.h"
#include "PS_Editor_TimelineSubsystem.h"
#include "JsonObjectConverter.h" #include "JsonObjectConverter.h"
#include "Misc/FileHelper.h" #include "Misc/FileHelper.h"
#include "Misc/Paths.h" #include "Misc/Paths.h"
#include "HAL/PlatformFileManager.h" #include "HAL/PlatformFileManager.h"
#include "UObject/UnrealType.h" #include "UObject/UnrealType.h"
#include "Components/ActorComponent.h" #include "Components/ActorComponent.h"
#include "Engine/World.h"
bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager) 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(); SceneData.Timestamp = FDateTime::Now().ToString();
// Use the current base level from PlayerController // Use the current base level from PlayerController
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter())) APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter());
if (EditorPC)
{ {
SceneData.LevelName = EditorPC->CurrentBaseLevel; 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<UPS_Editor_TimelineSubsystem>();
}
const TArray<TWeakObjectPtr<AActor>>& SpawnedActors = SpawnManager->GetSpawnedActors(); const TArray<TWeakObjectPtr<AActor>>& SpawnedActors = SpawnManager->GetSpawnedActors();
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors) for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{ {
@@ -47,6 +57,7 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
FPS_Editor_ActorData ActorData; FPS_Editor_ActorData ActorData;
ActorData.ClassPath = Actor->GetClass()->GetPathName(); ActorData.ClassPath = Actor->GetClass()->GetPathName();
ActorData.ActorId = TimelineSS ? TimelineSS->GetOrCreateActorId(Actor) : FGuid::NewGuid();
ActorData.Location = Actor->GetActorLocation(); ActorData.Location = Actor->GetActorLocation();
ActorData.Rotation = Actor->GetActorRotation(); ActorData.Rotation = Actor->GetActorRotation();
ActorData.Scale = Actor->GetActorScale3D(); ActorData.Scale = Actor->GetActorScale3D();
@@ -111,6 +122,12 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
SceneData.Actors.Add(ActorData); SceneData.Actors.Add(ActorData);
} }
// Pull the global timeline data (if any) from the world subsystem
if (TimelineSS)
{
SceneData.Timeline = TimelineSS->GetData();
}
// Convert to JSON // Convert to JSON
FString JsonString; FString JsonString;
if (!FJsonObjectConverter::UStructToJsonObjectString(SceneData, JsonString, 0, 0, 0, nullptr, true)) 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; UPS_Editor_SceneLoader::bIsCurrentlyLoading = true;
for (const FPS_Editor_ActorData& ActorData : SceneData.Actors) for (const FPS_Editor_ActorData& ActorData : SceneData.Actors)
{ {
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath); // TryLoadClass synchronously loads the BP package AND resolves the generated class,
// handling redirectors. Plain LoadObject<UClass> can return null on a cold first load
// of a Blueprint whose package hasn't been through post-load yet — that's the source
// of the "it works on the second try" bug.
FSoftClassPath SoftPath(ActorData.ClassPath);
UClass* LoadedClass = SoftPath.TryLoadClass<AActor>();
if (!LoadedClass)
{
// Fallback for paths that aren't a "Class'...'" soft ref (e.g. legacy data)
LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass) if (!LoadedClass)
{ {
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath); LoadedClass = FindObject<UClass>(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); AActor* SpawnedActor = SpawnManager->SpawnActorDirect(LoadedClass, ActorData.Location, ActorData.Rotation, ActorData.Scale);
if (SpawnedActor) 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<UPS_Editor_TimelineSubsystem>())
{
TS->RegisterActorId(SpawnedActor, ActorData.ActorId);
}
}
}
// Restore preload tag // Restore preload tag
if (ActorData.bPreload) if (ActorData.bPreload)
{ {
@@ -285,6 +324,18 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
UPS_Editor_SceneLoader::bIsCurrentlyLoading = false; UPS_Editor_SceneLoader::bIsCurrentlyLoading = false;
// Push timeline data into the world subsystem (editor mode: no auto-play)
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter()))
{
if (UWorld* World = EditorPC->GetWorld())
{
if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>())
{
TS->SetData(SceneData.Timeline);
}
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene loaded: %s (%d actors)"), *SceneName, SpawnedCount); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene loaded: %s (%d actors)"), *SceneName, SpawnedCount);
return true; return true;
} }
@@ -329,6 +380,18 @@ void UPS_Editor_SceneSerializer::ClearScene(UPS_Editor_SpawnManager* SpawnManage
{ {
SpawnManager->DestroyAllSpawnedActors(); SpawnManager->DestroyAllSpawnedActors();
} }
// Flush any timeline data tied to the previous scenario
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter()))
{
if (UWorld* World = EditorPC->GetWorld())
{
if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>())
{
TS->Clear();
}
}
}
} }
FString UPS_Editor_SceneSerializer::GetSceneFilePath(const FString& SceneName) const FString UPS_Editor_SceneSerializer::GetSceneFilePath(const FString& SceneName) const

View File

@@ -226,6 +226,18 @@ bool UPS_Editor_EditableComponent::RequiresRespawn(const FName& PropertyPath) co
return false; 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 FString UPS_Editor_EditableComponent::GetDisplayName(const FName& Path) const
{ {
for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties) for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties)

View File

@@ -35,6 +35,16 @@ struct FPS_Editor_EditablePropertyEntry
*/ */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
bool bNeedsRespawn = false; 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. */ /** Returns true if the given property path requires a full respawn. */
bool RequiresRespawn(const FName& PropertyPath) const; 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. */ /** Lists available component names on the owning actor. */
UFUNCTION() UFUNCTION()
TArray<FString> GetAvailableComponentNames() const; TArray<FString> GetAvailableComponentNames() const;

View File

@@ -4,11 +4,24 @@
#include "PS_Editor_EditableInterface.h" #include "PS_Editor_EditableInterface.h"
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_TimelineSubsystem.h"
#include "AIController.h" #include "AIController.h"
#include "BrainComponent.h" #include "BrainComponent.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "Engine/World.h" #include "Engine/World.h"
namespace
{
static void RegisterSpawnedActorId(UWorld* World, AActor* Actor)
{
if (!World || !Actor) return;
if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>())
{
TS->GetOrCreateActorId(Actor);
}
}
}
void UPS_Editor_SpawnManager::Initialize(APlayerController* InOwnerPC) void UPS_Editor_SpawnManager::Initialize(APlayerController* InOwnerPC)
{ {
OwnerPC = 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); SpawnedActors.Add(SpawnedActor);
RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor);
// Auto-snap to ground if configured // Auto-snap to ground if configured
UPS_Editor_SpawnableComponent* SpawnComp = SpawnedActor->FindComponentByClass<UPS_Editor_SpawnableComponent>(); UPS_Editor_SpawnableComponent* SpawnComp = SpawnedActor->FindComponentByClass<UPS_Editor_SpawnableComponent>();
@@ -260,6 +274,7 @@ AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Lo
SpawnedActor->SetActorScale3D(Scale); SpawnedActor->SetActorScale3D(Scale);
SpawnedActors.Add(SpawnedActor); SpawnedActors.Add(SpawnedActor);
RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor);
return SpawnedActor; return SpawnedActor;
} }
@@ -283,6 +298,10 @@ void UPS_Editor_SpawnManager::TrackSpawnedActor(AActor* Actor)
if (Actor) if (Actor)
{ {
SpawnedActors.Add(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(); UClass* ActorClass = OldActor->GetClass();
const FTransform OldTransform = OldActor->GetActorTransform(); 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<UPS_Editor_TimelineSubsystem>() : nullptr;
const FGuid PreservedId = TimelineSS ? TimelineSS->FindActorId(OldActor) : FGuid();
// 1. Remove old actor from tracking and destroy // 1. Remove old actor from tracking and destroy
SpawnedActors.RemoveAll([OldActor](const TWeakObjectPtr<AActor>& Weak) SpawnedActors.RemoveAll([OldActor](const TWeakObjectPtr<AActor>& Weak)
@@ -383,6 +405,18 @@ AActor* UPS_Editor_SpawnManager::RespawnActorWithProperties(AActor* OldActor, co
} }
SpawnedActors.Add(NewActor); 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()); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Respawned %s with %d properties"), *ActorClass->GetName(), PropertyValues.Num());
return NewActor; return NewActor;
} }

View File

@@ -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<FPS_Editor_TimelineKey> 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<FPS_Editor_TimelineTrack> Tracks;
};

View File

@@ -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<FFloatProperty>(Prop)) return true;
if (CastField<FDoubleProperty>(Prop)) return true;
if (CastField<FIntProperty>(Prop)) return true;
if (CastField<FInt64Property>(Prop)) return true;
if (const FStructProperty* Struct = CastField<FStructProperty>(Prop))
{
if (Struct->Struct == TBaseStructure<FVector>::Get()) return true;
if (Struct->Struct == TBaseStructure<FVector2D>::Get()) return true;
if (Struct->Struct == TBaseStructure<FRotator>::Get()) return true;
if (Struct->Struct == TBaseStructure<FLinearColor>::Get()) return true;
if (Struct->Struct == TBaseStructure<FColor>::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<FFloatProperty>(Prop))
{
const float Va = FCString::Atof(*A);
const float Vb = FCString::Atof(*B);
return FString::SanitizeFloat(FMath::Lerp(Va, Vb, Clamped));
}
// Double
if (CastField<FDoubleProperty>(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<FIntProperty>(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<FInt64Property>(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<FStructProperty>(Prop))
{
if (Struct->Struct == TBaseStructure<FVector>::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<FVector2D>::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<FRotator>::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<FLinearColor>::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<FColor>::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;
}

View File

@@ -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);
};

View File

@@ -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<APS_Editor_PlayerController>(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<UActorComponent>(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<AActor> 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<AActor>(Actor)))
{
return *Existing;
}
return FGuid();
}
AActor* UPS_Editor_TimelineSubsystem::FindActorById(const FGuid& Id) const
{
if (!Id.IsValid()) return nullptr;
for (const TPair<TWeakObjectPtr<AActor>, 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<AActor>(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<UActorComponent*> 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<FProperty>(OutTarget->GetClass(), *PropName);
}
}

View File

@@ -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<FPS_Editor_TimelineTrack>& 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<FString> LastAppliedValues;
/** Map of stable IDs for every actor that participates in the timeline / scene registry. */
TMap<TWeakObjectPtr<AActor>, 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);
};

View File

@@ -23,11 +23,15 @@
#include "Widgets/Input/SComboBox.h" #include "Widgets/Input/SComboBox.h"
#include "Engine/Texture2D.h" #include "Engine/Texture2D.h"
#include "Widgets/Input/SEditableTextBox.h" #include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/Input/SSlider.h"
#include "Widgets/Layout/SConstraintCanvas.h"
#include "Widgets/SOverlay.h" #include "Widgets/SOverlay.h"
#include "UObject/UnrealType.h" #include "UObject/UnrealType.h"
#include "Kismet/GameplayStatics.h" #include "Kismet/GameplayStatics.h"
#include "DesktopPlatformModule.h" #include "DesktopPlatformModule.h"
#include "PS_Editor_GameInstanceSubsystem.h" #include "PS_Editor_GameInstanceSubsystem.h"
#include "PS_Editor_TimelineSubsystem.h"
#include "Engine/World.h"
void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager) void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
{ {
@@ -171,6 +175,14 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
BuildOutliner() 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 // Bottom notification / help bar
+ SOverlay::Slot() + SOverlay::Slot()
.HAlign(HAlign_Center) .HAlign(HAlign_Center)
@@ -918,6 +930,24 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
+ SHorizontalBox::Slot() + SHorizontalBox::Slot()
.AutoWidth() .AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f) .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) SNew(SButton)
.OnClicked_Lambda([this]() -> FReply .OnClicked_Lambda([this]() -> FReply
@@ -947,9 +977,14 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (SceneSerializer.IsValid() && SpawnManager.IsValid()) 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); 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<APS_Editor_PlayerController>(GetOwningPlayer())) if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{ {
@@ -1142,6 +1177,42 @@ void UPS_Editor_MainWidget::NativeConstruct()
{ {
ShowLoadingScreen(false); 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<UPS_Editor_TimelineSubsystem>())
{
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<UPS_Editor_TimelineSubsystem>())
{
TS->OnTimelineChanged.Remove(TimelineChangedHandle);
}
}
TimelineChangedHandle.Reset();
}
Super::NativeDestruct();
} }
void UPS_Editor_MainWidget::PopulateSceneList() void UPS_Editor_MainWidget::PopulateSceneList()
@@ -1180,9 +1251,14 @@ void UPS_Editor_MainWidget::PopulateSceneList()
{ {
if (SceneSerializer.IsValid() && SpawnManager.IsValid()) 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); 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<APS_Editor_PlayerController>(GetOwningPlayer())) if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{ {
@@ -1435,6 +1511,7 @@ void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDelt
RefreshPropertiesFromSelection(); RefreshPropertiesFromSelection();
RefreshOutliner(); RefreshOutliner();
RefreshTimelineUI();
} }
void UPS_Editor_MainWidget::RefreshPropertiesFromSelection() void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
@@ -2083,8 +2160,40 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
} // end else (enum/struct/default type detection) } // end else (enum/struct/default type detection)
DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 2.0f) const bool bAnimatable = EditComp->IsAnimatable(Entry.Path);
[ ValueWidget ];
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); DynamicPropertyRows.Add(Row);
} }
@@ -2704,3 +2813,761 @@ void UPS_Editor_MainWidget::ExecuteClose(bool bSave)
// Delegate cleanup + transition to PlayerController // Delegate cleanup + transition to PlayerController
EditorPC->CloseEditor(); 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<UPS_Editor_TimelineSubsystem>() : 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<float>, 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<SWidget> 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<float> 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<float> DurationAttr;
TAttribute<float> CurrentTimeAttr;
FOnTimeSeek OnSeek;
};
TSharedRef<SWidget> 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<FPS_Editor_TimelineTrack>& 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<UPS_Editor_SpawnableComponent>())
{
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<SConstraintCanvas> 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<float> VisualTime = MakeShared<float>(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<UPS_Editor_MainWidget*>(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;
}

View File

@@ -50,6 +50,7 @@ public:
protected: protected:
virtual TSharedRef<SWidget> RebuildWidget() override; virtual TSharedRef<SWidget> RebuildWidget() override;
virtual void NativeConstruct() override; virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override; virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private: private:
@@ -122,6 +123,19 @@ private:
// Simulate button // Simulate button
TSharedPtr<STextBlock> SimulateButtonText; TSharedPtr<STextBlock> SimulateButtonText;
// Timeline panel
TSharedPtr<SWidget> TimelinePanel;
TSharedPtr<class SSlider> TimelineSlider;
TSharedPtr<STextBlock> TimelineTimeLabel;
TSharedPtr<STextBlock> TimelinePlayButtonText;
TSharedPtr<SEditableTextBox> TimelineDurationBox;
TSharedPtr<SVerticalBox> 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 // Close confirmation
TSharedPtr<SWidget> CloseConfirmOverlay; TSharedPtr<SWidget> CloseConfirmOverlay;
bool bSceneDirty = false; bool bSceneDirty = false;
@@ -140,6 +154,7 @@ public:
TSharedRef<SWidget> BuildSpawnCatalog(); TSharedRef<SWidget> BuildSpawnCatalog();
TSharedRef<SWidget> BuildSceneButtons(); TSharedRef<SWidget> BuildSceneButtons();
TSharedRef<SWidget> BuildOutliner(); TSharedRef<SWidget> BuildOutliner();
TSharedRef<SWidget> BuildTimelinePanel();
void RefreshOutliner(); void RefreshOutliner();
void PopulateSceneList(); void PopulateSceneList();
void PopulateSpawnCatalog(); void PopulateSpawnCatalog();
@@ -148,6 +163,14 @@ public:
void RebuildDynamicProperties(); void RebuildDynamicProperties();
void RefreshDynamicPropertyValues(); void RefreshDynamicPropertyValues();
void ApplyPropertyFromUI(int32 RowIndex); 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); static UObject* FindPropertyTarget(AActor* Actor, const FString& ComponentName);
FString FloatToString(float Value) const; FString FloatToString(float Value) const;