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);
}
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)
{
if (SelectionManager)

View File

@@ -15,6 +15,8 @@ class APS_Editor_SplineActor;
class IPS_Editor_PointPlaceable;
class ULevelStreamingDynamic;
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnEditorClosed);
/**
* Player controller for PS_Editor runtime editing.
* Owns the SelectionManager and wires it to the Pawn's click delegate.
@@ -47,6 +49,16 @@ public:
UPROPERTY(BlueprintReadWrite, Category = "PS Editor")
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.
* @param LevelName Display name stored in CurrentBaseLevel (used for scene filtering).
* @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;
}
// Spawn actor
// Spawn with tag set BEFORE BeginPlay so the BP can skip its default init
FActorSpawnParameters SpawnParams;
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);
if (!SpawnedActor)
@@ -148,10 +152,13 @@ AActor* UPS_Editor_SceneLoader::SpawnActorFromData(UWorld* World, const FPS_Edit
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()))
{
IPS_Editor_EditableInterface::Execute_OnEditorNeedsReinit(SpawnedActor);
IPS_Editor_EditableInterface::Execute_OnPropertiesLoaded(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()
{
return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("PS_Editor"), TEXT("Scenes"));

View File

@@ -70,6 +70,13 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS Editor")
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.
*/

View File

@@ -155,7 +155,6 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
if (!LoadedClass)
{
// Fallback for C++ native classes (e.g. /Script/PS_Editor.PS_Editor_SplineActor)
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath);
}
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
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);
}

View File

@@ -44,6 +44,14 @@ public:
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
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.
* 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
{
APlayerController* PC = OwnerPC.Get();
if (!PC)
if (!PC || !PC->GetWorld())
{
return FVector::ZeroVector;
}
@@ -182,8 +182,23 @@ FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const
FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
// Spawn 500 units in front of the camera
return CameraLocation + CameraRotation.Vector() * 500.0f;
const FVector Direction = CameraRotation.Vector();
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)

View File

@@ -81,6 +81,29 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f, 1.0f))
.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
@@ -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)
{
SceneSerializer->SaveScene(Name, SaveSM);
bSceneDirty = false;
PopulateSceneList();
}
}
@@ -530,6 +624,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (!FileName.IsEmpty() && SceneSerializer.IsValid() && SpawnManager.IsValid())
{
SceneSerializer->SaveScene(FileName, SpawnManager.Get());
bSceneDirty = false;
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::FromString(FileName));
@@ -562,6 +657,8 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
SceneSerializer->ClearScene(SpawnManager.Get());
bSceneDirty = false;
LastKnownSpawnedCount = 0;
}
return FReply::Handled();
})
@@ -713,6 +810,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
}
SceneSerializer->LoadScene(FileName, SpawnManager.Get());
bSceneDirty = false;
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::FromString(FileName));
@@ -969,6 +1067,7 @@ void UPS_Editor_MainWidget::PopulateSceneList()
}
SceneSerializer->LoadScene(Name, SpawnManager.Get());
bSceneDirty = false;
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::FromString(Name));
@@ -1140,6 +1239,7 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
}
Mgr->SpawnFromCatalog(EntryIndex);
bSceneDirty = true;
return FReply::Handled();
})
[
@@ -1157,6 +1257,21 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float 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();
RefreshOutliner();
}
@@ -1347,6 +1462,7 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
void UPS_Editor_MainWidget::ApplyTransformFromUI()
{
if (bIsRefreshing) return;
bSceneDirty = true;
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM || !SM->IsAnythingSelected()) return;
@@ -1886,6 +2002,7 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
{
if (bIsRefreshing) return;
bSceneDirty = true;
if (!DynamicPropertyRows.IsValidIndex(RowIndex)) return;
FPS_Editor_PropertyRowWidgets& Row = DynamicPropertyRows[RowIndex];
@@ -2066,3 +2183,46 @@ void UPS_Editor_MainWidget::OnModeButtonClicked(EPS_Editor_TransformMode 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> SplineDeletePointButton;
// Close confirmation
TSharedPtr<SWidget> CloseConfirmOverlay;
bool bSceneDirty = false;
int32 LastKnownSpawnedCount = 0;
// ---- Helpers ----
TSharedRef<SWidget> BuildToolbar();
TSharedRef<SWidget> BuildPropertiesPanel();
@@ -123,6 +128,8 @@ private:
FString FloatToString(float Value) const;
void OnModeButtonClicked(EPS_Editor_TransformMode Mode);
void OnCloseClicked();
void ExecuteClose(bool bSave);
/** Whether we are currently refreshing UI from selection (avoid feedback loop). */
bool bIsRefreshing = false;