Add Phase 4: JSON scene save/load system

Serialization:
- SceneData USTRUCT with Version, SceneName, Timestamp, Actors array
- ActorData: ClassPath, Location, Rotation, Scale per spawned actor
- SceneSerializer: SaveScene/LoadScene/ClearScene/GetSavedSceneNames
- Files stored in Saved/PS_Editor/Scenes/{name}.json
- Uses FJsonObjectConverter for clean USTRUCT-to-JSON conversion

SpawnManager:
- Tracks all spawned actors in SpawnedActors array
- SpawnActorDirect: spawn from class/transform (used by scene loading)
- DestroyAllSpawnedActors: clean scene clear

UI:
- Save field + button in toolbar area
- New Scene button to clear all spawned actors
- Saved scenes list with click-to-load
- Auto-refreshes scene list after save
- Clears selection on load/new

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 20:31:15 +02:00
parent 9f1604f57c
commit 36b04bee61
12 changed files with 497 additions and 1 deletions

View File

@@ -2,6 +2,7 @@
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_Pawn.h"
@@ -28,6 +29,9 @@ void APS_Editor_PlayerController::BeginPlay()
UPS_Editor_SpawnCatalog* LoadedCatalog = SpawnCatalogAsset.LoadSynchronous();
SpawnManager->Initialize(this, LoadedCatalog);
SceneSerializer = NewObject<UPS_Editor_SceneSerializer>(this);
SceneSerializer->SetSelectionManager(SelectionManager);
// After undo/redo, deselect hidden actors and refresh gizmo
UndoManager->OnUndoRedo.AddWeakLambda(this, [this]()
{

View File

@@ -8,6 +8,7 @@ class UPS_Editor_SelectionManager;
class UPS_Editor_UndoManager;
class UPS_Editor_SpawnManager;
class UPS_Editor_SpawnCatalog;
class UPS_Editor_SceneSerializer;
/**
* Player controller for PS_Editor runtime editing.
@@ -30,6 +31,9 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_SpawnManager* GetSpawnManager() const { return SpawnManager; }
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_SceneSerializer* GetSceneSerializer() const { return SceneSerializer; }
/** The spawn catalog DataAsset to load. Set this in the Blueprint or World Settings. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TSoftObjectPtr<UPS_Editor_SpawnCatalog> SpawnCatalogAsset;
@@ -48,5 +52,8 @@ private:
UPROPERTY()
TObjectPtr<UPS_Editor_SpawnManager> SpawnManager;
UPROPERTY()
TObjectPtr<UPS_Editor_SceneSerializer> SceneSerializer;
void HandleEditorClick(FVector2D ScreenPos, bool bAdditive);
};

View File

@@ -16,6 +16,7 @@ public class PS_Editor : ModuleRules
PublicIncludePaths.Add(Path.Combine(SourceDir, "Selection"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Gizmo"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Spawn"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Serialization"));
PublicDependencyModuleNames.AddRange(new string[]
{
@@ -24,7 +25,9 @@ public class PS_Editor : ModuleRules
"Engine",
"InputCore",
"EnhancedInput",
"UMG"
"UMG",
"Json",
"JsonUtilities"
});
PrivateDependencyModuleNames.AddRange(new string[]

View File

@@ -0,0 +1,43 @@
#pragma once
#include "CoreMinimal.h"
#include "PS_Editor_SceneData.generated.h"
/** Serialized data for a single spawned actor. */
USTRUCT()
struct FPS_Editor_ActorData
{
GENERATED_BODY()
/** Class path (e.g. "/Game/BP_Cube.BP_Cube_C"). */
UPROPERTY()
FString ClassPath;
UPROPERTY()
FVector Location = FVector::ZeroVector;
UPROPERTY()
FRotator Rotation = FRotator::ZeroRotator;
UPROPERTY()
FVector Scale = FVector::OneVector;
};
/** Serialized data for a complete scene. */
USTRUCT()
struct FPS_Editor_SceneData
{
GENERATED_BODY()
UPROPERTY()
int32 Version = 1;
UPROPERTY()
FString SceneName;
UPROPERTY()
FString Timestamp;
UPROPERTY()
TArray<FPS_Editor_ActorData> Actors;
};

View File

@@ -0,0 +1,165 @@
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SelectionManager.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "HAL/PlatformFileManager.h"
bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
{
if (!SpawnManager || SceneName.IsEmpty())
{
return false;
}
// Build scene data from currently spawned actors
FPS_Editor_SceneData SceneData;
SceneData.SceneName = SceneName;
SceneData.Timestamp = FDateTime::Now().ToString();
const TArray<TWeakObjectPtr<AActor>>& SpawnedActors = SpawnManager->GetSpawnedActors();
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
AActor* Actor = Weak.Get();
if (!Actor || Actor->IsHidden())
{
continue; // Skip destroyed or soft-deleted actors
}
FPS_Editor_ActorData ActorData;
ActorData.ClassPath = Actor->GetClass()->GetPathName();
ActorData.Location = Actor->GetActorLocation();
ActorData.Rotation = Actor->GetActorRotation();
ActorData.Scale = Actor->GetActorScale3D();
SceneData.Actors.Add(ActorData);
}
// Convert to JSON
FString JsonString;
if (!FJsonObjectConverter::UStructToJsonObjectString(SceneData, JsonString, 0, 0, 0, nullptr, true))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to serialize scene to JSON"));
return false;
}
// Ensure directory exists
const FString Directory = GetScenesDirectory();
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
if (!PlatformFile.DirectoryExists(*Directory))
{
PlatformFile.CreateDirectoryTree(*Directory);
}
// Write file
const FString FilePath = GetSceneFilePath(SceneName);
if (!FFileHelper::SaveStringToFile(JsonString, *FilePath))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to write scene file: %s"), *FilePath);
return false;
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene saved: %s (%d actors) -> %s"), *SceneName, SceneData.Actors.Num(), *FilePath);
return true;
}
bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
{
if (!SpawnManager || SceneName.IsEmpty())
{
return false;
}
// Read file
const FString FilePath = GetSceneFilePath(SceneName);
FString JsonString;
if (!FFileHelper::LoadFileToString(JsonString, *FilePath))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to read scene file: %s"), *FilePath);
return false;
}
// Parse JSON
FPS_Editor_SceneData SceneData;
if (!FJsonObjectConverter::JsonObjectStringToUStruct(JsonString, &SceneData, 0, 0))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to parse scene JSON"));
return false;
}
// Clear existing spawned actors
ClearScene(SpawnManager);
// Re-spawn actors from saved data
int32 SpawnedCount = 0;
for (const FPS_Editor_ActorData& ActorData : SceneData.Actors)
{
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class: %s"), *ActorData.ClassPath);
continue;
}
AActor* SpawnedActor = SpawnManager->SpawnActorDirect(LoadedClass, ActorData.Location, ActorData.Rotation, ActorData.Scale);
if (SpawnedActor)
{
SpawnedCount++;
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene loaded: %s (%d actors)"), *SceneName, SpawnedCount);
return true;
}
TArray<FString> UPS_Editor_SceneSerializer::GetSavedSceneNames() const
{
TArray<FString> SceneNames;
const FString Directory = GetScenesDirectory();
TArray<FString> Files;
IFileManager::Get().FindFiles(Files, *FPaths::Combine(Directory, TEXT("*.json")), true, false);
for (const FString& File : Files)
{
SceneNames.Add(FPaths::GetBaseFilename(File));
}
SceneNames.Sort();
return SceneNames;
}
bool UPS_Editor_SceneSerializer::DeleteScene(const FString& SceneName)
{
const FString FilePath = GetSceneFilePath(SceneName);
if (IFileManager::Get().Delete(*FilePath))
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene deleted: %s"), *SceneName);
return true;
}
return false;
}
void UPS_Editor_SceneSerializer::ClearScene(UPS_Editor_SpawnManager* SpawnManager)
{
if (UPS_Editor_SelectionManager* SM = SelectionManagerRef.Get())
{
SM->ClearSelection();
}
if (SpawnManager)
{
SpawnManager->DestroyAllSpawnedActors();
}
}
FString UPS_Editor_SceneSerializer::GetSceneFilePath(const FString& SceneName) const
{
return FPaths::Combine(GetScenesDirectory(), SceneName + TEXT(".json"));
}
FString UPS_Editor_SceneSerializer::GetScenesDirectory() const
{
return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("PS_Editor"), TEXT("Scenes"));
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "PS_Editor_SceneData.h"
#include "PS_Editor_SceneSerializer.generated.h"
class UPS_Editor_SpawnManager;
class UPS_Editor_SelectionManager;
/**
* Handles saving and loading PS_Editor scenes as JSON files.
* Scenes are stored in {ProjectDir}/Saved/PS_Editor/Scenes/{SceneName}.json
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_SceneSerializer : public UObject
{
GENERATED_BODY()
public:
/** Save the current spawned actors to a JSON file. */
bool SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager);
/** Load a scene from a JSON file. Destroys current spawned actors and re-spawns from file. */
bool LoadScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager);
/** Get a list of all saved scene names (without .json extension). */
TArray<FString> GetSavedSceneNames() const;
/** Delete a saved scene file. */
bool DeleteScene(const FString& SceneName);
/** Clear all spawned actors (new scene). Also clears selection and undo history. */
void ClearScene(UPS_Editor_SpawnManager* SpawnManager);
/** Set optional selection manager to clear on load/new. */
void SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager) { SelectionManagerRef = InSelectionManager; }
private:
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SelectionManager> SelectionManagerRef;
/** Get the full file path for a scene name. */
FString GetSceneFilePath(const FString& SceneName) const;
/** Get the directory where scenes are stored. */
FString GetScenesDirectory() const;
};

View File

@@ -138,6 +138,9 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
}
}
// Track spawned actor
SpawnedActors.Add(SpawnedActor);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spawned %s at %s"), *Entry.DisplayName, *WorldLocation.ToString());
return SpawnedActor;
}
@@ -158,6 +161,48 @@ FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const
return CameraLocation + CameraRotation.Vector() * 500.0f;
}
AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale)
{
APlayerController* PC = OwnerPC.Get();
if (!PC || !PC->GetWorld() || !ActorClass)
{
return nullptr;
}
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
AActor* SpawnedActor = PC->GetWorld()->SpawnActor<AActor>(ActorClass, Location, Rotation, SpawnParams);
if (!SpawnedActor)
{
return nullptr;
}
if (USceneComponent* Root = SpawnedActor->GetRootComponent())
{
Root->SetMobility(EComponentMobility::Movable);
}
SpawnedActor->SetActorScale3D(Scale);
SpawnedActors.Add(SpawnedActor);
return SpawnedActor;
}
void UPS_Editor_SpawnManager::DestroyAllSpawnedActors()
{
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
if (AActor* Actor = Weak.Get())
{
Actor->Destroy();
}
}
SpawnedActors.Empty();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: All spawned actors destroyed"));
}
TArray<FString> UPS_Editor_SpawnManager::GetCategories() const
{
TArray<FString> Categories;

View File

@@ -45,6 +45,15 @@ public:
/** Spawn the actor at a specific world location. */
AActor* SpawnFromCatalogAtLocation(int32 Index, FVector WorldLocation);
/** Spawn an actor directly from a class (used by scene loading). No undo recorded. */
AActor* SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale);
/** Destroy all spawned actors (for scene clear/load). */
void DestroyAllSpawnedActors();
/** Get all currently spawned actors. */
const TArray<TWeakObjectPtr<AActor>>& GetSpawnedActors() const { return SpawnedActors; }
/** Get the resolved catalog entries for UI display. */
const TArray<FPS_Editor_ResolvedEntry>& GetResolvedEntries() const { return ResolvedEntries; }
@@ -61,6 +70,10 @@ private:
UPROPERTY()
TArray<FPS_Editor_ResolvedEntry> ResolvedEntries;
/** All actors spawned by this manager (tracked for save/load). */
UPROPERTY()
TArray<TWeakObjectPtr<AActor>> SpawnedActors;
/** Resolve all catalog entries (load classes, read SpawnableComponent metadata). */
void ResolveCatalog();

View File

@@ -18,6 +18,7 @@ void APS_Editor_HUD::BeginPlay()
{
MainWidget->SetSelectionManager(EditorPC->GetSelectionManager());
MainWidget->SetSpawnManager(EditorPC->GetSpawnManager());
MainWidget->SetSceneSerializer(EditorPC->GetSceneSerializer());
}
MainWidget->AddToViewport(0);

View File

@@ -1,6 +1,7 @@
#include "PS_Editor_MainWidget.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "Widgets/SBoxPanel.h"
@@ -23,6 +24,11 @@ void UPS_Editor_MainWidget::SetSpawnManager(UPS_Editor_SpawnManager* InSpawnMana
SpawnManager = InSpawnManager;
}
void UPS_Editor_MainWidget::SetSceneSerializer(UPS_Editor_SceneSerializer* InSerializer)
{
SceneSerializer = InSerializer;
}
TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
{
return SNew(SOverlay)
@@ -70,6 +76,13 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
[
BuildToolbar()
]
// Scene save/load
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
BuildSceneButtons()
]
]
// Left panel: Spawn Catalogue
+ SOverlay::Slot()
@@ -258,6 +271,88 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildPropertiesPanel()
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
{
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(8.0f, 4.0f))
[
SNew(SVerticalBox)
// Save row: text field + save button
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 2.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
.Padding(0.0f, 0.0f, 4.0f, 0.0f)
[
SAssignNew(SaveNameField, SEditableTextBox)
.HintText(FText::FromString(TEXT("Scene name...")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
+ SHorizontalBox::Slot()
.AutoWidth()
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (SaveNameField.IsValid() && SceneSerializer.IsValid() && SpawnManager.IsValid())
{
FString Name = SaveNameField->GetText().ToString();
if (!Name.IsEmpty())
{
SceneSerializer->SaveScene(Name, SpawnManager.Get());
// Refresh scene list
PopulateSceneList();
}
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Save")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
]
// Action buttons: New + Load list
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 2.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0.0f, 0.0f, 4.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
SceneSerializer->ClearScene(SpawnManager.Get());
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("New Scene")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
]
// Saved scenes list
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
SAssignNew(SceneListContainer, SVerticalBox)
]
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSpawnCatalog()
{
TSharedRef<SVerticalBox> CatalogList = SNew(SVerticalBox);
@@ -302,6 +397,67 @@ void UPS_Editor_MainWidget::NativeConstruct()
{
Super::NativeConstruct();
PopulateSpawnCatalog();
PopulateSceneList();
}
void UPS_Editor_MainWidget::PopulateSceneList()
{
if (!SceneListContainer.IsValid()) return;
SceneListContainer->ClearChildren();
UPS_Editor_SceneSerializer* Ser = SceneSerializer.Get();
if (!Ser) return;
TArray<FString> Scenes = Ser->GetSavedSceneNames();
if (Scenes.Num() == 0)
{
SceneListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("No saved scenes")))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
];
return;
}
SceneListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Load scene:")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f))
];
for (const FString& SceneName : Scenes)
{
FString Name = SceneName;
SceneListContainer->AddSlot()
.AutoHeight()
.Padding(0.0f, 1.0f)
[
SNew(SButton)
.OnClicked_Lambda([this, Name]() -> FReply
{
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
SceneSerializer->LoadScene(Name, SpawnManager.Get());
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::FromString(Name));
}
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(SceneName))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
];
}
}
void UPS_Editor_MainWidget::PopulateSpawnCatalog()

View File

@@ -7,6 +7,7 @@
class UPS_Editor_SelectionManager;
class UPS_Editor_SpawnManager;
class UPS_Editor_SceneSerializer;
/**
* Main editor widget for PS_Editor.
@@ -22,6 +23,7 @@ public:
/** Set manager references. Must be called after construction. */
void SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager);
void SetSpawnManager(UPS_Editor_SpawnManager* InSpawnManager);
void SetSceneSerializer(UPS_Editor_SceneSerializer* InSerializer);
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
@@ -35,12 +37,19 @@ private:
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SpawnManager> SpawnManager;
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SceneSerializer> SceneSerializer;
// ---- Slate widget refs for dynamic updates ----
TSharedPtr<STextBlock> ModeText;
TSharedPtr<STextBlock> SelectionInfoText;
// Spawn catalog
TSharedPtr<SVerticalBox> SpawnListContainer;
// Scene save/load
TSharedPtr<SEditableTextBox> SaveNameField;
TSharedPtr<SVerticalBox> SceneListContainer;
TArray<TSharedPtr<FSlateBrush>> CachedBrushes;
// Transform fields
@@ -52,6 +61,8 @@ private:
TSharedRef<SWidget> BuildToolbar();
TSharedRef<SWidget> BuildPropertiesPanel();
TSharedRef<SWidget> BuildSpawnCatalog();
TSharedRef<SWidget> BuildSceneButtons();
void PopulateSceneList();
void PopulateSpawnCatalog();
void RefreshPropertiesFromSelection();
void ApplyTransformFromUI();