Compare commits
3 Commits
b08b17d762
...
4181801635
| Author | SHA1 | Date | |
|---|---|---|---|
| 4181801635 | |||
| 75f54e27c4 | |||
| 4b0a182013 |
@@ -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
3
.gitignore
vendored
@@ -16,3 +16,6 @@ Unreal/Plugins/*/Intermediate/
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# Claude Code per-user local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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[]
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,32 +330,79 @@ 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]()
|
||||
auto DisableAI = [](APawn* Pawn)
|
||||
{
|
||||
if (APawn* Pawn = WeakP.Get())
|
||||
if (!Pawn) return;
|
||||
Pawn->SetActorTickEnabled(false);
|
||||
if (AAIController* AIC = Cast<AAIController>(Pawn->GetController()))
|
||||
{
|
||||
if (AAIController* AIC = Cast<AAIController>(Pawn->GetController()))
|
||||
AIC->SetActorTickEnabled(false);
|
||||
if (UBrainComponent* Brain = AIC->GetBrainComponent())
|
||||
{
|
||||
AIC->SetActorTickEnabled(false);
|
||||
if (UBrainComponent* Brain = AIC->GetBrainComponent())
|
||||
{
|
||||
Brain->StopLogic(TEXT("PS_Editor"));
|
||||
}
|
||||
Pawn->SetActorTickEnabled(false);
|
||||
Brain->StopLogic(TEXT("PS_Editor"));
|
||||
}
|
||||
}
|
||||
};
|
||||
DisableAI(P);
|
||||
|
||||
TWeakObjectPtr<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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (!Pawn) return;
|
||||
Pawn->SetActorTickEnabled(false);
|
||||
if (AAIController* AIC = Cast<AAIController>(Pawn->GetController()))
|
||||
{
|
||||
if (AAIController* AIC = Cast<AAIController>(P->GetController()))
|
||||
AIC->SetActorTickEnabled(false);
|
||||
if (UBrainComponent* Brain = AIC->GetBrainComponent())
|
||||
{
|
||||
AIC->SetActorTickEnabled(false);
|
||||
if (UBrainComponent* Brain = AIC->GetBrainComponent())
|
||||
{
|
||||
Brain->StopLogic(TEXT("PS_Editor"));
|
||||
}
|
||||
// Disable AI perception
|
||||
P->SetActorTickEnabled(false);
|
||||
Brain->StopLogic(TEXT("PS_Editor"));
|
||||
}
|
||||
}
|
||||
};
|
||||
DisableAI(SpawnedPawn);
|
||||
|
||||
TWeakObjectPtr<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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user