Add Phase 2 selection system and translate gizmo

- Click-to-select with raycast (Movable actors only)
- Ctrl+click for multi-select, click empty to deselect
- Click vs drag detection (pixel threshold) to avoid accidental selection
- SelectionManager owned by PlayerController with OnSelectionChanged delegate
- Custom depth stencil toggling on selected actors
- Debug bounding box visualization for selection
- Custom translate gizmo actor with colored arrows (R/G/B) and cone tips
- Gizmo renders on top of scene (SDPG_Foreground + Translucent depth test disabled)
- Constant screen-size gizmo scaling based on camera distance
- Unlit gizmo material with Color vector parameter (M_PS_Editor_Gizmo)
- r.CustomDepth=3 enabled for future outline post-process

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:06:11 +02:00
parent cf1e2f8ccf
commit 8b2609669f
13 changed files with 896 additions and 35 deletions

View File

@@ -16,6 +16,8 @@ r.ReflectionMethod=1
r.SkinCache.CompileShaders=True
r.CustomDepth=3
r.RayTracing=True
r.Shadow.Virtual.Enable=1

Binary file not shown.

View File

@@ -8,6 +8,9 @@
#include "InputModifiers.h"
#include "InputTriggers.h"
#include "GameFramework/PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_PlayerController.h"
#include "DrawDebugHelpers.h"
APS_Editor_Pawn::APS_Editor_Pawn()
{
@@ -36,17 +39,32 @@ void APS_Editor_Pawn::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// Track Alt key state every frame
if (APlayerController* PC = Cast<APlayerController>(GetController()))
{
bAltHeld = PC->IsInputKeyDown(EKeys::LeftAlt) || PC->IsInputKeyDown(EKeys::RightAlt);
bCtrlHeld = PC->IsInputKeyDown(EKeys::LeftControl) || PC->IsInputKeyDown(EKeys::RightControl);
// Draw debug bounding box around selected actors
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
{
if (AActor* Actor = Weak.Get())
{
FVector Origin, Extent;
Actor->GetActorBounds(false, Origin, Extent);
DrawDebugBox(GetWorld(), Origin, Extent, FColor::Orange, false, -1.0f, 0, 2.0f);
}
}
}
}
}
}
void APS_Editor_Pawn::CreateInputActions()
{
// --- Create Input Actions ---
IA_Move = NewObject<UInputAction>(this, TEXT("IA_Move"));
IA_Move->ValueType = EInputActionValueType::Axis3D;
IA_Move->bConsumeInput = false;
@@ -66,7 +84,6 @@ void APS_Editor_Pawn::CreateInputActions()
IA_MiddleClick = NewObject<UInputAction>(this, TEXT("IA_MiddleClick"));
IA_MiddleClick->ValueType = EInputActionValueType::Boolean;
// --- Create Mapping Context ---
EditorMappingContext = NewObject<UInputMappingContext>(this, TEXT("EditorMappingContext"));
// Movement helpers
@@ -191,22 +208,42 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
const FVector2D LookInput = Value.Get<FVector2D>();
if (CameraMode == EPS_Editor_CameraMode::PendingClick)
{
// Check if mouse moved enough to become a drag
const FVector2D CurrentPos = GetMouseScreenPosition();
const float PixelDistance = FVector2D::Distance(PendingClickScreenPos, CurrentPos);
if (PixelDistance > ClickDragThreshold)
{
// Exceeded threshold: resolve to Pan or Orbit
if (bAltHeld)
{
ComputeOrbitFocalPoint();
SetCameraMode(EPS_Editor_CameraMode::Orbit);
}
else
{
SetCameraMode(EPS_Editor_CameraMode::Pan);
}
}
// If still under threshold, do nothing (wait for more movement or mouse up)
return;
}
if (CameraMode == EPS_Editor_CameraMode::Pan)
{
// Left-click drag: mouse X = yaw rotation (Z axis only), mouse Y = move forward/back on horizontal plane
const float YawDelta = LookInput.X * PanYawSensitivity;
FRotator CurrentRotation = GetActorRotation();
CurrentRotation.Yaw += YawDelta;
SetActorRotation(CurrentRotation);
// Forward/back movement on horizontal plane
const FVector HorizontalForward = FVector(GetActorForwardVector().X, GetActorForwardVector().Y, 0.0f).GetSafeNormal();
const FVector PanMovement = HorizontalForward * LookInput.Y * PanSpeed;
AddActorWorldOffset(PanMovement, true);
}
else if (CameraMode == EPS_Editor_CameraMode::Fly)
{
// Right-click drag: full free look
const float YawDelta = LookInput.X * LookSensitivity;
const float PitchDelta = LookInput.Y * LookSensitivity;
@@ -247,7 +284,6 @@ void APS_Editor_Pawn::HandleScroll(const FInputActionValue& Value)
}
else
{
// Idle or Pan: dolly forward/backward
const FVector Forward = GetActorForwardVector();
AddActorWorldOffset(Forward * ScrollValue * OrbitZoomStep * 5.0f, true);
}
@@ -257,20 +293,28 @@ void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value)
{
if (bAltHeld)
{
// Alt + left-click = orbit
// Alt + left-click = orbit immediately (no pending)
ComputeOrbitFocalPoint();
SetCameraMode(EPS_Editor_CameraMode::Orbit);
}
else
{
// Left-click = pan (yaw + forward/back on horizontal plane)
SetCameraMode(EPS_Editor_CameraMode::Pan);
// Enter PendingClick: wait to see if this is a click or a drag
PendingClickScreenPos = GetMouseScreenPosition();
SetCameraMode(EPS_Editor_CameraMode::PendingClick);
}
}
void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
{
if (CameraMode == EPS_Editor_CameraMode::Pan || CameraMode == EPS_Editor_CameraMode::Orbit)
if (CameraMode == EPS_Editor_CameraMode::PendingClick)
{
// Mouse released without exceeding drag threshold = this is a click (selection)
OnEditorClick.ExecuteIfBound(PendingClickScreenPos, bCtrlHeld);
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
else if (CameraMode == EPS_Editor_CameraMode::Pan || CameraMode == EPS_Editor_CameraMode::Orbit
|| CameraMode == EPS_Editor_CameraMode::GizmoDrag)
{
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
@@ -327,7 +371,8 @@ void APS_Editor_Pawn::UpdateCursorVisibility()
return;
}
const bool bShowCursor = (CameraMode == EPS_Editor_CameraMode::Idle);
const bool bShowCursor = (CameraMode == EPS_Editor_CameraMode::Idle
|| CameraMode == EPS_Editor_CameraMode::PendingClick);
PC->bShowMouseCursor = bShowCursor;
if (bShowCursor)
@@ -360,3 +405,17 @@ void APS_Editor_Pawn::ComputeOrbitFocalPoint()
OrbitFocalPoint = End;
}
}
FVector2D APS_Editor_Pawn::GetMouseScreenPosition() const
{
APlayerController* PC = Cast<APlayerController>(GetController());
if (!PC)
{
return FVector2D::ZeroVector;
}
float MouseX = 0.0f;
float MouseY = 0.0f;
PC->GetMousePosition(MouseX, MouseY);
return FVector2D(MouseX, MouseY);
}

View File

@@ -10,6 +10,8 @@ class USpringArmComponent;
class UInputAction;
class UInputMappingContext;
DECLARE_DELEGATE_TwoParams(FPS_Editor_OnClick, FVector2D /*ScreenPos*/, bool /*bAdditive*/);
/**
* Camera modes for the editor pawn.
*/
@@ -18,19 +20,23 @@ enum class EPS_Editor_CameraMode : uint8
{
/** Default mode - cursor visible, ZQSD/arrows still move. */
Idle,
/** Left-click held: mouse Y = forward/back on horizontal plane, mouse X = yaw rotation. */
/** Left mouse down, waiting to determine click vs drag (pixel threshold). */
PendingClick,
/** Left-click drag: mouse Y = forward/back on horizontal plane, mouse X = yaw rotation. */
Pan,
/** Right-click held: full free look + ZQSD movement. */
Fly,
/** Alt + left-click held: orbit around focal point. */
Orbit
/** Alt + left-click or middle-click: orbit around focal point. */
Orbit,
/** Dragging a gizmo handle. */
GizmoDrag
};
/**
* Editor camera pawn mimicking UE viewport navigation.
*
* Controls:
* - Left-click held: Pan (mouse Y = move forward/back, mouse X = yaw)
* - Left-click: Select object (or pan if dragged)
* - Right-click held: Fly (ZQSD + free mouse look)
* - Alt + left-click held: Orbit around focal point
* - Middle-click held: Orbit around focal point (alternative)
@@ -49,6 +55,9 @@ public:
virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
virtual void Tick(float DeltaTime) override;
/** Fired when a left-click is confirmed (mouse up without exceeding drag threshold). */
FPS_Editor_OnClick OnEditorClick;
protected:
virtual void BeginPlay() override;
@@ -65,60 +74,51 @@ protected:
// ---- Camera Settings ----
/** Base movement speed in fly mode (cm/s). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float FlySpeed = 1000.0f;
/** Minimum fly speed. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float FlySpeedMin = 100.0f;
/** Maximum fly speed. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float FlySpeedMax = 10000.0f;
/** Mouse look sensitivity. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float LookSensitivity = 2.5f;
/** Speed change per scroll tick. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float FlySpeedScrollStep = 200.0f;
/** Pan speed multiplier (left-click drag forward/back). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Pan")
float PanSpeed = 5.0f;
/** Pan yaw sensitivity (left-click drag left/right). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Pan")
float PanYawSensitivity = 1.0f;
/** Orbit distance from focal point. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitDistance = 500.0f;
/** Orbit zoom step per scroll tick. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitZoomStep = 50.0f;
/** Minimum orbit distance. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitDistanceMin = 50.0f;
/** Maximum orbit distance. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitDistanceMax = 5000.0f;
/** Orbit rotation sensitivity. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitSensitivity = 1.0f;
/** Pixel distance threshold to distinguish click from drag. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Selection")
float ClickDragThreshold = 8.0f;
// ---- State ----
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
EPS_Editor_CameraMode CameraMode = EPS_Editor_CameraMode::Idle;
/** The point in world space the orbit camera rotates around. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera|Orbit")
FVector OrbitFocalPoint = FVector::ZeroVector;
@@ -145,8 +145,12 @@ private:
UPROPERTY()
TObjectPtr<UInputAction> IA_MiddleClick;
/** Is the Alt key currently held down. */
// ---- Modifier key state ----
bool bAltHeld = false;
bool bCtrlHeld = false;
// ---- Click vs drag state ----
FVector2D PendingClickScreenPos = FVector2D::ZeroVector;
// ---- Input Handlers ----
void HandleMove(const FInputActionValue& Value);
@@ -164,9 +168,8 @@ private:
void SetCameraMode(EPS_Editor_CameraMode NewMode);
void UpdateCursorVisibility();
void ComputeOrbitFocalPoint();
FVector2D GetMouseScreenPosition() const;
/** Current pitch accumulated during orbit (clamped). */
float OrbitPitch = 0.0f;
/** Current yaw accumulated during orbit. */
float OrbitYaw = 0.0f;
};

View File

@@ -1,4 +1,6 @@
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_Pawn.h"
APS_Editor_PlayerController::APS_Editor_PlayerController()
{
@@ -10,6 +12,28 @@ void APS_Editor_PlayerController::BeginPlay()
{
Super::BeginPlay();
// Start in Game+UI mode so cursor is visible and clicks go to both UI and game
SetInputMode(FInputModeGameAndUI().SetHideCursorDuringCapture(false));
// Create selection manager
SelectionManager = NewObject<UPS_Editor_SelectionManager>(this);
SelectionManager->Initialize(this);
}
void APS_Editor_PlayerController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
// Bind pawn's click delegate to the selection manager
if (APS_Editor_Pawn* EditorPawn = Cast<APS_Editor_Pawn>(InPawn))
{
EditorPawn->OnEditorClick.BindUObject(this, &APS_Editor_PlayerController::HandleEditorClick);
}
}
void APS_Editor_PlayerController::HandleEditorClick(FVector2D ScreenPos, bool bAdditive)
{
if (SelectionManager)
{
SelectionManager->HandleClickAtScreenPosition(ScreenPos, bAdditive);
}
}

View File

@@ -4,9 +4,11 @@
#include "GameFramework/PlayerController.h"
#include "PS_Editor_PlayerController.generated.h"
class UPS_Editor_SelectionManager;
/**
* Player controller for PS_Editor runtime editing.
* Shows mouse cursor by default and handles input mode switching.
* Owns the SelectionManager and wires it to the Pawn's click delegate.
*/
UCLASS()
class PS_EDITOR_API APS_Editor_PlayerController : public APlayerController
@@ -16,6 +18,16 @@ class PS_EDITOR_API APS_Editor_PlayerController : public APlayerController
public:
APS_Editor_PlayerController();
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_SelectionManager* GetSelectionManager() const { return SelectionManager; }
protected:
virtual void BeginPlay() override;
virtual void OnPossess(APawn* InPawn) override;
private:
UPROPERTY()
TObjectPtr<UPS_Editor_SelectionManager> SelectionManager;
void HandleEditorClick(FVector2D ScreenPos, bool bAdditive);
};

View File

@@ -0,0 +1,297 @@
#include "PS_Editor_Gizmo.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "UObject/ConstructorHelpers.h"
#include "GameFramework/PlayerController.h"
APS_Editor_Gizmo::APS_Editor_Gizmo()
{
PrimaryActorTick.bCanEverTick = true;
GizmoRoot = CreateDefaultSubobject<USceneComponent>(TEXT("GizmoRoot"));
SetRootComponent(GizmoRoot);
TranslateRoot = CreateDefaultSubobject<USceneComponent>(TEXT("TranslateRoot"));
TranslateRoot->SetupAttachment(GizmoRoot);
const FRotator RotToX = FRotationMatrix::MakeFromZ(FVector::ForwardVector).Rotator();
const FRotator RotToY = FRotationMatrix::MakeFromZ(FVector::RightVector).Rotator();
const FRotator RotToZ = FRotator::ZeroRotator;
Arrow_X = CreateArrow(TEXT("Arrow_X"), TranslateRoot, RotToX);
Arrow_Y = CreateArrow(TEXT("Arrow_Y"), TranslateRoot, RotToY);
Arrow_Z = CreateArrow(TEXT("Arrow_Z"), TranslateRoot, RotToZ);
Cone_X = CreateCone(TEXT("Cone_X"), TranslateRoot, RotToX);
Cone_Y = CreateCone(TEXT("Cone_Y"), TranslateRoot, RotToY);
Cone_Z = CreateCone(TEXT("Cone_Z"), TranslateRoot, RotToZ);
}
void APS_Editor_Gizmo::BeginPlay()
{
Super::BeginPlay();
// Load the gizmo base material.
// The user must create: /PS_Editor/M_PS_Editor_Gizmo
// (Unlit material with a Vector Parameter named "Color" connected to Emissive Color)
UMaterialInterface* GizmoBaseMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
if (!GizmoBaseMat)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Material /PS_Editor/M_PS_Editor_Gizmo not found! "
"Create an Unlit material with a Vector Parameter 'Color' connected to Emissive Color "
"in Plugins/PS_Editor/Content/"));
}
auto MakeColorMat = [&](const FLinearColor& Color) -> UMaterialInstanceDynamic*
{
if (!GizmoBaseMat) return nullptr;
UMaterialInstanceDynamic* MID = UMaterialInstanceDynamic::Create(GizmoBaseMat, this);
MID->SetVectorParameterValue(TEXT("Color"), Color);
return MID;
};
Mat_X = MakeColorMat(FLinearColor::Red);
Mat_Y = MakeColorMat(FLinearColor::Green);
Mat_Z = MakeColorMat(FLinearColor(0.0f, 0.0f, 1.0f));
Mat_Highlight = MakeColorMat(FLinearColor::Yellow);
// Apply materials to shafts and cones
auto ApplyToAxis = [](UStaticMeshComponent* Shaft, UStaticMeshComponent* Tip, UMaterialInstanceDynamic* Mat)
{
if (Mat)
{
if (Shaft) Shaft->SetMaterial(0, Mat);
if (Tip) Tip->SetMaterial(0, Mat);
}
};
ApplyToAxis(Arrow_X, Cone_X, Mat_X);
ApplyToAxis(Arrow_Y, Cone_Y, Mat_Y);
ApplyToAxis(Arrow_Z, Cone_Z, Mat_Z);
// Start hidden
SetGizmoVisible(false);
}
void APS_Editor_Gizmo::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (bGizmoVisible)
{
UpdateConstantScreenSize();
}
}
static void ConfigureGizmoComponent(UStaticMeshComponent* Comp)
{
Comp->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Comp->SetCollisionResponseToAllChannels(ECR_Ignore);
Comp->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
Comp->SetCollisionObjectType(ECC_WorldDynamic);
Comp->SetCastShadow(false);
Comp->SetRenderCustomDepth(false);
// Render on top of everything
Comp->SetDepthPriorityGroup(SDPG_Foreground);
Comp->bUseViewOwnerDepthPriorityGroup = false;
Comp->SetTranslucentSortPriority(10000);
}
UStaticMeshComponent* APS_Editor_Gizmo::CreateArrow(const FName& Name, USceneComponent* Parent, const FRotator& Rotation)
{
UStaticMeshComponent* Comp = CreateDefaultSubobject<UStaticMeshComponent>(Name);
Comp->SetupAttachment(Parent);
static ConstructorHelpers::FObjectFinder<UStaticMesh> CylinderMesh(TEXT("/Engine/BasicShapes/Cylinder"));
if (CylinderMesh.Succeeded())
{
Comp->SetStaticMesh(CylinderMesh.Object);
}
// Shaft: radius ~3 units, length ~60 units
Comp->SetRelativeScale3D(FVector(0.06f, 0.06f, 0.6f));
Comp->SetRelativeRotation(Rotation);
const FVector AxisDir = Rotation.RotateVector(FVector::UpVector);
Comp->SetRelativeLocation(AxisDir * 30.0f);
ConfigureGizmoComponent(Comp);
return Comp;
}
UStaticMeshComponent* APS_Editor_Gizmo::CreateCone(const FName& Name, USceneComponent* Parent, const FRotator& Rotation)
{
UStaticMeshComponent* Comp = CreateDefaultSubobject<UStaticMeshComponent>(Name);
Comp->SetupAttachment(Parent);
static ConstructorHelpers::FObjectFinder<UStaticMesh> ConeMesh(TEXT("/Engine/BasicShapes/Cone"));
if (ConeMesh.Succeeded())
{
Comp->SetStaticMesh(ConeMesh.Object);
}
Comp->SetRelativeScale3D(FVector(0.15f, 0.15f, 0.2f));
Comp->SetRelativeRotation(Rotation);
const FVector AxisDir = Rotation.RotateVector(FVector::UpVector);
Comp->SetRelativeLocation(AxisDir * 65.0f);
ConfigureGizmoComponent(Comp);
return Comp;
}
void APS_Editor_Gizmo::SetGizmoActive(bool bActive, FVector WorldLocation)
{
if (bActive)
{
SetActorLocation(WorldLocation);
SetTransformMode(CurrentMode);
}
SetGizmoVisible(bActive);
SetActorEnableCollision(bActive);
}
void APS_Editor_Gizmo::SetGizmoVisible(bool bVisible)
{
bGizmoVisible = bVisible;
// Control visibility per-component instead of SetActorHiddenInGame
// so we don't override individual component visibility settings.
TranslateRoot->SetVisibility(bVisible && CurrentMode == EPS_Editor_TransformMode::Translate, true);
}
void APS_Editor_Gizmo::SetTransformMode(EPS_Editor_TransformMode NewMode)
{
CurrentMode = NewMode;
TranslateRoot->SetVisibility(bGizmoVisible && NewMode == EPS_Editor_TransformMode::Translate, true);
}
EPS_Editor_GizmoAxis APS_Editor_Gizmo::GetAxisUnderCursor(const FVector& RayOrigin, const FVector& RayDirection) const
{
if (!bGizmoVisible)
{
return EPS_Editor_GizmoAxis::None;
}
float BestDistance = TNumericLimits<float>::Max();
EPS_Editor_GizmoAxis BestAxis = EPS_Editor_GizmoAxis::None;
auto TestComponent = [&](UStaticMeshComponent* Comp, EPS_Editor_GizmoAxis Axis)
{
if (!Comp || !Comp->IsVisible()) return;
FHitResult Hit;
const FVector RayEnd = RayOrigin + RayDirection * 100000.0f;
if (Comp->LineTraceComponent(Hit, RayOrigin, RayEnd, FCollisionQueryParams::DefaultQueryParam))
{
if (Hit.Distance < BestDistance)
{
BestDistance = Hit.Distance;
BestAxis = Axis;
}
}
};
TestComponent(Arrow_X, EPS_Editor_GizmoAxis::X);
TestComponent(Cone_X, EPS_Editor_GizmoAxis::X);
TestComponent(Arrow_Y, EPS_Editor_GizmoAxis::Y);
TestComponent(Cone_Y, EPS_Editor_GizmoAxis::Y);
TestComponent(Arrow_Z, EPS_Editor_GizmoAxis::Z);
TestComponent(Cone_Z, EPS_Editor_GizmoAxis::Z);
return BestAxis;
}
void APS_Editor_Gizmo::SetHighlightedAxis(EPS_Editor_GizmoAxis Axis)
{
if (HighlightedAxis == Axis) return;
auto SetAxisMat = [this](EPS_Editor_GizmoAxis A, UMaterialInstanceDynamic* Mat)
{
UStaticMeshComponent* Shaft = GetComponentForAxis(A);
UStaticMeshComponent* Tip = nullptr;
switch (A)
{
case EPS_Editor_GizmoAxis::X: Tip = Cone_X; break;
case EPS_Editor_GizmoAxis::Y: Tip = Cone_Y; break;
case EPS_Editor_GizmoAxis::Z: Tip = Cone_Z; break;
default: break;
}
SetComponentMaterial(Shaft, Mat);
SetComponentMaterial(Tip, Mat);
};
// Restore previous
if (HighlightedAxis != EPS_Editor_GizmoAxis::None)
{
UMaterialInstanceDynamic* OrigMat = nullptr;
switch (HighlightedAxis)
{
case EPS_Editor_GizmoAxis::X: OrigMat = Mat_X; break;
case EPS_Editor_GizmoAxis::Y: OrigMat = Mat_Y; break;
case EPS_Editor_GizmoAxis::Z: OrigMat = Mat_Z; break;
default: break;
}
SetAxisMat(HighlightedAxis, OrigMat);
}
HighlightedAxis = Axis;
// Highlight new
if (Axis != EPS_Editor_GizmoAxis::None)
{
SetAxisMat(Axis, Mat_Highlight);
}
}
FVector APS_Editor_Gizmo::GetAxisDirection(EPS_Editor_GizmoAxis Axis) const
{
switch (Axis)
{
case EPS_Editor_GizmoAxis::X: return FVector::ForwardVector;
case EPS_Editor_GizmoAxis::Y: return FVector::RightVector;
case EPS_Editor_GizmoAxis::Z: return FVector::UpVector;
default: return FVector::ZeroVector;
}
}
void APS_Editor_Gizmo::UpdateConstantScreenSize()
{
APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (!PC) return;
FVector CameraLocation;
FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
const float Distance = FVector::Dist(CameraLocation, GetActorLocation());
if (Distance < 1.0f) return;
const float Scale = Distance * DesiredScreenSize * 0.01f;
SetActorScale3D(FVector(Scale));
}
UStaticMeshComponent* APS_Editor_Gizmo::GetComponentForAxis(EPS_Editor_GizmoAxis Axis) const
{
switch (Axis)
{
case EPS_Editor_GizmoAxis::X: return Arrow_X;
case EPS_Editor_GizmoAxis::Y: return Arrow_Y;
case EPS_Editor_GizmoAxis::Z: return Arrow_Z;
default: return nullptr;
}
}
void APS_Editor_Gizmo::SetComponentMaterial(UStaticMeshComponent* Comp, UMaterialInstanceDynamic* Mat)
{
if (Comp && Mat)
{
Comp->SetMaterial(0, Mat);
}
}

View File

@@ -0,0 +1,102 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_Editor_SelectionTypes.h"
#include "PS_Editor_Gizmo.generated.h"
class UStaticMeshComponent;
class UMaterialInstanceDynamic;
/**
* Custom runtime transform gizmo for PS_Editor.
* Uses engine BasicShapes (Cone, Cylinder, Cube) to build translate/rotate/scale handles.
* Maintains constant screen size by adjusting scale based on camera distance.
*
* Phase 1: Translate mode only (3 axis arrows + 3 plane handles).
*/
UCLASS()
class PS_EDITOR_API APS_Editor_Gizmo : public AActor
{
GENERATED_BODY()
public:
APS_Editor_Gizmo();
virtual void Tick(float DeltaTime) override;
/** Show/hide the gizmo and set its position. */
void SetGizmoActive(bool bActive, FVector WorldLocation = FVector::ZeroVector);
/** Switch between Translate/Rotate/Scale modes. */
void SetTransformMode(EPS_Editor_TransformMode NewMode);
/** Test which gizmo axis is under the given world ray. */
EPS_Editor_GizmoAxis GetAxisUnderCursor(const FVector& RayOrigin, const FVector& RayDirection) const;
/** Highlight the hovered axis (change material color). */
void SetHighlightedAxis(EPS_Editor_GizmoAxis Axis);
/** Get the world-space direction for a given axis. */
FVector GetAxisDirection(EPS_Editor_GizmoAxis Axis) const;
EPS_Editor_TransformMode GetTransformMode() const { return CurrentMode; }
protected:
virtual void BeginPlay() override;
private:
// ---- Root ----
UPROPERTY(VisibleAnywhere)
TObjectPtr<USceneComponent> GizmoRoot;
// ---- Translate handles ----
UPROPERTY(VisibleAnywhere)
TObjectPtr<USceneComponent> TranslateRoot;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Arrow_X;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Arrow_Y;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Arrow_Z;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Cone_X;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Cone_Y;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Cone_Z;
// ---- Materials ----
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_X;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Y;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Z;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Highlight;
// ---- State ----
EPS_Editor_TransformMode CurrentMode = EPS_Editor_TransformMode::Translate;
EPS_Editor_GizmoAxis HighlightedAxis = EPS_Editor_GizmoAxis::None;
bool bGizmoVisible = false;
float DesiredScreenSize = 0.25f;
// ---- Helpers ----
UStaticMeshComponent* CreateArrow(const FName& Name, USceneComponent* Parent, const FRotator& Rotation);
UStaticMeshComponent* CreateCone(const FName& Name, USceneComponent* Parent, const FRotator& Rotation);
void UpdateConstantScreenSize();
void SetGizmoVisible(bool bVisible);
UStaticMeshComponent* GetComponentForAxis(EPS_Editor_GizmoAxis Axis) const;
void SetComponentMaterial(UStaticMeshComponent* Comp, UMaterialInstanceDynamic* Mat);
};

View File

@@ -13,6 +13,8 @@ public class PS_Editor : ModuleRules
PublicIncludePaths.Add(Path.Combine(SourceDir, "GameMode"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "UI"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "UI", "Widgets"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Selection"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Gizmo"));
PublicDependencyModuleNames.AddRange(new string[]
{

View File

@@ -0,0 +1,244 @@
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_Gizmo.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
#include "Components/PrimitiveComponent.h"
void UPS_Editor_SelectionManager::Initialize(APlayerController* InOwnerPC)
{
OwnerPC = InOwnerPC;
// Spawn the gizmo actor (kept alive for the whole session, shown/hidden as needed)
if (UWorld* World = InOwnerPC->GetWorld())
{
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = InOwnerPC;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
Gizmo = World->SpawnActor<APS_Editor_Gizmo>(APS_Editor_Gizmo::StaticClass(), FVector::ZeroVector, FRotator::ZeroRotator, SpawnParams);
}
}
void UPS_Editor_SelectionManager::HandleClickAtScreenPosition(FVector2D ScreenPos, bool bAdditive)
{
APlayerController* PC = OwnerPC.Get();
if (!PC)
{
return;
}
// Deproject screen position to world ray
FVector WorldOrigin, WorldDirection;
if (!PC->DeprojectScreenPositionToWorld(ScreenPos.X, ScreenPos.Y, WorldOrigin, WorldDirection))
{
return;
}
// Raycast
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(PC->GetPawn());
if (Gizmo)
{
Params.AddIgnoredActor(Gizmo);
}
const FVector End = WorldOrigin + WorldDirection * 100000.0f;
const bool bHit = PC->GetWorld()->LineTraceSingleByChannel(Hit, WorldOrigin, End, ECC_Visibility, Params);
if (bHit && Hit.GetActor())
{
AActor* HitActor = Hit.GetActor();
// Only allow selection of Movable actors (skip static scene geometry like floors/walls)
USceneComponent* RootComp = HitActor->GetRootComponent();
const bool bIsSelectable = RootComp && RootComp->Mobility == EComponentMobility::Movable;
if (bIsSelectable)
{
if (bAdditive)
{
ToggleActor(HitActor);
}
else
{
ClearSelection();
SelectActor(HitActor);
}
}
else
{
// Hit a non-selectable actor (static geometry): treat as clicking empty space
if (!bAdditive)
{
ClearSelection();
}
}
}
else
{
// Clicked on nothing: deselect all
if (!bAdditive)
{
ClearSelection();
}
}
}
void UPS_Editor_SelectionManager::SelectActor(AActor* Actor)
{
if (!Actor || IsActorSelected(Actor))
{
return;
}
SelectedActors.Add(Actor);
SetActorHighlight(Actor, true);
UpdateGizmo();
OnSelectionChanged.Broadcast();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Selected %s"), *Actor->GetName());
}
void UPS_Editor_SelectionManager::DeselectActor(AActor* Actor)
{
if (!Actor)
{
return;
}
const int32 Removed = SelectedActors.RemoveAll([Actor](const TWeakObjectPtr<AActor>& Weak)
{
return Weak.Get() == Actor;
});
if (Removed > 0)
{
SetActorHighlight(Actor, false);
UpdateGizmo();
OnSelectionChanged.Broadcast();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Deselected %s"), *Actor->GetName());
}
}
void UPS_Editor_SelectionManager::ToggleActor(AActor* Actor)
{
if (IsActorSelected(Actor))
{
DeselectActor(Actor);
}
else
{
SelectActor(Actor);
}
}
void UPS_Editor_SelectionManager::ClearSelection()
{
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
if (AActor* Actor = Weak.Get())
{
SetActorHighlight(Actor, false);
}
}
const int32 Count = SelectedActors.Num();
SelectedActors.Empty();
UpdateGizmo();
OnSelectionChanged.Broadcast();
if (Count > 0)
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Cleared selection (%d actors)"), Count);
}
}
bool UPS_Editor_SelectionManager::IsActorSelected(AActor* Actor) const
{
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
if (Weak.Get() == Actor)
{
return true;
}
}
return false;
}
FVector UPS_Editor_SelectionManager::GetSelectionPivot() const
{
if (SelectedActors.Num() == 0)
{
return FVector::ZeroVector;
}
FVector Sum = FVector::ZeroVector;
int32 Count = 0;
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
if (AActor* Actor = Weak.Get())
{
Sum += Actor->GetActorLocation();
Count++;
}
}
return Count > 0 ? (Sum / Count) : FVector::ZeroVector;
}
void UPS_Editor_SelectionManager::SetTransformMode(EPS_Editor_TransformMode NewMode)
{
CurrentTransformMode = NewMode;
OnSelectionChanged.Broadcast();
}
void UPS_Editor_SelectionManager::SetTransformSpace(EPS_Editor_Space NewSpace)
{
CurrentSpace = NewSpace;
OnSelectionChanged.Broadcast();
}
void UPS_Editor_SelectionManager::SetActorHighlight(AActor* Actor, bool bHighlight)
{
if (!Actor)
{
return;
}
TArray<UPrimitiveComponent*> Primitives;
Actor->GetComponents<UPrimitiveComponent>(Primitives);
for (UPrimitiveComponent* Prim : Primitives)
{
Prim->SetRenderCustomDepth(bHighlight);
Prim->SetCustomDepthStencilValue(bHighlight ? 1 : 0);
}
}
void UPS_Editor_SelectionManager::UpdateGizmo()
{
if (!Gizmo)
{
return;
}
if (IsAnythingSelected())
{
Gizmo->SetGizmoActive(true, GetSelectionPivot());
}
else
{
Gizmo->SetGizmoActive(false);
}
}
void UPS_Editor_SelectionManager::CleanupStaleReferences()
{
SelectedActors.RemoveAll([](const TWeakObjectPtr<AActor>& Weak)
{
return !Weak.IsValid();
});
}

View File

@@ -0,0 +1,81 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "PS_Editor_SelectionTypes.h"
#include "PS_Editor_SelectionManager.generated.h"
class APlayerController;
class APS_Editor_Gizmo;
DECLARE_MULTICAST_DELEGATE(FPS_Editor_OnSelectionChanged);
/**
* Manages the selection state for the PS_Editor runtime editor.
* Owned by APS_Editor_PlayerController.
*
* Handles: raycast selection, multi-select, highlight toggling (custom depth stencil),
* transform mode, and selection-changed notifications.
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_SelectionManager : public UObject
{
GENERATED_BODY()
public:
/** Initialize with owning player controller. Must be called after construction. */
void Initialize(APlayerController* InOwnerPC);
// ---- Selection Operations ----
/** Perform a raycast at the given screen position and select/deselect accordingly. */
void HandleClickAtScreenPosition(FVector2D ScreenPos, bool bAdditive);
void SelectActor(AActor* Actor);
void DeselectActor(AActor* Actor);
void ToggleActor(AActor* Actor);
void ClearSelection();
// ---- Queries ----
const TArray<TWeakObjectPtr<AActor>>& GetSelectedActors() const { return SelectedActors; }
bool IsAnythingSelected() const { return SelectedActors.Num() > 0; }
bool IsActorSelected(AActor* Actor) const;
FVector GetSelectionPivot() const;
// ---- Transform Mode ----
EPS_Editor_TransformMode GetTransformMode() const { return CurrentTransformMode; }
void SetTransformMode(EPS_Editor_TransformMode NewMode);
APS_Editor_Gizmo* GetGizmo() const { return Gizmo; }
EPS_Editor_Space GetTransformSpace() const { return CurrentSpace; }
void SetTransformSpace(EPS_Editor_Space NewSpace);
// ---- Delegates ----
FPS_Editor_OnSelectionChanged OnSelectionChanged;
private:
UPROPERTY()
TWeakObjectPtr<APlayerController> OwnerPC;
UPROPERTY()
TArray<TWeakObjectPtr<AActor>> SelectedActors;
UPROPERTY()
TObjectPtr<APS_Editor_Gizmo> Gizmo;
EPS_Editor_TransformMode CurrentTransformMode = EPS_Editor_TransformMode::Translate;
EPS_Editor_Space CurrentSpace = EPS_Editor_Space::World;
/** Toggle custom depth stencil rendering on an actor's primitive components. */
void SetActorHighlight(AActor* Actor, bool bHighlight);
/** Clean up any stale weak pointers in the selection array. */
void CleanupStaleReferences();
/** Update gizmo visibility and position based on current selection. */
void UpdateGizmo();
};

View File

@@ -0,0 +1,35 @@
#pragma once
#include "CoreMinimal.h"
#include "PS_Editor_SelectionTypes.generated.h"
/** Transform mode for the gizmo. */
UENUM(BlueprintType)
enum class EPS_Editor_TransformMode : uint8
{
Translate,
Rotate,
Scale
};
/** Which gizmo axis/plane is being interacted with. */
UENUM(BlueprintType)
enum class EPS_Editor_GizmoAxis : uint8
{
None,
X,
Y,
Z,
XY,
XZ,
YZ,
XYZ
};
/** Transform coordinate space. */
UENUM(BlueprintType)
enum class EPS_Editor_Space : uint8
{
World,
Local
};