Standalone editor: scenario workflow, BaseLevel combo, ReturnToEditor

Editor workflow:
- Removed auto-detection of BaseLevel (strip _Editor). Editor is standalone.
- User selects BaseLevel from ComboBox (populated from MasterCatalog.BaseLevels)
- "None" option for editing without a base level
- Selecting BaseLevel: clears current scenario, hides persistent level actors,
  loads sublevel. Selecting "None" restores persistent level actors.

UI terminology: Scene → Scenario (save field, buttons, notifications)

GameInstanceSubsystem:
- Renamed PendingSceneName → PendingScenarioName
- Added LastEditedScenarioName/LastEditedBaseLevel (persist after Consume)
- SetPendingScenario() stores in both Pending and LastEdited
- ReturnToEditor() restores Pending from LastEdited, opens editor map
- EditorMapName auto-saved on first Play, used by ReturnToEditor
- OpenEditor() / ReturnToEditor() callable from gameplay BP

Play button: resets input mode before OpenLevel (fixes cursor in FirstPerson GM)

Test assets: FirstPerson template for testing Play/ReturnToEditor flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 16:12:10 +02:00
parent 472811b858
commit 9c43fd523a
170 changed files with 175 additions and 82 deletions

View File

@@ -1,16 +1,46 @@
#include "PS_Editor_GameInstanceSubsystem.h"
#include "Kismet/GameplayStatics.h"
void UPS_Editor_GameInstanceSubsystem::SetPendingScene(const FString& SceneName, const FString& BaseLevel)
void UPS_Editor_GameInstanceSubsystem::SetPendingScenario(const FString& ScenarioName, const FString& BaseLevel)
{
PendingSceneName = SceneName;
PendingScenarioName = ScenarioName;
PendingBaseLevel = BaseLevel;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Pending scene set: '%s' (level: '%s')"), *SceneName, *BaseLevel);
LastEditedScenarioName = ScenarioName;
LastEditedBaseLevel = BaseLevel;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Pending scenario set: '%s' (base level: '%s')"), *ScenarioName, *BaseLevel);
}
FString UPS_Editor_GameInstanceSubsystem::ConsumePendingScene()
FString UPS_Editor_GameInstanceSubsystem::ConsumePendingScenario()
{
FString Result = PendingSceneName;
PendingSceneName.Empty();
FString Result = PendingScenarioName;
PendingScenarioName.Empty();
PendingBaseLevel.Empty();
// LastEdited fields are NOT cleared — ReturnToEditor uses them
return Result;
}
void UPS_Editor_GameInstanceSubsystem::OpenEditor(const UObject* WorldContextObject)
{
if (EditorMapName.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: EditorMapName not set on GameInstanceSubsystem"));
return;
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Opening editor '%s' (scenario: '%s', base level: '%s')"),
*EditorMapName, *PendingScenarioName, *PendingBaseLevel);
UGameplayStatics::OpenLevel(WorldContextObject, FName(*EditorMapName), true, TEXT(""));
}
void UPS_Editor_GameInstanceSubsystem::ReturnToEditor(const UObject* WorldContextObject)
{
// Restore pending from LastEdited so the editor picks them up
PendingScenarioName = LastEditedScenarioName;
PendingBaseLevel = LastEditedBaseLevel;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Returning to editor (scenario: '%s', base level: '%s')"),
*PendingScenarioName, *PendingBaseLevel);
OpenEditor(WorldContextObject);
}

View File

@@ -6,10 +6,10 @@
/**
* Lightweight subsystem that survives between level transitions (OpenLevel).
* Used to pass the scene name from the editor to the gameplay level.
* Used to pass scenario/base level info between PROSERVE and the editor.
*
* Usage from Blueprint:
* Get Game Instance → Get Subsystem (PS_Editor_GameInstanceSubsystem) → Pending Scene Name
* Get Game Instance → Get Subsystem (PS_Editor_GameInstanceSubsystem) → Pending Scenario Name
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_GameInstanceSubsystem : public UGameInstanceSubsystem
@@ -17,19 +17,38 @@ class PS_EDITOR_API UPS_Editor_GameInstanceSubsystem : public UGameInstanceSubsy
GENERATED_BODY()
public:
/** Scene name to load on the next level. Set by PS_Editor Play button, read by gameplay GameMode. */
/** Scenario name to load on the next level. Set by PROSERVE or Play button, consumed on load. */
UPROPERTY(BlueprintReadWrite, Category = "PS Editor")
FString PendingSceneName;
FString PendingScenarioName;
/** Base level name associated with the pending scene. */
/** Base level name associated with the pending scenario. */
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);
/** Last scenario/base level edited. Persists after consume — used by ReturnToEditor. */
UPROPERTY(BlueprintReadOnly, Category = "PS Editor")
FString LastEditedScenarioName;
/** Helper: consume the pending scene (reads and clears). */
UPROPERTY(BlueprintReadOnly, Category = "PS Editor")
FString LastEditedBaseLevel;
/** Name of the editor map to return to. Set this once at startup. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
FString EditorMapName;
/** Set pending scenario + base level (for opening the editor or gameplay). */
UFUNCTION(BlueprintCallable, Category = "PS Editor")
FString ConsumePendingScene();
void SetPendingScenario(const FString& ScenarioName, const FString& BaseLevel);
/** Consume the pending scenario (reads and clears). */
UFUNCTION(BlueprintCallable, Category = "PS Editor")
FString ConsumePendingScenario();
/** Open the editor with the current pending scenario + base level. */
UFUNCTION(BlueprintCallable, Category = "PS Editor", meta = (WorldContext = "WorldContextObject"))
void OpenEditor(const UObject* WorldContextObject);
/** Return to the editor, keeping the current base level and scenario. */
UFUNCTION(BlueprintCallable, Category = "PS Editor", meta = (WorldContext = "WorldContextObject"))
void ReturnToEditor(const UObject* WorldContextObject);
};

View File

@@ -10,6 +10,9 @@
#include "PS_Editor_PointPlaceable.h"
#include "Engine/LevelStreamingDynamic.h"
#include "PS_Editor_GameInstanceSubsystem.h"
#include "GameFramework/GameModeBase.h"
#include "GameFramework/GameStateBase.h"
#include "GameFramework/PlayerState.h"
APS_Editor_PlayerController::APS_Editor_PlayerController()
{
@@ -43,24 +46,7 @@ void APS_Editor_PlayerController::BeginPlay()
}
}
// 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
// Check if PROSERVE set a pending base level + scenario via the subsystem
if (UGameInstance* GI = GetGameInstance())
{
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
@@ -68,19 +54,18 @@ void APS_Editor_PlayerController::BeginPlay()
if (!Sub->PendingBaseLevel.IsEmpty())
{
LoadBaseLevelAsSublevel(Sub->PendingBaseLevel);
// Also load pending scene if set
if (!Sub->PendingSceneName.IsEmpty() && SpawnManager)
if (!Sub->PendingScenarioName.IsEmpty() && SpawnManager)
{
SceneSerializer->LoadScene(Sub->PendingSceneName, SpawnManager);
SceneSerializer->LoadScene(Sub->PendingScenarioName, SpawnManager);
}
Sub->PendingSceneName.Empty();
Sub->PendingScenarioName.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);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: MasterCatalog loaded (%d catalogs, %d entries). BaseLevel: '%s'"),
Master->Catalogs.Num(), SpawnManager->GetResolvedEntries().Num(), *CurrentBaseLevel);
}
else
{
@@ -206,9 +191,7 @@ void APS_Editor_PlayerController::CancelPointPlacement()
void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelName)
{
if (LevelName.IsEmpty()) return;
// Unload previous sublevel
// Unload previous sublevel first
if (CurrentSublevel)
{
CurrentSublevel->SetShouldBeLoaded(false);
@@ -218,6 +201,35 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Unloaded previous sublevel"));
}
// Hide/show persistent level scene actors (lights, meshes, post-process, etc.)
// Exclude PS_Editor system actors (pawn, controller, gizmo, gamemode, spawned objects)
if (ULevel* PersistentLevel = GetWorld()->PersistentLevel)
{
for (AActor* Actor : PersistentLevel->Actors)
{
if (!Actor) continue;
// Skip system actors
if (Actor == this || Actor == GetPawn()) continue;
if (Cast<AGameModeBase>(Actor) || Cast<AGameStateBase>(Actor) || Cast<APlayerState>(Actor)) continue;
if (Cast<APS_Editor_Gizmo>(Actor)) continue;
// Skip PS_Editor spawned actors (tracked by SpawnManager)
if (SpawnManager && SpawnManager->IsActorSpawned(Actor)) continue;
// Hide persistent level actors when loading a BaseLevel, show when clearing
const bool bHide = !LevelName.IsEmpty() && LevelName != TEXT("None");
Actor->SetActorHiddenInGame(bHide);
Actor->SetActorEnableCollision(!bHide);
}
}
// "None" or empty = no sublevel, just clear
if (LevelName.IsEmpty() || LevelName == TEXT("None"))
{
CurrentBaseLevel = TEXT("");
UE_LOG(LogTemp, Log, TEXT("PS_Editor: No BaseLevel selected, persistent level actors restored"));
return;
}
// Load new sublevel
bool bSuccess = false;
CurrentSublevel = ULevelStreamingDynamic::LoadLevelInstance(

View File

@@ -17,6 +17,7 @@
#include "Widgets/Input/SButton.h"
#include "Widgets/Images/SImage.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Input/SComboBox.h"
#include "Engine/Texture2D.h"
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/SOverlay.h"
@@ -453,7 +454,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
.Padding(0.0f, 0.0f, 4.0f, 0.0f)
[
SAssignNew(SaveNameField, SEditableTextBox)
.HintText(FText::FromString(TEXT("Scene name...")))
.HintText(FText::FromString(TEXT("Scenario name...")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
+ SHorizontalBox::Slot()
@@ -502,7 +503,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("New Scene")))
.Text(FText::FromString(TEXT("New Scenario")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
@@ -540,10 +541,21 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
{
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
{
Sub->SetPendingScene(SceneName, BaseLevel);
Sub->SetPendingScenario(SceneName, BaseLevel);
// Remember editor map for ReturnToEditor
if (Sub->EditorMapName.IsEmpty())
{
FString MapName = EditorPC->GetWorld()->GetMapName();
MapName.RemoveFromStart(EditorPC->GetWorld()->StreamingLevelsPrefix);
Sub->EditorMapName = MapName;
}
}
}
// Reset input mode before transition so the target GameMode starts clean
EditorPC->bShowMouseCursor = false;
EditorPC->SetInputMode(FInputModeGameOnly());
UGameplayStatics::OpenLevel(EditorPC, FName(*BaseLevel), true, TEXT(""));
return FReply::Handled();
})
@@ -555,7 +567,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
]
]
]
// BaseLevel selector row
// BaseLevel combo box
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 2.0f)
@@ -570,46 +582,46 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
]
+ 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
SAssignNew(BaseLevelCombo, SComboBox<TSharedPtr<FString>>)
.OptionsSource(&BaseLevelOptions)
.OnSelectionChanged_Lambda([this](TSharedPtr<FString> Selected, ESelectInfo::Type)
{
if (!Selected.IsValid()) return;
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return FReply::Handled();
if (!EditorPC) return;
// Get BaseLevels from MasterCatalog
TArray<FString> Levels;
if (UPS_Editor_MasterCatalog* Master = EditorPC->MasterCatalogAsset.Get())
// Clear current scenario before switching BaseLevel
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
Levels = Master->BaseLevels;
SceneSerializer->ClearScene(SpawnManager.Get());
}
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::GetEmpty());
}
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]);
EditorPC->LoadBaseLevelAsSublevel(*Selected);
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (LevelFilterText.IsValid())
if (BaseLevelComboText.IsValid())
{
LevelFilterText->SetText(FText::FromString(CurrentLevelFilter));
BaseLevelComboText->SetText(FText::FromString(*Selected));
}
PopulateSceneList();
return FReply::Handled();
LastOutlinerActorCount = -1; // Force outliner refresh
})
.OnGenerateWidget_Lambda([](TSharedPtr<FString> Item) -> TSharedRef<SWidget>
{
return SNew(STextBlock)
.Text(FText::FromString(Item.IsValid() ? *Item : TEXT("")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9));
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT(">")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
SAssignNew(BaseLevelComboText, STextBlock)
.Text(FText::FromString(TEXT("Select...")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 9))
.ColorAndOpacity(FLinearColor(0.4f, 0.8f, 0.4f))
]
]
]
@@ -668,13 +680,31 @@ void UPS_Editor_MainWidget::NativeConstruct()
Super::NativeConstruct();
PopulateSpawnCatalog();
// Init BaseLevel filter from PlayerController auto-detection
// Populate BaseLevel combo from MasterCatalog
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (LevelFilterText.IsValid() && !CurrentLevelFilter.IsEmpty())
BaseLevelOptions.Add(MakeShared<FString>(TEXT("None")));
if (UPS_Editor_MasterCatalog* Master = EditorPC->MasterCatalogAsset.Get())
{
LevelFilterText->SetText(FText::FromString(CurrentLevelFilter));
for (const FString& Level : Master->BaseLevels)
{
BaseLevelOptions.Add(MakeShared<FString>(Level));
}
}
// If a BaseLevel was set (e.g. from subsystem), select it in the combo
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
if (!CurrentLevelFilter.IsEmpty())
{
for (const TSharedPtr<FString>& Opt : BaseLevelOptions)
{
if (*Opt == CurrentLevelFilter)
{
if (BaseLevelCombo.IsValid()) BaseLevelCombo->SetSelectedItem(Opt);
if (BaseLevelComboText.IsValid()) BaseLevelComboText->SetText(FText::FromString(CurrentLevelFilter));
break;
}
}
}
}
@@ -698,7 +728,7 @@ void UPS_Editor_MainWidget::PopulateSceneList()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(CurrentLevelFilter.IsEmpty() ? TEXT("No saved scenes") : FString::Printf(TEXT("No scenes for '%s'"), *CurrentLevelFilter)))
.Text(FText::FromString(CurrentLevelFilter.IsEmpty() ? TEXT("No saved scenarios") : FString::Printf(TEXT("No scenarios for '%s'"), *CurrentLevelFilter)))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
];

View File

@@ -71,7 +71,9 @@ private:
// Scene save/load
TSharedPtr<SEditableTextBox> SaveNameField;
TSharedPtr<SVerticalBox> SceneListContainer;
TSharedPtr<STextBlock> LevelFilterText;
TSharedPtr<SComboBox<TSharedPtr<FString>>> BaseLevelCombo;
TArray<TSharedPtr<FString>> BaseLevelOptions;
TSharedPtr<STextBlock> BaseLevelComboText;
FString CurrentLevelFilter;
TArray<TSharedPtr<FSlateBrush>> CachedBrushes;