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:
@@ -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())
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user