Editor integration: editable interface, enum support, CameraStart, spawn options, crash fix

- Add IPS_Editor_EditableInterface with events: OnEditorPropertyChanged,
  OnEditorNeedsReinit, OnEditorNeedsRespawn, OnEditorModeChanged, OnEditorTick
- Add FPS_Editor_EditablePropertyEntry struct replacing separate property/display/respawn lists
  with unified Path + DisplayName + bNeedsReinit + bNeedsRespawn per entry
- Add enum property support in UI (SComboBox with display names)
- Add FVector2D property support in UI (X/Y fields)
- Add PS_Editor_CameraStart actor for initial camera position in base levels
- Add SpawnableComponent options: bAutoSnapToGround, bYawOnly, bUniformScaleOnly, GroundOffset
- Add bIsPlayMode to GameInstanceSubsystem for gameplay vs editor context
- Add PlayerController::Tick for editor tick dispatch to spawned actors
- Add SpawnManager::RespawnActorWithProperties, NotifyEditorModeChanged, TickEditorMode
- Fix recursive crash in ClearSelection/FinishPointPlacement loop
- Fix SScrollBox mouse wheel passthrough to camera
- Fix gizmo yaw-only: grey disabled arcs, block drag, preserve highlight
- Call OnEditorNeedsReinit after scene load (editor + gameplay) for property re-init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 18:20:03 +02:00
parent 83840d1626
commit 2b5015a9af
21 changed files with 759 additions and 63 deletions

View File

@@ -0,0 +1,36 @@
#include "PS_Editor_CameraStart.h"
#include "Components/ArrowComponent.h"
#include "Components/BillboardComponent.h"
APS_Editor_CameraStart::APS_Editor_CameraStart()
{
PrimaryActorTick.bCanEverTick = false;
USceneComponent* Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
RootComponent = Root;
#if WITH_EDITORONLY_DATA
SpriteComponent = CreateDefaultSubobject<UBillboardComponent>(TEXT("Sprite"));
SpriteComponent->SetupAttachment(Root);
SpriteComponent->SetHiddenInGame(true);
SpriteComponent->bIsScreenSizeScaled = true;
SpriteComponent->ScreenSize = 0.0015f;
// Default icon — can be overridden in derived Blueprints
static ConstructorHelpers::FObjectFinder<UTexture2D> IconFinder(TEXT("/Engine/EditorResources/S_Note"));
if (IconFinder.Succeeded())
{
SpriteComponent->SetSprite(IconFinder.Object);
}
ArrowComponent = CreateDefaultSubobject<UArrowComponent>(TEXT("Arrow"));
ArrowComponent->SetupAttachment(Root);
ArrowComponent->ArrowColor = FColor(0, 180, 255);
ArrowComponent->ArrowSize = 1.5f;
ArrowComponent->bIsScreenSizeScaled = true;
ArrowComponent->SetHiddenInGame(true);
#endif
// Hide in game — this is just a marker
SetActorHiddenInGame(true);
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_Editor_CameraStart.generated.h"
/**
* Place this actor in a base level to define the initial camera position
* when the PS_Editor loads that level as a sublevel.
* The pawn will be teleported to this actor's location and rotation.
*/
UCLASS(BlueprintType, Blueprintable, meta = (DisplayName = "PS Editor Camera Start"))
class PS_EDITOR_API APS_Editor_CameraStart : public AActor
{
GENERATED_BODY()
public:
APS_Editor_CameraStart();
#if WITH_EDITORONLY_DATA
protected:
/** Icon visible in the editor viewport. Override the sprite in a derived Blueprint if needed. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TObjectPtr<class UBillboardComponent> SpriteComponent;
/** Arrow showing the view direction in the editor viewport. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TObjectPtr<class UArrowComponent> ArrowComponent;
#endif
};

View File

@@ -38,6 +38,7 @@ void UPS_Editor_GameInstanceSubsystem::ReturnToEditor(const UObject* WorldContex
// Restore pending from LastEdited so the editor picks them up // Restore pending from LastEdited so the editor picks them up
PendingScenarioName = LastEditedScenarioName; PendingScenarioName = LastEditedScenarioName;
PendingBaseLevel = LastEditedBaseLevel; PendingBaseLevel = LastEditedBaseLevel;
bIsPlayMode = false;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Returning to editor (scenario: '%s', base level: '%s')"), UE_LOG(LogTemp, Log, TEXT("PS_Editor: Returning to editor (scenario: '%s', base level: '%s')"),
*PendingScenarioName, *PendingBaseLevel); *PendingScenarioName, *PendingBaseLevel);

View File

@@ -36,6 +36,10 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
FString EditorMapName; FString EditorMapName;
/** True when the Play button was pressed (gameplay mode). False when just loading/editing a scenario. */
UPROPERTY(BlueprintReadWrite, Category = "PS Editor")
bool bIsPlayMode = false;
/** Set pending scenario + base level (for opening the editor or gameplay). */ /** Set pending scenario + base level (for opening the editor or gameplay). */
UFUNCTION(BlueprintCallable, Category = "PS Editor") UFUNCTION(BlueprintCallable, Category = "PS Editor")
void SetPendingScenario(const FString& ScenarioName, const FString& BaseLevel); void SetPendingScenario(const FString& ScenarioName, const FString& BaseLevel);

View File

@@ -454,7 +454,7 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
if (AActor* Actor = Selected[i].Get()) if (AActor* Actor = Selected[i].Get())
{ {
FVector NewScale = GizmoDragInitialTransforms[i].GetScale3D(); FVector NewScale = GizmoDragInitialTransforms[i].GetScale3D();
if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::XYZ) if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::XYZ || Gizmo->bIsUniformScaleOnly)
{ {
NewScale *= ScaleFactor; // Uniform scale NewScale *= ScaleFactor; // Uniform scale
} }
@@ -1380,10 +1380,17 @@ void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis)
ActiveGizmoAxis = Axis; ActiveGizmoAxis = Axis;
// Block rotation on non-yaw axes if YawOnly is set
const EPS_Editor_TransformMode Mode = SM->GetTransformMode();
if (Mode == EPS_Editor_TransformMode::Rotate && Gizmo->bIsYawOnly
&& Axis != EPS_Editor_GizmoAxis::Z)
{
return;
}
// Store axis direction at drag start - used for entire drag duration // Store axis direction at drag start - used for entire drag duration
GizmoDragAxisDir = Gizmo->GetAxisDirection(Axis); GizmoDragAxisDir = Gizmo->GetAxisDirection(Axis);
const FVector AxisDir = GizmoDragAxisDir; const FVector AxisDir = GizmoDragAxisDir;
const EPS_Editor_TransformMode Mode = SM->GetTransformMode();
FVector PlaneNormal; FVector PlaneNormal;
if (Mode == EPS_Editor_TransformMode::Rotate) if (Mode == EPS_Editor_TransformMode::Rotate)

View File

@@ -10,6 +10,7 @@
#include "PS_Editor_PointPlaceable.h" #include "PS_Editor_PointPlaceable.h"
#include "Engine/LevelStreamingDynamic.h" #include "Engine/LevelStreamingDynamic.h"
#include "Engine/PostProcessVolume.h" #include "Engine/PostProcessVolume.h"
#include "PS_Editor_CameraStart.h"
#include "PS_Editor_GameInstanceSubsystem.h" #include "PS_Editor_GameInstanceSubsystem.h"
#include "GameFramework/GameModeBase.h" #include "GameFramework/GameModeBase.h"
#include "GameFramework/GameStateBase.h" #include "GameFramework/GameStateBase.h"
@@ -131,6 +132,16 @@ void APS_Editor_PlayerController::BeginPlay()
}); });
} }
void APS_Editor_PlayerController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (SpawnManager)
{
SpawnManager->TickEditorMode(DeltaTime);
}
}
void APS_Editor_PlayerController::OnPossess(APawn* InPawn) void APS_Editor_PlayerController::OnPossess(APawn* InPawn)
{ {
Super::OnPossess(InPawn); Super::OnPossess(InPawn);
@@ -181,22 +192,25 @@ void APS_Editor_PlayerController::BeginPointPlacementAppend(AActor* ExistingActo
void APS_Editor_PlayerController::FinishPointPlacement() void APS_Editor_PlayerController::FinishPointPlacement()
{ {
// Just exit the mode — actor is already tracked, all edits already have undo // Exit the mode FIRST to prevent re-entrant ClearSelection loop
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(ActivePlacementActor)) AActor* PlacementActor = ActivePlacementActor;
ActivePlacementActor = nullptr;
AppendInitialPoints.Empty();
EditorMode = EPS_Editor_EditorMode::Normal;
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(PlacementActor))
{ {
Spline->SetHandlesVisible(false); Spline->SetHandlesVisible(false);
Spline->ClearHandleHighlight(); Spline->ClearHandleHighlight();
} }
if (ActivePlacementActor && SelectionManager) if (PlacementActor && SelectionManager)
{ {
SelectionManager->ClearSelection(); SelectionManager->ClearSelection();
SelectionManager->SelectActor(ActivePlacementActor); SelectionManager->SelectActor(PlacementActor);
} }
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Exited placement mode")); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Exited placement mode"));
ActivePlacementActor = nullptr;
bAppendingToActor = false; bAppendingToActor = false;
AppendInitialPoints.Empty(); AppendInitialPoints.Empty();
EditorMode = EPS_Editor_EditorMode::Normal; EditorMode = EPS_Editor_EditorMode::Normal;
@@ -285,15 +299,38 @@ void APS_Editor_PlayerController::DisableSublevelPostProcessMaterials()
ULevel* Level = CurrentSublevel->GetLoadedLevel(); ULevel* Level = CurrentSublevel->GetLoadedLevel();
if (!Level) return; if (!Level) return;
APS_Editor_CameraStart* CameraStart = nullptr;
for (AActor* Actor : Level->Actors) for (AActor* Actor : Level->Actors)
{ {
if (!Actor) continue; if (!Actor) continue;
APostProcessVolume* PPVolume = Cast<APostProcessVolume>(Actor);
if (!PPVolume) continue;
// Disable custom post-process materials
if (APostProcessVolume* PPVolume = Cast<APostProcessVolume>(Actor))
{
PPVolume->Settings.WeightedBlendables.Array.Empty(); PPVolume->Settings.WeightedBlendables.Array.Empty();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Disabled PP materials on '%s'"), *PPVolume->GetName()); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Disabled PP materials on '%s'"), *PPVolume->GetName());
} }
// Find camera start marker
if (!CameraStart)
{
CameraStart = Cast<APS_Editor_CameraStart>(Actor);
}
}
// Teleport pawn to camera start position (yaw only — keep camera level)
if (CameraStart)
{
if (APawn* MyPawn = GetPawn())
{
const FRotator YawOnly(CameraStart->GetActorRotation().Pitch, CameraStart->GetActorRotation().Yaw, 0.0f);
MyPawn->SetActorLocationAndRotation(CameraStart->GetActorLocation(), YawOnly);
SetControlRotation(YawOnly);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Camera moved to CameraStart at %s"),
*CameraStart->GetActorLocation().ToString());
}
}
} }
void APS_Editor_PlayerController::HandleEditorClick(FVector2D ScreenPos, bool bAdditive) void APS_Editor_PlayerController::HandleEditorClick(FVector2D ScreenPos, bool bAdditive)

View File

@@ -89,6 +89,7 @@ public:
protected: protected:
virtual void BeginPlay() override; virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
virtual void OnPossess(APawn* InPawn) override; virtual void OnPossess(APawn* InPawn) override;
private: private:

View File

@@ -142,6 +142,7 @@ void APS_Editor_Gizmo::BeginPlay()
// Grey center cube for uniform scale // Grey center cube for uniform scale
Mat_Center = MakeColorMat(FLinearColor(0.5f, 0.5f, 0.5f)); Mat_Center = MakeColorMat(FLinearColor(0.5f, 0.5f, 0.5f));
Mat_Disabled = MakeColorMat(FLinearColor(0.15f, 0.15f, 0.15f));
ApplyMat(ScaleCube_Center, Mat_Center); ApplyMat(ScaleCube_Center, Mat_Center);
SetGizmoVisible(false); SetGizmoVisible(false);
@@ -339,6 +340,13 @@ void APS_Editor_Gizmo::SetTransformMode(EPS_Editor_TransformMode NewMode)
ScaleRoot->SetVisibility(bGizmoVisible && NewMode == EPS_Editor_TransformMode::Scale, true); ScaleRoot->SetVisibility(bGizmoVisible && NewMode == EPS_Editor_TransformMode::Scale, true);
} }
void APS_Editor_Gizmo::SetYawOnly(bool bYawOnly)
{
bIsYawOnly = bYawOnly;
if (Arc_X) Arc_X->SetMaterial(0, bYawOnly && Mat_Disabled ? Mat_Disabled : Mat_X);
if (Arc_Y) Arc_Y->SetMaterial(0, bYawOnly && Mat_Disabled ? Mat_Disabled : Mat_Y);
}
// ---- Hit detection ---- // ---- Hit detection ----
EPS_Editor_GizmoAxis APS_Editor_Gizmo::GetAxisUnderCursor(const FVector& RayOrigin, const FVector& RayDirection) const EPS_Editor_GizmoAxis APS_Editor_Gizmo::GetAxisUnderCursor(const FVector& RayOrigin, const FVector& RayDirection) const
@@ -421,13 +429,36 @@ void APS_Editor_Gizmo::SetHighlightedAxis(EPS_Editor_GizmoAxis Axis)
default: break; default: break;
} }
SetAxisMaterial(HighlightedAxis, OrigMat); SetAxisMaterial(HighlightedAxis, OrigMat);
// Restore disabled arcs to grey after un-highlight
if (bIsYawOnly && Mat_Disabled)
{
if (HighlightedAxis == EPS_Editor_GizmoAxis::X && Arc_X) Arc_X->SetMaterial(0, Mat_Disabled);
if (HighlightedAxis == EPS_Editor_GizmoAxis::Y && Arc_Y) Arc_Y->SetMaterial(0, Mat_Disabled);
}
} }
HighlightedAxis = Axis; HighlightedAxis = Axis;
if (Axis != EPS_Editor_GizmoAxis::None) if (Axis != EPS_Editor_GizmoAxis::None)
{
// In yaw-only mode, don't highlight rotation arcs on X/Y but still highlight translate/scale
if (bIsYawOnly && CurrentMode == EPS_Editor_TransformMode::Rotate
&& (Axis == EPS_Editor_GizmoAxis::X || Axis == EPS_Editor_GizmoAxis::Y))
{
// Don't highlight at all in rotate mode for disabled axes
}
else
{ {
SetAxisMaterial(Axis, Mat_Highlight); SetAxisMaterial(Axis, Mat_Highlight);
// Re-grey the arcs even if translate/scale axes are highlighted
if (bIsYawOnly && Mat_Disabled)
{
if (Axis == EPS_Editor_GizmoAxis::X && Arc_X) Arc_X->SetMaterial(0, Mat_Disabled);
if (Axis == EPS_Editor_GizmoAxis::Y && Arc_Y) Arc_Y->SetMaterial(0, Mat_Disabled);
}
}
} }
} }
@@ -490,9 +521,9 @@ void APS_Editor_Gizmo::UpdateArcFacing()
CreateArcRing(Arc_Y, LocalY, ArcY_Start, ArcY_End, ArcRadius, ArcWidth, ArcSegments); CreateArcRing(Arc_Y, LocalY, ArcY_Start, ArcY_End, ArcRadius, ArcWidth, ArcSegments);
CreateArcRing(Arc_Z, LocalZ, ArcZ_Start, ArcZ_End, ArcRadius, ArcWidth, ArcSegments); CreateArcRing(Arc_Z, LocalZ, ArcZ_Start, ArcZ_End, ArcRadius, ArcWidth, ArcSegments);
// Re-apply materials // Re-apply materials (respect YawOnly constraint)
if (Mat_X) Arc_X->SetMaterial(0, Mat_X); if (Mat_X) Arc_X->SetMaterial(0, bIsYawOnly && Mat_Disabled ? Mat_Disabled : Mat_X);
if (Mat_Y) Arc_Y->SetMaterial(0, Mat_Y); if (Mat_Y) Arc_Y->SetMaterial(0, bIsYawOnly && Mat_Disabled ? Mat_Disabled : Mat_Y);
if (Mat_Z) Arc_Z->SetMaterial(0, Mat_Z); if (Mat_Z) Arc_Z->SetMaterial(0, Mat_Z);
if (HighlightedAxis != EPS_Editor_GizmoAxis::None) if (HighlightedAxis != EPS_Editor_GizmoAxis::None)
@@ -512,9 +543,9 @@ void APS_Editor_Gizmo::UpdateArcFacing()
PositionGuideLine(GuideLine_B, LocalY * NewOctant.Y); PositionGuideLine(GuideLine_B, LocalY * NewOctant.Y);
PositionGuideLine(GuideLine_C, LocalZ * NewOctant.Z); PositionGuideLine(GuideLine_C, LocalZ * NewOctant.Z);
// Re-apply materials (mesh sections are recreated) // Re-apply materials (mesh sections are recreated, respect YawOnly)
if (Mat_X) Arc_X->SetMaterial(0, Mat_X); if (Mat_X) Arc_X->SetMaterial(0, bIsYawOnly && Mat_Disabled ? Mat_Disabled : Mat_X);
if (Mat_Y) Arc_Y->SetMaterial(0, Mat_Y); if (Mat_Y) Arc_Y->SetMaterial(0, bIsYawOnly && Mat_Disabled ? Mat_Disabled : Mat_Y);
if (Mat_Z) Arc_Z->SetMaterial(0, Mat_Z); if (Mat_Z) Arc_Z->SetMaterial(0, Mat_Z);
// Restore highlight if needed // Restore highlight if needed

View File

@@ -31,6 +31,9 @@ public:
void SetHighlightedAxis(EPS_Editor_GizmoAxis Axis); void SetHighlightedAxis(EPS_Editor_GizmoAxis Axis);
FVector GetAxisDirection(EPS_Editor_GizmoAxis Axis) const; FVector GetAxisDirection(EPS_Editor_GizmoAxis Axis) const;
/** Constrain rotation to yaw only: hides X/Y rotation arcs and blocks drag on those axes. */
void SetYawOnly(bool bYawOnly);
/** Set the orientation for local space mode (matches selected actor's rotation). */ /** Set the orientation for local space mode (matches selected actor's rotation). */
void SetLocalOrientation(FRotator ActorRotation); void SetLocalOrientation(FRotator ActorRotation);
@@ -116,11 +119,18 @@ private:
TObjectPtr<UMaterialInstanceDynamic> Mat_Highlight; TObjectPtr<UMaterialInstanceDynamic> Mat_Highlight;
UPROPERTY() UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Center; TObjectPtr<UMaterialInstanceDynamic> Mat_Center;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Disabled;
// ---- State ---- // ---- State ----
EPS_Editor_TransformMode CurrentMode = EPS_Editor_TransformMode::Translate; EPS_Editor_TransformMode CurrentMode = EPS_Editor_TransformMode::Translate;
EPS_Editor_GizmoAxis HighlightedAxis = EPS_Editor_GizmoAxis::None; EPS_Editor_GizmoAxis HighlightedAxis = EPS_Editor_GizmoAxis::None;
bool bGizmoVisible = false; bool bGizmoVisible = false;
public:
bool bIsYawOnly = false;
bool bIsUniformScaleOnly = false;
private:
float DesiredScreenSize = 0.25f; float DesiredScreenSize = 0.25f;
// ---- Helpers ---- // ---- Helpers ----

View File

@@ -4,6 +4,7 @@
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_SpawnManager.h" #include "PS_Editor_SpawnManager.h"
#include "PS_Editor_EditableComponent.h" #include "PS_Editor_EditableComponent.h"
#include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineActor.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "Engine/World.h" #include "Engine/World.h"
@@ -20,11 +21,11 @@ static TMap<FString, FString> ExportEditableProps(AActor* Actor)
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>(); UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
if (!EditComp) return Result; if (!EditComp) return Result;
for (const FName& Path : EditComp->EditableProperties) for (const FPS_Editor_EditablePropertyEntry& Entry : EditComp->EditableProperties)
{ {
FString CompName, PropName; FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName); UPS_Editor_EditableComponent::ParsePropertyPath(Entry.Path, CompName, PropName);
const FString Key = Path.ToString(); const FString Key = Entry.Path.ToString();
UObject* Target = CompName.IsEmpty() ? static_cast<UObject*>(Actor) : nullptr; UObject* Target = CompName.IsEmpty() ? static_cast<UObject*>(Actor) : nullptr;
if (!Target) if (!Target)
@@ -56,11 +57,11 @@ static void ImportEditableProps(AActor* Actor, const TMap<FString, FString>& Pro
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>(); UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
if (!EditComp) return; if (!EditComp) return;
for (const FName& Path : EditComp->EditableProperties) for (const FPS_Editor_EditablePropertyEntry& Entry : EditComp->EditableProperties)
{ {
FString CompName, PropName; FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName); UPS_Editor_EditableComponent::ParsePropertyPath(Entry.Path, CompName, PropName);
const FString Key = Path.ToString(); const FString Key = Entry.Path.ToString();
const FString* ValStr = Properties.Find(Key); const FString* ValStr = Properties.Find(Key);
if (!ValStr) continue; if (!ValStr) continue;
@@ -456,7 +457,13 @@ void UPS_Editor_SelectionManager::SnapSelectionToGround()
FVector Origin, Extent; FVector Origin, Extent;
Actor->GetActorBounds(false, Origin, Extent); Actor->GetActorBounds(false, Origin, Extent);
const float BottomOffset = Extent.Z; float BottomOffset = Extent.Z;
// Add custom ground offset from SpawnableComponent if available
if (UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
{
BottomOffset += SpawnComp->GroundOffset;
}
const FVector Start = Actor->GetActorLocation(); const FVector Start = Actor->GetActorLocation();
const FVector End = Start - FVector::UpVector * 100000.0f; const FVector End = Start - FVector::UpVector * 100000.0f;
@@ -472,6 +479,16 @@ void UPS_Editor_SelectionManager::SnapSelectionToGround()
FVector NewLocation = Hit.ImpactPoint; FVector NewLocation = Hit.ImpactPoint;
NewLocation.Z += BottomOffset; NewLocation.Z += BottomOffset;
Actor->SetActorLocation(NewLocation); Actor->SetActorLocation(NewLocation);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: SnapToGround '%s' -> Hit '%s' (%s) at Z=%.1f, BottomOffset=%.1f, NewZ=%.1f"),
*Actor->GetName(),
*Hit.GetActor()->GetName(),
*Hit.GetComponent()->GetName(),
Hit.ImpactPoint.Z, BottomOffset, NewLocation.Z);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: SnapToGround '%s' -> No hit"), *Actor->GetName());
} }
UndoAction->NewTransforms.Add(Actor->GetActorTransform()); UndoAction->NewTransforms.Add(Actor->GetActorTransform());
@@ -514,11 +531,21 @@ void UPS_Editor_SelectionManager::UpdateGizmo()
Gizmo->SetGizmoActive(true, Pivot); Gizmo->SetGizmoActive(true, Pivot);
// Update gizmo orientation // Update gizmo orientation and yaw-only constraint
if (AActor* Actor = SelectedActors[0].Get()) if (AActor* Actor = SelectedActors[0].Get())
{ {
bool bUseLocal = (CurrentTransformMode == EPS_Editor_TransformMode::Scale) || (CurrentSpace == EPS_Editor_Space::Local); bool bUseLocal = (CurrentTransformMode == EPS_Editor_TransformMode::Scale) || (CurrentSpace == EPS_Editor_Space::Local);
Gizmo->SetLocalOrientation(bUseLocal ? Actor->GetActorRotation() : FRotator::ZeroRotator); Gizmo->SetLocalOrientation(bUseLocal ? Actor->GetActorRotation() : FRotator::ZeroRotator);
bool bYaw = false;
bool bUniformScale = false;
if (UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
{
bYaw = SpawnComp->bYawOnly;
bUniformScale = SpawnComp->bUniformScaleOnly;
}
Gizmo->SetYawOnly(bYaw);
Gizmo->bIsUniformScaleOnly = bUniformScale;
} }
} }
else else

View File

@@ -1,6 +1,7 @@
#include "PS_Editor_SceneLoader.h" #include "PS_Editor_SceneLoader.h"
#include "PS_Editor_SceneData.h" #include "PS_Editor_SceneData.h"
#include "PS_Editor_EditableComponent.h" #include "PS_Editor_EditableComponent.h"
#include "PS_Editor_EditableInterface.h"
#include "PS_Editor_SpawnableComponent.h" #include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineActor.h"
#include "PS_Editor_PointPlaceable.h" #include "PS_Editor_PointPlaceable.h"
@@ -147,6 +148,12 @@ 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
if (SpawnedActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorNeedsReinit(SpawnedActor);
}
return SpawnedActor; return SpawnedActor;
} }
@@ -155,11 +162,11 @@ void UPS_Editor_SceneLoader::ApplyCustomProperties(AActor* Actor, const TMap<FSt
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>(); UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
if (!EditComp) return; if (!EditComp) return;
for (const FName& Path : EditComp->EditableProperties) for (const FPS_Editor_EditablePropertyEntry& Entry : EditComp->EditableProperties)
{ {
FString CompName, PropName; FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName); UPS_Editor_EditableComponent::ParsePropertyPath(Entry.Path, CompName, PropName);
const FString Key = Path.ToString(); const FString Key = Entry.Path.ToString();
const FString* ValueStr = CustomProperties.Find(Key); const FString* ValueStr = CustomProperties.Find(Key);
if (!ValueStr) continue; if (!ValueStr) continue;

View File

@@ -2,6 +2,7 @@
#include "PS_Editor_SpawnManager.h" #include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SelectionManager.h" #include "PS_Editor_SelectionManager.h"
#include "PS_Editor_EditableComponent.h" #include "PS_Editor_EditableComponent.h"
#include "PS_Editor_EditableInterface.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineActor.h"
#include "JsonObjectConverter.h" #include "JsonObjectConverter.h"
@@ -55,11 +56,11 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
// Export custom editable properties // Export custom editable properties
if (UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>()) if (UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>())
{ {
for (const FName& Path : EditComp->EditableProperties) for (const FPS_Editor_EditablePropertyEntry& Entry : EditComp->EditableProperties)
{ {
FString CompName, PropName; FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName); UPS_Editor_EditableComponent::ParsePropertyPath(Entry.Path, CompName, PropName);
const FString Key = Path.ToString(); const FString Key = Entry.Path.ToString();
UObject* Target = CompName.IsEmpty() ? static_cast<UObject*>(Actor) : nullptr; UObject* Target = CompName.IsEmpty() ? static_cast<UObject*>(Actor) : nullptr;
if (!Target) if (!Target)
@@ -171,11 +172,11 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
{ {
if (UPS_Editor_EditableComponent* EditComp = SpawnedActor->FindComponentByClass<UPS_Editor_EditableComponent>()) if (UPS_Editor_EditableComponent* EditComp = SpawnedActor->FindComponentByClass<UPS_Editor_EditableComponent>())
{ {
for (const FName& Path : EditComp->EditableProperties) for (const FPS_Editor_EditablePropertyEntry& Entry : EditComp->EditableProperties)
{ {
FString CompName, PropName; FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName); UPS_Editor_EditableComponent::ParsePropertyPath(Entry.Path, CompName, PropName);
const FString Key = Path.ToString(); const FString Key = Entry.Path.ToString();
const FString* ValueStr = ActorData.CustomProperties.Find(Key); const FString* ValueStr = ActorData.CustomProperties.Find(Key);
if (!ValueStr) continue; if (!ValueStr) continue;
@@ -217,6 +218,13 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
Spline->ImportFromCustomProperties(ActorData.CustomProperties); Spline->ImportFromCustomProperties(ActorData.CustomProperties);
} }
// 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_OnEditorModeChanged(SpawnedActor, true);
}
SpawnedCount++; SpawnedCount++;
} }
} }

View File

@@ -166,9 +166,21 @@ void UPS_Editor_EditableComponent::PostEditChangeProperty(FPropertyChangedEvent&
FullPath = FName(SelComp + TEXT(".") + AddProperty.ToString()); FullPath = FName(SelComp + TEXT(".") + AddProperty.ToString());
} }
if (!EditableProperties.Contains(FullPath)) // Check if already exists
bool bExists = false;
for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties)
{ {
EditableProperties.Add(FullPath); if (Entry.Path == FullPath)
{
bExists = true;
break;
}
}
if (!bExists)
{
FPS_Editor_EditablePropertyEntry NewEntry;
NewEntry.Path = FullPath;
EditableProperties.Add(NewEntry);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Added editable property: %s"), *FullPath.ToString()); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Added editable property: %s"), *FullPath.ToString());
} }
@@ -190,13 +202,37 @@ void UPS_Editor_EditableComponent::ParsePropertyPath(const FName& Path, FString&
} }
} }
bool UPS_Editor_EditableComponent::RequiresReinit(const FName& PropertyPath) const
{
for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties)
{
if (Entry.Path == PropertyPath)
{
return Entry.bNeedsReinit;
}
}
return false;
}
bool UPS_Editor_EditableComponent::RequiresRespawn(const FName& PropertyPath) const
{
for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties)
{
if (Entry.Path == PropertyPath)
{
return Entry.bNeedsRespawn;
}
}
return false;
}
FString UPS_Editor_EditableComponent::GetDisplayName(const FName& Path) const FString UPS_Editor_EditableComponent::GetDisplayName(const FName& Path) const
{ {
if (const FString* Override = DisplayNames.Find(Path)) for (const FPS_Editor_EditablePropertyEntry& Entry : EditableProperties)
{ {
if (!Override->IsEmpty()) if (Entry.Path == Path && !Entry.DisplayName.IsEmpty())
{ {
return *Override; return Entry.DisplayName;
} }
} }

View File

@@ -4,6 +4,39 @@
#include "Components/ActorComponent.h" #include "Components/ActorComponent.h"
#include "PS_Editor_EditableComponent.generated.h" #include "PS_Editor_EditableComponent.generated.h"
/**
* A single editable property entry with optional respawn flag.
*/
USTRUCT(BlueprintType)
struct FPS_Editor_EditablePropertyEntry
{
GENERATED_BODY()
/**
* Property path: "ComponentName.PropertyName" or just "PropertyName" for actor-level.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
FName Path;
/** Display name override for the runtime editor UI. If empty, the property name is used. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
FString DisplayName;
/**
* If true, calls OnEditorPropertiesChanged (IPS_Editor_EditableInterface)
* after changing this property, so the actor can re-run its initialization logic.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
bool bNeedsReinit = false;
/**
* If true, the actor is destroyed and respawned with all current properties applied,
* then OnEditorPropertiesChanged is called. Use when reinit alone is not enough.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
bool bNeedsRespawn = false;
};
/** /**
* Marker component that declares which properties are editable in the PS_Editor runtime editor. * Marker component that declares which properties are editable in the PS_Editor runtime editor.
* Separate from SpawnableComponent (which handles catalogue metadata). * Separate from SpawnableComponent (which handles catalogue metadata).
@@ -31,19 +64,16 @@ public:
/** /**
* Properties exposed in the runtime editor. * Properties exposed in the runtime editor.
* Format: "ComponentName.PropertyName" or just "PropertyName" for actor-level. * Each entry has a path, optional display name, and optional respawn flag.
* Populated via the dropdowns above, or editable manually.
*/ */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editable Properties") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editable Properties")
TArray<FName> EditableProperties; TArray<FPS_Editor_EditablePropertyEntry> EditableProperties;
/** /** Returns true if the given property path requires a reinit call. */
* Optional display name overrides for the runtime editor UI. bool RequiresReinit(const FName& PropertyPath) const;
* Key = property path (same as in EditableProperties), Value = label shown in UI.
* If not set, the property name is used as label. /** Returns true if the given property path requires a full respawn. */
*/ bool RequiresRespawn(const FName& PropertyPath) const;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editable Properties")
TMap<FName, FString> DisplayNames;
/** Lists available component names on the owning actor. */ /** Lists available component names on the owning actor. */
UFUNCTION() UFUNCTION()

View File

@@ -0,0 +1,62 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "PS_Editor_EditableInterface.generated.h"
UINTERFACE(BlueprintType, meta = (DisplayName = "PS Editor Editable"))
class PS_EDITOR_API UPS_Editor_EditableInterface : public UInterface
{
GENERATED_BODY()
};
/**
* Interface for actors that need to react to property changes
* made at runtime in the PS_Editor.
*
* Implement OnEditorPropertiesChanged in your Blueprint to handle
* re-initialization or any post-edit logic.
*/
class PS_EDITOR_API IPS_Editor_EditableInterface
{
GENERATED_BODY()
public:
/**
* Called every time an editable property is modified at runtime.
* The new value is already applied when this is called.
* @param PropertyPath The property that changed (e.g. "CharacterType" or "MyComponent.SomeProperty").
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
void OnEditorPropertyChanged(const FString& PropertyPath);
/**
* Called when a property with bNeedsReinit is changed.
* The new value is already applied. Use this to re-run initialization logic.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
void OnEditorNeedsReinit();
/**
* Called on the NEW actor after a respawn triggered by bNeedsRespawn.
* All properties have been applied. Use this for post-respawn setup.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
void OnEditorNeedsRespawn();
/**
* Called when the actor enters or exits editor mode.
* Use this to show/hide editor-only helpers (VR guides, placement aids, debug visuals, etc.).
* @param bIsEditing True when entering edit mode, false when leaving (Play, return to game).
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
void OnEditorModeChanged(bool bIsEditing);
/**
* Called every frame while in editor mode.
* Use this to update visuals dynamically based on property changes, animate helpers, etc.
* @param DeltaTime Time since last frame.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor")
void OnEditorTick(float DeltaTime);
};

View File

@@ -1,5 +1,7 @@
#include "PS_Editor_SpawnManager.h" #include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SpawnableComponent.h" #include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_EditableInterface.h"
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
@@ -134,6 +136,36 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
// Track spawned actor // Track spawned actor
SpawnedActors.Add(SpawnedActor); SpawnedActors.Add(SpawnedActor);
// Auto-snap to ground if configured
UPS_Editor_SpawnableComponent* SpawnComp = SpawnedActor->FindComponentByClass<UPS_Editor_SpawnableComponent>();
if (SpawnComp && SpawnComp->bAutoSnapToGround && PC->GetWorld())
{
FVector Origin, Extent;
SpawnedActor->GetActorBounds(false, Origin, Extent);
float BottomOffset = Extent.Z + SpawnComp->GroundOffset;
const FVector Start = SpawnedActor->GetActorLocation();
const FVector End = Start - FVector::UpVector * 100000.0f;
FHitResult Hit;
FCollisionQueryParams SnapParams;
SnapParams.AddIgnoredActor(SpawnedActor);
SnapParams.AddIgnoredActor(PC->GetPawn());
if (PC->GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Camera, SnapParams))
{
FVector NewLoc = Hit.ImpactPoint;
NewLoc.Z += BottomOffset;
SpawnedActor->SetActorLocation(NewLoc);
}
}
// Notify: we are in editor mode
if (SpawnedActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorModeChanged(SpawnedActor, true);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spawned %s at %s"), *Entry.DisplayName, *WorldLocation.ToString()); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spawned %s at %s"), *Entry.DisplayName, *WorldLocation.ToString());
return SpawnedActor; return SpawnedActor;
} }
@@ -218,6 +250,119 @@ bool UPS_Editor_SpawnManager::IsActorSpawned(AActor* Actor) const
return false; return false;
} }
AActor* UPS_Editor_SpawnManager::RespawnActorWithProperties(AActor* OldActor, const TMap<FString, FString>& PropertyValues)
{
if (!OldActor) return nullptr;
APlayerController* PC = OwnerPC.Get();
if (!PC || !PC->GetWorld()) return nullptr;
UClass* ActorClass = OldActor->GetClass();
const FTransform OldTransform = OldActor->GetActorTransform();
// 1. Remove old actor from tracking and destroy
SpawnedActors.RemoveAll([OldActor](const TWeakObjectPtr<AActor>& Weak)
{
return Weak.Get() == OldActor;
});
OldActor->Destroy();
// 2. Spawn fresh actor (full spawn — BP components are created, BeginPlay runs with defaults)
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
AActor* NewActor = PC->GetWorld()->SpawnActor<AActor>(ActorClass, OldTransform, SpawnParams);
if (!NewActor)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: RespawnActorWithProperties failed to spawn %s"), *ActorClass->GetName());
return nullptr;
}
// 3. Apply all property values on the spawned actor (BP components now exist)
for (const auto& Pair : PropertyValues)
{
FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(FName(*Pair.Key), CompName, PropName);
UObject* Target = nullptr;
if (CompName.IsEmpty())
{
Target = NewActor;
}
else
{
// Match component: by instance name, by class name, or by class name without _C suffix
TArray<UActorComponent*> Components;
NewActor->GetComponents(Components);
for (UActorComponent* Comp : Components)
{
if (!Comp) continue;
const FString InstanceName = Comp->GetName();
const FString ClassName = Comp->GetClass()->GetName();
FString CleanClassName = ClassName;
CleanClassName.RemoveFromEnd(TEXT("_C"));
if (InstanceName == CompName || ClassName == CompName || CleanClassName == CompName)
{
Target = Comp;
break;
}
}
}
if (Target)
{
FProperty* Prop = FindFProperty<FProperty>(Target->GetClass(), *PropName);
if (Prop)
{
Prop->ImportText_InContainer(*Pair.Value, Target, Target, PPF_None);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Respawn apply [%s] = '%s' on %s"), *Pair.Key, *Pair.Value, *Target->GetName());
}
}
}
// 4. Notify the new actor after respawn
if (NewActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorNeedsRespawn(NewActor);
}
if (USceneComponent* Root = NewActor->GetRootComponent())
{
Root->SetMobility(EComponentMobility::Movable);
}
SpawnedActors.Add(NewActor);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Respawned %s with %d properties"), *ActorClass->GetName(), PropertyValues.Num());
return NewActor;
}
void UPS_Editor_SpawnManager::NotifyEditorModeChanged(bool bIsEditing)
{
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
AActor* Actor = Weak.Get();
if (!Actor) continue;
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorModeChanged(Actor, bIsEditing);
}
}
}
void UPS_Editor_SpawnManager::TickEditorMode(float DeltaTime)
{
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
AActor* Actor = Weak.Get();
if (!Actor) continue;
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorTick(Actor, DeltaTime);
}
}
}
TArray<FString> UPS_Editor_SpawnManager::GetCategories() const TArray<FString> UPS_Editor_SpawnManager::GetCategories() const
{ {
TArray<FString> Categories; TArray<FString> Categories;

View File

@@ -69,6 +69,21 @@ public:
/** Returns true if this actor was spawned/tracked by the editor (catalogue, duplicate, paste, load). */ /** Returns true if this actor was spawned/tracked by the editor (catalogue, duplicate, paste, load). */
bool IsActorSpawned(AActor* Actor) const; bool IsActorSpawned(AActor* Actor) const;
/** Notify all spawned actors that implement IPS_Editor_EditableInterface of editor mode change. */
void NotifyEditorModeChanged(bool bIsEditing);
/** Tick all spawned actors that implement IPS_Editor_EditableInterface. */
void TickEditorMode(float DeltaTime);
/**
* Respawn an actor: destroy it and create a new one of the same class,
* applying all given property values BEFORE BeginPlay (via SpawnActorDeferred).
* @param OldActor The actor to replace.
* @param PropertyValues Map of "ComponentName.PropertyName" -> exported text value.
* @return The newly spawned actor, or nullptr on failure.
*/
AActor* RespawnActorWithProperties(AActor* OldActor, const TMap<FString, FString>& PropertyValues);
private: private:
UPROPERTY() UPROPERTY()
TWeakObjectPtr<APlayerController> OwnerPC; TWeakObjectPtr<APlayerController> OwnerPC;

View File

@@ -27,4 +27,24 @@ public:
/** Optional thumbnail texture shown in the catalogue. Use "Capture All Thumbnails" on the SpawnCatalog DataAsset to auto-generate. */ /** Optional thumbnail texture shown in the catalogue. Use "Capture All Thumbnails" on the SpawnCatalog DataAsset to auto-generate. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
TObjectPtr<UTexture2D> Thumbnail; TObjectPtr<UTexture2D> Thumbnail;
/**
* Z offset applied when snapping to ground (End key).
* 0 = actor pivot sits on the ground. Adjust for actors whose pivot is not at the bottom.
* Negative values sink the actor below the ground.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
float GroundOffset = 0.0f;
/** If true, the actor is automatically snapped to ground when spawned from the catalogue. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
bool bAutoSnapToGround = false;
/** If true, rotation is constrained to the vertical axis (Yaw only). Useful for characters and upright objects. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
bool bYawOnly = false;
/** If true, scaling is always uniform on all axes. Dragging any single axis scales all three equally. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
bool bUniformScaleOnly = false;
}; };

View File

@@ -42,9 +42,9 @@ APS_Editor_SplineActor::APS_Editor_SplineActor()
// Editable: exposes SplineColor and SplineTag in the runtime property panel // Editable: exposes SplineColor and SplineTag in the runtime property panel
EditableComp = CreateDefaultSubobject<UPS_Editor_EditableComponent>(TEXT("PS_Editor_Editable")); EditableComp = CreateDefaultSubobject<UPS_Editor_EditableComponent>(TEXT("PS_Editor_Editable"));
EditableComp->EditableProperties.Add(FName(TEXT("SplineColor"))); EditableComp->EditableProperties.Add({FName(TEXT("SplineColor"))});
EditableComp->EditableProperties.Add(FName(TEXT("SplineSelectedColor"))); EditableComp->EditableProperties.Add({FName(TEXT("SplineSelectedColor"))});
EditableComp->EditableProperties.Add(FName(TEXT("SplineTag"))); EditableComp->EditableProperties.Add({FName(TEXT("SplineTag"))});
} }
void APS_Editor_SplineActor::BeginPlay() void APS_Editor_SplineActor::BeginPlay()

View File

@@ -6,6 +6,7 @@
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_EditableComponent.h" #include "PS_Editor_EditableComponent.h"
#include "PS_Editor_EditableInterface.h"
#include "PS_Editor_SpawnableComponent.h" #include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineActor.h"
#include "PS_Editor_Pawn.h" #include "PS_Editor_Pawn.h"
@@ -607,6 +608,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>()) if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
{ {
Sub->SetPendingScenario(SceneName, BaseLevel); Sub->SetPendingScenario(SceneName, BaseLevel);
Sub->bIsPlayMode = true;
// Remember editor map for ReturnToEditor // Remember editor map for ReturnToEditor
if (Sub->EditorMapName.IsEmpty()) if (Sub->EditorMapName.IsEmpty())
{ {
@@ -617,6 +619,12 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
} }
} }
// Notify all spawned actors: leaving editor mode
if (UPS_Editor_SpawnManager* PlaySM2 = EditorPC->GetSpawnManager())
{
PlaySM2->NotifyEditorModeChanged(false);
}
// Unload sublevel before OpenLevel to avoid name conflict // Unload sublevel before OpenLevel to avoid name conflict
EditorPC->LoadBaseLevelAsSublevel(TEXT("None")); EditorPC->LoadBaseLevelAsSublevel(TEXT("None"));
@@ -739,6 +747,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
[ [
SAssignNew(BaseLevelCombo, SComboBox<TSharedPtr<FString>>) SAssignNew(BaseLevelCombo, SComboBox<TSharedPtr<FString>>)
.OptionsSource(&BaseLevelOptions) .OptionsSource(&BaseLevelOptions)
.Method(EPopupMethod::UseCurrentWindow)
.OnSelectionChanged_Lambda([this](TSharedPtr<FString> Selected, ESelectInfo::Type SelectInfo) .OnSelectionChanged_Lambda([this](TSharedPtr<FString> Selected, ESelectInfo::Type SelectInfo)
{ {
if (!Selected.IsValid()) return; if (!Selected.IsValid()) return;
@@ -833,6 +842,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSpawnCatalog()
.MaxDesiredHeight(400.0f) .MaxDesiredHeight(400.0f)
[ [
SNew(SScrollBox) SNew(SScrollBox)
.ConsumeMouseWheel(EConsumeMouseWheel::Always)
+ SScrollBox::Slot() + SScrollBox::Slot()
[ [
CatalogList CatalogList
@@ -1397,6 +1407,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildOutliner()
+ SVerticalBox::Slot().FillHeight(1.0f).Padding(0.0f, 4.0f, 0.0f, 0.0f) + SVerticalBox::Slot().FillHeight(1.0f).Padding(0.0f, 4.0f, 0.0f, 0.0f)
[ [
SNew(SScrollBox) SNew(SScrollBox)
.ConsumeMouseWheel(EConsumeMouseWheel::Always)
+ SScrollBox::Slot() + SScrollBox::Slot()
[ [
SAssignNew(OutlinerListContainer, SVerticalBox) SAssignNew(OutlinerListContainer, SVerticalBox)
@@ -1555,12 +1566,12 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
for (int32 i = 0; i < EditComp->EditableProperties.Num(); i++) for (int32 i = 0; i < EditComp->EditableProperties.Num(); i++)
{ {
const FName& Path = EditComp->EditableProperties[i]; const FPS_Editor_EditablePropertyEntry& Entry = EditComp->EditableProperties[i];
FPS_Editor_PropertyRowWidgets Row; FPS_Editor_PropertyRowWidgets Row;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, Row.ComponentName, Row.PropertyName); UPS_Editor_EditableComponent::ParsePropertyPath(Entry.Path, Row.ComponentName, Row.PropertyName);
Row.DisplayName = EditComp->GetDisplayName(Path); Row.DisplayName = EditComp->GetDisplayName(Entry.Path);
Row.Key = Path.ToString(); Row.Key = Entry.Path.ToString();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolving [%s] comp='%s' prop='%s'"), *Row.Key, *Row.ComponentName, *Row.PropertyName); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolving [%s] comp='%s' prop='%s'"), *Row.Key, *Row.ComponentName, *Row.PropertyName);
@@ -1600,7 +1611,61 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
// Value widget depends on property type // Value widget depends on property type
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget; TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
if (CastField<FBoolProperty>(Row.CachedProperty)) // Detect enum type (FEnumProperty for C++ enum class, FByteProperty with UEnum for legacy)
UEnum* FoundEnum = nullptr;
if (FEnumProperty* EnumProp = CastField<FEnumProperty>(Row.CachedProperty))
{
FoundEnum = EnumProp->GetEnum();
}
else if (FByteProperty* ByteProp = CastField<FByteProperty>(Row.CachedProperty))
{
FoundEnum = ByteProp->GetIntPropertyEnum();
}
if (FoundEnum)
{
Row.CachedEnum = FoundEnum;
Row.EnumOptions = MakeShared<TArray<TSharedPtr<FString>>>();
for (int32 Idx = 0; Idx < FoundEnum->NumEnums() - 1; ++Idx) // -1 to skip _MAX
{
FString Name = FoundEnum->GetDisplayNameTextByIndex(Idx).ToString();
Row.EnumOptions->Add(MakeShared<FString>(Name));
}
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Enum '%s' for property '%s' — %d options, OptionsPtr=%p"),
*FoundEnum->GetName(), *Row.PropertyName, Row.EnumOptions->Num(), Row.EnumOptions.Get());
for (int32 Dbg = 0; Dbg < Row.EnumOptions->Num(); ++Dbg)
{
UE_LOG(LogTemp, Warning, TEXT(" [%d] %s"), Dbg, **(*Row.EnumOptions)[Dbg]);
}
ValueWidget = SAssignNew(Row.EnumCombo, SComboBox<TSharedPtr<FString>>)
.OptionsSource(Row.EnumOptions.Get())
.Method(EPopupMethod::UseCurrentWindow)
.IsFocusable(true)
.OnSelectionChanged_Lambda([this, RowIndex](TSharedPtr<FString> Selected, ESelectInfo::Type SelectInfo)
{
if (!Selected.IsValid() || SelectInfo == ESelectInfo::Direct) return;
FPS_Editor_PropertyRowWidgets& R = DynamicPropertyRows[RowIndex];
if (R.EnumComboText.IsValid())
{
R.EnumComboText->SetText(FText::FromString(*Selected));
}
ApplyPropertyFromUI(RowIndex);
})
.OnGenerateWidget_Lambda([](TSharedPtr<FString> Item) -> TSharedRef<SWidget>
{
return SNew(STextBlock)
.Text(FText::FromString(Item.IsValid() ? *Item : TEXT("")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9));
})
[
SAssignNew(Row.EnumComboText, STextBlock)
.Text(FText::FromString(TEXT("Select...")))
.Font(ValueFont)
];
}
else if (CastField<FBoolProperty>(Row.CachedProperty))
{ {
ValueWidget = SNew(SHorizontalBox) ValueWidget = SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth() + SHorizontalBox::Slot().AutoWidth()
@@ -1636,6 +1701,14 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f) + SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.VecZ) ]; [ MakeTextField(Row.VecZ) ];
} }
else if (StructProp->Struct == TBaseStructure<FVector2D>::Get())
{
ValueWidget = SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.VecX) ]
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.VecY) ];
}
else if (StructProp->Struct == TBaseStructure<FLinearColor>::Get()) else if (StructProp->Struct == TBaseStructure<FLinearColor>::Get())
{ {
ValueWidget = SNew(SHorizontalBox) ValueWidget = SNew(SHorizontalBox)
@@ -1698,7 +1771,34 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
UObject* Target = FindPropertyTarget(Actor, Row.ComponentName); UObject* Target = FindPropertyTarget(Actor, Row.ComponentName);
if (!Target) continue; if (!Target) continue;
if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Row.CachedProperty)) if (Row.CachedEnum && Row.EnumCombo.IsValid() && !Row.EnumCombo->IsOpen())
{
// Read current enum value index
int64 EnumValue = 0;
if (FEnumProperty* EnumProp = CastField<FEnumProperty>(Row.CachedProperty))
{
FNumericProperty* UnderlyingProp = EnumProp->GetUnderlyingProperty();
const void* ValuePtr = Row.CachedProperty->ContainerPtrToValuePtr<void>(Target);
EnumValue = UnderlyingProp->GetSignedIntPropertyValue(ValuePtr);
}
else if (FByteProperty* ByteProp = CastField<FByteProperty>(Row.CachedProperty))
{
EnumValue = ByteProp->GetPropertyValue_InContainer(Target);
}
int32 DisplayIdx = Row.CachedEnum->GetIndexByValue(EnumValue);
if (Row.EnumOptions.IsValid() && Row.EnumOptions->IsValidIndex(DisplayIdx))
{
// Only update if value actually changed
FString NewText = *(*Row.EnumOptions)[DisplayIdx];
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != NewText)
{
Row.EnumCombo->SetSelectedItem((*Row.EnumOptions)[DisplayIdx]);
Row.EnumComboText->SetText(FText::FromString(NewText));
}
}
}
else if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Row.CachedProperty))
{ {
if (Row.BoolField.IsValid()) if (Row.BoolField.IsValid())
{ {
@@ -1733,6 +1833,12 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
SetIfNoFocus(Row.VecY, Rot.Yaw); SetIfNoFocus(Row.VecY, Rot.Yaw);
SetIfNoFocus(Row.VecZ, Rot.Roll); SetIfNoFocus(Row.VecZ, Rot.Roll);
} }
else if (StructProp->Struct == TBaseStructure<FVector2D>::Get())
{
const FVector2D& Vec2 = *static_cast<const FVector2D*>(ValuePtr);
SetIfNoFocus(Row.VecX, Vec2.X);
SetIfNoFocus(Row.VecY, Vec2.Y);
}
else if (StructProp->Struct == TBaseStructure<FLinearColor>::Get()) else if (StructProp->Struct == TBaseStructure<FLinearColor>::Get())
{ {
const FLinearColor& Col = *static_cast<const FLinearColor*>(ValuePtr); const FLinearColor& Col = *static_cast<const FLinearColor*>(ValuePtr);
@@ -1801,7 +1907,19 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
// Build new value text from UI and import // Build new value text from UI and import
FString NewValueText; FString NewValueText;
if (CastField<FBoolProperty>(Row.CachedProperty)) if (Row.CachedEnum && Row.EnumComboText.IsValid())
{
FString DisplayName = Row.EnumComboText->GetText().ToString();
for (int32 Idx = 0; Idx < Row.CachedEnum->NumEnums() - 1; ++Idx)
{
if (Row.CachedEnum->GetDisplayNameTextByIndex(Idx).ToString() == DisplayName)
{
NewValueText = Row.CachedEnum->GetNameStringByIndex(Idx);
break;
}
}
}
else if (CastField<FBoolProperty>(Row.CachedProperty))
{ {
NewValueText = Row.BoolField.IsValid() && Row.BoolField->IsChecked() ? TEXT("True") : TEXT("False"); NewValueText = Row.BoolField.IsValid() && Row.BoolField->IsChecked() ? TEXT("True") : TEXT("False");
} }
@@ -1816,6 +1934,10 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
{ {
NewValueText = FString::Printf(TEXT("(X=%s,Y=%s,Z=%s)"), *GetText(Row.VecX), *GetText(Row.VecY), *GetText(Row.VecZ)); NewValueText = FString::Printf(TEXT("(X=%s,Y=%s,Z=%s)"), *GetText(Row.VecX), *GetText(Row.VecY), *GetText(Row.VecZ));
} }
else if (StructProp->Struct == TBaseStructure<FVector2D>::Get())
{
NewValueText = FString::Printf(TEXT("(X=%s,Y=%s)"), *GetText(Row.VecX), *GetText(Row.VecY));
}
else if (StructProp->Struct == TBaseStructure<FRotator>::Get()) else if (StructProp->Struct == TBaseStructure<FRotator>::Get())
{ {
NewValueText = FString::Printf(TEXT("(Pitch=%s,Yaw=%s,Roll=%s)"), *GetText(Row.VecX), *GetText(Row.VecY), *GetText(Row.VecZ)); NewValueText = FString::Printf(TEXT("(Pitch=%s,Yaw=%s,Roll=%s)"), *GetText(Row.VecX), *GetText(Row.VecY), *GetText(Row.VecZ));
@@ -1834,10 +1956,16 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
NewValueText = Row.SingleField->GetText().ToString(); NewValueText = Row.SingleField->GetText().ToString();
} }
if (NewValueText.IsEmpty()) return; if (NewValueText.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: ApplyProperty '%s' - NewValueText is EMPTY, aborting"), *Row.Key);
return;
}
// Apply the new value // Apply the new value
Row.CachedProperty->ImportText_InContainer(*NewValueText, Target, Target, PPF_None); const TCHAR* ImportResult = Row.CachedProperty->ImportText_InContainer(*NewValueText, Target, Target, PPF_None);
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: ApplyProperty '%s' = '%s' -> Import %s"),
*Row.Key, *NewValueText, ImportResult ? TEXT("OK") : TEXT("FAILED"));
// Refresh rendering // Refresh rendering
if (UActorComponent* Comp = Cast<UActorComponent>(Target)) if (UActorComponent* Comp = Cast<UActorComponent>(Target))
@@ -1869,6 +1997,61 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
} }
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Property %s changed: %s → %s"), *Row.Key, *OldValue, *NewValue); UE_LOG(LogTemp, Log, TEXT("PS_Editor: Property %s changed: %s → %s"), *Row.Key, *OldValue, *NewValue);
// Always notify property changed
const bool bImplementsInterface = Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass());
if (bImplementsInterface)
{
IPS_Editor_EditableInterface::Execute_OnEditorPropertyChanged(Actor, Row.Key);
}
// Check if this property requires reinit or respawn
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
if (!EditComp) return;
const FName PropertyPath(*Row.Key);
const bool bRespawn = EditComp->RequiresRespawn(PropertyPath);
const bool bReinit = EditComp->RequiresReinit(PropertyPath);
if (bRespawn)
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return;
UPS_Editor_SpawnManager* SpawnMgr = EditorPC->GetSpawnManager();
if (!SpawnMgr) return;
// Collect ALL current editable property values (including the one just changed)
TMap<FString, FString> AllProps;
for (const FPS_Editor_PropertyRowWidgets& PropRow : DynamicPropertyRows)
{
if (!PropRow.CachedProperty) continue;
UObject* PropTarget = FindPropertyTarget(Actor, PropRow.ComponentName);
if (!PropTarget) continue;
FString ExportedVal;
PropRow.CachedProperty->ExportText_InContainer(0, ExportedVal, PropTarget, PropTarget, nullptr, PPF_None);
AllProps.Add(PropRow.Key, ExportedVal);
}
// Deselect before destroying
UPS_Editor_SelectionManager* SelMgr = SelectionManager.Get();
if (SelMgr) SelMgr->ClearSelection();
// Respawn: destroy + spawn + apply all props
AActor* NewActor = SpawnMgr->RespawnActorWithProperties(Actor, AllProps);
if (NewActor && SelMgr)
{
SelMgr->SelectActor(NewActor);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Respawned actor for property '%s'"), *Row.Key);
}
else if (bReinit)
{
if (bImplementsInterface)
{
IPS_Editor_EditableInterface::Execute_OnEditorNeedsReinit(Actor);
}
}
} }
FString UPS_Editor_MainWidget::FloatToString(float Value) const FString UPS_Editor_MainWidget::FloatToString(float Value) const

View File

@@ -23,6 +23,12 @@ struct FPS_Editor_PropertyRowWidgets
TSharedPtr<SCheckBox> BoolField; // bool TSharedPtr<SCheckBox> BoolField; // bool
TSharedPtr<SEditableTextBox> VecX, VecY, VecZ; // FVector, FRotator TSharedPtr<SEditableTextBox> VecX, VecY, VecZ; // FVector, FRotator
TSharedPtr<SEditableTextBox> ColR, ColG, ColB, ColA; // FLinearColor TSharedPtr<SEditableTextBox> ColR, ColG, ColB, ColA; // FLinearColor
// Enum support
TSharedPtr<SComboBox<TSharedPtr<FString>>> EnumCombo;
TSharedPtr<STextBlock> EnumComboText;
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
UEnum* CachedEnum = nullptr;
}; };
/** /**