Compare commits

...

3 Commits

Author SHA1 Message Date
4181801635 Timeline polish + simulate/timeline lockstep + default base level
Timeline behavior:
- Natural end of playback now clamps to Duration and holds values there
  (no auto-rewind, no simulation reset) — symmetric between editor and
  gameplay. New Restart button does the rewind+replay sequence explicitly.
- Pause now freezes character movement too (new SetSimulationPaused on
  the PlayerController stops AI ticks without restoring saved transforms
  the way StopSimulation does).
- Simulate toolbar button is wired through the timeline: with at least one
  track, it toggles Play/Pause; without tracks it falls back to direct
  StartSimulation/StopSimulation. Label stays "Simulate"/"Stop" so it
  doesn't duplicate the timeline panel's own Play button.
- SetKeyTime re-sorts keys; clicking a key selects the animated actor
  exclusively (ClearSelection first) so splines release their handles.
- Seek now invalidates LastAppliedValues: a manual property edit between
  two seeks used to leak onto the next key because the cache made the
  next seek skip the write.
- Keyframe colors are saturated: vivid blue / orange / green / yellow
  instead of the washed-out pastels.

Scene load robustness:
- New LoadActorClassRobust chains TryLoadClass, LoadPackage+retry,
  LoadObject, StaticLoadClass — covers cold BP post-load races that made
  actors silently fail to spawn on the first PIE of a session.
- Failed class loads and null-spawn actors now log Error with class path
  and location; a Warning/Log recap lands at the end of every load.
- Actors that end up hidden or invalid after the pipeline get their own
  Warning/Error lines for post-mortem diagnosis.

Base level handling:
- New bIsDefault flag on FPS_Editor_BaseLevelEntry. The entry marked
  default is loaded automatically at editor startup (when no pending
  scenario is queued) and as fallback when a scenario saved without a
  base level ("Unfiled" folder) is opened.
- Removed the hardcoded "None" option from the base level popup — the
  project defines its own default entry instead (e.g. renamed to "None").
- Previous behavior of LoadBaseLevelAsSublevel("Unfiled") is gone; the
  bogus sublevel load used to leave the loading screen stuck.

Spline visuals:
- SetRenderCustomDepth is now off by default and only turned on while the
  spline is selected — project outline post-process materials no longer
  highlight every loaded spline.
- ImportFromCustomProperties forces SetHandlesVisible(false) and
  SetSelected(false) after setting points (both PS_Editor and
  PS_BehaviorEditor versions) so loaded splines start in the deselected
  visual state.

AI in editor:
- Spawned pawns now get their AI disabled synchronously right after
  SpawnActor (in addition to the existing next-tick fallback) so the BT
  can't drive the character for the 1-frame gap between spawn and timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:47:45 +02:00
75f54e27c4 Ignore .claude/settings.local.json
Per-user local Claude Code settings have no business being versioned —
remove the previously tracked file and add the path to .gitignore so
local permission changes don't show up in future diffs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:32:49 +02:00
4b0a182013 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>
2026-04-18 11:28:21 +02:00
22 changed files with 2365 additions and 90 deletions

View File

@@ -1,31 +0,0 @@
{
"permissions": {
"allow": [
"Bash(reg query:*)",
"Bash(\"C:/Program Files/Epic Games/UE_5.5/Engine/Build/BatchFiles/Build.bat\" PS_ProserveEditorEditor Win64 Development -Project=\"C:/ASTERION/GIT/PS_ProserveEditor/Unreal/PS_ProserveEditor.uproject\" -WaitMutex -FromMsBuild)",
"Bash(cp \"C:/Users/j_foucher/.claude/projects/C--ASTERION-GIT-PS-ProserveEditor/memory/MEMORY.md\" \"C:/ASTERION/GIT/PS_ProserveEditor/.claude/memory/MEMORY.md\")",
"Bash(cp \"C:/Users/j_foucher/.claude/projects/C--ASTERION-GIT-PS-ProserveEditor/memory/project_ps_editor.md\" \"C:/ASTERION/GIT/PS_ProserveEditor/.claude/memory/project_ps_editor.md\")",
"Bash(cp \"C:/Users/j_foucher/.claude/projects/C--ASTERION-GIT-PS-ProserveEditor/memory/feedback_naming.md\" \"C:/ASTERION/GIT/PS_ProserveEditor/.claude/memory/feedback_naming.md\")",
"Bash(cp \"C:/Users/j_foucher/.claude/projects/C--ASTERION-GIT-PS-ProserveEditor/memory/reference_build.md\" \"C:/ASTERION/GIT/PS_ProserveEditor/.claude/memory/reference_build.md\")",
"Bash(cp \"C:/Users/j_foucher/.claude/projects/C--ASTERION-GIT-PS-ProserveEditor/memory/feedback_build_workflow.md\" \"C:/ASTERION/GIT/PS_ProserveEditor/.claude/memory/feedback_build_workflow.md\")",
"Bash(powershell -Command \"\\(Get-ItemProperty 'HKLM:\\\\SOFTWARE\\\\EpicGames\\\\Unreal Engine\\\\5.5' -Name 'InstalledDirectory'\\).InstalledDirectory\")",
"Bash(\"C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" PS_ProserveEditorEditor Win64 Development -Project=\"C:\\\\ASTERION\\\\GIT\\\\PS_ProserveEditor\\\\Unreal\\\\PS_ProserveEditor.uproject\" -WaitMutex -FromMsBuild)",
"Bash(tasklist)",
"Bash(fsutil reparsepoint:*)",
"Bash(svn propget:*)",
"Bash(where svn:*)",
"Bash(where TortoiseProc)",
"Bash(dir \"C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\")",
"Bash(dir \"D:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\")",
"Bash(dir \"E:\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\")",
"Bash(where UnrealBuildTool)",
"Bash(git add:*)",
"Bash(git commit -m ':*)",
"Bash(cmd //c mklink /J \"C:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Plugins\\\\PS_BehaviorEditor\" \"C:\\\\ASTERION\\\\GIT\\\\PS_ProserveEditor\\\\Unreal\\\\Plugins\\\\PS_BehaviorEditor\")",
"Bash(cmd /c \"mklink /J \\\\\"C:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Plugins\\\\PS_BehaviorEditor\\\\\" \\\\\"C:\\\\ASTERION\\\\GIT\\\\PS_ProserveEditor\\\\Unreal\\\\Plugins\\\\PS_BehaviorEditor\\\\\"\")",
"Bash(cmd /c \"dir C:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Plugins\\\\PS_BehaviorEditor\")",
"Bash(powershell -Command \"New-Item -ItemType Junction -Path 'C:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Plugins\\\\PS_BehaviorEditor' -Target 'C:\\\\ASTERION\\\\GIT\\\\PS_ProserveEditor\\\\Unreal\\\\Plugins\\\\PS_BehaviorEditor'\")",
"Read(//c/ASTERION/SVN/DEV/PROSERVE_UE_5_5/**)"
]
}
}

3
.gitignore vendored
View File

@@ -16,3 +16,6 @@ Unreal/Plugins/*/Intermediate/
Thumbs.db
Desktop.ini
.DS_Store
# Claude Code per-user local settings
.claude/settings.local.json

View File

@@ -28,7 +28,9 @@ APS_BehaviorEditor_AISpline::APS_BehaviorEditor_AISpline()
SplineMeshComp->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
SplineMeshComp->bUseComplexAsSimpleCollision = true;
SplineMeshComp->SetCastShadow(false);
SplineMeshComp->SetRenderCustomDepth(true);
// Custom depth off by default; SetSelected enables it while selected so the outline PP
// only catches the currently-selected spline.
SplineMeshComp->SetRenderCustomDepth(false);
SplineMeshComp->SetCustomDepthStencilValue(2);
// Handle parent
@@ -409,6 +411,8 @@ void APS_BehaviorEditor_AISpline::SetSelected(bool bSelected)
{
UMaterialInstanceDynamic* Mat = bSelected ? SplineMatSelected : SplineMat;
if (Mat) SplineMeshComp->SetMaterial(0, Mat);
// Only participate in the custom-depth pass while selected (see SplineActor).
SplineMeshComp->SetRenderCustomDepth(bSelected);
SplineMeshComp->SetCustomDepthStencilValue(bSelected ? 1 : 2);
}
if (CenterCubeMat)
@@ -528,4 +532,10 @@ void APS_BehaviorEditor_AISpline::ImportFromCustomProperties(const TMap<FString,
Priority = FCString::Atoi(**PriStr);
UpdateSplineMaterials();
// Force deselected state on load: handles default to visible on creation and
// UpdateVisualization uses handle visibility to pick the "selected" material.
SetHandlesVisible(false);
SetSelected(false);
UpdateVisualization();
}

View File

@@ -16,6 +16,8 @@
#include "PS_Editor_SplineActor.h"
#include "PS_Editor_SplineEditable.h"
#include "PS_Editor_ChildMovable.h"
#include "PS_Editor_HUD.h"
#include "PS_Editor_MainWidget.h"
#include "Components/SplineComponent.h"
#include "DrawDebugHelpers.h"
#include "PS_Editor_ConfirmDialog.h"
@@ -1026,6 +1028,18 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return;
// 1st priority: if a timeline key is selected in the HUD, delete it and stop.
if (APS_Editor_HUD* EditorHUD = Cast<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
if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement
&& ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())

View File

@@ -66,6 +66,7 @@ void APS_Editor_PlayerController::BeginPlay()
}
// Check if PROSERVE set a pending base level + scenario via the subsystem
bool bHandledStartupLevel = false;
if (UGameInstance* GI = GetGameInstance())
{
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
@@ -88,6 +89,7 @@ void APS_Editor_PlayerController::BeginPlay()
}
}
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel, ResolvedMapPath, ResolvedExtras);
bHandledStartupLevel = true;
if (!Sub->PendingScenarioName.IsEmpty() && SpawnManager && SceneSerializer)
{
@@ -101,6 +103,23 @@ void APS_Editor_PlayerController::BeginPlay()
}
}
// No pending base level from the subsystem — fall back to the catalog's default
// (entry with bIsDefault=true) so the editor always starts with some ground/lighting
// instead of a bare persistent level.
if (!bHandledStartupLevel)
{
for (const FPS_Editor_BaseLevelEntry& Entry : Master->BaseLevels)
{
if (Entry.bIsDefault && !Entry.DisplayName.IsEmpty() && !Entry.MapPath.IsEmpty())
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Loading catalog default base level at startup — DisplayName='%s', MapPath='%s'"),
*Entry.DisplayName, *Entry.MapPath);
LoadBaseLevelAsSublevel(Entry.DisplayName, Entry.MapPath, Entry.AdditionalSublevels);
break;
}
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: MasterCatalog loaded (%d catalogs, %d entries). BaseLevel: '%s'"),
Master->Catalogs.Num(), SpawnManager->GetResolvedEntries().Num(), *CurrentBaseLevel);
}
@@ -259,6 +278,37 @@ void APS_Editor_PlayerController::StartSimulation()
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Simulation started (%d actors saved)"), SimulationSavedTransforms.Num());
}
void APS_Editor_PlayerController::SetSimulationPaused(bool bPaused)
{
// Toggle AI tick + BT on every spawned pawn without touching bSimulating or the saved
// transforms — the timeline's Pause uses this so characters freeze instead of snapping
// back to their pre-play positions.
if (!SpawnManager) return;
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
{
APawn* P = Cast<APawn>(Weak.Get());
if (!P) continue;
P->SetActorTickEnabled(!bPaused);
if (AAIController* AIC = Cast<AAIController>(P->GetController()))
{
AIC->SetActorTickEnabled(!bPaused);
if (UBrainComponent* Brain = AIC->GetBrainComponent())
{
if (bPaused)
{
Brain->StopLogic(TEXT("PS_Editor_Pause"));
}
else
{
Brain->RestartLogic();
}
}
}
}
}
void APS_Editor_PlayerController::StopSimulation()
{
if (!bSimulating) return;
@@ -531,6 +581,17 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa
else
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load sublevel '%s' (path: '%s')"), *LevelName, *LevelToLoad);
// Safety net: the async load failed, OnLevelShown will never fire, so we must
// release the loading screen and the fade-to-black here or the UI gets stuck.
ShowLoadingScreen(false);
bWaitingForSublevelFadeIn = false;
bPendingFadeToBlack = false;
if (PlayerCameraManager)
{
// Fade back in immediately so the user isn't staring at a black screen
PlayerCameraManager->StartCameraFade(1.0f, 0.0f, 0.0f, FLinearColor::Black, false, false);
}
}
}

View File

@@ -74,6 +74,14 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
bool IsSimulating() const { return bSimulating; }
/**
* Toggle AI execution on all spawned pawns without changing the simulation's
* saved-transforms state. Used by the timeline's Pause so the characters freeze
* in place instead of snapping back to their pre-play positions.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
void SetSimulationPaused(bool bPaused);
/** Load a base level as sublevel for visual context. Unloads previous sublevel.
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
* @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse").

View File

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

View File

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

View File

@@ -7,11 +7,44 @@
#include "PS_Editor_SplineActor.h"
#include "PS_Editor_SplineEditable.h"
#include "PS_Editor_PointPlaceable.h"
#include "PS_Editor_TimelineSubsystem.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_PlayerController.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Misc/PackageName.h"
#include "UObject/UnrealType.h"
#include "UObject/Package.h"
#include "Components/ActorComponent.h"
#include "Engine/World.h"
#include "GameFramework/PlayerController.h"
namespace
{
/** Same robust class loader as in SceneSerializer — kept local to avoid an extra export. */
static UClass* LoadActorClassRobust_SceneLoader(const FString& ClassPath)
{
if (ClassPath.IsEmpty()) return nullptr;
FSoftClassPath SoftPath(ClassPath);
if (UClass* C = SoftPath.TryLoadClass<AActor>()) return C;
const FString PackageName = FPackageName::ObjectPathToPackageName(ClassPath);
if (!PackageName.IsEmpty())
{
if (UPackage* Package = LoadPackage(nullptr, *PackageName, LOAD_None))
{
if (UClass* C = SoftPath.TryLoadClass<AActor>()) return C;
if (UClass* C = FindObject<UClass>(nullptr, *ClassPath)) return C;
}
}
if (UClass* C = LoadObject<UClass>(nullptr, *ClassPath)) return C;
if (UClass* C = StaticLoadClass(AActor::StaticClass(), nullptr, *ClassPath)) return C;
return nullptr;
}
}
bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors, bool bStripEditorComponents, EPS_Editor_SceneLoadFilter Filter)
{
@@ -95,6 +128,8 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S
return false;
}
UPS_Editor_TimelineSubsystem* TimelineSS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>();
int32 SpawnedCount = 0;
bIsCurrentlyLoading = true;
@@ -103,12 +138,32 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S
AActor* SpawnedActor = SpawnActorFromData(World, ActorData, Filter);
if (SpawnedActor)
{
// Register the stable ID so timeline tracks can find this actor
if (TimelineSS && ActorData.ActorId.IsValid())
{
TimelineSS->RegisterActorId(SpawnedActor, ActorData.ActorId);
}
else if (TimelineSS)
{
TimelineSS->GetOrCreateActorId(SpawnedActor);
}
OutActors.Add(SpawnedActor);
SpawnedCount++;
}
}
bIsCurrentlyLoading = false;
// Push timeline data into the subsystem, and auto-play if we're in gameplay (not the runtime editor)
if (TimelineSS)
{
TimelineSS->SetData(SceneData.Timeline);
if (!IsInEditorMode() && SceneData.Timeline.Tracks.Num() > 0)
{
TimelineSS->Play();
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor SceneLoader: Loaded %d actors from scene '%s'"),
SpawnedCount, *SceneData.SceneName);
return SpawnedCount > 0;
@@ -120,15 +175,13 @@ AActor* UPS_Editor_SceneLoader::SpawnActorFromData(UWorld* World, const FPS_Edit
if (Filter == EPS_Editor_SceneLoadFilter::PreloadOnly && !ActorData.bPreload) return nullptr;
if (Filter == EPS_Editor_SceneLoadFilter::ExcludePreload && ActorData.bPreload) return nullptr;
// Load class (supports both Blueprint and C++ classes)
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
// Robust class load: tries TryLoadClass, then force-loads the package and retries,
// then falls back to LoadObject / StaticLoadClass. Covers cold-start races where a BP
// package isn't fully post-loaded yet.
UClass* LoadedClass = LoadActorClassRobust_SceneLoader(ActorData.ClassPath);
if (!LoadedClass)
{
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor SceneLoader: Failed to load class: %s"), *ActorData.ClassPath);
UE_LOG(LogTemp, Error, TEXT("PS_Editor SceneLoader: Failed to load class after all fallbacks: %s"), *ActorData.ClassPath);
return nullptr;
}
@@ -138,7 +191,8 @@ AActor* UPS_Editor_SceneLoader::SpawnActorFromData(UWorld* World, const FPS_Edit
AActor* SpawnedActor = World->SpawnActor<AActor>(LoadedClass, ActorData.Location, ActorData.Rotation, SpawnParams);
if (!SpawnedActor)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor SceneLoader: Failed to spawn: %s"), *ActorData.ClassPath);
UE_LOG(LogTemp, Error, TEXT("PS_Editor SceneLoader: SpawnActor returned null for %s at %s"),
*ActorData.ClassPath, *ActorData.Location.ToString());
return nullptr;
}

View File

@@ -11,12 +11,50 @@
#include "AIController.h"
#include "BrainComponent.h"
#include "PS_Editor_SplineEditable.h"
#include "PS_Editor_TimelineSubsystem.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Misc/PackageName.h"
#include "HAL/PlatformFileManager.h"
#include "UObject/UnrealType.h"
#include "UObject/Package.h"
#include "Components/ActorComponent.h"
#include "Engine/World.h"
namespace
{
/**
* Robust class load for Blueprint actor classes. Layers several strategies so we cover
* cold-start cases where the package isn't in memory yet and async post-load phases
* that leave TryLoadClass returning null briefly.
*/
static UClass* LoadActorClassRobust(const FString& ClassPath)
{
if (ClassPath.IsEmpty()) return nullptr;
// 1. SoftClassPath::TryLoadClass forces the full BP load chain (package + generated class + post-load)
FSoftClassPath SoftPath(ClassPath);
if (UClass* C = SoftPath.TryLoadClass<AActor>()) return C;
// 2. Force-load the containing package synchronously, then retry
const FString PackageName = FPackageName::ObjectPathToPackageName(ClassPath);
if (!PackageName.IsEmpty())
{
UPackage* Package = LoadPackage(nullptr, *PackageName, LOAD_None);
if (Package)
{
if (UClass* C = SoftPath.TryLoadClass<AActor>()) return C;
if (UClass* C = FindObject<UClass>(nullptr, *ClassPath)) return C;
}
}
// 3. LoadObject / StaticLoadClass as last-resort fallbacks
if (UClass* C = LoadObject<UClass>(nullptr, *ClassPath)) return C;
if (UClass* C = StaticLoadClass(AActor::StaticClass(), nullptr, *ClassPath)) return C;
return nullptr;
}
}
bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
{
@@ -31,11 +69,19 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
SceneData.Timestamp = FDateTime::Now().ToString();
// Use the current base level from PlayerController
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter()))
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter());
if (EditorPC)
{
SceneData.LevelName = EditorPC->CurrentBaseLevel;
}
// Resolve the timeline subsystem (used to assign / query stable actor IDs)
UPS_Editor_TimelineSubsystem* TimelineSS = nullptr;
if (EditorPC && EditorPC->GetWorld())
{
TimelineSS = EditorPC->GetWorld()->GetSubsystem<UPS_Editor_TimelineSubsystem>();
}
const TArray<TWeakObjectPtr<AActor>>& SpawnedActors = SpawnManager->GetSpawnedActors();
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
@@ -47,6 +93,7 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
FPS_Editor_ActorData ActorData;
ActorData.ClassPath = Actor->GetClass()->GetPathName();
ActorData.ActorId = TimelineSS ? TimelineSS->GetOrCreateActorId(Actor) : FGuid::NewGuid();
ActorData.Location = Actor->GetActorLocation();
ActorData.Rotation = Actor->GetActorRotation();
ActorData.Scale = Actor->GetActorScale3D();
@@ -111,6 +158,12 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
SceneData.Actors.Add(ActorData);
}
// Pull the global timeline data (if any) from the world subsystem
if (TimelineSS)
{
SceneData.Timeline = TimelineSS->GetData();
}
// Convert to JSON
FString JsonString;
if (!FJsonObjectConverter::UStructToJsonObjectString(SceneData, JsonString, 0, 0, 0, nullptr, true))
@@ -168,23 +221,41 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
// Re-spawn actors from saved data
int32 SpawnedCount = 0;
int32 FailedClassCount = 0;
int32 FailedSpawnCount = 0;
UPS_Editor_SceneLoader::bIsCurrentlyLoading = true;
for (const FPS_Editor_ActorData& ActorData : SceneData.Actors)
{
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
UClass* LoadedClass = LoadActorClassRobust(ActorData.ClassPath);
if (!LoadedClass)
{
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class: %s"), *ActorData.ClassPath);
++FailedClassCount;
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to load class after all fallbacks: %s"), *ActorData.ClassPath);
continue;
}
AActor* SpawnedActor = SpawnManager->SpawnActorDirect(LoadedClass, ActorData.Location, ActorData.Rotation, ActorData.Scale);
if (!SpawnedActor)
{
++FailedSpawnCount;
UE_LOG(LogTemp, Error, TEXT("PS_Editor: SpawnActor returned null for class %s at %s"),
*ActorData.ClassPath, *ActorData.Location.ToString());
continue;
}
if (SpawnedActor)
{
// Restore stable actor ID from the JSON (needed so timeline tracks can find the actor)
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
if (ActorData.bPreload)
{
@@ -259,13 +330,16 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
}
// Stop AI on spawned pawns (editor mode — AI only runs during Simulate)
// Apply immediately if the AIController is already assigned (typical path, since
// SpawnActor completes the full PossessedBy chain synchronously), and keep the
// deferred call as a safety net for any case where auto-possession takes longer.
// Without the immediate call, the BT has a 1-frame window to move the pawn.
if (APawn* P = Cast<APawn>(SpawnedActor))
{
TWeakObjectPtr<APawn> WeakP = P;
P->GetWorldTimerManager().SetTimerForNextTick([WeakP]()
{
if (APawn* Pawn = WeakP.Get())
auto DisableAI = [](APawn* Pawn)
{
if (!Pawn) return;
Pawn->SetActorTickEnabled(false);
if (AAIController* AIC = Cast<AAIController>(Pawn->GetController()))
{
AIC->SetActorTickEnabled(false);
@@ -273,18 +347,62 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
{
Brain->StopLogic(TEXT("PS_Editor"));
}
Pawn->SetActorTickEnabled(false);
}
}
};
DisableAI(P);
TWeakObjectPtr<APawn> WeakP = P;
P->GetWorldTimerManager().SetTimerForNextTick([WeakP, DisableAI]()
{
DisableAI(WeakP.Get());
});
}
// Post-spawn diagnostics: if the actor got hidden or destroyed by its own BP logic
// we want that visible in the log so the "character not loading first time" bug
// can be traced to a specific actor.
if (!IsValid(SpawnedActor))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Actor %s became invalid after load pipeline"), *ActorData.ClassPath);
}
else if (SpawnedActor->IsHidden())
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Actor %s (%s) spawned HIDDEN after load"), *SpawnedActor->GetName(), *ActorData.ClassPath);
}
else
{
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Spawned %s (%s) at %s"), *SpawnedActor->GetName(), *ActorData.ClassPath, *ActorData.Location.ToString());
}
SpawnedCount++;
}
}
UPS_Editor_SceneLoader::bIsCurrentlyLoading = false;
const int32 TotalExpected = SceneData.Actors.Num();
if (FailedClassCount > 0 || FailedSpawnCount > 0)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Scene load incomplete — %d/%d actors spawned, %d class loads failed, %d spawns failed"),
SpawnedCount, TotalExpected, FailedClassCount, FailedSpawnCount);
}
else
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene load complete — %d/%d actors spawned"), SpawnedCount, TotalExpected);
}
// Push timeline data into the world subsystem (editor mode: no auto-play)
if (APS_Editor_PlayerController* EditorPC = Cast<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);
return true;
}
@@ -329,6 +447,18 @@ void UPS_Editor_SceneSerializer::ClearScene(UPS_Editor_SpawnManager* SpawnManage
{
SpawnManager->DestroyAllSpawnedActors();
}
// Flush any timeline data tied to the previous scenario
if (APS_Editor_PlayerController* EditorPC = Cast<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

View File

@@ -226,6 +226,18 @@ bool UPS_Editor_EditableComponent::RequiresRespawn(const FName& PropertyPath) co
return false;
}
bool UPS_Editor_EditableComponent::IsAnimatable(const FName& PropertyPath) const
{
for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties)
{
if (Entry.Path == PropertyPath)
{
return Entry.bAnimatable;
}
}
return false;
}
FString UPS_Editor_EditableComponent::GetDisplayName(const FName& Path) const
{
for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties)

View File

@@ -35,6 +35,16 @@ struct FPS_Editor_EditablePropertyEntry
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
bool bNeedsRespawn = false;
/**
* If true, this property can be animated on the global timeline.
* Adds a "+" button in the runtime editor property panel to add keyframes.
* For the animation to replicate to clients, the underlying UPROPERTY must be marked
* Replicated (ideally ReplicatedUsing=OnRep_X) — the server-side timeline writes the value
* and standard UE replication pushes it to clients.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
bool bAnimatable = false;
};
/**
@@ -75,6 +85,9 @@ public:
/** Returns true if the given property path requires a full respawn. */
bool RequiresRespawn(const FName& PropertyPath) const;
/** Returns true if the given property path is marked animatable on this component. */
bool IsAnimatable(const FName& PropertyPath) const;
/** Lists available component names on the owning actor. */
UFUNCTION()
TArray<FString> GetAvailableComponentNames() const;

View File

@@ -85,6 +85,15 @@ struct FPS_Editor_BaseLevelEntry
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
TArray<TObjectPtr<UPS_Editor_SpawnCatalog>> Catalogs;
/**
* If true, this entry is used as a fallback when loading a scenario that was saved
* without a base level. Exactly one entry should be marked as default — if several
* are, the first one wins; if none is, scenarios without a base level keep whatever
* sublevel is currently loaded.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
bool bIsDefault = false;
};
/**

View File

@@ -4,11 +4,24 @@
#include "PS_Editor_EditableInterface.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_TimelineSubsystem.h"
#include "AIController.h"
#include "BrainComponent.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
namespace
{
static void RegisterSpawnedActorId(UWorld* World, AActor* Actor)
{
if (!World || !Actor) return;
if (UPS_Editor_TimelineSubsystem* TS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>())
{
TS->GetOrCreateActorId(Actor);
}
}
}
void UPS_Editor_SpawnManager::Initialize(APlayerController* InOwnerPC)
{
OwnerPC = InOwnerPC;
@@ -140,8 +153,9 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
}
}
// Track spawned actor
// Track spawned actor + assign stable ID via the timeline subsystem
SpawnedActors.Add(SpawnedActor);
RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor);
// Auto-snap to ground if configured
UPS_Editor_SpawnableComponent* SpawnComp = SpawnedActor->FindComponentByClass<UPS_Editor_SpawnableComponent>();
@@ -173,29 +187,35 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
IPS_Editor_EditableInterface::Execute_OnEditorModeChanged(SpawnedActor, true);
}
// Stop AI logic on spawned pawns in editor mode (deferred — AIController may not be assigned yet)
// Stop AI logic on spawned pawns in editor mode. Apply synchronously (AIController is
// usually already possessing by the time SpawnActor returns, so waiting a frame lets the
// BT kick off and move the pawn) and keep the deferred fallback for the occasional case
// where auto-possession happens later.
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (!EditorPC->IsSimulating())
{
if (APawn* SpawnedPawn = Cast<APawn>(SpawnedActor))
{
TWeakObjectPtr<APawn> WeakPawn = SpawnedPawn;
SpawnedPawn->GetWorldTimerManager().SetTimerForNextTick([WeakPawn]()
auto DisableAI = [](APawn* Pawn)
{
if (APawn* P = WeakPawn.Get())
{
if (AAIController* AIC = Cast<AAIController>(P->GetController()))
if (!Pawn) return;
Pawn->SetActorTickEnabled(false);
if (AAIController* AIC = Cast<AAIController>(Pawn->GetController()))
{
AIC->SetActorTickEnabled(false);
if (UBrainComponent* Brain = AIC->GetBrainComponent())
{
Brain->StopLogic(TEXT("PS_Editor"));
}
// Disable AI perception
P->SetActorTickEnabled(false);
}
}
};
DisableAI(SpawnedPawn);
TWeakObjectPtr<APawn> WeakPawn = SpawnedPawn;
SpawnedPawn->GetWorldTimerManager().SetTimerForNextTick([WeakPawn, DisableAI]()
{
DisableAI(WeakPawn.Get());
});
}
}
@@ -260,6 +280,7 @@ AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Lo
SpawnedActor->SetActorScale3D(Scale);
SpawnedActors.Add(SpawnedActor);
RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor);
return SpawnedActor;
}
@@ -283,6 +304,10 @@ void UPS_Editor_SpawnManager::TrackSpawnedActor(AActor* Actor)
if (Actor)
{
SpawnedActors.Add(Actor);
if (APlayerController* PC = OwnerPC.Get())
{
RegisterSpawnedActorId(PC->GetWorld(), Actor);
}
}
}
@@ -309,6 +334,9 @@ AActor* UPS_Editor_SpawnManager::RespawnActorWithProperties(AActor* OldActor, co
UClass* ActorClass = OldActor->GetClass();
const FTransform OldTransform = OldActor->GetActorTransform();
// Preserve the stable ID across respawn (if any), then drop the old actor's registration
UPS_Editor_TimelineSubsystem* TimelineSS = PC->GetWorld() ? PC->GetWorld()->GetSubsystem<UPS_Editor_TimelineSubsystem>() : nullptr;
const FGuid PreservedId = TimelineSS ? TimelineSS->FindActorId(OldActor) : FGuid();
// 1. Remove old actor from tracking and destroy
SpawnedActors.RemoveAll([OldActor](const TWeakObjectPtr<AActor>& Weak)
@@ -383,6 +411,18 @@ AActor* UPS_Editor_SpawnManager::RespawnActorWithProperties(AActor* OldActor, co
}
SpawnedActors.Add(NewActor);
// Restore preserved ID (or generate a fresh one if it was missing)
if (TimelineSS)
{
if (PreservedId.IsValid())
{
TimelineSS->RegisterActorId(NewActor, PreservedId);
}
else
{
TimelineSS->GetOrCreateActorId(NewActor);
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Respawned %s with %d properties"), *ActorClass->GetName(), PropertyValues.Num());
return NewActor;
}

View File

@@ -30,7 +30,10 @@ APS_Editor_SplineActor::APS_Editor_SplineActor()
SplineMeshComp->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
SplineMeshComp->bUseComplexAsSimpleCollision = true;
SplineMeshComp->SetCastShadow(false);
SplineMeshComp->SetRenderCustomDepth(true);
// Custom depth is OFF by default and only enabled while the spline is selected —
// otherwise any project-level outline PP material picks up every loaded spline as if
// it were selected. SetSelected() flips this on/off.
SplineMeshComp->SetRenderCustomDepth(false);
SplineMeshComp->SetCustomDepthStencilValue(2);
// Handle parent
@@ -427,7 +430,9 @@ void APS_Editor_SplineActor::SetSelected(bool bSelected)
{
SplineMeshComp->SetMaterial(0, Mat);
}
// Restore stencil 2 for occluded PP effect when deselected (selection sets it to 1)
// Only participate in the custom-depth pass while selected, so any project outline PP
// only highlights the selected spline (not every spline in the scene).
SplineMeshComp->SetRenderCustomDepth(bSelected);
SplineMeshComp->SetCustomDepthStencilValue(bSelected ? 1 : 2);
}
@@ -580,4 +585,12 @@ void APS_Editor_SplineActor::ImportFromCustomProperties(const TMap<FString, FStr
SplineTag = *TagStr;
}
UpdateSplineMaterials();
// After load, force the deselected visual state: handles default to visible on creation,
// and UpdateVisualization reads handle visibility to pick "selected vs. normal" material
// — so without this the line would render with the selected color until the user clicks
// the spline once.
SetHandlesVisible(false);
SetSelected(false);
UpdateVisualization();
}

View File

@@ -0,0 +1,66 @@
#pragma once
#include "CoreMinimal.h"
#include "PS_Editor_TimelineData.generated.h"
/**
* A single key on a timeline track.
* Value is stored as exported text (same format as FProperty::ExportText_InContainer),
* so any property type can be serialized via the existing reflection path.
*/
USTRUCT(BlueprintType)
struct FPS_Editor_TimelineKey
{
GENERATED_BODY()
/** Time in seconds, from the start of the timeline. */
UPROPERTY()
float Time = 0.f;
/** Exported text value (FProperty::ExportText_InContainer format). */
UPROPERTY()
FString Value;
/** If true, linearly interpolate from THIS key's value towards the next key's value.
* Ignored for non-numeric property types (stepped behaviour always). */
UPROPERTY()
bool bInterpolate = true;
};
/**
* A single animation track: ties one actor to one property, with an ordered list of keys.
*/
USTRUCT(BlueprintType)
struct FPS_Editor_TimelineTrack
{
GENERATED_BODY()
/** Stable GUID referencing the target actor. Resolved at runtime via the SpawnManager map. */
UPROPERTY()
FGuid ActorId;
/** Property path: "ComponentName.PropertyName" or just "PropertyName" for actor-level. */
UPROPERTY()
FName PropertyPath;
/** Keys sorted by Time (ascending). */
UPROPERTY()
TArray<FPS_Editor_TimelineKey> Keys;
};
/**
* Global timeline data attached to a scenario. Serialised with the scene JSON.
*/
USTRUCT(BlueprintType)
struct FPS_Editor_TimelineData
{
GENERATED_BODY()
/** Total timeline duration, in seconds. User-editable in the UI. */
UPROPERTY()
float Duration = 10.f;
/** All tracks for all animated (actor, property) pairs. */
UPROPERTY()
TArray<FPS_Editor_TimelineTrack> Tracks;
};

View File

@@ -0,0 +1,139 @@
#include "PS_Editor_TimelineInterpolator.h"
#include "UObject/UnrealType.h"
bool FPS_Editor_TimelineInterpolator::IsNumericProperty(const FProperty* Prop)
{
if (!Prop) return false;
if (CastField<FFloatProperty>(Prop)) return true;
if (CastField<FDoubleProperty>(Prop)) return true;
if (CastField<FIntProperty>(Prop)) return true;
if (CastField<FInt64Property>(Prop)) return true;
if (const FStructProperty* Struct = CastField<FStructProperty>(Prop))
{
if (Struct->Struct == TBaseStructure<FVector>::Get()) return true;
if (Struct->Struct == TBaseStructure<FVector2D>::Get()) return true;
if (Struct->Struct == TBaseStructure<FRotator>::Get()) return true;
if (Struct->Struct == TBaseStructure<FLinearColor>::Get()) return true;
if (Struct->Struct == TBaseStructure<FColor>::Get()) return true;
}
return false;
}
namespace
{
/** Parse "(X=1.0,Y=2.0,Z=3.0)" style exported vector. Returns FVector::ZeroVector on parse failure. */
static bool ParseVectorText(const FString& Text, FVector& Out)
{
return Out.InitFromString(Text);
}
static bool ParseRotatorText(const FString& Text, FRotator& Out)
{
return Out.InitFromString(Text);
}
static bool ParseLinearColorText(const FString& Text, FLinearColor& Out)
{
return Out.InitFromString(Text);
}
}
FString FPS_Editor_TimelineInterpolator::InterpolateValue(const FProperty* Prop, const FString& A, const FString& B, float Alpha, bool bInterpolate)
{
// Non-numeric or stepped → always return the "previous" value
if (!bInterpolate || !IsNumericProperty(Prop))
{
return A;
}
const float Clamped = FMath::Clamp(Alpha, 0.f, 1.f);
// Float
if (CastField<FFloatProperty>(Prop))
{
const float Va = FCString::Atof(*A);
const float Vb = FCString::Atof(*B);
return FString::SanitizeFloat(FMath::Lerp(Va, Vb, Clamped));
}
// Double
if (CastField<FDoubleProperty>(Prop))
{
const double Va = FCString::Atod(*A);
const double Vb = FCString::Atod(*B);
return FString::SanitizeFloat(FMath::Lerp(Va, Vb, (double)Clamped));
}
// Int32 / Int64 — keep integer output (round to nearest)
if (CastField<FIntProperty>(Prop))
{
const int32 Va = FCString::Atoi(*A);
const int32 Vb = FCString::Atoi(*B);
const int32 Result = FMath::RoundToInt(FMath::Lerp((float)Va, (float)Vb, Clamped));
return FString::FromInt(Result);
}
if (CastField<FInt64Property>(Prop))
{
const int64 Va = FCString::Atoi64(*A);
const int64 Vb = FCString::Atoi64(*B);
const int64 Result = (int64)FMath::RoundToDouble(FMath::Lerp((double)Va, (double)Vb, (double)Clamped));
return FString::Printf(TEXT("%lld"), Result);
}
// Struct types
if (const FStructProperty* Struct = CastField<FStructProperty>(Prop))
{
if (Struct->Struct == TBaseStructure<FVector>::Get())
{
FVector Va = FVector::ZeroVector, Vb = FVector::ZeroVector;
ParseVectorText(A, Va);
ParseVectorText(B, Vb);
const FVector Result = FMath::Lerp(Va, Vb, Clamped);
return FString::Printf(TEXT("(X=%f,Y=%f,Z=%f)"), Result.X, Result.Y, Result.Z);
}
if (Struct->Struct == TBaseStructure<FVector2D>::Get())
{
FVector2D Va = FVector2D::ZeroVector, Vb = FVector2D::ZeroVector;
Va.InitFromString(A);
Vb.InitFromString(B);
const FVector2D Result = FMath::Lerp(Va, Vb, Clamped);
return FString::Printf(TEXT("(X=%f,Y=%f)"), Result.X, Result.Y);
}
if (Struct->Struct == TBaseStructure<FRotator>::Get())
{
FRotator Ra = FRotator::ZeroRotator, Rb = FRotator::ZeroRotator;
ParseRotatorText(A, Ra);
ParseRotatorText(B, Rb);
// Use quaternion slerp to get the shortest path
const FQuat Qa = Ra.Quaternion();
const FQuat Qb = Rb.Quaternion();
const FRotator Result = FQuat::Slerp(Qa, Qb, Clamped).Rotator();
return FString::Printf(TEXT("(Pitch=%f,Yaw=%f,Roll=%f)"), Result.Pitch, Result.Yaw, Result.Roll);
}
if (Struct->Struct == TBaseStructure<FLinearColor>::Get())
{
FLinearColor Ca(ForceInitToZero), Cb(ForceInitToZero);
ParseLinearColorText(A, Ca);
ParseLinearColorText(B, Cb);
const FLinearColor Result = FMath::Lerp(Ca, Cb, Clamped);
return FString::Printf(TEXT("(R=%f,G=%f,B=%f,A=%f)"), Result.R, Result.G, Result.B, Result.A);
}
if (Struct->Struct == TBaseStructure<FColor>::Get())
{
// FColor exports as "(R=..,G=..,B=..,A=..)" with integer components
FColor Ca = FColor::Black, Cb = FColor::Black;
Ca.InitFromString(A);
Cb.InitFromString(B);
const FColor Result(
(uint8)FMath::RoundToInt(FMath::Lerp((float)Ca.R, (float)Cb.R, Clamped)),
(uint8)FMath::RoundToInt(FMath::Lerp((float)Ca.G, (float)Cb.G, Clamped)),
(uint8)FMath::RoundToInt(FMath::Lerp((float)Ca.B, (float)Cb.B, Clamped)),
(uint8)FMath::RoundToInt(FMath::Lerp((float)Ca.A, (float)Cb.A, Clamped))
);
return FString::Printf(TEXT("(R=%d,G=%d,B=%d,A=%d)"), Result.R, Result.G, Result.B, Result.A);
}
}
// Fallback: step
return A;
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include "CoreMinimal.h"
class FProperty;
/**
* Helpers to parse exported-text property values, interpolate between them, and export the result.
* Handles the types exposed by UPS_Editor_EditableComponent:
* - Numeric scalars (float, double, int)
* - FVector, FRotator, FLinearColor (linear component lerp; rotator uses FQuat::Slerp)
* - Bool / Enum / FString / unsupported types: stepped behaviour (returns the A value)
*
* The buffer used during lerp is allocated/initialised/destroyed exactly like UE does internally,
* so no hidden state survives across calls.
*/
class PS_EDITOR_API FPS_Editor_TimelineInterpolator
{
public:
/** Returns true if Prop is a numeric type that the interpolator can lerp (float/double/int, FVector, FRotator, FLinearColor). */
static bool IsNumericProperty(const FProperty* Prop);
/**
* Interpolate between the two text-exported values A and B.
* @param Prop Property being animated (used to pick the parser and interpolation code).
* @param A Exported text value of the "previous" key.
* @param B Exported text value of the "next" key.
* @param Alpha 0..1 interpolation factor.
* @param bInterpolate If false or if Prop is non-numeric, always returns A (stepped).
* @return Text-exported interpolated value suitable for FProperty::ImportText_InContainer.
*/
static FString InterpolateValue(const FProperty* Prop, const FString& A, const FString& B, float Alpha, bool bInterpolate);
};

View File

@@ -0,0 +1,527 @@
#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;
if (CurrentTime >= Data.Duration)
{
// Natural end: clamp to the last frame and stop advancing. Values are left at
// whatever the last keyframe produced (no rewind), and simulation keeps running so
// the gameplay / editor scenario can continue unimpeded. Use Restart() to rewind
// and replay from the beginning.
CurrentTime = Data.Duration;
bIsPlaying = false;
}
ApplyValuesAtTime(CurrentTime);
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: start simulation if it isn't running,
// or un-pause it if it was frozen by a previous Pause (keeping the actor transforms in
// place). In pure gameplay, FindEditorPC returns null so we skip — AI is driven by the
// normal game flow anyway.
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (!EditorPC->IsSimulating())
{
EditorPC->StartSimulation();
}
else
{
EditorPC->SetSimulationPaused(false);
}
}
OnTimelineChanged.Broadcast();
}
void UPS_Editor_TimelineSubsystem::Pause()
{
// Pause stops timeline advance AND freezes character movement (AI tick off, BT stopped)
// while keeping the pawns where they are — no transform restore, that's what Stop is for.
bIsPlaying = false;
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (EditorPC->IsSimulating())
{
EditorPC->SetSimulationPaused(true);
}
}
OnTimelineChanged.Broadcast();
}
void UPS_Editor_TimelineSubsystem::Stop()
{
bIsPlaying = false;
CurrentTime = 0.f;
LastAppliedValues.Empty();
LastAppliedValues.SetNum(Data.Tracks.Num());
// Full reset: stop simulation (restores pre-play transforms) and apply t=0 values so
// the scene visually matches the timeline start.
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (EditorPC->IsSimulating())
{
EditorPC->StopSimulation();
}
}
ApplyValuesAtTime(CurrentTime);
OnTimelineChanged.Broadcast();
}
void UPS_Editor_TimelineSubsystem::Restart()
{
// Rewind + replay from the start. Done in two steps so simulation transforms are restored
// by Stop() before Play() re-enables AI, giving a clean "from the beginning" rerun.
Stop();
Play();
}
void UPS_Editor_TimelineSubsystem::Seek(float Time)
{
CurrentTime = FMath::Clamp(Time, 0.f, Data.Duration);
// Invalidate the per-track value cache: the user may have edited properties manually
// between two seeks, so the actor's actual value can have drifted from what was last
// applied. Without this, a seek to a key whose stored value matches LastAppliedValues
// silently skips the write and leaves the manual edit visible — making every key
// "look like" it has the edited value until we seek to one with a different stored value.
LastAppliedValues.Empty();
LastAppliedValues.SetNum(Data.Tracks.Num());
ApplyValuesAtTime(CurrentTime);
OnTimelineChanged.Broadcast();
}
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;
UE_LOG(LogTemp, Log, TEXT("PS_Editor Timeline: AddOrUpdateKey — Actor=%s ActorId=%s Prop=%s CurrentTime=%.3f"),
*Actor->GetName(), *ActorId.ToString(), *PropertyPath.ToString(), CurrentTime);
// Resolve property to read current value
UObject* Target = nullptr;
FProperty* Prop = nullptr;
ResolveTarget(Actor, PropertyPath, Target, Prop);
if (!Target || !Prop) return false;
FString CurrentValue;
Prop->ExportText_InContainer(0, CurrentValue, Target, Target, nullptr, PPF_None);
// Locate or create the track
int32 TrackIndex = FindTrack(Data, ActorId, PropertyPath);
if (TrackIndex == INDEX_NONE)
{
FPS_Editor_TimelineTrack NewTrack;
NewTrack.ActorId = ActorId;
NewTrack.PropertyPath = PropertyPath;
TrackIndex = Data.Tracks.Add(NewTrack);
LastAppliedValues.SetNum(Data.Tracks.Num());
}
FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
// If a key already exists at ~CurrentTime, update it; else insert sorted
const float Epsilon = 0.0001f;
int32 InsertAt = 0;
for (; InsertAt < Track.Keys.Num(); ++InsertAt)
{
if (FMath::IsNearlyEqual(Track.Keys[InsertAt].Time, CurrentTime, Epsilon))
{
Track.Keys[InsertAt].Value = CurrentValue;
OnTimelineChanged.Broadcast();
return true;
}
if (Track.Keys[InsertAt].Time > CurrentTime)
{
break;
}
}
FPS_Editor_TimelineKey NewKey;
NewKey.Time = CurrentTime;
NewKey.Value = CurrentValue;
NewKey.bInterpolate = true;
Track.Keys.Insert(NewKey, InsertAt);
OnTimelineChanged.Broadcast();
return true;
}
void UPS_Editor_TimelineSubsystem::RemoveTrack(int32 TrackIndex)
{
if (!Data.Tracks.IsValidIndex(TrackIndex)) return;
Data.Tracks.RemoveAt(TrackIndex);
LastAppliedValues.SetNum(Data.Tracks.Num());
OnTimelineChanged.Broadcast();
}
void UPS_Editor_TimelineSubsystem::RemoveKey(int32 TrackIndex, int32 KeyIndex)
{
if (!Data.Tracks.IsValidIndex(TrackIndex)) return;
FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
if (!Track.Keys.IsValidIndex(KeyIndex)) return;
Track.Keys.RemoveAt(KeyIndex);
// If the track is empty, drop it
if (Track.Keys.Num() == 0)
{
Data.Tracks.RemoveAt(TrackIndex);
LastAppliedValues.SetNum(Data.Tracks.Num());
}
OnTimelineChanged.Broadcast();
}
void UPS_Editor_TimelineSubsystem::SetKeyInterpolate(int32 TrackIndex, int32 KeyIndex, bool bInterpolate)
{
if (!Data.Tracks.IsValidIndex(TrackIndex)) return;
FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
if (!Track.Keys.IsValidIndex(KeyIndex)) return;
Track.Keys[KeyIndex].bInterpolate = bInterpolate;
OnTimelineChanged.Broadcast();
}
void UPS_Editor_TimelineSubsystem::SetKeyTime(int32 TrackIndex, int32 KeyIndex, float NewTime)
{
if (!Data.Tracks.IsValidIndex(TrackIndex)) return;
FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
if (!Track.Keys.IsValidIndex(KeyIndex)) return;
Track.Keys[KeyIndex].Time = FMath::Clamp(NewTime, 0.f, Data.Duration);
// Re-sort keys by time so downstream logic (FindSurroundingKeys, etc.) stays correct.
Track.Keys.Sort([](const FPS_Editor_TimelineKey& A, const FPS_Editor_TimelineKey& B)
{
return A.Time < B.Time;
});
OnTimelineChanged.Broadcast();
}
// ---- Value application ----
void UPS_Editor_TimelineSubsystem::ApplyValuesAtTime(float Time)
{
// Only the server drives the animation; clients receive via replication.
if (UWorld* World = GetWorld())
{
if (World->GetNetMode() == NM_Client) return;
}
if (LastAppliedValues.Num() != Data.Tracks.Num())
{
LastAppliedValues.SetNum(Data.Tracks.Num());
}
for (int32 i = 0; i < Data.Tracks.Num(); ++i)
{
ApplyTrackAtTime(i, Time);
}
}
bool UPS_Editor_TimelineSubsystem::FindSurroundingKeys(const FPS_Editor_TimelineTrack& Track, float Time, int32& OutPrev, int32& OutNext)
{
if (Track.Keys.Num() == 0) return false;
OutPrev = 0;
OutNext = 0;
for (int32 i = 0; i < Track.Keys.Num(); ++i)
{
if (Track.Keys[i].Time <= Time)
{
OutPrev = i;
}
if (Track.Keys[i].Time >= Time)
{
OutNext = i;
break;
}
OutNext = i; // fallback: past last key
}
return true;
}
void UPS_Editor_TimelineSubsystem::ApplyTrackAtTime(int32 TrackIndex, float Time)
{
if (!Data.Tracks.IsValidIndex(TrackIndex)) return;
const FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
if (Track.Keys.Num() == 0) return;
AActor* Actor = FindActorById(Track.ActorId);
if (!Actor) return;
UObject* Target = nullptr;
FProperty* Prop = nullptr;
ResolveTarget(Actor, Track.PropertyPath, Target, Prop);
if (!Target || !Prop) return;
// Bracket the time
int32 PrevIdx = 0, NextIdx = 0;
if (!FindSurroundingKeys(Track, Time, PrevIdx, NextIdx)) return;
const FPS_Editor_TimelineKey& Prev = Track.Keys[PrevIdx];
const FPS_Editor_TimelineKey& Next = Track.Keys[NextIdx];
FString ValueText;
if (PrevIdx == NextIdx || Next.Time <= Prev.Time)
{
ValueText = Prev.Value;
}
else
{
const float Alpha = (Time - Prev.Time) / (Next.Time - Prev.Time);
ValueText = FPS_Editor_TimelineInterpolator::InterpolateValue(Prop, Prev.Value, Next.Value, Alpha, Prev.bInterpolate);
}
// Skip the write (and the OnEditorPropertyChanged event) if the value hasn't moved
if (LastAppliedValues.IsValidIndex(TrackIndex) && LastAppliedValues[TrackIndex] == ValueText)
{
return;
}
// Apply via the same reflection path as the UI
Prop->ImportText_InContainer(*ValueText, Target, Target, PPF_None);
if (UActorComponent* AsComp = Cast<UActorComponent>(Target))
{
AsComp->MarkRenderStateDirty();
}
// Force net update + auto-invoke RepNotify on the server
Actor->ForceNetUpdate();
if (!Prop->RepNotifyFunc.IsNone())
{
if (UFunction* RepFunc = Target->FindFunction(Prop->RepNotifyFunc))
{
Target->ProcessEvent(RepFunc, nullptr);
}
}
// Notify the actor so it can react (bNeedsReinit etc.)
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorPropertyChanged(Actor, Track.PropertyPath.ToString());
}
if (LastAppliedValues.IsValidIndex(TrackIndex))
{
LastAppliedValues[TrackIndex] = ValueText;
}
}
// ---- Stable actor IDs ----
FGuid UPS_Editor_TimelineSubsystem::GetOrCreateActorId(AActor* Actor)
{
if (!Actor) return FGuid();
const TWeakObjectPtr<AActor> Weak(Actor);
if (const FGuid* Existing = ActorIds.Find(Weak))
{
if (Existing->IsValid())
{
return *Existing;
}
}
const FGuid NewId = FGuid::NewGuid();
ActorIds.Add(Weak, NewId);
return NewId;
}
FGuid UPS_Editor_TimelineSubsystem::FindActorId(AActor* Actor) const
{
if (!Actor) return FGuid();
if (const FGuid* Existing = ActorIds.Find(TWeakObjectPtr<AActor>(Actor)))
{
return *Existing;
}
return FGuid();
}
AActor* UPS_Editor_TimelineSubsystem::FindActorById(const FGuid& Id) const
{
if (!Id.IsValid()) return nullptr;
for (const TPair<TWeakObjectPtr<AActor>, FGuid>& Pair : ActorIds)
{
if (Pair.Value == Id)
{
return Pair.Key.Get();
}
}
return nullptr;
}
void UPS_Editor_TimelineSubsystem::RegisterActorId(AActor* Actor, const FGuid& Id)
{
if (!Actor || !Id.IsValid()) return;
ActorIds.Add(TWeakObjectPtr<AActor>(Actor), Id);
}
// ---- Helpers ----
void UPS_Editor_TimelineSubsystem::ResolveTarget(AActor* Actor, const FName& PropertyPath, UObject*& OutTarget, FProperty*& OutProperty)
{
OutTarget = nullptr;
OutProperty = nullptr;
if (!Actor) return;
FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(PropertyPath, CompName, PropName);
if (CompName.IsEmpty())
{
OutTarget = Actor;
}
else
{
TArray<UActorComponent*> Components;
Actor->GetComponents(Components);
for (UActorComponent* Comp : Components)
{
if (!Comp) continue;
const FString InstanceName = Comp->GetName();
const FString ClassName = Comp->GetClass()->GetName();
FString CleanClassName = ClassName;
CleanClassName.RemoveFromEnd(TEXT("_C"));
if (InstanceName == CompName || ClassName == CompName || CleanClassName == CompName)
{
OutTarget = Comp;
break;
}
}
}
if (OutTarget)
{
OutProperty = FindFProperty<FProperty>(OutTarget->GetClass(), *PropName);
}
}

View File

@@ -0,0 +1,163 @@
#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. Simulation keeps running. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Pause();
/** Pause playback, rewind to CurrentTime=0, stop simulation and apply t=0 values. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Stop();
/** Rewind to t=0 and immediately replay from the start (re-enables simulation). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Restart();
/** Jump to a specific time (seconds, clamped to [0, Duration]). Applies values immediately. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void Seek(float Time);
/** True while Tick is advancing CurrentTime. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline")
bool IsPlaying() const { return bIsPlaying; }
/** Get current playback time (seconds). */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline")
float GetCurrentTime() const { return CurrentTime; }
/** Get the configured duration (seconds). */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline")
float GetDuration() const { return Data.Duration; }
/** Change the timeline duration. Values <= 0 are clamped to 0.1. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
void SetDuration(float NewDuration);
// ---- Tracks / keys ----
/** Number of tracks. */
int32 GetNumTracks() const { return Data.Tracks.Num(); }
const TArray<FPS_Editor_TimelineTrack>& GetTracks() const { return Data.Tracks; }
/**
* Add a key at CurrentTime for (Actor, PropertyPath), reading the actor's current value.
* Creates the track if it doesn't exist. If a key already exists at this time, updates its value.
* @return True on success.
*/
bool AddOrUpdateKeyAtCurrentTime(AActor* Actor, const FName& PropertyPath);
/** Remove a track by index. */
void RemoveTrack(int32 TrackIndex);
/** Remove a single key. */
void RemoveKey(int32 TrackIndex, int32 KeyIndex);
/** Toggle the interpolate flag on a key. */
void SetKeyInterpolate(int32 TrackIndex, int32 KeyIndex, bool bInterpolate);
/**
* Move a key to a different time. The track is re-sorted after the update, so the caller
* cannot assume the key keeps the same KeyIndex afterwards. Clamps to [0, Duration].
*/
void SetKeyTime(int32 TrackIndex, int32 KeyIndex, float NewTime);
// ---- Data accessors (save/load integration) ----
const FPS_Editor_TimelineData& GetData() const { return Data; }
/** Replace the entire timeline data (used by scene loader). Resets CurrentTime to 0, pauses. */
void SetData(const FPS_Editor_TimelineData& NewData);
/** Clear all tracks and reset state. */
void Clear();
// ---- Stable actor IDs (used by tracks to reference actors across save/load) ----
/** Get the stable ID for this actor, generating one if it has no entry yet. */
FGuid GetOrCreateActorId(AActor* Actor);
/** Returns an invalid GUID if this actor has no registered ID. */
FGuid FindActorId(AActor* Actor) const;
/** Find the actor associated with a stable ID, or nullptr if none / stale. */
AActor* FindActorById(const FGuid& Id) const;
/** Register a specific ID for an actor (e.g. when loading a scene). Overwrites any existing ID. */
void RegisterActorId(AActor* Actor, const FGuid& Id);
// ---- UI refresh signal ----
FPS_Editor_OnTimelineChanged OnTimelineChanged;
/** Apply interpolated values of all tracks at a given time (no tick advance). */
void ApplyValuesAtTime(float Time);
private:
FPS_Editor_TimelineData Data;
float CurrentTime = 0.f;
bool bIsPlaying = false;
/** Cache of last applied exported-text value per track, used to avoid re-firing OnEditorPropertyChanged every frame when the value hasn't actually changed. */
TArray<FString> LastAppliedValues;
/** Map of stable IDs for every actor that participates in the timeline / scene registry. */
TMap<TWeakObjectPtr<AActor>, FGuid> ActorIds;
/** Find prev / next keys bracketing the requested time. Returns false if the track is empty. */
static bool FindSurroundingKeys(const FPS_Editor_TimelineTrack& Track, float Time, int32& OutPrev, int32& OutNext);
/** Apply a single track at the given time. */
void ApplyTrackAtTime(int32 TrackIndex, float Time);
/** Resolve the UObject target (actor or component) and the FProperty pointer. Nullable. */
static void ResolveTarget(AActor* Actor, const FName& PropertyPath, UObject*& OutTarget, FProperty*& OutProperty);
};

View File

@@ -50,6 +50,7 @@ public:
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private:
@@ -122,6 +123,19 @@ private:
// Simulate button
TSharedPtr<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
TSharedPtr<SWidget> CloseConfirmOverlay;
bool bSceneDirty = false;
@@ -140,6 +154,7 @@ public:
TSharedRef<SWidget> BuildSpawnCatalog();
TSharedRef<SWidget> BuildSceneButtons();
TSharedRef<SWidget> BuildOutliner();
TSharedRef<SWidget> BuildTimelinePanel();
void RefreshOutliner();
void PopulateSceneList();
void PopulateSpawnCatalog();
@@ -148,6 +163,14 @@ public:
void RebuildDynamicProperties();
void RefreshDynamicPropertyValues();
void ApplyPropertyFromUI(int32 RowIndex);
void AddKeyForRow(int32 RowIndex);
void ToggleTimelineVisible();
void RebuildTimelineTracksList();
void RefreshTimelineUI();
/** Try to delete the currently selected timeline key. Returns true if one was deleted.
* Called by the Pawn's global Delete action before it falls back to actor deletion. */
bool TryDeleteSelectedTimelineKey();
static UObject* FindPropertyTarget(AActor* Actor, const FString& ComponentName);
FString FloatToString(float Value) const;