Add PROSERVE integration: sublevel loading, Play button, GameInstance subsystem

Architecture for single editor map workflow:
- MasterCatalog.BaseLevels lists available base levels
- BaseLevel selector in UI (cycle button) loads selected level as sublevel
- Sublevel provides visual context only — save/load only touches PS_Editor objects
- No auto-load at startup: user picks via UI or PROSERVE sets via subsystem

GameInstanceSubsystem (survives OpenLevel):
- PendingSceneName + PendingBaseLevel set by PROSERVE before opening editor
- Editor reads them at BeginPlay: loads sublevel + scene automatically
- ConsumePendingScene() for gameplay side to read and clear

Play button:
- Auto-saves current scene
- Stores scene name in GameInstanceSubsystem
- Opens the BaseLevel via OpenLevel

SceneLoader:
- bStripEditorComponents parameter (default true) removes Spawnable/Editable
  components after load for clean gameplay actors

SceneData v3: LevelName field associates scenes with base levels.
Scene list filtered by selected BaseLevel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 15:32:43 +02:00
parent aafcbbb7f9
commit 472811b858
10 changed files with 338 additions and 145 deletions

View File

@@ -0,0 +1,16 @@
#include "PS_Editor_GameInstanceSubsystem.h"
void UPS_Editor_GameInstanceSubsystem::SetPendingScene(const FString& SceneName, const FString& BaseLevel)
{
PendingSceneName = SceneName;
PendingBaseLevel = BaseLevel;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Pending scene set: '%s' (level: '%s')"), *SceneName, *BaseLevel);
}
FString UPS_Editor_GameInstanceSubsystem::ConsumePendingScene()
{
FString Result = PendingSceneName;
PendingSceneName.Empty();
PendingBaseLevel.Empty();
return Result;
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "PS_Editor_GameInstanceSubsystem.generated.h"
/**
* Lightweight subsystem that survives between level transitions (OpenLevel).
* Used to pass the scene name from the editor to the gameplay level.
*
* Usage from Blueprint:
* Get Game Instance → Get Subsystem (PS_Editor_GameInstanceSubsystem) → Pending Scene Name
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_GameInstanceSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
/** Scene name to load on the next level. Set by PS_Editor Play button, read by gameplay GameMode. */
UPROPERTY(BlueprintReadWrite, Category = "PS Editor")
FString PendingSceneName;
/** Base level name associated with the pending scene. */
UPROPERTY(BlueprintReadWrite, Category = "PS Editor")
FString PendingBaseLevel;
/** Helper: set both values at once. */
UFUNCTION(BlueprintCallable, Category = "PS Editor")
void SetPendingScene(const FString& SceneName, const FString& BaseLevel);
/** Helper: consume the pending scene (reads and clears). */
UFUNCTION(BlueprintCallable, Category = "PS Editor")
FString ConsumePendingScene();
};

View File

@@ -8,6 +8,8 @@
#include "PS_Editor_Pawn.h"
#include "PS_Editor_SplineActor.h"
#include "PS_Editor_PointPlaceable.h"
#include "Engine/LevelStreamingDynamic.h"
#include "PS_Editor_GameInstanceSubsystem.h"
APS_Editor_PlayerController::APS_Editor_PlayerController()
{
@@ -40,8 +42,45 @@ void APS_Editor_PlayerController::BeginPlay()
SpawnManager->AddCatalog(Catalog);
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: MasterCatalog loaded with %d sub-catalogs, %d total entries"),
Master->Catalogs.Num(), SpawnManager->GetResolvedEntries().Num());
// Auto-detect base level: strip "_Editor"/"_editor" from map name, match against BaseLevels
FString MapName = GetWorld()->GetMapName();
MapName.RemoveFromStart(GetWorld()->StreamingLevelsPrefix);
FString Candidate = MapName;
Candidate.RemoveFromEnd(TEXT("_Editor"));
Candidate.RemoveFromEnd(TEXT("_editor"));
if (Master->BaseLevels.Contains(Candidate))
{
CurrentBaseLevel = Candidate;
}
else if (Master->BaseLevels.Num() > 0)
{
CurrentBaseLevel = Master->BaseLevels[0];
}
// Check if PROSERVE set a pending base level via the subsystem
if (UGameInstance* GI = GetGameInstance())
{
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
{
if (!Sub->PendingBaseLevel.IsEmpty())
{
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel);
// Also load pending scene if set
if (!Sub->PendingSceneName.IsEmpty() && SpawnManager)
{
SceneSerializer->LoadScene(Sub->PendingSceneName, SpawnManager);
}
Sub->PendingSceneName.Empty();
Sub->PendingBaseLevel.Empty();
}
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: MasterCatalog loaded (%d catalogs, %d entries). BaseLevel: '%s' (map: %s)"),
Master->Catalogs.Num(), SpawnManager->GetResolvedEntries().Num(), *CurrentBaseLevel, *MapName);
}
else
{
@@ -165,6 +204,42 @@ void APS_Editor_PlayerController::CancelPointPlacement()
FinishPointPlacement();
}
void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelName)
{
if (LevelName.IsEmpty()) return;
// Unload previous sublevel
if (CurrentSublevel)
{
CurrentSublevel->SetShouldBeLoaded(false);
CurrentSublevel->SetShouldBeVisible(false);
CurrentSublevel->SetIsRequestingUnloadAndRemoval(true);
CurrentSublevel = nullptr;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Unloaded previous sublevel"));
}
// Load new sublevel
bool bSuccess = false;
CurrentSublevel = ULevelStreamingDynamic::LoadLevelInstance(
GetWorld(),
LevelName,
FVector::ZeroVector,
FRotator::ZeroRotator,
bSuccess);
if (bSuccess && CurrentSublevel)
{
CurrentSublevel->SetShouldBeLoaded(true);
CurrentSublevel->SetShouldBeVisible(true);
CurrentBaseLevel = LevelName;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Loaded sublevel '%s'"), *LevelName);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load sublevel '%s'"), *LevelName);
}
}
void APS_Editor_PlayerController::HandleEditorClick(FVector2D ScreenPos, bool bAdditive)
{
if (EditorMode == EPS_Editor_EditorMode::SplinePlacement)

View File

@@ -13,6 +13,7 @@ class UPS_Editor_MasterCatalog;
class UPS_Editor_SceneSerializer;
class APS_Editor_SplineActor;
class IPS_Editor_PointPlaceable;
class ULevelStreamingDynamic;
/**
* Player controller for PS_Editor runtime editing.
@@ -42,14 +43,13 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TSoftObjectPtr<UPS_Editor_MasterCatalog> MasterCatalogAsset;
/**
* Override the level name stored in saved scenes.
* If empty, auto-detected from the current map name.
* Use this when editing on a dedicated map (e.g. "Warehouse_Editor")
* but scenes should be associated with the base level ("Warehouse").
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
FString BaseLevelNameOverride;
/** Current base level for scene save/load filtering. Auto-detected at startup, switchable via UI. */
UPROPERTY(BlueprintReadWrite, Category = "PS Editor")
FString CurrentBaseLevel;
/** Load a base level as sublevel for visual context. Unloads previous sublevel. */
UFUNCTION(BlueprintCallable, Category = "PS Editor")
void LoadBaseLevelAsSublevel(const FString& LevelName);
// ---- Editor Mode (Normal / SplinePlacement) ----
@@ -115,5 +115,9 @@ private:
/** Snapshot of spline points before append, for undo. */
TArray<FVector> AppendInitialPoints;
/** Currently loaded sublevel instance. */
UPROPERTY()
TObjectPtr<ULevelStreamingDynamic> CurrentSublevel;
void HandleEditorClick(FVector2D ScreenPos, bool bAdditive);
};

View File

@@ -1,14 +1,16 @@
#include "PS_Editor_SceneLoader.h"
#include "PS_Editor_SceneData.h"
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_SplineActor.h"
#include "PS_Editor_PointPlaceable.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "UObject/UnrealType.h"
#include "Components/ActorComponent.h"
bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors)
bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors, bool bStripEditorComponents)
{
OutActors.Empty();
@@ -40,7 +42,41 @@ bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const
return false;
}
return LoadSceneFromData(World, SceneData, OutActors);
if (!LoadSceneFromData(World, SceneData, OutActors))
{
return false;
}
// Strip editor-only components for clean gameplay
if (bStripEditorComponents)
{
for (AActor* Actor : OutActors)
{
if (!Actor) continue;
TArray<UActorComponent*> ToRemove;
for (UActorComponent* Comp : Actor->GetComponentsByInterface(UPS_Editor_PointPlaceable::StaticClass()))
{
// Don't remove — PointPlaceable is on the actor itself, not a component
}
if (UActorComponent* Spawnable = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
{
ToRemove.Add(Spawnable);
}
if (UActorComponent* Editable = Actor->FindComponentByClass<UPS_Editor_EditableComponent>())
{
ToRemove.Add(Editable);
}
for (UActorComponent* Comp : ToRemove)
{
Comp->DestroyComponent();
}
}
}
return true;
}
bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_SceneData& SceneData, TArray<AActor*>& OutActors)

View File

@@ -32,11 +32,12 @@ public:
*
* @param World The world to spawn actors into.
* @param SceneName Scene name (without .json extension).
* @param OutActors Optional: filled with all spawned actors.
* @param OutActors Filled with all spawned actors.
* @param bStripEditorComponents If true, removes SpawnableComponent and EditableComponent after spawn (cleaner for gameplay).
* @return True if the scene was loaded successfully.
*/
UFUNCTION(BlueprintCallable, Category = "PS Editor", meta = (WorldContext = "WorldContextObject"))
static bool LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors);
static bool LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors, bool bStripEditorComponents = true);
/**
* Load scene from an already-parsed SceneData struct.

View File

@@ -23,18 +23,10 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
SceneData.SceneName = SceneName;
SceneData.Timestamp = FDateTime::Now().ToString();
// Use BaseLevelNameOverride if set, otherwise auto-detect from map name
// Use the current base level from PlayerController
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter()))
{
if (!EditorPC->BaseLevelNameOverride.IsEmpty())
{
SceneData.LevelName = EditorPC->BaseLevelNameOverride;
}
else if (UWorld* World = EditorPC->GetWorld())
{
SceneData.LevelName = World->GetMapName();
SceneData.LevelName.RemoveFromStart(World->StreamingLevelsPrefix);
}
SceneData.LevelName = EditorPC->CurrentBaseLevel;
}
const TArray<TWeakObjectPtr<AActor>>& SpawnedActors = SpawnManager->GetSpawnedActors();

View File

@@ -58,4 +58,12 @@ public:
/** Sub-catalogs to load. Each one contributes its entries to the runtime spawn UI. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TArray<TSoftObjectPtr<UPS_Editor_SpawnCatalog>> Catalogs;
/**
* List of base level names available for scene editing.
* Used to filter saved scenes by level. Auto-matched by stripping "_Editor" from the current map name.
* Example: "Warehouse", "Office", "Parking"
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TArray<FString> BaseLevels;
};

View File

@@ -21,6 +21,8 @@
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/SOverlay.h"
#include "UObject/UnrealType.h"
#include "Kismet/GameplayStatics.h"
#include "PS_Editor_GameInstanceSubsystem.h"
void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
{
@@ -504,11 +506,117 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return FReply::Handled();
// Auto-save current scene before playing
if (SaveNameField.IsValid() && SceneSerializer.IsValid() && SpawnManager.IsValid())
{
FString SceneName = SaveNameField->GetText().ToString();
if (!SceneName.IsEmpty())
{
SceneSerializer->SaveScene(SceneName, SpawnManager.Get());
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Auto-saved '%s' before Play"), *SceneName);
}
}
// Store scene name in GameInstance subsystem (survives OpenLevel)
FString BaseLevel = EditorPC->CurrentBaseLevel;
if (BaseLevel.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: No BaseLevel set, cannot Play"));
return FReply::Handled();
}
FString SceneName = SaveNameField.IsValid() ? SaveNameField->GetText().ToString() : TEXT("");
if (UGameInstance* GI = EditorPC->GetGameInstance())
{
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
{
Sub->SetPendingScene(SceneName, BaseLevel);
}
}
UGameplayStatics::OpenLevel(EditorPC, FName(*BaseLevel), true, TEXT(""));
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Play")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 10))
.ColorAndOpacity(FLinearColor(0.2f, 0.9f, 0.2f))
]
]
]
// BaseLevel selector row
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 2.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 4.0f, 0.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("BaseLevel:")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f))
]
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
[
SAssignNew(LevelFilterText, STextBlock)
.Text(FText::FromString(TEXT("...")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 9))
.ColorAndOpacity(FLinearColor(0.4f, 0.8f, 0.4f))
]
+ SHorizontalBox::Slot().AutoWidth().Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return FReply::Handled();
// Get BaseLevels from MasterCatalog
TArray<FString> Levels;
if (UPS_Editor_MasterCatalog* Master = EditorPC->MasterCatalogAsset.Get())
{
Levels = Master->BaseLevels;
}
if (Levels.Num() == 0) return FReply::Handled();
// Cycle to next
int32 Idx = Levels.Find(EditorPC->CurrentBaseLevel);
Idx = (Idx + 1) % Levels.Num();
// Load as sublevel + update filter
EditorPC->LoadBaseLevelAsSublevel(Levels[Idx]);
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (LevelFilterText.IsValid())
{
LevelFilterText->SetText(FText::FromString(CurrentLevelFilter));
}
PopulateSceneList();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT(">")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
]
// Saved scenes list
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
.Padding(0.0f, 2.0f, 0.0f, 0.0f)
[
SAssignNew(SceneListContainer, SVerticalBox)
]
@@ -559,6 +667,17 @@ void UPS_Editor_MainWidget::NativeConstruct()
{
Super::NativeConstruct();
PopulateSpawnCatalog();
// Init BaseLevel filter from PlayerController auto-detection
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (LevelFilterText.IsValid() && !CurrentLevelFilter.IsEmpty())
{
LevelFilterText->SetText(FText::FromString(CurrentLevelFilter));
}
}
PopulateSceneList();
}
@@ -570,65 +689,23 @@ void UPS_Editor_MainWidget::PopulateSceneList()
UPS_Editor_SceneSerializer* Ser = SceneSerializer.Get();
if (!Ser) return;
// Get current level name for filtering (use override if set)
FString CurrentLevel;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (!EditorPC->BaseLevelNameOverride.IsEmpty())
{
CurrentLevel = EditorPC->BaseLevelNameOverride;
}
else if (UWorld* World = EditorPC->GetWorld())
{
CurrentLevel = World->GetMapName();
CurrentLevel.RemoveFromStart(World->StreamingLevelsPrefix);
}
}
// Filter scenes by selected BaseLevel
TArray<FString> Scenes = UPS_Editor_SceneLoader::GetSavedSceneNames(CurrentLevelFilter);
// Show current level
if (!CurrentLevel.IsEmpty())
if (Scenes.Num() == 0)
{
SceneListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::Format(NSLOCTEXT("PS_Editor", "LevelLabel", "Level: {0}"), FText::FromString(CurrentLevel)))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 8))
.ColorAndOpacity(FLinearColor(0.4f, 0.6f, 0.4f))
];
}
// Get scenes for current level + all scenes
TArray<FString> LevelScenes = UPS_Editor_SceneLoader::GetSavedSceneNames(CurrentLevel);
TArray<FString> AllScenes = UPS_Editor_SceneLoader::GetSavedSceneNames();
if (AllScenes.Num() == 0)
{
SceneListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("No saved scenes")))
.Text(FText::FromString(CurrentLevelFilter.IsEmpty() ? TEXT("No saved scenes") : FString::Printf(TEXT("No scenes for '%s'"), *CurrentLevelFilter)))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
];
return;
}
// Show scenes for current level first
if (LevelScenes.Num() > 0)
{
SceneListContainer->AddSlot()
.AutoHeight()
.Padding(0.0f, 2.0f, 0.0f, 1.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("This level:")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 9))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f))
];
for (const FString& SceneName : LevelScenes)
for (const FString& SceneName : Scenes)
{
FString Name = SceneName;
SceneListContainer->AddSlot()
@@ -655,59 +732,6 @@ void UPS_Editor_MainWidget::PopulateSceneList()
]
];
}
}
// Show other scenes (from different levels) in a collapsed section
TArray<FString> OtherScenes;
for (const FString& S : AllScenes)
{
if (!LevelScenes.Contains(S))
{
OtherScenes.Add(S);
}
}
if (OtherScenes.Num() > 0)
{
SceneListContainer->AddSlot()
.AutoHeight()
.Padding(0.0f, 6.0f, 0.0f, 1.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Other levels:")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 8))
.ColorAndOpacity(FLinearColor(0.4f, 0.4f, 0.4f))
];
for (const FString& SceneName : OtherScenes)
{
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", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
]
];
}
}
}
void UPS_Editor_MainWidget::PopulateSpawnCatalog()

View File

@@ -71,6 +71,8 @@ private:
// Scene save/load
TSharedPtr<SEditableTextBox> SaveNameField;
TSharedPtr<SVerticalBox> SceneListContainer;
TSharedPtr<STextBlock> LevelFilterText;
FString CurrentLevelFilter;
TArray<TSharedPtr<FSlateBrush>> CachedBrushes;
// Transform fields