From 1d5d448bf16ac37aa417de4ef97fadded516fec9 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Tue, 14 Apr 2026 09:26:41 +0200 Subject: [PATCH] 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) --- .../GameMode/PS_Editor_PlayerController.cpp | 31 ++++ .../GameMode/PS_Editor_PlayerController.h | 12 ++ .../Serialization/PS_Editor_SceneLoader.cpp | 18 +- .../Serialization/PS_Editor_SceneLoader.h | 7 + .../PS_Editor_SceneSerializer.cpp | 3 +- .../Spawn/PS_Editor_EditableInterface.h | 8 + .../Spawn/PS_Editor_SpawnManager.cpp | 21 ++- .../UI/Widgets/PS_Editor_MainWidget.cpp | 160 ++++++++++++++++++ .../UI/Widgets/PS_Editor_MainWidget.h | 7 + 9 files changed, 259 insertions(+), 8 deletions(-) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp index f6930d5..e94ef3a 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.cpp @@ -158,6 +158,37 @@ APS_Editor_SplineActor* APS_Editor_PlayerController::GetActiveSplineActor() cons return Cast(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 InActorClass) { if (SelectionManager) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h index c5246f3..bd0c49f 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_PlayerController.h @@ -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"). diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.cpp index 679bdcf..52505ac 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.cpp @@ -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(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 TMapActorHasTag(FName("PS_Editor_Loading")); +} + FString UPS_Editor_SceneLoader::GetScenesDirectory() { return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("PS_Editor"), TEXT("Scenes")); diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.h index a73861a..a8d36e4 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneLoader.h @@ -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. */ diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp index c3b2f36..cbd6c4b 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Serialization/PS_Editor_SceneSerializer.cpp @@ -155,7 +155,6 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_ UClass* LoadedClass = LoadObject(nullptr, *ActorData.ClassPath); if (!LoadedClass) { - // Fallback for C++ native classes (e.g. /Script/PS_Editor.PS_Editor_SplineActor) LoadedClass = FindObject(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); } diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableInterface.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableInterface.h index 1cabd50..6f01d7d 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableInterface.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableInterface.h @@ -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.). diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp index cc8fa98..81355e7 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp @@ -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) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp index 5fc226c..d89fb70 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp @@ -81,6 +81,29 @@ TSharedRef 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 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 UPS_Editor_MainWidget::BuildSceneButtons() if (SaveSM) { SceneSerializer->SaveScene(Name, SaveSM); + bSceneDirty = false; PopulateSceneList(); } } @@ -530,6 +624,7 @@ TSharedRef 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 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 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(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(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(); +} diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h index e556224..a2ee4a5 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h @@ -105,6 +105,11 @@ private: TSharedPtr SplineEditButton; TSharedPtr SplineDeletePointButton; + // Close confirmation + TSharedPtr CloseConfirmOverlay; + bool bSceneDirty = false; + int32 LastKnownSpawnedCount = 0; + // ---- Helpers ---- TSharedRef BuildToolbar(); TSharedRef 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;