Add Phase 3: object spawn system with catalogue UI

Spawn System:
- SpawnableComponent: marker component for Blueprint actors (name, category, thumbnail)
- SpawnCatalog DataAsset: lists all spawnable Blueprints with Factory for easy creation
- SpawnManager: loads catalog, spawns actors in front of camera, ensures Movable mobility
- Reads metadata from Blueprint component templates (no temp spawn needed)

Catalogue UI:
- Left panel with scrollable list grouped by category
- Thumbnail cards (80x80) with name below, click to spawn
- "Capture All Thumbnails" button on DataAsset: auto-generates from UE thumbnails

Undo/Redo improvements:
- SpawnAction: undo hides spawned actor, redo shows it
- Delete confirmation dialog (Yes/No) before soft-delete
- Undo/redo auto-deselects hidden actors and refreshes gizmo
- Proper cleanup of hidden actors when actions pruned from stack

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 20:17:57 +02:00
parent ccb154c44f
commit 9f1604f57c
27 changed files with 1009 additions and 20 deletions

View File

@@ -8,11 +8,35 @@ PS_Editor is a runtime editor plugin for Unreal Engine 5.5 that will be integrat
**Why:** Enable runtime scene editing in packaged builds - place objects, configure properties, define scenarios, manipulate splines, modify lighting, and save/load scenes as JSON.
**How to apply:** All development is incremental, phase by phase. Phase 1 = GameMode + Camera (completed). Future phases: object spawning/transform, JSON serialization, splines, lighting, AI characters.
Key decisions:
- UI: UMG driven by C++ (not Blueprints)
- Gizmos: InteractiveToolsFramework (UE5 built-in)
- Serialization: JSON via Json/JsonUtilities modules
- UI: UMG driven by C++ (Slate)
- Gizmos: Custom actor with BasicShapes + unlit material (InteractiveToolsFramework is editor-only)
- Serialization: JSON via Json/JsonUtilities modules (future)
- Naming: PS_Editor_ prefix on all files/classes
- Plugin path: Unreal/Plugins/PS_Editor/
- AZERTY keyboard layout: arrows + A/Q for movement, E/R/T for modes
## Completed Phases
### Phase 1 - GameMode + Camera
- PS_Editor_GameMode, PS_Editor_Pawn, PS_Editor_PlayerController, PS_Editor_HUD
- Camera: Fly (RMB), Pan (LMB drag), Orbit (Alt+LMB / MMB)
- Movement: arrows + A (up) / Q (down)
### Phase 2 - Selection + Transform + Undo
- SelectionManager: raycast click-to-select, Ctrl+click multi-select, Movable actors only
- Gizmo: custom actor with colored arrows (R/G/B), cones, SDPG_Foreground, constant screen size
- Translate/Rotate/Scale via gizmo drag, hotkeys E/R/T
- Snap to ground (End key), soft-delete (Delete key)
- Undo/Redo system: custom UndoManager, Ctrl+Z / Ctrl+Y, 50 action stack
- Properties panel: editable Location/Rotation/Scale fields
- Toolbar: mode buttons + snap ground
- Material: M_PS_Editor_Gizmo (Unlit, Vector Parameter "Color" → Emissive, Translucent, Disable Depth Test)
## Next: Phase 3 - Object Spawning
- Catalog/list of objects to place
- Drag & drop or click to spawn
- Duplication of existing objects
## Next: Phase 4 - JSON Serialization
- Save/load scenes as JSON

View File

@@ -12,3 +12,7 @@ Build command (Editor target, Development):
```
**How to apply:** Use this command to compile after code changes. Timeout should be set to 600000ms (10 min) for safety. The target name is `PS_ProserveEditorEditor` (Editor suffix) for editor builds.
**When to use CLI vs Live Coding:**
- Live Coding (Ctrl+Alt+F11): .cpp only changes (no new UCLASS, USTRUCT, UENUM, UPROPERTY in headers)
- Full CLI build (UE must be closed): new files, header changes with new reflected types, Build.cs changes

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -13,6 +13,7 @@
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h"
#include "DrawDebugHelpers.h"
#include "Misc/MessageDialog.h"
APS_Editor_Pawn::APS_Editor_Pawn()
{
@@ -573,6 +574,18 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
if (!SM || !SM->IsAnythingSelected()) return;
// Confirmation dialog
const int32 Count = SM->GetSelectedActors().Num();
const FText Message = FText::Format(
NSLOCTEXT("PS_Editor", "DeleteConfirm", "Delete {0} selected object(s)?"),
FText::AsNumber(Count));
EAppReturnType::Type Result = FMessageDialog::Open(EAppMsgType::YesNo, Message);
if (Result != EAppReturnType::Yes)
{
return;
}
// Soft-delete: hide actors instead of destroying (enables undo)
TSharedPtr<FPS_Editor_DeleteAction> Action = MakeShared<FPS_Editor_DeleteAction>();
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
@@ -584,14 +597,14 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
}
SM->ClearSelection();
Action->Execute(); // Hide the actors
Action->Execute();
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->RecordAction(Action);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Soft-deleted %d actor(s)"), Action->Actors.Num());
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Soft-deleted %d actor(s)"), Count);
}
void APS_Editor_Pawn::HandleUndo(const FInputActionValue& Value)

View File

@@ -1,6 +1,7 @@
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_Pawn.h"
@@ -22,14 +23,43 @@ void APS_Editor_PlayerController::BeginPlay()
UndoManager = NewObject<UPS_Editor_UndoManager>(this);
// After undo/redo, refresh the gizmo position
// Load spawn catalog and create spawn manager
SpawnManager = NewObject<UPS_Editor_SpawnManager>(this);
UPS_Editor_SpawnCatalog* LoadedCatalog = SpawnCatalogAsset.LoadSynchronous();
SpawnManager->Initialize(this, LoadedCatalog);
// After undo/redo, deselect hidden actors and refresh gizmo
UndoManager->OnUndoRedo.AddWeakLambda(this, [this]()
{
if (SelectionManager && SelectionManager->IsAnythingSelected())
if (SelectionManager)
{
if (APS_Editor_Gizmo* Gizmo = SelectionManager->GetGizmo())
// Deselect any actors that are now hidden (undone spawn or redone delete)
TArray<TWeakObjectPtr<AActor>> ToDeselect;
for (const TWeakObjectPtr<AActor>& Weak : SelectionManager->GetSelectedActors())
{
Gizmo->SetActorLocation(SelectionManager->GetSelectionPivot());
if (AActor* Actor = Weak.Get())
{
if (Actor->IsHidden())
{
ToDeselect.Add(Actor);
}
}
}
for (const TWeakObjectPtr<AActor>& Weak : ToDeselect)
{
if (AActor* Actor = Weak.Get())
{
SelectionManager->DeselectActor(Actor);
}
}
// Refresh gizmo position
if (SelectionManager->IsAnythingSelected())
{
if (APS_Editor_Gizmo* Gizmo = SelectionManager->GetGizmo())
{
Gizmo->SetActorLocation(SelectionManager->GetSelectionPivot());
}
}
}
});

View File

@@ -6,6 +6,8 @@
class UPS_Editor_SelectionManager;
class UPS_Editor_UndoManager;
class UPS_Editor_SpawnManager;
class UPS_Editor_SpawnCatalog;
/**
* Player controller for PS_Editor runtime editing.
@@ -25,6 +27,13 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_UndoManager* GetUndoManager() const { return UndoManager; }
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_SpawnManager* GetSpawnManager() const { return SpawnManager; }
/** 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;
protected:
virtual void BeginPlay() override;
virtual void OnPossess(APawn* InPawn) override;
@@ -36,5 +45,8 @@ private:
UPROPERTY()
TObjectPtr<UPS_Editor_UndoManager> UndoManager;
UPROPERTY()
TObjectPtr<UPS_Editor_SpawnManager> SpawnManager;
void HandleEditorClick(FVector2D ScreenPos, bool bAdditive);
};

View File

@@ -15,6 +15,7 @@ public class PS_Editor : ModuleRules
PublicIncludePaths.Add(Path.Combine(SourceDir, "UI", "Widgets"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Selection"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Gizmo"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Spawn"));
PublicDependencyModuleNames.AddRange(new string[]
{
@@ -31,5 +32,11 @@ public class PS_Editor : ModuleRules
"Slate",
"SlateCore"
});
// Editor-only: Factory for SpawnCatalog DataAsset creation
if (Target.bBuildEditor)
{
PrivateDependencyModuleNames.Add("UnrealEd");
}
}
}

View File

@@ -89,16 +89,30 @@ void UPS_Editor_UndoManager::PruneHistory()
void UPS_Editor_UndoManager::CleanupDeleteAction(TSharedPtr<FPS_Editor_Action> Action)
{
// If a delete action is being pruned from history, truly destroy the hidden actors
if (!Action.IsValid() || Action->ActionType != EPS_Editor_ActionType::Delete) return;
FPS_Editor_DeleteAction* DeleteAction = static_cast<FPS_Editor_DeleteAction*>(Action.Get());
for (const TWeakObjectPtr<AActor>& Weak : DeleteAction->Actors)
if (!Action.IsValid()) return;
// Truly destroy hidden actors when their action is pruned from history
TArray<TWeakObjectPtr<AActor>>* ActorsToClean = nullptr;
if (Action->ActionType == EPS_Editor_ActionType::Delete)
{
if (AActor* Actor = Weak.Get())
ActorsToClean = &static_cast<FPS_Editor_DeleteAction*>(Action.Get())->Actors;
}
else if (Action->ActionType == EPS_Editor_ActionType::Spawn)
{
ActorsToClean = &static_cast<FPS_Editor_SpawnAction*>(Action.Get())->Actors;
}
if (ActorsToClean)
{
for (const TWeakObjectPtr<AActor>& Weak : *ActorsToClean)
{
if (Actor->IsHidden())
if (AActor* Actor = Weak.Get())
{
Actor->Destroy();
if (Actor->IsHidden())
{
Actor->Destroy();
}
}
}
}

View File

@@ -7,7 +7,8 @@
enum class EPS_Editor_ActionType : uint8
{
Transform,
Delete
Delete,
Spawn
};
/**
@@ -110,6 +111,49 @@ struct FPS_Editor_DeleteAction : public FPS_Editor_Action
}
};
/**
* Records a spawn action. Undo hides the spawned actor, redo shows it again.
*/
struct FPS_Editor_SpawnAction : public FPS_Editor_Action
{
FPS_Editor_SpawnAction() : FPS_Editor_Action(EPS_Editor_ActionType::Spawn) {}
TArray<TWeakObjectPtr<AActor>> Actors;
virtual void Execute() override
{
// Redo: show the spawned actors
for (const TWeakObjectPtr<AActor>& Weak : Actors)
{
if (AActor* Actor = Weak.Get())
{
Actor->SetActorHiddenInGame(false);
Actor->SetActorEnableCollision(true);
Actor->SetActorTickEnabled(true);
}
}
}
virtual void Undo() override
{
// Undo spawn: hide the actors
for (const TWeakObjectPtr<AActor>& Weak : Actors)
{
if (AActor* Actor = Weak.Get())
{
Actor->SetActorHiddenInGame(true);
Actor->SetActorEnableCollision(false);
Actor->SetActorTickEnabled(false);
}
}
}
virtual FString GetDescription() const override
{
return FString::Printf(TEXT("Spawn %d actor(s)"), Actors.Num());
}
};
/**
* Runtime undo/redo manager for PS_Editor.
* Maintains a linear action stack with a cursor.

View File

@@ -0,0 +1,113 @@
#include "PS_Editor_SpawnCatalog.h"
#if WITH_EDITOR
#include "PS_Editor_SpawnableComponent.h"
#include "Engine/Blueprint.h"
#include "Engine/Texture2D.h"
#include "ThumbnailRendering/ThumbnailManager.h"
#include "ObjectTools.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "UObject/SavePackage.h"
#include "Misc/PackageName.h"
void UPS_Editor_SpawnCatalog::CaptureAllThumbnails()
{
int32 Captured = 0;
for (FPS_Editor_SpawnEntry& Entry : Entries)
{
UClass* LoadedClass = Entry.ActorClass.LoadSynchronous();
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class for catalog entry"));
continue;
}
// Find the SpawnableComponent template
UPS_Editor_SpawnableComponent* SpawnComp = nullptr;
TArray<UObject*> ClassSubObjects;
GetObjectsWithOuter(LoadedClass, ClassSubObjects, true);
for (UObject* Sub : ClassSubObjects)
{
SpawnComp = Cast<UPS_Editor_SpawnableComponent>(Sub);
if (SpawnComp) break;
}
if (!SpawnComp)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: No SpawnableComponent on %s, skipping"), *LoadedClass->GetName());
continue;
}
// Skip if already has a custom thumbnail
if (SpawnComp->Thumbnail)
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: %s already has a thumbnail, skipping"), *LoadedClass->GetName());
continue;
}
// Find the Blueprint
UBlueprint* Blueprint = Cast<UBlueprint>(LoadedClass->ClassGeneratedBy);
if (!Blueprint)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: %s is not a Blueprint, skipping"), *LoadedClass->GetName());
continue;
}
// Generate thumbnail
FObjectThumbnail* ObjThumb = ThumbnailTools::GenerateThumbnailForObjectToSaveToDisk(Blueprint);
if (!ObjThumb || ObjThumb->GetImageWidth() <= 0 || ObjThumb->GetImageHeight() <= 0)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to generate thumbnail for %s"), *Blueprint->GetName());
continue;
}
const int32 Width = ObjThumb->GetImageWidth();
const int32 Height = ObjThumb->GetImageHeight();
const TArray<uint8>& PixelData = ObjThumb->GetUncompressedImageData();
if (PixelData.Num() != Width * Height * 4)
{
continue;
}
// Save texture next to the Blueprint
FString BlueprintPath = Blueprint->GetOutermost()->GetName();
FString BasePath = FPaths::GetPath(BlueprintPath);
FString AssetName = FString::Printf(TEXT("T_%s_Thumb"), *Blueprint->GetName());
FString FullPath = FString::Printf(TEXT("%s/%s"), *BasePath, *AssetName);
UPackage* Package = CreatePackage(*FullPath);
if (!Package) continue;
UTexture2D* Texture = NewObject<UTexture2D>(Package, *AssetName, RF_Public | RF_Standalone);
Texture->Source.Init(Width, Height, 1, 1, TSF_BGRA8);
uint8* MipData = Texture->Source.LockMip(0);
FMemory::Memcpy(MipData, PixelData.GetData(), PixelData.Num());
Texture->Source.UnlockMip(0);
Texture->SRGB = true;
Texture->CompressionSettings = TC_Default;
Texture->MipGenSettings = TMGS_NoMipmaps;
Texture->PostEditChange();
Texture->UpdateResource();
FString PackageFilename = FPackageName::LongPackageNameToFilename(FullPath, FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Public | RF_Standalone;
UPackage::SavePackage(Package, Texture, *PackageFilename, SaveArgs);
FAssetRegistryModule::AssetCreated(Texture);
// Assign to the component and mark Blueprint dirty
SpawnComp->Thumbnail = Texture;
Blueprint->MarkPackageDirty();
Captured++;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Captured thumbnail for %s -> %s"), *Blueprint->GetName(), *FullPath);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Captured %d thumbnails"), Captured);
}
#endif

View File

@@ -0,0 +1,45 @@
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "Engine/AssetManager.h"
#include "PS_Editor_SpawnCatalog.generated.h"
/**
* A single entry in the spawn catalogue.
*/
USTRUCT(BlueprintType)
struct FPS_Editor_SpawnEntry
{
GENERATED_BODY()
/** The Blueprint class to spawn. Must have a PS_Editor_SpawnableComponent for name/category. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TSoftClassPtr<AActor> ActorClass;
};
/**
* Data asset listing all Blueprints available for spawning in the PS_Editor runtime catalogue.
*
* Usage:
* 1. Create a DataAsset of type PS_Editor_SpawnCatalog in Content Browser
* 2. Add entries pointing to your prepared Blueprints
* 3. Click "Capture All Thumbnails" to auto-generate thumbnails
* 4. Reference this DataAsset in the PlayerController's SpawnCatalogAsset property
*/
UCLASS(BlueprintType)
class PS_EDITOR_API UPS_Editor_SpawnCatalog : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
/** List of spawnable actors. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TArray<FPS_Editor_SpawnEntry> Entries;
#if WITH_EDITOR
/** Captures UE-generated thumbnails for all Blueprints in the catalog and assigns them to their SpawnableComponents. */
UFUNCTION(CallInEditor, Category = "PS Editor")
void CaptureAllThumbnails();
#endif
};

View File

@@ -0,0 +1,27 @@
#if WITH_EDITOR
#include "PS_Editor_SpawnCatalogFactory.h"
#include "PS_Editor_SpawnCatalog.h"
#define LOCTEXT_NAMESPACE "PS_Editor"
UPS_Editor_SpawnCatalogFactory::UPS_Editor_SpawnCatalogFactory()
{
SupportedClass = UPS_Editor_SpawnCatalog::StaticClass();
bCreateNew = true;
bEditAfterNew = true;
}
UObject* UPS_Editor_SpawnCatalogFactory::FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
{
return NewObject<UPS_Editor_SpawnCatalog>(InParent, InClass, InName, Flags);
}
FText UPS_Editor_SpawnCatalogFactory::GetDisplayName() const
{
return LOCTEXT("SpawnCatalogDisplayName", "PS Editor Spawn Catalog");
}
#undef LOCTEXT_NAMESPACE
#endif

View File

@@ -0,0 +1,22 @@
#pragma once
#if WITH_EDITOR
#include "CoreMinimal.h"
#include "Factories/Factory.h"
#include "PS_Editor_SpawnCatalogFactory.generated.h"
UCLASS(HideCategories = Object)
class UPS_Editor_SpawnCatalogFactory : public UFactory
{
GENERATED_BODY()
public:
UPS_Editor_SpawnCatalogFactory();
virtual UObject* FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
virtual bool ShouldShowInNewMenu() const override { return true; }
virtual FText GetDisplayName() const override;
};
#endif

View File

@@ -0,0 +1,170 @@
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
void UPS_Editor_SpawnManager::Initialize(APlayerController* InOwnerPC, UPS_Editor_SpawnCatalog* InCatalog)
{
OwnerPC = InOwnerPC;
Catalog = InCatalog;
if (Catalog)
{
ResolveCatalog();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: SpawnCatalog loaded with %d entries"), ResolvedEntries.Num());
}
else
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: No SpawnCatalog assigned. Spawn catalogue will be empty."));
}
}
void UPS_Editor_SpawnManager::ResolveCatalog()
{
ResolvedEntries.Empty();
if (!Catalog)
{
return;
}
for (const FPS_Editor_SpawnEntry& Entry : Catalog->Entries)
{
// Synchronously load the class
UClass* LoadedClass = Entry.ActorClass.LoadSynchronous();
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class for catalog entry"));
continue;
}
FPS_Editor_ResolvedEntry Resolved;
Resolved.ActorClass = LoadedClass;
// Find the SpawnableComponent template in the Blueprint class hierarchy.
// Blueprint-added components are stored as subobjects of the UBlueprintGeneratedClass.
UPS_Editor_SpawnableComponent* SpawnComp = nullptr;
// Search BPGC subobjects (Blueprint-added components)
TArray<UObject*> ClassSubObjects;
GetObjectsWithOuter(LoadedClass, ClassSubObjects, true);
for (UObject* Sub : ClassSubObjects)
{
SpawnComp = Cast<UPS_Editor_SpawnableComponent>(Sub);
if (SpawnComp)
{
break;
}
}
// Fallback: check CDO subobjects (C++ constructor components)
if (!SpawnComp)
{
AActor* CDO = LoadedClass->GetDefaultObject<AActor>();
if (CDO)
{
SpawnComp = CDO->FindComponentByClass<UPS_Editor_SpawnableComponent>();
}
}
if (SpawnComp && !SpawnComp->DisplayName.IsEmpty())
{
Resolved.DisplayName = SpawnComp->DisplayName;
}
else
{
Resolved.DisplayName = LoadedClass->GetName();
Resolved.DisplayName.RemoveFromEnd(TEXT("_C"));
}
Resolved.Category = SpawnComp ? SpawnComp->Category : TEXT("Default");
Resolved.Thumbnail = SpawnComp ? SpawnComp->Thumbnail : nullptr;
ResolvedEntries.Add(Resolved);
}
}
AActor* UPS_Editor_SpawnManager::SpawnFromCatalog(int32 Index)
{
return SpawnFromCatalogAtLocation(Index, ComputeSpawnLocation());
}
AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector WorldLocation)
{
if (!ResolvedEntries.IsValidIndex(Index))
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Invalid catalog index %d"), Index);
return nullptr;
}
APlayerController* PC = OwnerPC.Get();
if (!PC || !PC->GetWorld())
{
return nullptr;
}
const FPS_Editor_ResolvedEntry& Entry = ResolvedEntries[Index];
if (!Entry.ActorClass)
{
return nullptr;
}
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
AActor* SpawnedActor = PC->GetWorld()->SpawnActor<AActor>(Entry.ActorClass, WorldLocation, FRotator::ZeroRotator, SpawnParams);
if (!SpawnedActor)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to spawn %s"), *Entry.DisplayName);
return nullptr;
}
// Ensure the spawned actor is Movable (so it's selectable)
if (USceneComponent* Root = SpawnedActor->GetRootComponent())
{
Root->SetMobility(EComponentMobility::Movable);
}
// Record undo action (undo = hide the spawned actor)
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
TSharedPtr<FPS_Editor_SpawnAction> Action = MakeShared<FPS_Editor_SpawnAction>();
Action->Actors.Add(SpawnedActor);
UM->RecordAction(Action);
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spawned %s at %s"), *Entry.DisplayName, *WorldLocation.ToString());
return SpawnedActor;
}
FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const
{
APlayerController* PC = OwnerPC.Get();
if (!PC)
{
return FVector::ZeroVector;
}
FVector CameraLocation;
FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
// Spawn 500 units in front of the camera
return CameraLocation + CameraRotation.Vector() * 500.0f;
}
TArray<FString> UPS_Editor_SpawnManager::GetCategories() const
{
TArray<FString> Categories;
for (const FPS_Editor_ResolvedEntry& Entry : ResolvedEntries)
{
Categories.AddUnique(Entry.Category);
}
Categories.Sort();
return Categories;
}

View File

@@ -0,0 +1,69 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "PS_Editor_SpawnCatalog.h"
#include "PS_Editor_SpawnManager.generated.h"
class APlayerController;
class UPS_Editor_UndoManager;
/**
* Resolved catalog entry with display info ready for UI.
*/
USTRUCT()
struct FPS_Editor_ResolvedEntry
{
GENERATED_BODY()
UPROPERTY()
TSubclassOf<AActor> ActorClass;
FString DisplayName;
FString Category;
UPROPERTY()
TObjectPtr<UTexture2D> Thumbnail;
};
/**
* Manages spawning of prepared Blueprint actors at runtime.
* Loads a SpawnCatalog DataAsset and provides spawn functions.
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_SpawnManager : public UObject
{
GENERATED_BODY()
public:
/** Initialize with owning controller and load catalog. */
void Initialize(APlayerController* InOwnerPC, UPS_Editor_SpawnCatalog* InCatalog);
/** Spawn the actor at the given catalog index, positioned in front of the camera. Returns the spawned actor. */
AActor* SpawnFromCatalog(int32 Index);
/** Spawn the actor at a specific world location. */
AActor* SpawnFromCatalogAtLocation(int32 Index, FVector WorldLocation);
/** Get the resolved catalog entries for UI display. */
const TArray<FPS_Editor_ResolvedEntry>& GetResolvedEntries() const { return ResolvedEntries; }
/** Get unique categories for filtering. */
TArray<FString> GetCategories() const;
private:
UPROPERTY()
TWeakObjectPtr<APlayerController> OwnerPC;
UPROPERTY()
TObjectPtr<UPS_Editor_SpawnCatalog> Catalog;
UPROPERTY()
TArray<FPS_Editor_ResolvedEntry> ResolvedEntries;
/** Resolve all catalog entries (load classes, read SpawnableComponent metadata). */
void ResolveCatalog();
/** Compute spawn location in front of the camera. */
FVector ComputeSpawnLocation() const;
};

View File

@@ -0,0 +1,6 @@
#include "PS_Editor_SpawnableComponent.h"
UPS_Editor_SpawnableComponent::UPS_Editor_SpawnableComponent()
{
PrimaryComponentTick.bCanEverTick = false;
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "PS_Editor_SpawnableComponent.generated.h"
/**
* Marker component for Blueprints that can be spawned via the PS_Editor catalogue.
* Add this component to any Blueprint to make it appear in the runtime spawn catalogue.
*/
UCLASS(ClassGroup = (PSEditor), meta = (BlueprintSpawnableComponent, DisplayName = "PS Editor Spawnable"))
class PS_EDITOR_API UPS_Editor_SpawnableComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_Editor_SpawnableComponent();
/** Display name shown in the spawn catalogue UI. If empty, uses the actor's class name. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
FString DisplayName;
/** Category for filtering in the catalogue (e.g. "Characters", "Props", "Vehicles"). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
FString Category = TEXT("Default");
/** Optional thumbnail texture shown in the catalogue. Use "Capture All Thumbnails" on the SpawnCatalog DataAsset to auto-generate. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
TObjectPtr<UTexture2D> Thumbnail;
};

View File

@@ -0,0 +1,139 @@
#if WITH_EDITOR
#include "PS_Editor_SpawnableComponentCustomization.h"
#include "PS_Editor_SpawnableComponent.h"
#include "DetailLayoutBuilder.h"
#include "DetailCategoryBuilder.h"
#include "DetailWidgetRow.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Text/STextBlock.h"
#include "Engine/Blueprint.h"
#include "Engine/Texture2D.h"
#include "ThumbnailRendering/ThumbnailManager.h"
#include "ObjectTools.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "UObject/SavePackage.h"
#include "Misc/PackageName.h"
#include "HAL/FileManager.h"
TSharedRef<IDetailCustomization> FPS_Editor_SpawnableComponentCustomization::MakeInstance()
{
return MakeShareable(new FPS_Editor_SpawnableComponentCustomization);
}
void FPS_Editor_SpawnableComponentCustomization::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder)
{
TArray<TWeakObjectPtr<UObject>> Objects;
DetailBuilder.GetObjectsBeingCustomized(Objects);
if (Objects.Num() > 0)
{
EditedComponent = Objects[0];
}
IDetailCategoryBuilder& Category = DetailBuilder.EditCategory("PS Editor|Spawnable");
Category.AddCustomRow(FText::FromString(TEXT("Capture Thumbnail")))
.NameContent()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Auto Thumbnail")))
.Font(IDetailLayoutBuilder::GetDetailFont())
]
.ValueContent()
.MinDesiredWidth(150.0f)
[
SNew(SButton)
.HAlign(HAlign_Center)
.OnClicked(FOnClicked::CreateRaw(this, &FPS_Editor_SpawnableComponentCustomization::OnCaptureThumbnailClicked))
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Capture Thumbnail")))
.Font(IDetailLayoutBuilder::GetDetailFont())
]
];
}
FReply FPS_Editor_SpawnableComponentCustomization::OnCaptureThumbnailClicked()
{
UPS_Editor_SpawnableComponent* Comp = Cast<UPS_Editor_SpawnableComponent>(EditedComponent.Get());
if (!Comp)
{
return FReply::Handled();
}
// Find the owning Blueprint
AActor* OwnerActor = Comp->GetOwner();
if (!OwnerActor)
{
return FReply::Handled();
}
UClass* ActorClass = OwnerActor->GetClass();
UBlueprint* Blueprint = Cast<UBlueprint>(ActorClass->ClassGeneratedBy);
if (!Blueprint)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Cannot capture thumbnail - not a Blueprint actor"));
return FReply::Handled();
}
// Generate thumbnail
FObjectThumbnail* ObjThumb = ThumbnailTools::GenerateThumbnailForObjectToSaveToDisk(Blueprint);
if (!ObjThumb || ObjThumb->GetImageWidth() <= 0 || ObjThumb->GetImageHeight() <= 0)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to generate thumbnail for %s"), *Blueprint->GetName());
return FReply::Handled();
}
const int32 Width = ObjThumb->GetImageWidth();
const int32 Height = ObjThumb->GetImageHeight();
const TArray<uint8>& PixelData = ObjThumb->GetUncompressedImageData();
if (PixelData.Num() != Width * Height * 4)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Invalid thumbnail pixel data"));
return FReply::Handled();
}
// Save as a persistent texture asset next to the Blueprint
FString BlueprintPath = Blueprint->GetOutermost()->GetName();
FString BasePath = FPaths::GetPath(BlueprintPath);
FString AssetName = FString::Printf(TEXT("T_%s_Thumb"), *Blueprint->GetName());
FString FullPath = FString::Printf(TEXT("%s/%s"), *BasePath, *AssetName);
UPackage* Package = CreatePackage(*FullPath);
if (!Package)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to create package for thumbnail"));
return FReply::Handled();
}
UTexture2D* Texture = NewObject<UTexture2D>(Package, *AssetName, RF_Public | RF_Standalone);
Texture->Source.Init(Width, Height, 1, 1, TSF_BGRA8);
uint8* MipData = Texture->Source.LockMip(0);
FMemory::Memcpy(MipData, PixelData.GetData(), PixelData.Num());
Texture->Source.UnlockMip(0);
Texture->SRGB = true;
Texture->CompressionSettings = TC_Default;
Texture->MipGenSettings = TMGS_NoMipmaps;
Texture->PostEditChange();
Texture->UpdateResource();
// Save the package
FString PackageFilename = FPackageName::LongPackageNameToFilename(FullPath, FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Public | RF_Standalone;
UPackage::SavePackage(Package, Texture, *PackageFilename, SaveArgs);
FAssetRegistryModule::AssetCreated(Texture);
// Assign to the component
Comp->Thumbnail = Texture;
Comp->MarkPackageDirty();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Thumbnail captured and saved to %s"), *FullPath);
return FReply::Handled();
}
#endif

View File

@@ -0,0 +1,26 @@
#pragma once
#if WITH_EDITOR
#include "CoreMinimal.h"
#include "IDetailCustomization.h"
/**
* Detail panel customization for UPS_Editor_SpawnableComponent.
* Adds a "Capture Thumbnail" button that grabs the Blueprint's auto-generated
* thumbnail and saves it as a persistent UTexture2D asset.
*/
class FPS_Editor_SpawnableComponentCustomization : public IDetailCustomization
{
public:
static TSharedRef<IDetailCustomization> MakeInstance();
virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override;
private:
FReply OnCaptureThumbnailClicked();
TWeakObjectPtr<UObject> EditedComponent;
};
#endif

View File

@@ -17,6 +17,7 @@ void APS_Editor_HUD::BeginPlay()
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
MainWidget->SetSelectionManager(EditorPC->GetSelectionManager());
MainWidget->SetSpawnManager(EditorPC->GetSpawnManager());
}
MainWidget->AddToViewport(0);

View File

@@ -1,5 +1,6 @@
#include "PS_Editor_MainWidget.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "Widgets/SBoxPanel.h"
@@ -7,6 +8,8 @@
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SScrollBox.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Images/SImage.h"
#include "Engine/Texture2D.h"
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/SOverlay.h"
@@ -15,6 +18,11 @@ void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InS
SelectionManager = InSelectionManager;
}
void UPS_Editor_MainWidget::SetSpawnManager(UPS_Editor_SpawnManager* InSpawnManager)
{
SpawnManager = InSpawnManager;
}
TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
{
return SNew(SOverlay)
@@ -63,6 +71,14 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
BuildToolbar()
]
]
// Left panel: Spawn Catalogue
+ SOverlay::Slot()
.HAlign(HAlign_Left)
.VAlign(VAlign_Center)
.Padding(16.0f, 80.0f, 0.0f, 60.0f)
[
BuildSpawnCatalog()
]
// Right panel: Properties
+ SOverlay::Slot()
.HAlign(HAlign_Right)
@@ -242,9 +258,175 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildPropertiesPanel()
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSpawnCatalog()
{
TSharedRef<SVerticalBox> CatalogList = SNew(SVerticalBox);
// Header
CatalogList->AddSlot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 4.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Spawn Catalogue")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
];
// Note: entries will be populated in NativeConstruct when SpawnManager is set.
// For now, show a placeholder. The actual list is built dynamically.
CatalogList->AddSlot()
.AutoHeight()
[
SAssignNew(SpawnListContainer, SVerticalBox)
];
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(10.0f, 8.0f))
[
SNew(SBox)
.MinDesiredWidth(180.0f)
.MaxDesiredHeight(400.0f)
[
SNew(SScrollBox)
+ SScrollBox::Slot()
[
CatalogList
]
]
];
}
void UPS_Editor_MainWidget::NativeConstruct()
{
Super::NativeConstruct();
PopulateSpawnCatalog();
}
void UPS_Editor_MainWidget::PopulateSpawnCatalog()
{
if (!SpawnListContainer.IsValid())
{
return;
}
SpawnListContainer->ClearChildren();
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
if (!SM)
{
SpawnListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("No catalog loaded")))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
];
return;
}
const TArray<FPS_Editor_ResolvedEntry>& Entries = SM->GetResolvedEntries();
if (Entries.Num() == 0)
{
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))
];
return;
}
// Group by category
FString CurrentCategory;
for (int32 i = 0; i < Entries.Num(); ++i)
{
const FPS_Editor_ResolvedEntry& Entry = Entries[i];
// Category header
if (Entry.Category != CurrentCategory)
{
CurrentCategory = Entry.Category;
SpawnListContainer->AddSlot()
.AutoHeight()
.Padding(0.0f, 6.0f, 0.0f, 2.0f)
[
SNew(STextBlock)
.Text(FText::FromString(CurrentCategory))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 9))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f))
];
}
// Spawn card: thumbnail on top, name below
const int32 EntryIndex = i;
const float CardSize = 80.0f;
TSharedRef<SVerticalBox> CardContent = SNew(SVerticalBox);
// Thumbnail
if (Entry.Thumbnail)
{
TSharedPtr<FSlateBrush> Brush = MakeShared<FSlateBrush>();
Brush->SetResourceObject(Entry.Thumbnail);
Brush->ImageSize = FVector2D(CardSize, CardSize);
Brush->DrawAs = ESlateBrushDrawType::Image;
Brush->Tiling = ESlateBrushTileType::NoTile;
CachedBrushes.Add(Brush);
TWeakPtr<FSlateBrush> WeakBrush = Brush;
CardContent->AddSlot()
.AutoHeight()
.HAlign(HAlign_Center)
[
SNew(SImage)
.Image_Lambda([WeakBrush]() -> const FSlateBrush*
{
return WeakBrush.IsValid() ? WeakBrush.Pin().Get() : nullptr;
})
.DesiredSizeOverride(FVector2D(CardSize, CardSize))
];
}
// Name below thumbnail
CardContent->AddSlot()
.AutoHeight()
.HAlign(HAlign_Center)
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
SNew(STextBlock)
.Text(FText::FromString(Entry.DisplayName))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.Justification(ETextJustify::Center)
.ColorAndOpacity(FLinearColor::White)
];
SpawnListContainer->AddSlot()
.AutoHeight()
.Padding(4.0f, 4.0f)
[
SNew(SButton)
.OnClicked_Lambda([this, EntryIndex]() -> FReply
{
if (UPS_Editor_SpawnManager* Mgr = SpawnManager.Get())
{
Mgr->SpawnFromCatalog(EntryIndex);
}
return FReply::Handled();
})
[
SNew(SBox)
.MinDesiredWidth(CardSize + 8.0f)
[
CardContent
]
]
];
}
}
void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)

View File

@@ -6,6 +6,7 @@
#include "PS_Editor_MainWidget.generated.h"
class UPS_Editor_SelectionManager;
class UPS_Editor_SpawnManager;
/**
* Main editor widget for PS_Editor.
@@ -18,8 +19,9 @@ class PS_EDITOR_API UPS_Editor_MainWidget : public UUserWidget
GENERATED_BODY()
public:
/** Set the selection manager reference. Must be called after construction. */
/** Set manager references. Must be called after construction. */
void SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager);
void SetSpawnManager(UPS_Editor_SpawnManager* InSpawnManager);
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
@@ -30,10 +32,17 @@ private:
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SelectionManager> SelectionManager;
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SpawnManager> SpawnManager;
// ---- Slate widget refs for dynamic updates ----
TSharedPtr<STextBlock> ModeText;
TSharedPtr<STextBlock> SelectionInfoText;
// Spawn catalog
TSharedPtr<SVerticalBox> SpawnListContainer;
TArray<TSharedPtr<FSlateBrush>> CachedBrushes;
// Transform fields
TSharedPtr<SEditableTextBox> LocX, LocY, LocZ;
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;
@@ -42,6 +51,8 @@ private:
// ---- Helpers ----
TSharedRef<SWidget> BuildToolbar();
TSharedRef<SWidget> BuildPropertiesPanel();
TSharedRef<SWidget> BuildSpawnCatalog();
void PopulateSpawnCatalog();
void RefreshPropertiesFromSelection();
void ApplyTransformFromUI();
FString FloatToString(float Value) const;