Fix packaged build: weak ptr timing, factory module, hard refs, brace fix

Critical fixes for packaged builds:
- Widget weak ptrs (SpawnManager/SceneSerializer) are null in packaged due to
  HUD deferred init. All button lambdas now get references fresh from
  PlayerController via GetOwningPlayer() cast instead of cached weak ptrs.
- HUD BeginPlay deferred by one tick (SetTimerForNextTick) to ensure
  PlayerController has initialized SpawnManager before wiring.

Module split:
- Factories moved to separate PS_EditorTools module (Type: Editor)
  to fix UHT error with UFactory in packaging builds.
- PS_Editor module no longer depends on UnrealEd for factories.

Asset references:
- TSoftObjectPtr/TSoftClassPtr changed to TObjectPtr/TSubclassOf for
  MasterCatalog, SpawnCatalogs, and ActorClass entries. Hard references
  ensure assets are cooked in packaged builds.

Other fixes:
- GetActorLabel() replaced with GetClass()->GetName() (editor-only API)
- Missing brace in Browse dialog BaseLevel sync code
- BaseLevel sync only when scenario specifies a LevelName (no more clearing on empty)
- Save As dialog with native Windows file picker
- Debug info in catalog UI when empty (shows chain: Master→Catalogs→Entries)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 18:17:52 +02:00
parent aa4313057c
commit 7a008c7e60
103 changed files with 35306 additions and 53 deletions

View File

@@ -14,6 +14,11 @@
"Name": "PS_Editor",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "PS_EditorTools",
"Type": "Editor",
"LoadingPhase": "Default"
}
],
"Plugins": [

View File

@@ -41,11 +41,11 @@ void APS_Editor_PlayerController::BeginPlay()
// Load catalogs from MasterCatalog
if (UPS_Editor_MasterCatalog* Master = MasterCatalogAsset.LoadSynchronous())
if (UPS_Editor_MasterCatalog* Master = MasterCatalogAsset)
{
for (const TSoftObjectPtr<UPS_Editor_SpawnCatalog>& CatalogRef : Master->Catalogs)
for (UPS_Editor_SpawnCatalog* Catalog : Master->Catalogs)
{
if (UPS_Editor_SpawnCatalog* Catalog = CatalogRef.LoadSynchronous())
if (Catalog)
{
SpawnManager->AddCatalog(Catalog);
}

View File

@@ -41,7 +41,7 @@ public:
/** Master catalog grouping all sub-catalogs (Tools, Characters, Props...). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TSoftObjectPtr<UPS_Editor_MasterCatalog> MasterCatalogAsset;
TObjectPtr<UPS_Editor_MasterCatalog> MasterCatalogAsset;
/** Current base level for scene save/load filtering. Auto-detected at startup, switchable via UI. */
UPROPERTY(BlueprintReadWrite, Category = "PS Editor")

View File

@@ -29,7 +29,8 @@ public class PS_Editor : ModuleRules
"UMG",
"Json",
"JsonUtilities",
"ProceduralMeshComponent"
"ProceduralMeshComponent",
"DesktopPlatform"
});
PrivateDependencyModuleNames.AddRange(new string[]
@@ -38,7 +39,7 @@ public class PS_Editor : ModuleRules
"SlateCore"
});
// Editor-only: Factory for SpawnCatalog DataAsset creation
// Editor-only: CaptureAllThumbnails uses ThumbnailTools, material auto-generation uses AssetRegistry
if (Target.bBuildEditor)
{
PrivateDependencyModuleNames.Add("UnrealEd");

View File

@@ -16,7 +16,7 @@ void UPS_Editor_SpawnCatalog::CaptureAllThumbnails()
for (FPS_Editor_SpawnEntry& Entry : Entries)
{
UClass* LoadedClass = Entry.ActorClass.LoadSynchronous();
UClass* LoadedClass = Entry.ActorClass;
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class for catalog entry"));

View File

@@ -15,7 +15,7 @@ struct FPS_Editor_SpawnEntry
/** The Blueprint class to spawn. Must have a PS_Editor_SpawnableComponent for name/category. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TSoftClassPtr<AActor> ActorClass;
TSubclassOf<AActor> ActorClass;
};
/**
@@ -57,7 +57,7 @@ class PS_EDITOR_API UPS_Editor_MasterCatalog : public UPrimaryDataAsset
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;
TArray<TObjectPtr<UPS_Editor_SpawnCatalog>> Catalogs;
/**
* List of base level names available for scene editing.

View File

@@ -18,8 +18,7 @@ void UPS_Editor_SpawnManager::AddCatalog(UPS_Editor_SpawnCatalog* InCatalog)
for (const FPS_Editor_SpawnEntry& Entry : InCatalog->Entries)
{
// Synchronously load the class
UClass* LoadedClass = Entry.ActorClass.LoadSynchronous();
UClass* LoadedClass = Entry.ActorClass;
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class for catalog entry"));

View File

@@ -2,26 +2,29 @@
#include "PS_Editor_MainWidget.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_SpawnManager.h"
#include "Blueprint/UserWidget.h"
void APS_Editor_HUD::BeginPlay()
{
Super::BeginPlay();
if (APlayerController* PC = GetOwningPlayerController())
// Defer widget wiring to next tick to ensure PlayerController::BeginPlay has run
GetWorldTimerManager().SetTimerForNextTick([this]()
{
MainWidget = CreateWidget<UPS_Editor_MainWidget>(PC, UPS_Editor_MainWidget::StaticClass());
if (MainWidget)
{
// Wire the SelectionManager to the UI
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
MainWidget->SetSelectionManager(EditorPC->GetSelectionManager());
MainWidget->SetSpawnManager(EditorPC->GetSpawnManager());
MainWidget->SetSceneSerializer(EditorPC->GetSceneSerializer());
}
APlayerController* PC = GetOwningPlayerController();
if (!PC) return;
MainWidget->AddToViewport(0);
MainWidget = CreateWidget<UPS_Editor_MainWidget>(PC, UPS_Editor_MainWidget::StaticClass());
if (!MainWidget) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
MainWidget->SetSelectionManager(EditorPC->GetSelectionManager());
MainWidget->SetSpawnManager(EditorPC->GetSpawnManager());
MainWidget->SetSceneSerializer(EditorPC->GetSceneSerializer());
}
}
MainWidget->AddToViewport(0);
});
}

View File

@@ -23,6 +23,7 @@
#include "Widgets/SOverlay.h"
#include "UObject/UnrealType.h"
#include "Kismet/GameplayStatics.h"
#include "DesktopPlatformModule.h"
#include "PS_Editor_GameInstanceSubsystem.h"
void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
@@ -463,14 +464,24 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (SaveNameField.IsValid() && SceneSerializer.IsValid() && SpawnManager.IsValid())
if (SaveNameField.IsValid() && SceneSerializer.IsValid())
{
FString Name = SaveNameField->GetText().ToString();
if (!Name.IsEmpty())
{
SceneSerializer->SaveScene(Name, SpawnManager.Get());
// Refresh scene list
PopulateSceneList();
// Get SpawnManager fresh from PlayerController (widget weak ptr may be stale in packaged)
UPS_Editor_SpawnManager* SaveSM = nullptr;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
SaveSM = EditorPC->GetSpawnManager();
}
if (!SaveSM) SaveSM = SpawnManager.Get();
if (SaveSM)
{
SceneSerializer->SaveScene(Name, SaveSM);
PopulateSceneList();
}
}
}
return FReply::Handled();
@@ -481,6 +492,53 @@ 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
{
IDesktopPlatform* DesktopPlatform = FDesktopPlatformModule::Get();
if (!DesktopPlatform) return FReply::Handled();
const FString DefaultPath = UPS_Editor_SceneLoader::GetScenesDirectory();
TArray<FString> OutFiles;
void* ParentWindowHandle = FSlateApplication::Get().GetActiveTopLevelWindow().IsValid()
? FSlateApplication::Get().GetActiveTopLevelWindow()->GetNativeWindow()->GetOSWindowHandle()
: nullptr;
bool bOpened = DesktopPlatform->SaveFileDialog(
ParentWindowHandle,
TEXT("Save Scenario As"),
DefaultPath,
SaveNameField.IsValid() ? SaveNameField->GetText().ToString() + TEXT(".json") : TEXT(""),
TEXT("Scenario Files (*.json)|*.json"),
0,
OutFiles);
if (bOpened && OutFiles.Num() > 0)
{
FString FileName = FPaths::GetBaseFilename(OutFiles[0]);
if (!FileName.IsEmpty() && SceneSerializer.IsValid() && SpawnManager.IsValid())
{
SceneSerializer->SaveScene(FileName, SpawnManager.Get());
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::FromString(FileName));
}
PopulateSceneList();
}
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Save As")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
]
// Action buttons: New + Load list
+ SVerticalBox::Slot()
@@ -518,13 +576,14 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (!EditorPC) return FReply::Handled();
// Auto-save current scene before playing
if (SaveNameField.IsValid() && SceneSerializer.IsValid() && SpawnManager.IsValid())
UPS_Editor_SpawnManager* PlaySM = EditorPC->GetSpawnManager();
UPS_Editor_SceneSerializer* PlaySer = EditorPC->GetSceneSerializer();
if (SaveNameField.IsValid() && PlaySer && PlaySM)
{
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);
PlaySer->SaveScene(SceneName, PlaySM);
}
}
@@ -532,7 +591,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
FString BaseLevel = EditorPC->CurrentBaseLevel;
if (BaseLevel.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: No BaseLevel set, cannot Play"));
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: No BaseLevel set, cannot Play. CurrentBaseLevel is empty."));
return FReply::Handled();
}
@@ -566,6 +625,78 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
.ColorAndOpacity(FLinearColor(0.2f, 0.9f, 0.2f))
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
IDesktopPlatform* DesktopPlatform = FDesktopPlatformModule::Get();
if (!DesktopPlatform) return FReply::Handled();
const FString DefaultPath = UPS_Editor_SceneLoader::GetScenesDirectory();
TArray<FString> OutFiles;
void* ParentWindowHandle = FSlateApplication::Get().GetActiveTopLevelWindow().IsValid()
? FSlateApplication::Get().GetActiveTopLevelWindow()->GetNativeWindow()->GetOSWindowHandle()
: nullptr;
bool bOpened = DesktopPlatform->OpenFileDialog(
ParentWindowHandle,
TEXT("Load Scenario"),
DefaultPath,
TEXT(""),
TEXT("Scenario Files (*.json)|*.json"),
0,
OutFiles);
if (bOpened && OutFiles.Num() > 0)
{
FString FileName = FPaths::GetBaseFilename(OutFiles[0]);
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
// Sync BaseLevel sublevel only if scenario specifies one
FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(FileName);
if (!SceneLevelName.IsEmpty())
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (EditorPC->CurrentBaseLevel != SceneLevelName)
{
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName);
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
for (const TSharedPtr<FString>& Opt : BaseLevelOptions)
{
if (*Opt == SceneLevelName)
{
if (BaseLevelCombo.IsValid()) BaseLevelCombo->SetSelectedItem(Opt);
if (BaseLevelComboText.IsValid()) BaseLevelComboText->SetText(FText::FromString(SceneLevelName));
break;
}
}
}
}
}
SceneSerializer->LoadScene(FileName, SpawnManager.Get());
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::FromString(FileName));
}
PopulateSceneList();
LastOutlinerActorCount = -1;
}
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Browse")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
]
// BaseLevel combo box
+ SVerticalBox::Slot()
@@ -594,9 +725,12 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (!EditorPC) return;
// Clear current scenario before switching BaseLevel
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
if (UPS_Editor_SceneSerializer* Ser = EditorPC->GetSceneSerializer())
{
SceneSerializer->ClearScene(SpawnManager.Get());
if (UPS_Editor_SpawnManager* SM = EditorPC->GetSpawnManager())
{
Ser->ClearScene(SM);
}
}
if (SaveNameField.IsValid())
{
@@ -686,7 +820,7 @@ void UPS_Editor_MainWidget::NativeConstruct()
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
BaseLevelOptions.Add(MakeShared<FString>(TEXT("None")));
if (UPS_Editor_MasterCatalog* Master = EditorPC->MasterCatalogAsset.Get())
if (UPS_Editor_MasterCatalog* Master = EditorPC->MasterCatalogAsset)
{
for (const FString& Level : Master->BaseLevels)
{
@@ -761,11 +895,36 @@ void UPS_Editor_MainWidget::PopulateSceneList()
{
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
// Sync the BaseLevel sublevel only if scenario specifies one
FString SceneLevelName = UPS_Editor_SceneLoader::GetSceneLevelName(Name);
if (!SceneLevelName.IsEmpty())
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (EditorPC->CurrentBaseLevel != SceneLevelName)
{
EditorPC->LoadBaseLevelAsSublevel(SceneLevelName);
CurrentLevelFilter = EditorPC->CurrentBaseLevel;
for (const TSharedPtr<FString>& Opt : BaseLevelOptions)
{
if (*Opt == SceneLevelName)
{
if (BaseLevelCombo.IsValid()) BaseLevelCombo->SetSelectedItem(Opt);
if (BaseLevelComboText.IsValid()) BaseLevelComboText->SetText(FText::FromString(SceneLevelName));
break;
}
}
}
}
}
SceneSerializer->LoadScene(Name, SpawnManager.Get());
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::FromString(Name));
}
LastOutlinerActorCount = -1;
}
return FReply::Handled();
})
@@ -794,9 +953,9 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("No catalog loaded")))
.Text(FText::FromString(TEXT("SpawnManager is null")))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
.ColorAndOpacity(FLinearColor(1.0f, 0.3f, 0.3f))
];
return;
}
@@ -804,13 +963,35 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
const TArray<FPS_Editor_ResolvedEntry>& Entries = SM->GetResolvedEntries();
if (Entries.Num() == 0)
{
// Debug: show why catalog is empty
FString DebugInfo = TEXT("Catalog empty.");
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (EditorPC->MasterCatalogAsset)
{
DebugInfo += FString::Printf(TEXT(" Master OK (%d sub-catalogs)."),
EditorPC->MasterCatalogAsset->Catalogs.Num());
for (UPS_Editor_SpawnCatalog* Cat : EditorPC->MasterCatalogAsset->Catalogs)
{
DebugInfo += FString::Printf(TEXT(" [%s:%d]"),
Cat ? *Cat->GetName() : TEXT("NULL"),
Cat ? Cat->Entries.Num() : 0);
}
}
else
{
DebugInfo += TEXT(" MasterCatalog is NULL!");
}
}
SpawnListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Catalog is empty")))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
.Text(FText::FromString(DebugInfo))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 8))
.ColorAndOpacity(FLinearColor(1.0f, 0.5f, 0.3f))
.AutoWrapText(true)
];
return;
}
@@ -886,7 +1067,13 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
SNew(SButton)
.OnClicked_Lambda([this, EntryIndex]() -> FReply
{
UPS_Editor_SpawnManager* Mgr = SpawnManager.Get();
// Get SpawnManager fresh from PlayerController
UPS_Editor_SpawnManager* Mgr = nullptr;
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
Mgr = PC->GetSpawnManager();
}
if (!Mgr) Mgr = SpawnManager.Get();
if (!Mgr) return FReply::Handled();
// Check if this actor implements IPS_Editor_PointPlaceable → enter placement mode
@@ -1073,7 +1260,9 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
if (SelectionInfoText.IsValid())
{
FString Info = FString::Printf(TEXT("%s (%d)"), *Actor->GetActorLabel(), Selected.Num());
FString ActorName = Actor->GetClass()->GetName();
ActorName.RemoveFromEnd(TEXT("_C"));
FString Info = FString::Printf(TEXT("%s (%d)"), *ActorName, Selected.Num());
SelectionInfoText->SetText(FText::FromString(Info));
}

View File

@@ -0,0 +1,18 @@
using UnrealBuildTool;
public class PS_EditorTools : ModuleRules
{
public PS_EditorTools(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"PS_Editor",
"UnrealEd"
});
}
}

View File

@@ -0,0 +1,3 @@
#include "PS_EditorTools.h"
IMPLEMENT_MODULE(FPS_EditorToolsModule, PS_EditorTools)

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Modules/ModuleManager.h"
class FPS_EditorToolsModule : public IModuleInterface
{
public:
virtual void StartupModule() override {}
virtual void ShutdownModule() override {}
};

View File

@@ -1,10 +1,10 @@
#if WITH_EDITOR
#include "PS_Editor_SpawnCatalogFactory.h"
#include "PS_Editor_SpawnCatalog.h"
#define LOCTEXT_NAMESPACE "PS_Editor"
// ---- SpawnCatalog Factory ----
UPS_Editor_SpawnCatalogFactory::UPS_Editor_SpawnCatalogFactory()
{
SupportedClass = UPS_Editor_SpawnCatalog::StaticClass();
@@ -22,7 +22,7 @@ FText UPS_Editor_SpawnCatalogFactory::GetDisplayName() const
return LOCTEXT("SpawnCatalogDisplayName", "PS Editor Spawn Catalog");
}
// ---- Master Catalog Factory ----
// ---- MasterCatalog Factory ----
UPS_Editor_MasterCatalogFactory::UPS_Editor_MasterCatalogFactory()
{
@@ -42,5 +42,3 @@ FText UPS_Editor_MasterCatalogFactory::GetDisplayName() const
}
#undef LOCTEXT_NAMESPACE
#endif

View File

@@ -1,7 +1,5 @@
#pragma once
#if WITH_EDITOR
#include "CoreMinimal.h"
#include "Factories/Factory.h"
#include "PS_Editor_SpawnCatalogFactory.generated.h"
@@ -31,5 +29,3 @@ public:
virtual bool ShouldShowInNewMenu() const override { return true; }
virtual FText GetDisplayName() const override;
};
#endif