Close button, OnPropertiesLoaded event, spawn raytrace, IsLoadingFromScene

- Add close button (X) in title bar with save confirmation overlay
- Add OnEditorClosed delegate on PlayerController (BP-bindable)
- Add OnPropertiesLoaded event to EditableInterface (fires after scene load)
- Add IsLoadingFromScene() helper for BP BeginPlay skip pattern
- ComputeSpawnLocation now raycasts to avoid spawning through floors
- Track dirty state for save confirmation on close
- Scene load uses PS_Editor_Loading tag via CustomPreSpawnInitalization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 09:26:41 +02:00
parent 2b5015a9af
commit 1d5d448bf1
9 changed files with 259 additions and 8 deletions

View File

@@ -158,6 +158,37 @@ APS_Editor_SplineActor* APS_Editor_PlayerController::GetActiveSplineActor() cons
return Cast<APS_Editor_SplineActor>(ActivePlacementActor); return Cast<APS_Editor_SplineActor>(ActivePlacementActor);
} }
void APS_Editor_PlayerController::CloseEditor()
{
// Cancel active placement
if (EditorMode == EPS_Editor_EditorMode::SplinePlacement)
{
CancelPointPlacement();
}
// Clear selection
if (SelectionManager)
{
SelectionManager->ClearSelection();
}
// Notify actors: leaving editor mode
if (SpawnManager)
{
SpawnManager->NotifyEditorModeChanged(false);
}
// Unload sublevel
LoadBaseLevelAsSublevel(TEXT("None"));
// Reset input
bShowMouseCursor = false;
SetInputMode(FInputModeGameOnly());
// Broadcast delegate — the game handles the transition
OnEditorClosed.Broadcast();
}
void APS_Editor_PlayerController::BeginPointPlacement(TSubclassOf<AActor> InActorClass) void APS_Editor_PlayerController::BeginPointPlacement(TSubclassOf<AActor> InActorClass)
{ {
if (SelectionManager) if (SelectionManager)

View File

@@ -15,6 +15,8 @@ class APS_Editor_SplineActor;
class IPS_Editor_PointPlaceable; class IPS_Editor_PointPlaceable;
class ULevelStreamingDynamic; class ULevelStreamingDynamic;
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnEditorClosed);
/** /**
* Player controller for PS_Editor runtime editing. * Player controller for PS_Editor runtime editing.
* Owns the SelectionManager and wires it to the Pawn's click delegate. * Owns the SelectionManager and wires it to the Pawn's click delegate.
@@ -47,6 +49,16 @@ public:
UPROPERTY(BlueprintReadWrite, Category = "PS Editor") UPROPERTY(BlueprintReadWrite, Category = "PS Editor")
FString CurrentBaseLevel; FString CurrentBaseLevel;
/** Fired when the editor Close button is pressed (after optional save).
* Bind this in Blueprint to override the default behavior (quit application).
* If nothing is bound, the application exits. */
UPROPERTY(BlueprintAssignable, Category = "PS Editor")
FOnEditorClosed OnEditorClosed;
/** Call to close the editor. Broadcasts OnEditorClosed; if unbound, quits the app. */
UFUNCTION(BlueprintCallable, Category = "PS Editor")
void CloseEditor();
/** Load a base level as sublevel for visual context. Unloads previous sublevel. /** Load a base level as sublevel for visual context. Unloads previous sublevel.
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering). * @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
* @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse"). * @param MapPath Full package path for LoadLevelInstance (e.g. "/Game/Maps/Warehouse").

View File

@@ -118,9 +118,13 @@ AActor* UPS_Editor_SceneLoader::SpawnActorFromData(UWorld* World, const FPS_Edit
return nullptr; return nullptr;
} }
// Spawn actor // Spawn with tag set BEFORE BeginPlay so the BP can skip its default init
FActorSpawnParameters SpawnParams; FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
SpawnParams.CustomPreSpawnInitalization = [](AActor* Actor)
{
Actor->Tags.Add(FName("PS_Editor_Loading"));
};
AActor* SpawnedActor = World->SpawnActor<AActor>(LoadedClass, ActorData.Location, ActorData.Rotation, SpawnParams); AActor* SpawnedActor = World->SpawnActor<AActor>(LoadedClass, ActorData.Location, ActorData.Rotation, SpawnParams);
if (!SpawnedActor) if (!SpawnedActor)
@@ -148,10 +152,13 @@ AActor* UPS_Editor_SceneLoader::SpawnActorFromData(UWorld* World, const FPS_Edit
Spline->ImportFromCustomProperties(ActorData.CustomProperties); Spline->ImportFromCustomProperties(ActorData.CustomProperties);
} }
// Notify the actor so it can re-initialize with the loaded property values // Remove loading tag now that properties are applied
SpawnedActor->Tags.Remove(FName("PS_Editor_Loading"));
// Notify the actor that all properties are ready
if (SpawnedActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) if (SpawnedActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{ {
IPS_Editor_EditableInterface::Execute_OnEditorNeedsReinit(SpawnedActor); IPS_Editor_EditableInterface::Execute_OnPropertiesLoaded(SpawnedActor);
} }
return SpawnedActor; return SpawnedActor;
@@ -202,6 +209,11 @@ void UPS_Editor_SceneLoader::ApplyCustomProperties(AActor* Actor, const TMap<FSt
} }
} }
bool UPS_Editor_SceneLoader::IsLoadingFromScene(const AActor* Actor)
{
return Actor && Actor->ActorHasTag(FName("PS_Editor_Loading"));
}
FString UPS_Editor_SceneLoader::GetScenesDirectory() FString UPS_Editor_SceneLoader::GetScenesDirectory()
{ {
return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("PS_Editor"), TEXT("Scenes")); return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("PS_Editor"), TEXT("Scenes"));

View File

@@ -70,6 +70,13 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS Editor") UFUNCTION(BlueprintCallable, Category = "PS Editor")
static FString GetSceneLevelName(const FString& SceneName); static FString GetSceneLevelName(const FString& SceneName);
/**
* Returns true if this actor is currently being spawned from a scene load.
* Use in BeginPlay to skip default initialization — wait for OnPropertiesLoaded instead.
*/
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS Editor")
static bool IsLoadingFromScene(const AActor* Actor);
/** /**
* Get all saved scenes for the current level. * Get all saved scenes for the current level.
*/ */

View File

@@ -155,7 +155,6 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath); UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
if (!LoadedClass) if (!LoadedClass)
{ {
// Fallback for C++ native classes (e.g. /Script/PS_Editor.PS_Editor_SplineActor)
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath); LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath);
} }
if (!LoadedClass) if (!LoadedClass)
@@ -221,7 +220,7 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
// Notify the actor that properties were loaded and we are in editor mode // Notify the actor that properties were loaded and we are in editor mode
if (SpawnedActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) if (SpawnedActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{ {
IPS_Editor_EditableInterface::Execute_OnEditorNeedsReinit(SpawnedActor); IPS_Editor_EditableInterface::Execute_OnPropertiesLoaded(SpawnedActor);
IPS_Editor_EditableInterface::Execute_OnEditorModeChanged(SpawnedActor, true); IPS_Editor_EditableInterface::Execute_OnEditorModeChanged(SpawnedActor, true);
} }

View File

@@ -44,6 +44,14 @@ public:
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor") UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
void OnEditorNeedsRespawn(); void OnEditorNeedsRespawn();
/**
* Called after all properties have been applied from a scene load (JSON).
* Fires both in editor (SceneSerializer) and in gameplay (SceneLoader).
* Use this to re-initialize your actor based on the loaded property values.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
void OnPropertiesLoaded();
/** /**
* Called when the actor enters or exits editor mode. * Called when the actor enters or exits editor mode.
* Use this to show/hide editor-only helpers (VR guides, placement aids, debug visuals, etc.). * Use this to show/hide editor-only helpers (VR guides, placement aids, debug visuals, etc.).

View File

@@ -173,7 +173,7 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const
{ {
APlayerController* PC = OwnerPC.Get(); APlayerController* PC = OwnerPC.Get();
if (!PC) if (!PC || !PC->GetWorld())
{ {
return FVector::ZeroVector; return FVector::ZeroVector;
} }
@@ -182,8 +182,23 @@ FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const
FRotator CameraRotation; FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation); PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
// Spawn 500 units in front of the camera const FVector Direction = CameraRotation.Vector();
return CameraLocation + CameraRotation.Vector() * 500.0f; const float MaxDistance = 5000.0f;
const float DefaultDistance = 500.0f;
const float SurfaceOffset = 5.0f;
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(PC->GetPawn());
if (PC->GetWorld()->LineTraceSingleByChannel(Hit, CameraLocation, CameraLocation + Direction * MaxDistance, ECC_Visibility, Params))
{
// Hit something — place at impact point, slightly offset along the surface normal
return Hit.ImpactPoint + Hit.ImpactNormal * SurfaceOffset;
}
// Nothing hit — fallback to default distance
return CameraLocation + Direction * DefaultDistance;
} }
AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale) AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale)

View File

@@ -81,6 +81,29 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f, 1.0f)) .ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f, 1.0f))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 11)) .Font(FCoreStyle::GetDefaultFontStyle("Regular", 11))
] ]
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
[
SNullWidget::NullWidget
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(8.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
OnCloseClicked();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("X")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 12))
.ColorAndOpacity(FLinearColor(1.0f, 0.3f, 0.3f))
]
]
] ]
] ]
// Toolbar // Toolbar
@@ -220,6 +243,76 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
] ]
] ]
] ]
]
// Close confirmation overlay
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SAssignNew(CloseConfirmOverlay, SBorder)
.Visibility(EVisibility::Collapsed)
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
.BorderBackgroundColor(FLinearColor(0.08f, 0.08f, 0.08f, 0.97f))
.Padding(FMargin(32.0f, 20.0f))
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 0.0f, 0.0f, 16.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Save changes before closing?")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 13))
.ColorAndOpacity(FLinearColor::White)
]
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(6.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
ExecuteClose(true);
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Save & Close")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor(0.2f, 0.9f, 0.2f))
]
]
+ SHorizontalBox::Slot().AutoWidth().Padding(6.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
ExecuteClose(false);
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Close without saving")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 11))
.ColorAndOpacity(FLinearColor(1.0f, 0.5f, 0.3f))
]
]
+ SHorizontalBox::Slot().AutoWidth().Padding(6.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (CloseConfirmOverlay.IsValid())
CloseConfirmOverlay->SetVisibility(EVisibility::Collapsed);
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Cancel")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 11))
]
]
]
]
]; ];
} }
@@ -486,6 +579,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (SaveSM) if (SaveSM)
{ {
SceneSerializer->SaveScene(Name, SaveSM); SceneSerializer->SaveScene(Name, SaveSM);
bSceneDirty = false;
PopulateSceneList(); PopulateSceneList();
} }
} }
@@ -530,6 +624,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (!FileName.IsEmpty() && SceneSerializer.IsValid() && SpawnManager.IsValid()) if (!FileName.IsEmpty() && SceneSerializer.IsValid() && SpawnManager.IsValid())
{ {
SceneSerializer->SaveScene(FileName, SpawnManager.Get()); SceneSerializer->SaveScene(FileName, SpawnManager.Get());
bSceneDirty = false;
if (SaveNameField.IsValid()) if (SaveNameField.IsValid())
{ {
SaveNameField->SetText(FText::FromString(FileName)); SaveNameField->SetText(FText::FromString(FileName));
@@ -562,6 +657,8 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (SceneSerializer.IsValid() && SpawnManager.IsValid()) if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{ {
SceneSerializer->ClearScene(SpawnManager.Get()); SceneSerializer->ClearScene(SpawnManager.Get());
bSceneDirty = false;
LastKnownSpawnedCount = 0;
} }
return FReply::Handled(); return FReply::Handled();
}) })
@@ -713,6 +810,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
} }
SceneSerializer->LoadScene(FileName, SpawnManager.Get()); SceneSerializer->LoadScene(FileName, SpawnManager.Get());
bSceneDirty = false;
if (SaveNameField.IsValid()) if (SaveNameField.IsValid())
{ {
SaveNameField->SetText(FText::FromString(FileName)); SaveNameField->SetText(FText::FromString(FileName));
@@ -969,6 +1067,7 @@ void UPS_Editor_MainWidget::PopulateSceneList()
} }
SceneSerializer->LoadScene(Name, SpawnManager.Get()); SceneSerializer->LoadScene(Name, SpawnManager.Get());
bSceneDirty = false;
if (SaveNameField.IsValid()) if (SaveNameField.IsValid())
{ {
SaveNameField->SetText(FText::FromString(Name)); SaveNameField->SetText(FText::FromString(Name));
@@ -1140,6 +1239,7 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
} }
Mgr->SpawnFromCatalog(EntryIndex); Mgr->SpawnFromCatalog(EntryIndex);
bSceneDirty = true;
return FReply::Handled(); return FReply::Handled();
}) })
[ [
@@ -1157,6 +1257,21 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime) void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{ {
Super::NativeTick(MyGeometry, InDeltaTime); Super::NativeTick(MyGeometry, InDeltaTime);
// Track dirty state: detect spawned actor count changes (spawn, delete, undo, paste)
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (UPS_Editor_SpawnManager* SM = EditorPC->GetSpawnManager())
{
int32 CurrentCount = SM->GetSpawnedActors().Num();
if (CurrentCount != LastKnownSpawnedCount)
{
if (CurrentCount > 0) bSceneDirty = true;
LastKnownSpawnedCount = CurrentCount;
}
}
}
RefreshPropertiesFromSelection(); RefreshPropertiesFromSelection();
RefreshOutliner(); RefreshOutliner();
} }
@@ -1347,6 +1462,7 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
void UPS_Editor_MainWidget::ApplyTransformFromUI() void UPS_Editor_MainWidget::ApplyTransformFromUI()
{ {
if (bIsRefreshing) return; if (bIsRefreshing) return;
bSceneDirty = true;
UPS_Editor_SelectionManager* SM = SelectionManager.Get(); UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM || !SM->IsAnythingSelected()) return; if (!SM || !SM->IsAnythingSelected()) return;
@@ -1886,6 +2002,7 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex) void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
{ {
if (bIsRefreshing) return; if (bIsRefreshing) return;
bSceneDirty = true;
if (!DynamicPropertyRows.IsValidIndex(RowIndex)) return; if (!DynamicPropertyRows.IsValidIndex(RowIndex)) return;
FPS_Editor_PropertyRowWidgets& Row = DynamicPropertyRows[RowIndex]; FPS_Editor_PropertyRowWidgets& Row = DynamicPropertyRows[RowIndex];
@@ -2066,3 +2183,46 @@ void UPS_Editor_MainWidget::OnModeButtonClicked(EPS_Editor_TransformMode Mode)
SM->SetTransformMode(Mode); SM->SetTransformMode(Mode);
} }
} }
void UPS_Editor_MainWidget::OnCloseClicked()
{
if (CloseConfirmOverlay.IsValid())
{
CloseConfirmOverlay->SetVisibility(EVisibility::Visible);
}
}
void UPS_Editor_MainWidget::ExecuteClose(bool bSave)
{
if (CloseConfirmOverlay.IsValid())
{
CloseConfirmOverlay->SetVisibility(EVisibility::Collapsed);
}
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return;
if (bSave)
{
UPS_Editor_SpawnManager* SM = EditorPC->GetSpawnManager();
UPS_Editor_SceneSerializer* Ser = EditorPC->GetSceneSerializer();
if (SM && Ser)
{
FString SceneName = SaveNameField.IsValid() ? SaveNameField->GetText().ToString() : TEXT("");
if (SceneName.IsEmpty())
{
if (HelpBarText.IsValid())
{
HelpBarText->SetText(FText::FromString(TEXT("Enter a scenario name before saving.")));
HelpBarText->SetColorAndOpacity(FLinearColor(1.0f, 0.4f, 0.2f));
}
return;
}
Ser->SaveScene(SceneName, SM);
bSceneDirty = false;
}
}
// Delegate cleanup + transition to PlayerController
EditorPC->CloseEditor();
}

View File

@@ -105,6 +105,11 @@ private:
TSharedPtr<SWidget> SplineEditButton; TSharedPtr<SWidget> SplineEditButton;
TSharedPtr<SWidget> SplineDeletePointButton; TSharedPtr<SWidget> SplineDeletePointButton;
// Close confirmation
TSharedPtr<SWidget> CloseConfirmOverlay;
bool bSceneDirty = false;
int32 LastKnownSpawnedCount = 0;
// ---- Helpers ---- // ---- Helpers ----
TSharedRef<SWidget> BuildToolbar(); TSharedRef<SWidget> BuildToolbar();
TSharedRef<SWidget> BuildPropertiesPanel(); TSharedRef<SWidget> BuildPropertiesPanel();
@@ -123,6 +128,8 @@ private:
FString FloatToString(float Value) const; FString FloatToString(float Value) const;
void OnModeButtonClicked(EPS_Editor_TransformMode Mode); void OnModeButtonClicked(EPS_Editor_TransformMode Mode);
void OnCloseClicked();
void ExecuteClose(bool bSave);
/** Whether we are currently refreshing UI from selection (avoid feedback loop). */ /** Whether we are currently refreshing UI from selection (avoid feedback loop). */
bool bIsRefreshing = false; bool bIsRefreshing = false;